Compare commits
184
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab01021be | ||
|
|
a684d51996 | ||
|
|
422697acec | ||
|
|
d1c57db967 | ||
|
|
071f3447fd | ||
|
|
98f2ebf118 | ||
|
|
916a568e9f | ||
|
|
32f4092156 | ||
|
|
6a9b227dda | ||
|
|
156c5bae35 | ||
|
|
69e61ab4b2 | ||
|
|
3dd6b54a35 | ||
|
|
d7eeff3a68 | ||
|
|
f43a1ef872 | ||
|
|
fd7f883d6a | ||
|
|
4cfcccd272 | ||
|
|
024f9fdd46 | ||
|
|
fc8918c1f9 | ||
|
|
9be71e8fd1 | ||
|
|
aba0d187d7 | ||
|
|
1a01834068 | ||
|
|
bfcf6278e9 | ||
|
|
e5fd74d63d | ||
|
|
cf38fdb245 | ||
|
|
2438777462 | ||
|
|
f9561c8c54 | ||
|
|
f30fc6efec | ||
|
|
eb4290a45e | ||
|
|
38c8d22e5d | ||
|
|
9885b37af5 | ||
|
|
86aacbe1a8 | ||
|
|
f0a1508acd | ||
|
|
66db1db507 | ||
|
|
b670b436c3 | ||
|
|
d89930e218 | ||
|
|
45ee004672 | ||
|
|
536e4e9f86 | ||
|
|
51f3fe81b6 | ||
|
|
321a193b83 | ||
|
|
4ac57e1c4c | ||
|
|
0d0d722dd7 | ||
|
|
a30e82accf | ||
|
|
6791345f77 | ||
|
|
fb68fa6485 | ||
|
|
c6840db0bb | ||
|
|
f5d2a23a5f | ||
|
|
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 | ||
|
|
53c40f856f |
@@ -0,0 +1 @@
|
||||
cloud-hosting-platform/site-builder
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This site builder integrates with WHP (Web Hosting Panel) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account.
|
||||
This site builder integrates with WHP (Web Hosting Platform) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
+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
+217
@@ -8,6 +8,15 @@
|
||||
"name": "whp-site-builder",
|
||||
"version": "2.0.0",
|
||||
"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",
|
||||
@@ -373,6 +382,133 @@
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
|
||||
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/theme-one-dark": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0",
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@craftjs/core": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz",
|
||||
@@ -1093,6 +1229,69 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
|
||||
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
|
||||
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
@@ -1873,6 +2072,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-color-keywords": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
|
||||
@@ -2686,6 +2891,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/styled-components": {
|
||||
"version": "6.3.12",
|
||||
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz",
|
||||
@@ -3096,6 +3307,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
"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",
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -23,7 +24,7 @@ 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 />
|
||||
|
||||
@@ -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,51 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
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 { HtmlBlock } from './HtmlBlock';
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
describe('HtmlBlock render ignores the style prop (matches toHtml)', () => {
|
||||
test('a stored backgroundColor/color/padding is NOT applied to the wrapper', () => {
|
||||
render(
|
||||
React.createElement(HtmlBlock as any, {
|
||||
code: '<p>hello</p>',
|
||||
style: { backgroundColor: 'rgb(255, 0, 0)', color: 'rgb(0, 0, 255)', padding: '40px' },
|
||||
}),
|
||||
);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
expect(wrapper.style.backgroundColor).toBe('');
|
||||
expect(wrapper.style.color).toBe('');
|
||||
expect(wrapper.style.padding).toBe('');
|
||||
});
|
||||
|
||||
test('the editor affordances survive: minHeight is kept, content still renders', () => {
|
||||
render(React.createElement(HtmlBlock as any, { code: '<p>hello</p>', style: {} }));
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
expect(wrapper.style.minHeight).toBe('40px');
|
||||
expect(wrapper.innerHTML).toContain('hello');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { purifyHtml } from './HtmlBlock';
|
||||
// Vite/Vitest `?raw` import -- ships the exact bytes of the file as a
|
||||
// string, declared by node_modules/vite/client.d.ts. This is a checked-in
|
||||
// copy of the reference acceptance fixture used for Task 24 (widening the
|
||||
// Custom HTML block's sanitiser allow-list); keep it byte-identical to the
|
||||
// external fixture used to drive this task so these tests cannot silently
|
||||
// drift from the thing they are supposed to be testing against.
|
||||
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
|
||||
|
||||
/**
|
||||
* Task 24: the site owner tested a broad HTML fixture against the shipped
|
||||
* sanitiser config and found 38% of it silently deleted -- merged table
|
||||
* cells collapsing (colspan/rowspan/scope stripped), <dl>/<sub>/<details>/
|
||||
* inline <svg>/<video>/<audio> dropped wholesale, lang/dir/role stripped
|
||||
* (breaking RTL rendering), <ol start/reversed> flattened. The fix widens
|
||||
* ALLOWED_TAGS/ALLOWED_ATTR in HtmlBlock.tsx. These tests run the *actual*
|
||||
* reference fixture through the *actual* purifyHtml() and assert the
|
||||
* previously-broken constructs now survive with their meaningful
|
||||
* attributes intact, while re-confirming (with attack payloads spliced
|
||||
* into the newly-widened surface -- forms, media, inline svg) that the
|
||||
* four non-negotiable security properties still hold.
|
||||
*/
|
||||
|
||||
describe('purifyHtml -- Task 24 fixture regression (formerly-dropped constructs survive)', () => {
|
||||
const out = purifyHtml(fixtureHtml);
|
||||
|
||||
test('table merged cells keep colspan/rowspan/scope', () => {
|
||||
expect(out).toContain('<td colspan="2">');
|
||||
expect(out).toContain('<td rowspan="2">');
|
||||
expect(out).toContain('<th scope="col">');
|
||||
expect(out).toContain('<th scope="row">');
|
||||
});
|
||||
|
||||
test('definition list keeps its dl/dt/dd structure (was flattened to "TermDef")', () => {
|
||||
expect(out).toMatch(/<dl>[\s\S]*<dt>Term one<\/dt>[\s\S]*<dd>Definition of the first term\.<\/dd>[\s\S]*<\/dl>/);
|
||||
});
|
||||
|
||||
test('menu list survives with nested buttons', () => {
|
||||
expect(out).toMatch(/<menu>[\s\S]*<button type="button">Copy<\/button>[\s\S]*<\/menu>/);
|
||||
});
|
||||
|
||||
test('sub/sup survive (was flattened to "H2O")', () => {
|
||||
expect(out).toContain('H<sub>2</sub>O');
|
||||
expect(out).toContain('x<sup>2</sup>');
|
||||
});
|
||||
|
||||
test('details/summary survive with the open attribute (was flattened)', () => {
|
||||
expect(out).toContain('<summary>Collapsed disclosure</summary>');
|
||||
expect(out).toContain('<details open="">');
|
||||
});
|
||||
|
||||
test('hgroup survives', () => {
|
||||
expect(out).toMatch(/<hgroup>[\s\S]*<h2>Grouped heading<\/h2>/);
|
||||
});
|
||||
|
||||
test('inline svg survives with its shape children and role/aria-label (was deleted entirely)', () => {
|
||||
expect(out).toMatch(/<svg[^>]*role="img"[^>]*aria-label="Two shapes"[^>]*>/);
|
||||
expect(out).toMatch(/<rect[^>]*fill="none"[^>]*stroke="currentColor"[^>]*>/);
|
||||
expect(out).toMatch(/<circle[^>]*cx="135"[^>]*cy="45"[^>]*r="40"[^>]*>/);
|
||||
expect(out).toMatch(/<text[^>]*text-anchor="middle"[^>]*>svg<\/text>/);
|
||||
});
|
||||
|
||||
test('picture/source with media+srcset survive', () => {
|
||||
expect(out).toContain('<source media="(min-width: 800px)" srcset="wide.png">');
|
||||
expect(out).toContain('<source media="(min-width: 400px)" srcset="medium.png">');
|
||||
});
|
||||
|
||||
test('video/audio survive with source/track children (was deleted entirely)', () => {
|
||||
expect(out).toMatch(/<video[^>]*controls=""[^>]*poster="poster\.jpg"[^>]*>/);
|
||||
expect(out).toContain('<source src="clip.webm" type="video/webm">');
|
||||
expect(out).toContain('<track kind="captions" src="captions.vtt" srclang="en" label="English">');
|
||||
expect(out).toMatch(/<audio[^>]*controls=""[^>]*>/);
|
||||
});
|
||||
|
||||
test('canvas survives with its fallback text', () => {
|
||||
expect(out).toContain('<canvas width="200" height="60">Canvas fallback text</canvas>');
|
||||
});
|
||||
|
||||
test('mark/small/del/ins survive as distinct elements (was flattened to "msdi")', () => {
|
||||
expect(out).toContain('<mark>mark</mark>');
|
||||
expect(out).toContain('<small>small</small>');
|
||||
expect(out).toContain('<del>del</del>');
|
||||
expect(out).toContain('<ins>ins</ins>');
|
||||
});
|
||||
|
||||
test('lang/dir preserved for RTL text (was stripped, breaking Arabic/Hebrew rendering)', () => {
|
||||
expect(out).toContain('lang="ar" dir="rtl"');
|
||||
expect(out).toContain('lang="he" dir="rtl"');
|
||||
});
|
||||
|
||||
test('role attribute preserved alongside aria-* (role was stripped)', () => {
|
||||
expect(out).toMatch(/<nav aria-label="Primary">/);
|
||||
expect(out).toMatch(/role="img"/);
|
||||
});
|
||||
|
||||
test('ol start/reversed preserved (was flattened to plain <ol>)', () => {
|
||||
expect(out).toContain('<ol start="5" reversed="">');
|
||||
});
|
||||
|
||||
test('text semantics survive: abbr/cite/q/time/data/kbd/samp/var/dfn/address/bdi/bdo/ruby', () => {
|
||||
expect(out).toContain('<abbr title="HyperText Markup Language">HTML</abbr>');
|
||||
expect(out).toContain('<kbd>Ctrl</kbd>');
|
||||
expect(out).toContain('<samp>output text</samp>');
|
||||
expect(out).toContain('<var>variable</var>');
|
||||
expect(out).toContain('<dfn>definition term</dfn>');
|
||||
expect(out).toContain('<address>');
|
||||
expect(out).toContain('<bdi>');
|
||||
expect(out).toContain('<bdo dir="rtl">');
|
||||
expect(out).toContain('<ruby>');
|
||||
expect(out).toContain('<rt>kan</rt>');
|
||||
expect(out).toContain('<time datetime="2026-08-09">');
|
||||
expect(out).toContain('<data value="42">');
|
||||
});
|
||||
|
||||
test('wbr survives (word-break opportunity)', () => {
|
||||
expect(out).toContain('super<wbr>cali<wbr>fragilistic');
|
||||
});
|
||||
|
||||
test('hidden attribute survives', () => {
|
||||
expect(out).toContain('<p hidden="">');
|
||||
});
|
||||
|
||||
test('forms survive end-to-end: fieldset/legend/label/select/optgroup/option/textarea/datalist/output/progress/meter', () => {
|
||||
expect(out).toContain('<form action="#" method="get">');
|
||||
expect(out).toContain('<fieldset>');
|
||||
expect(out).toContain('<legend>Text inputs</legend>');
|
||||
expect(out).toContain('<label for="f-text">Text</label>');
|
||||
expect(out).toContain('<input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled">');
|
||||
expect(out).toContain('<input id="f-email" type="email" required="">');
|
||||
expect(out).toContain('<input id="f-num" type="number" min="0" max="100" step="5" value="25">');
|
||||
expect(out).toContain('<input id="f-ro" type="text" value="read only" readonly="">');
|
||||
expect(out).toContain('<input id="f-dis" type="text" value="disabled" disabled="">');
|
||||
expect(out).toContain('<input type="checkbox" name="c" value="1" checked="">');
|
||||
expect(out).toContain('<select id="f-select" name="select">');
|
||||
expect(out).toContain('<optgroup label="Group one">');
|
||||
expect(out).toContain('<option value="1" selected="">One</option>');
|
||||
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
|
||||
expect(out).toContain('<datalist id="suggestions">');
|
||||
expect(out).toContain('<textarea id="f-area" rows="4" cols="40">');
|
||||
expect(out).toContain('<output name="result" for="f-num f-range">');
|
||||
expect(out).toContain('<progress id="f-prog" value="0.6">');
|
||||
expect(out).toContain('<meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">');
|
||||
expect(out).toContain('<button type="submit">Submit</button>');
|
||||
});
|
||||
|
||||
test('bug fix: <select size> and <meter low/high/optimum> survive (both tags were already allowed, only these four attrs were missing)', () => {
|
||||
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
|
||||
expect(out).toContain('low="30" high="80" optimum="90"');
|
||||
});
|
||||
|
||||
test('fixture byte survival crosses 90% (was 61.6% -- 9739/15815 -- before Task 24)', () => {
|
||||
expect(out.length).toBeGreaterThan(fixtureHtml.length * 0.9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml -- Task 24: things in the fixture that must still be dropped', () => {
|
||||
const out = purifyHtml(fixtureHtml);
|
||||
|
||||
test('style tag never survives', () => {
|
||||
expect(out).not.toMatch(/<style[\s>]/i);
|
||||
});
|
||||
|
||||
test('script tag never survives', () => {
|
||||
expect(out).not.toMatch(/<script[\s>]/i);
|
||||
});
|
||||
|
||||
test('dialog/template stay excluded (not in the widened allow-list)', () => {
|
||||
expect(out).not.toContain('<dialog');
|
||||
expect(out).not.toContain('<template');
|
||||
});
|
||||
|
||||
test('no on* handler survives anywhere in the widened output, including inside the dialog fallback content', () => {
|
||||
expect(out).not.toMatch(/\son[a-z]+\s*=/i);
|
||||
// The fixture's dialog/close buttons carry onclick specifically to
|
||||
// prove this; their text content should still come through once the
|
||||
// handler is stripped and (for dialog) the wrapping tag is dropped.
|
||||
expect(out).toContain('Open dialog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml -- Task 24: security properties on newly-allowed elements', () => {
|
||||
test('script inside a newly-allowed <form> still never survives', () => {
|
||||
const out = purifyHtml('<form><script>alert(1)</script></form>');
|
||||
expect(out).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('on* handlers never survive on newly-allowed form controls', () => {
|
||||
const out = purifyHtml('<input onfocus="alert(1)" value="x">');
|
||||
expect(out).not.toMatch(/onfocus/i);
|
||||
const out2 = purifyHtml('<select onchange="alert(1)"><option>x</option></select>');
|
||||
expect(out2).not.toMatch(/onchange/i);
|
||||
});
|
||||
|
||||
test('javascript: blocked in <form action>', () => {
|
||||
const out = purifyHtml('<form action="javascript:alert(1)"><button type="submit">go</button></form>');
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('formaction is not in the allow-list at all -- dropped regardless of value', () => {
|
||||
const out = purifyHtml('<button formaction="javascript:alert(1)">go</button>');
|
||||
expect(out).not.toContain('formaction');
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('javascript: blocked on svg <a xlink:href> (xlink:href is not allow-listed at all)', () => {
|
||||
const out = purifyHtml('<svg><a xlink:href="javascript:alert(1)">click</a></svg>');
|
||||
expect(out).not.toContain('javascript:');
|
||||
expect(out).not.toContain('xlink:href');
|
||||
});
|
||||
|
||||
test('javascript: blocked in newly-allowed media URL attributes (poster, source src)', () => {
|
||||
const out = purifyHtml('<video poster="javascript:alert(1)"><source src="javascript:alert(2)"></video>');
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('javascript: still blocked in plain href alongside the widened surface', () => {
|
||||
const out = purifyHtml('<a href="javascript:alert(1)"><svg><text>x</text></svg></a>');
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('iframe still gets the forced restrictive sandbox + referrerpolicy alongside the widened surface', () => {
|
||||
const out = purifyHtml('<form><input></form><iframe src="https://example.com/"></iframe>');
|
||||
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||
const sandbox = out.match(/sandbox="([^"]*)"/)![1];
|
||||
expect(sandbox).not.toMatch(/allow-top-navigation/);
|
||||
expect(out).toContain('referrerpolicy="no-referrer"');
|
||||
});
|
||||
|
||||
test('on* on an iframe is still stripped even though iframe now sits among many more allowed siblings', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe>');
|
||||
expect(out).not.toMatch(/onload/i);
|
||||
});
|
||||
|
||||
test('Task 25: style tag nested inside the newly-allowed inline svg now survives, scoped', () => {
|
||||
// Was "style tag stays blocked" pre-Task-25 -- <style> is now a
|
||||
// deliberate escape hatch (see HtmlBlock.tsx's ALLOWED_TAGS/Task 25
|
||||
// comment), including copies nested inside inline SVG:
|
||||
// querySelectorAll('style') in scopeStyleBlocks() doesn't care about
|
||||
// namespace/nesting depth, because CSS itself doesn't respect SVG
|
||||
// subtree boundaries -- an unscoped <style> inside <svg> would still
|
||||
// apply page-wide, so it needs the same scoping as a top-level one.
|
||||
const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>');
|
||||
expect(out).toMatch(/<style/i);
|
||||
expect(out).not.toContain('<style>svg{color:red}</style>'); // rewritten, not verbatim
|
||||
expect(out).toMatch(/\.whp-html-\w+ svg\{color:red\}/);
|
||||
expect(out).toContain('<rect');
|
||||
});
|
||||
|
||||
test('contenteditable does not smuggle an event handler in alongside it', () => {
|
||||
const out = purifyHtml('<div contenteditable="true" onblur="alert(1)">x</div>');
|
||||
expect(out).not.toMatch(/onblur/i);
|
||||
expect(out).toContain('contenteditable="true"');
|
||||
});
|
||||
|
||||
test('dialog stays excluded even with an attack payload; its inert children still render', () => {
|
||||
const out = purifyHtml('<dialog onclick="alert(1)"><p>hi</p></dialog>');
|
||||
expect(out).not.toContain('<dialog');
|
||||
expect(out).not.toMatch(/onclick/i);
|
||||
expect(out).toContain('<p>hi</p>');
|
||||
});
|
||||
|
||||
test('javascript: blocked via data: smuggling on newly-allowed poster/cite/action attributes', () => {
|
||||
// data: is only allow-listed for data:image/*;base64, -- confirm the
|
||||
// regex is not accidentally satisfied by a text/html or bare data:
|
||||
// payload on any of the newly URI-checked attributes.
|
||||
const out = purifyHtml(
|
||||
'<video poster="data:text/html,<script>alert(1)</script>"></video>' +
|
||||
'<blockquote cite="data:text/html,x">q</blockquote>' +
|
||||
'<form action="data:text/html,x"></form>',
|
||||
);
|
||||
expect(out).not.toContain('data:text/html');
|
||||
});
|
||||
|
||||
test('review fix: data:image/*;base64, URIs now actually survive on poster/cite/href (dead-code regex bug)', () => {
|
||||
// ALLOWED_URI_REGEXP used to put the data:image arm inside the group
|
||||
// that gets a trailing `:` appended to every alternative, requiring a
|
||||
// second colon after the one already in "base64," -- which no real
|
||||
// data URI has, so the clause could never match anything. Confirm the
|
||||
// fixed regex actually allows a real base64 image data URI through on
|
||||
// ordinary URI-checked attributes (not just the DATA_URI_TAGS-covered
|
||||
// src ones tested below).
|
||||
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||
const out = purifyHtml(
|
||||
`<video poster="data:image/png;base64,${b64}"></video>` +
|
||||
`<blockquote cite="data:image/png;base64,${b64}">q</blockquote>` +
|
||||
`<a href="data:image/png;base64,${b64}">img</a>`,
|
||||
);
|
||||
expect(out).toContain(`poster="data:image/png;base64,${b64}"`);
|
||||
expect(out).toContain(`cite="data:image/png;base64,${b64}"`);
|
||||
expect(out).toContain(`href="data:image/png;base64,${b64}"`);
|
||||
});
|
||||
|
||||
test('documented reality: data: on img/video/audio/source src is mimetype-blind (DOMPurify DATA_URI_TAGS bypasses ALLOWED_URI_REGEXP)', () => {
|
||||
// This is NOT gated by ALLOWED_URI_REGEXP at all -- DOMPurify has its
|
||||
// own internal DATA_URI_TAGS allow-list (img, video, audio, source,
|
||||
// image, track) that accepts ANY data: URI on the `src` attribute of
|
||||
// those tags regardless of declared mimetype, before our regex is ever
|
||||
// consulted. Acceptable because none of those tags execute their src
|
||||
// as a document/script context in mainstream browsers -- the sink
|
||||
// doesn't execute. Pinned here so a future DOMPurify version change to
|
||||
// DATA_URI_TAGS shows up as a failing test, not a surprise in
|
||||
// production. <iframe> -- the one tag where this WOULD be dangerous --
|
||||
// is correctly not in DOMPurify's DATA_URI_TAGS list, so its src still
|
||||
// goes through the normal ALLOWED_URI_REGEXP check and gets stripped.
|
||||
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||
const imgOut = purifyHtml(`<img src="data:text/html;base64,${b64}">`);
|
||||
expect(imgOut).toContain(`src="data:text/html;base64,${b64}"`);
|
||||
|
||||
const iframeOut = purifyHtml(`<iframe src="data:text/html;base64,${b64}"></iframe>`);
|
||||
expect(iframeOut).not.toContain('data:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml -- bug fix: size/low/high/optimum were stripped despite select/meter being allowed tags', () => {
|
||||
test('<select size> survives with its value intact (multi-select row count)', () => {
|
||||
const out = purifyHtml('<select size="4"><option>a</option></select>');
|
||||
expect(out).toContain('size="4"');
|
||||
});
|
||||
|
||||
test('<input size> survives with its value intact', () => {
|
||||
const out = purifyHtml('<input type="text" size="10">');
|
||||
expect(out).toContain('size="10"');
|
||||
});
|
||||
|
||||
test('<meter low/high/optimum> survive with their values intact (threshold-based gauge colouring)', () => {
|
||||
const out = purifyHtml('<meter low="1" high="9" optimum="5" value="4" min="0" max="10"></meter>');
|
||||
expect(out).toContain('low="1"');
|
||||
expect(out).toContain('high="9"');
|
||||
expect(out).toContain('optimum="5"');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { purifyHtml } from './HtmlBlock';
|
||||
import { stableHash } from '../../utils/escape';
|
||||
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
|
||||
// Ground truth "before" output: purifyHtml(fixtureHtml) computed with the
|
||||
// EXACT HtmlBlock.tsx code as it stood at commit 6a9b227 (the commit
|
||||
// immediately before Task 25 -- `git show
|
||||
// 6a9b227:craft/src/components/basic/HtmlBlock.tsx`), run against the real
|
||||
// dompurify+jsdom, not guessed at or re-derived from reading the code. See
|
||||
// the "byte-diff against 6a9b227" describe block below -- this is the
|
||||
// literal regression check the Task 25 review asked for, after the first
|
||||
// round of `.not.toContain(...)`-style tests passed while FORCE_BODY was
|
||||
// silently changing output for a comment-led, style-free fixture.
|
||||
import preTask25FixtureOutput from './__fixtures__/html-block-test-body.pre-task25-output.html?raw';
|
||||
|
||||
describe('purifyHtml', () => {
|
||||
test('strips script tags', () => {
|
||||
@@ -17,7 +29,372 @@ describe('purifyHtml', () => {
|
||||
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');
|
||||
test('allows form/input (Task 24: forms are a deliberate escape-hatch addition) but still strips on*/script inside them', () => {
|
||||
const out = purifyHtml('<form><input name="x" onfocus="bad()"><script>alert(1)</script></form>');
|
||||
expect(out).toContain('<form');
|
||||
expect(out).toContain('<input name="x">');
|
||||
expect(out).not.toContain('onfocus');
|
||||
expect(out).not.toContain('<script');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml markup path (C1 review finding)', () => {
|
||||
test('a style attribute survives sanitization (colour picker output must not be silently dropped)', () => {
|
||||
const out = purifyHtml('<p style="color: #ff0000">red text</p>');
|
||||
expect(out).toBe('<p style="color: #ff0000">red text</p>');
|
||||
});
|
||||
|
||||
test('an id attribute survives sanitization (anchor targets)', () => {
|
||||
const out = purifyHtml('<a href="#section" id="section">link</a>');
|
||||
expect(out).toContain('id="section"');
|
||||
});
|
||||
|
||||
test('a pasted table survives sanitization', () => {
|
||||
const input = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||
expect(purifyHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
test('script tags still do not survive alongside a style attribute', () => {
|
||||
const out = purifyHtml('<p style="color:#ff0000">ok</p><script>alert(1)</script>');
|
||||
expect(out).not.toContain('<script');
|
||||
expect(out).toContain('style="color:#ff0000"');
|
||||
});
|
||||
|
||||
test('on* handlers still do not survive on an element that also carries style', () => {
|
||||
const out = purifyHtml('<p style="color:#ff0000" onclick="bad()">x</p>');
|
||||
expect(out).not.toContain('onclick');
|
||||
expect(out).toContain('style="color:#ff0000"');
|
||||
});
|
||||
|
||||
test('javascript: URLs still do not survive on an element that also carries style', () => {
|
||||
const out = purifyHtml('<a style="color:#ff0000" href="javascript:void(0)">x</a>');
|
||||
expect(out).not.toContain('javascript:');
|
||||
expect(out).toContain('style="color:#ff0000"');
|
||||
});
|
||||
});
|
||||
|
||||
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>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml -- Task 25: block-scoped <style> support', () => {
|
||||
test('a block with no <style> at all is untouched: no wrapper div added', () => {
|
||||
const out = purifyHtml('<p>hello</p>');
|
||||
expect(out).toBe('<p>hello</p>');
|
||||
expect(out).not.toContain('<div');
|
||||
});
|
||||
|
||||
test('blocks WITHOUT <style> are byte-identical to pre-Task-25 output (no wrapper regression)', () => {
|
||||
// Same representative inputs the Task 24 suite already pins to an exact
|
||||
// string -- re-asserted here under the Task 25 name so a future change
|
||||
// that starts wrapping every block (not just style-bearing ones) fails
|
||||
// loudly and obviously, not just as a Task 24 side-effect.
|
||||
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
|
||||
const table = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||
expect(purifyHtml(table)).toBe(table);
|
||||
expect(purifyHtml('<a href="/x">x</a>')).toBe('<a href="/x">x</a>');
|
||||
});
|
||||
|
||||
test('the full Task 24 fixture (no <style> in it) produces no wrapper and is unaffected', () => {
|
||||
// The fixture is the broadest real-world stand-in this repo has for
|
||||
// "a customer's actual pasted block". It contains no <style>, so this
|
||||
// is the closest thing to a real before/after diff over a large,
|
||||
// realistic input: the only lever Task 25 pulled (allowing <style> +
|
||||
// FORCE_BODY) must produce PRECISELY the same output as before for
|
||||
// content that never touches that lever.
|
||||
const out = purifyHtml(fixtureHtml);
|
||||
expect(out).not.toContain('<div class="whp-html-');
|
||||
expect(out).not.toMatch(/<style[\s>]/i); // still no bare <style> in this fixture
|
||||
});
|
||||
|
||||
test('an empty <style></style> (no CSS content) does not trigger a wrapper', () => {
|
||||
const out = purifyHtml('<p>hi</p><style></style>');
|
||||
expect(out).not.toContain('<div class="whp-html-');
|
||||
});
|
||||
|
||||
test('a whitespace-only <style> does not trigger a wrapper', () => {
|
||||
const out = purifyHtml('<p>hi</p><style> \n </style>');
|
||||
expect(out).not.toContain('<div class="whp-html-');
|
||||
});
|
||||
|
||||
test('a block WITH real <style> content gets wrapped in a scope-class div', () => {
|
||||
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
|
||||
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
|
||||
expect(out).toContain('<h1>Hi</h1>');
|
||||
});
|
||||
|
||||
test('the style content is rewritten to only match inside the wrapper (the actual leak-prevention property)', () => {
|
||||
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
|
||||
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||
expect(out).toContain(`.${scopeClass} h1 { color: red; }`);
|
||||
// The bare, unscoped rule must not appear anywhere in the output --
|
||||
// that's exactly the leak this feature exists to close.
|
||||
expect(out).not.toContain('<style>h1 { color: red; }</style>');
|
||||
});
|
||||
|
||||
test('scope class is deterministic: the SAME code produces the SAME class across repeated calls', () => {
|
||||
const code = '<style>p { color: blue; }</style><p>x</p>';
|
||||
const out1 = purifyHtml(code);
|
||||
const out2 = purifyHtml(code);
|
||||
expect(out1).toBe(out2);
|
||||
const class1 = out1.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||
const class2 = out2.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||
expect(class1).toBe(class2);
|
||||
});
|
||||
|
||||
test('pinned scope class for a known input -- guards against silent hash-function drift', () => {
|
||||
// If this ever needs to change, it means the hash function itself
|
||||
// changed -- which would silently churn every stored site's HTML on
|
||||
// next save and desync already-published pages from a fresh Preview.
|
||||
// That should be a loud, deliberate decision, not a side-effect of an
|
||||
// unrelated refactor -- hence pinning the literal output here.
|
||||
const code = '<style>h1{color:red}</style>';
|
||||
expect(stableHash(code)).toBe('5fwbyn');
|
||||
const out = purifyHtml(code);
|
||||
expect(out).toContain('class="whp-html-5fwbyn"');
|
||||
});
|
||||
|
||||
test('scope class is a pure function of `code` -- does not depend on Craft node id or call order', () => {
|
||||
// purifyHtml's signature only ever takes the code string -- there is no
|
||||
// node id parameter it could even reach for. This test documents that
|
||||
// invariant so a future refactor threading a node id through here (as
|
||||
// html-export.ts's renderNode already does for OTHER components, see
|
||||
// its `scopeId` comment) doesn't silently get wired into this path too.
|
||||
const codeA = '<style>h1 { color: red; }</style><h1>same content</h1>';
|
||||
const codeB = '<style>h1 { color: red; }</style><h1>same content</h1>';
|
||||
expect(codeA).toBe(codeB); // sanity: truly identical strings
|
||||
const outA = purifyHtml(codeA);
|
||||
const outB = purifyHtml(codeB);
|
||||
expect(outA).toBe(outB);
|
||||
});
|
||||
|
||||
test('FORCE_BODY regression: a block whose ENTIRE code is a leading <style> (nothing before it) still survives', () => {
|
||||
// Without FORCE_BODY, DOMPurify parses `code` via DOMParser as a mini
|
||||
// HTML document and serializes only <body>. Per the HTML5 parsing
|
||||
// algorithm, a <style> tag with nothing before it is implicitly placed
|
||||
// in the parser's <head>, which DOMPurify's body-only serialization
|
||||
// never looks at -- the whole block would silently vanish. Confirmed
|
||||
// empirically against dompurify+jsdom directly before this fix existed.
|
||||
const out = purifyHtml('<style>h1{color:red}</style>');
|
||||
expect(out).toContain('<style>');
|
||||
expect(out).toContain('color:red');
|
||||
});
|
||||
|
||||
test('FORCE_BODY regression: leading <style> immediately followed by markup, both survive', () => {
|
||||
const out = purifyHtml('<style>h1{color:red}</style><h1>Hi</h1>');
|
||||
expect(out).toContain('<h1>Hi</h1>');
|
||||
expect(out).toMatch(/<style>[\s\S]*color:\s*red/);
|
||||
});
|
||||
|
||||
test(':root / html / body inside a block map to the block wrapper itself (end-to-end through purifyHtml)', () => {
|
||||
const out = purifyHtml('<style>:root { --brand: red; } body { margin: 0; }</style><p>x</p>');
|
||||
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||
expect(out).toContain(`.${scopeClass} { --brand: red; }`);
|
||||
expect(out).toContain(`.${scopeClass} { margin: 0; }`);
|
||||
});
|
||||
|
||||
test('@import is stripped end-to-end (network-fetch/exfiltration channel)', () => {
|
||||
const out = purifyHtml('<style>@import url("https://evil.example/x.css"); h1{color:red}</style><h1>x</h1>');
|
||||
expect(out).not.toContain('@import');
|
||||
expect(out).not.toContain('evil.example');
|
||||
expect(out).toContain('color:red');
|
||||
});
|
||||
|
||||
test('@keyframes body is not scoped (animation would otherwise break) -- end-to-end through purifyHtml', () => {
|
||||
const out = purifyHtml(
|
||||
'<style>@keyframes spin { from { opacity: 0; } to { opacity: 1; } }</style><h1>x</h1>',
|
||||
);
|
||||
expect(out).toContain('@keyframes spin');
|
||||
expect(out).toMatch(/@keyframes spin\s*\{\s*from\s*\{\s*opacity:\s*0;?\s*\}\s*to\s*\{\s*opacity:\s*1;?\s*\}\s*\}/);
|
||||
});
|
||||
|
||||
test('multiple <style> blocks in one Custom HTML block are each scoped under the SAME class', () => {
|
||||
const out = purifyHtml('<style>h1{color:red}</style><h1>A</h1><style>p{color:blue}</style><p>B</p>');
|
||||
const classes = [...out.matchAll(/class="(whp-html-[0-9a-z]+)"/g)].map((m) => m[1]);
|
||||
expect(classes.length).toBeGreaterThanOrEqual(1);
|
||||
expect(new Set(classes).size).toBe(1); // same block -> same scope class everywhere
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml -- Task 25: security properties of the newly-allowed <style>', () => {
|
||||
test('</style> inside a CSS comment cannot break out into executable markup', () => {
|
||||
const out = purifyHtml(
|
||||
'<style>/* </style><script>alert(1)</script> */ h1{color:red}</style><p>hi</p>',
|
||||
);
|
||||
expect(out).not.toContain('<script');
|
||||
expect(out).not.toMatch(/on[a-z]+\s*=/i);
|
||||
});
|
||||
|
||||
test('</style> inside a CSS string cannot break out into executable markup', () => {
|
||||
const out = purifyHtml(
|
||||
'<style>h1::before{content:"</style><script>alert(1)</script>"}</style><p>hi</p>',
|
||||
);
|
||||
expect(out).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('script/on*/javascript: are still stripped from markup sitting alongside a styled block', () => {
|
||||
const out = purifyHtml(
|
||||
'<style>h1{color:red}</style><p onclick="alert(1)">x</p><script>alert(2)</script><a href="javascript:alert(3)">y</a>',
|
||||
);
|
||||
expect(out).not.toMatch(/onclick/i);
|
||||
expect(out).not.toContain('<script');
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('iframe sandboxing still applies alongside a styled block', () => {
|
||||
const out = purifyHtml('<style>h1{color:red}</style><iframe src="https://example.com/"></iframe>');
|
||||
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||
});
|
||||
|
||||
test(
|
||||
'documented reality: DOMPurify does not sanitize CSS declaration values -- ' +
|
||||
'expression()/behavior/-moz-binding pass through untouched (dead in modern browsers, ' +
|
||||
'not exploitable there, but not filtered by this pipeline either)',
|
||||
() => {
|
||||
const out = purifyHtml(
|
||||
'<style>div{width:expression(alert(1));behavior:url(evil.htc);-moz-binding:url(evil.xml#x)}</style><div>x</div>',
|
||||
);
|
||||
expect(out).toContain('expression(alert(1))');
|
||||
expect(out).toContain('behavior:url(evil.htc)');
|
||||
expect(out).toContain('-moz-binding:url(evil.xml#x)');
|
||||
},
|
||||
);
|
||||
|
||||
test('documented reality: url() to a remote host survives (legitimate for background-image, but a known CSS-exfiltration channel already accepted elsewhere in this config)', () => {
|
||||
const out = purifyHtml('<style>div{background:url(https://tracker.example/pixel.png)}</style><div>x</div>');
|
||||
expect(out).toContain('tracker.example');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml -- review finding: raw byte-diff against HtmlBlock.tsx@6a9b227 (the commit before Task 25)', () => {
|
||||
// Round 1 of this task's tests used `.not.toContain(...)`/`.toContain(...)`
|
||||
// assertions for the "no <style> => unchanged" guarantee. Those all
|
||||
// passed while FORCE_BODY: true (applied unconditionally at the time)
|
||||
// was silently changing the ACTUAL bytes for any style-free block that
|
||||
// starts with a multi-line HTML comment -- including this repo's own
|
||||
// fixture, which is exactly that shape. `.not.toContain` can't catch an
|
||||
// extra leading newline; only a raw diff against the real old output
|
||||
// can. These tests do that: `preTask25FixtureOutput` is
|
||||
// `purifyHtml(fixtureHtml)` computed with the UNMODIFIED HtmlBlock.tsx
|
||||
// source at 6a9b227 (via `git show 6a9b227:...`), run against the real
|
||||
// dompurify+jsdom, not re-derived from reading the code -- see that
|
||||
// fixture file's own header comment.
|
||||
test('the fixture (comment-led, no <style>) is byte-identical to the pre-Task-25 output', () => {
|
||||
expect(fixtureHtml.startsWith('<!--')).toBe(true); // sanity: this IS the comment-led shape
|
||||
expect(purifyHtml(fixtureHtml)).toBe(preTask25FixtureOutput);
|
||||
});
|
||||
|
||||
test('a short comment-led, style-free block matches pre-Task-25 output exactly (including the dropped leading whitespace quirk)', () => {
|
||||
// Confirmed independently against 6a9b227's exact code: a multi-line
|
||||
// leading comment followed by blank-line whitespace, with no <style>
|
||||
// anywhere, produces "<p>hi</p>" -- both the comment AND the
|
||||
// whitespace between it and <p> are dropped by the parser's
|
||||
// "before head" insertion-mode rules (unrelated to this task; that's
|
||||
// the pre-existing, unconditional behavior with FORCE_BODY off). The
|
||||
// point of this test is that the NEW code must reproduce that exact
|
||||
// old quirk byte-for-byte for style-free input, not "improve" on it.
|
||||
const commentLed =
|
||||
'<!-- ============================================================\n' +
|
||||
' HTML test fixture header\n' +
|
||||
' ============================================================ -->\n' +
|
||||
'\n<p>hi</p>';
|
||||
expect(purifyHtml(commentLed)).toBe('<p>hi</p>');
|
||||
});
|
||||
|
||||
test('plain style-free inputs (no comment involved) still match pre-Task-25 output', () => {
|
||||
expect(purifyHtml('<p>hello</p>')).toBe('<p>hello</p>');
|
||||
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml -- review finding: never throws, even on pathological deeply-nested @media input', () => {
|
||||
function buildDeeplyNestedMedia(count: number): string {
|
||||
// ~7000 nested @media blocks (the review's exact repro shape) reproduced
|
||||
// through the REAL purifyHtml() call, not just scopeCss() in isolation
|
||||
// -- proving the fix holds end-to-end through DOMPurify + scopeStyleBlocks,
|
||||
// not merely in the unit-tested function.
|
||||
let css = 'h1{color:red}';
|
||||
for (let i = 0; i < count; i++) css = `@media (min-width: 1px) {${css}}`;
|
||||
return `<style>${css}</style><h1>x</h1>`;
|
||||
}
|
||||
|
||||
test('~7000 levels of nested @media does not crash purifyHtml (was: RangeError: Maximum call stack size exceeded)', () => {
|
||||
const code = buildDeeplyNestedMedia(7000);
|
||||
expect(() => purifyHtml(code)).not.toThrow();
|
||||
const out = purifyHtml(code);
|
||||
expect(out).toContain('<h1>x</h1>');
|
||||
expect(out).toContain('@media');
|
||||
});
|
||||
|
||||
test('a scope class + wrapper is still produced for the pathological input (best-effort, not a silent no-op)', () => {
|
||||
const code = buildDeeplyNestedMedia(7000);
|
||||
const out = purifyHtml(code);
|
||||
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml -- review finding: idempotent over its own prior output', () => {
|
||||
test('running purifyHtml() twice (customer pastes previously-published output into a fresh block) does not nest a second wrapper', () => {
|
||||
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
|
||||
const once = purifyHtml(code);
|
||||
const twice = purifyHtml(once);
|
||||
expect(twice).toBe(once);
|
||||
// Specifically: no second wrapper div, no double-prefixed selector.
|
||||
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
test('idempotent for a block using :root/media too', () => {
|
||||
const code = '<style>:root{--x:1} @media (min-width: 600px) { h1, p { color: red; } }</style><h1>Hi</h1><p>x</p>';
|
||||
const once = purifyHtml(code);
|
||||
const twice = purifyHtml(once);
|
||||
expect(twice).toBe(once);
|
||||
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
|
||||
});
|
||||
|
||||
test('three generations (paste published output into a block, publish again, paste THAT) stay stable', () => {
|
||||
const code = '<style>h1{color:red}</style><h1>Hi</h1>';
|
||||
const gen1 = purifyHtml(code);
|
||||
const gen2 = purifyHtml(gen1);
|
||||
const gen3 = purifyHtml(gen2);
|
||||
expect(gen3).toBe(gen1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { HtmlBlock, purifyHtml } 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>');
|
||||
});
|
||||
});
|
||||
|
||||
test('toHtml never emits the style prop (the other half of the render/export contract)', () => {
|
||||
const out = (HtmlBlock as any).toHtml(
|
||||
{ code: '<p>hi</p>', style: { backgroundColor: '#ff0000', padding: '40px' } },
|
||||
'',
|
||||
);
|
||||
expect(out.html).toBe('<p>hi</p>');
|
||||
expect(out.html).not.toContain('background');
|
||||
expect(out.html).not.toContain('40px');
|
||||
});
|
||||
|
||||
describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
|
||||
test('a style attribute inside `code` (e.g. from the toolbar colour picker) reaches exported output', () => {
|
||||
const { html } = toHtml({ code: '<p style="color: #ff0000">red text</p>' }, '');
|
||||
expect(html).toBe('<p style="color: #ff0000">red text</p>');
|
||||
});
|
||||
|
||||
test('a table inside `code` reaches exported output', () => {
|
||||
const code = '<table><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||
const { html } = toHtml({ code }, '');
|
||||
expect(html).toBe(code);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HtmlBlock.toHtml -- Task 25: block-scoped <style>, and editor/export byte-parity', () => {
|
||||
test('a <style>-bearing block exports the same scoped wrapper purifyHtml() would produce in the editor canvas', () => {
|
||||
// The editor canvas (HtmlBlock component) and toHtml() (Preview +
|
||||
// Published export) both call the exact same purifyHtml(code) -- this
|
||||
// is the byte-parity invariant this project treats as a hard
|
||||
// requirement. Proven here by calling purifyHtml directly (as the
|
||||
// canvas's useMemo does) and toHtml (as export does) on the identical
|
||||
// code string and asserting the two never diverge.
|
||||
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
|
||||
const { html } = toHtml({ code }, '');
|
||||
expect(html).toBe(purifyHtml(code));
|
||||
});
|
||||
|
||||
test('a <style>-free block still exports byte-identical to pre-Task-25 output (no wrapper regression) via toHtml', () => {
|
||||
const code = '<p>hello</p>';
|
||||
const { html } = toHtml({ code }, '');
|
||||
expect(html).toBe('<p>hello</p>');
|
||||
expect(html).not.toContain('<div');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { CSSProperties, useMemo } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { stableHash } from '../../utils/escape';
|
||||
import { scopeCss } from '../../utils/scope-css';
|
||||
|
||||
interface HtmlBlockProps {
|
||||
code: string;
|
||||
@@ -9,116 +11,378 @@ interface HtmlBlockProps {
|
||||
node_id?: string;
|
||||
}
|
||||
|
||||
// Task 24: widening the allow-list after a customer's broad HTML fixture
|
||||
// showed 38% of it silently deleted (tables losing colspan/rowspan/scope,
|
||||
// <dl>/<sub>/<details>/inline <svg>/<video>/<audio> dropped wholesale,
|
||||
// lang/dir/role stripped, <ol start/reversed> flattened). The owner's call:
|
||||
// be generous -- this block is an explicit escape hatch and customers
|
||||
// reasonably expect it to render ordinary HTML, including forms. The four
|
||||
// non-negotiables (no <script>, no on*, no javascript: URLs, iframes stay
|
||||
// sandboxed) are unaffected by the widening and are covered by dedicated
|
||||
// tests in HtmlBlock.test.ts / HtmlBlock.security.test.ts.
|
||||
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',
|
||||
'h1','h2','h3','h4','h5','h6','hgroup',
|
||||
'em','strong','b','i','u','s',
|
||||
'blockquote','code','pre',
|
||||
'img','figure','figcaption',
|
||||
'iframe',
|
||||
// Tables: pasted content commonly includes these; dropping them
|
||||
// silently ate customer-pasted tables (see C1 review finding).
|
||||
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
|
||||
// Text semantics (Task 24).
|
||||
'sub','sup','small','mark','del','ins','abbr','cite','q','time','data',
|
||||
'kbd','samp','var','dfn','address','bdi','bdo','ruby','rt','rp','wbr',
|
||||
// Lists (Task 24).
|
||||
'dl','dt','dd','menu',
|
||||
// Disclosure widget (Task 24). Note: <dialog> and <template> are
|
||||
// deliberately NOT added -- the fixture exercises them wrapped in
|
||||
// on*= handlers specifically to prove they still get neutralized/
|
||||
// dropped by staying outside the allow-list.
|
||||
'details','summary',
|
||||
// Media (Task 24). URL-bearing attributes on these (poster, srcset,
|
||||
// action, cite...) go through the ALLOWED_URI_REGEXP gate like
|
||||
// everything else -- see _isValidAttribute in dompurify, which
|
||||
// URI-checks every allowed attribute value except a small fixed
|
||||
// "inert" list (alt, class, id, style, title, ...) that never includes
|
||||
// src/poster/srcset. The one exception: `src` itself on img/video/
|
||||
// audio/source/image/track is additionally covered by DOMPurify's own
|
||||
// `DATA_URI_TAGS` allow-list, which accepts any data: URI on those
|
||||
// tag/attribute pairs regardless of mimetype, bypassing this regex --
|
||||
// see the ALLOWED_URI_REGEXP comment below and
|
||||
// HtmlBlock.security.test.ts. Not a gap in the four non-negotiables:
|
||||
// none of those tags execute their src as a document.
|
||||
'picture','source','video','audio','track','canvas',
|
||||
// Forms (Task 24). Site owner's explicit decision: allow the full
|
||||
// ordinary form surface. No on*= survives (FORBID_ATTR below), and
|
||||
// action/formaction-style URLs are gated by ALLOWED_URI_REGEXP the
|
||||
// same as href/src, so `javascript:` still cannot survive here either.
|
||||
'form','input','button','select','option','optgroup','textarea',
|
||||
'label','fieldset','legend','datalist','output','progress','meter',
|
||||
// Inline SVG (Task 24) -- see the block comment on IFRAME_SANDBOX_HOOK's
|
||||
// neighbor below for why this is an explicit tag list rather than
|
||||
// DOMPurify's USE_PROFILES svg profile. Deliberately excludes <use> and
|
||||
// <image> (both need xlink:href, an external-reference vector DOMPurify
|
||||
// itself excludes from its own SVG defaults) and <a>/<foreignObject>
|
||||
// (not needed by the fixture; foreignObject can embed arbitrary HTML).
|
||||
'svg','g','defs','symbol','title','desc','rect','circle','ellipse',
|
||||
'line','polyline','polygon','path','text','tspan',
|
||||
'lineargradient','radialgradient','stop','clippath','mask','marker',
|
||||
'pattern','switch','view',
|
||||
// Task 25: block-scoped <style> support. Formerly in FORBID_TAGS
|
||||
// (stripped entirely). Now allowed through sanitisation -- its CSS is
|
||||
// rewritten by scopeStyleBlocks()/scopeCss() below, immediately after
|
||||
// DOMPurify runs, so it can only match inside this block's own wrapper
|
||||
// element. See the FORCE_BODY comment below and scopeStyleBlocks() for
|
||||
// why allowing the tag alone is not sufficient.
|
||||
//
|
||||
// Review note (Task 25 follow-up, documented not fixed): DOMPurify's
|
||||
// SAFE_FOR_XML default (on unless a caller explicitly disables it,
|
||||
// which PURIFY_CONFIG does not) silently drops an ENTIRE <style>
|
||||
// element -- not just the offending part -- if its text content
|
||||
// contains anything that merely LOOKS tag-like (a `<` followed by a
|
||||
// word character, `/`, or `!`), as an mXSS-namespace-confusion defense
|
||||
// that isn't specific to <style>. So `.x::after{content:"<Read
|
||||
// More>"}` -- a plausible, entirely benign real-world CSS content
|
||||
// string -- makes the whole style block vanish with no error, the same
|
||||
// way a `<script>` would. This is a GOOD security property (better
|
||||
// paranoid than exploitable), but it's an undocumented interaction
|
||||
// with this newly-widened surface that will otherwise confuse whoever
|
||||
// debugs the inevitable "my CSS just disappeared" report -- confirmed
|
||||
// empirically against dompurify+jsdom directly, not guessed at.
|
||||
'style',
|
||||
],
|
||||
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
|
||||
// allowlist rather than extending it, so anything the product needs
|
||||
// (style, id, ...) must be listed explicitly here even though DOMPurify
|
||||
// would allow it by default.
|
||||
ALLOWED_ATTR: [
|
||||
'href','src','alt','title','target','rel',
|
||||
'width','height','class',
|
||||
'width','height','class','id','style',
|
||||
'allowfullscreen','allow','frameborder',
|
||||
'sandbox','referrerpolicy',
|
||||
// Task 24 additions.
|
||||
'colspan','rowspan','scope','headers','span','start','reversed',
|
||||
'type','value','name','placeholder','required','disabled','readonly',
|
||||
'checked','selected','multiple','size','min','max','step','minlength',
|
||||
'maxlength','pattern','rows','cols','accept','action','method','for',
|
||||
'list','label','datetime','cite','lang','dir','role','srcset','media',
|
||||
'sizes','loading','controls','poster','loop','muted','autoplay',
|
||||
'preload','playsinline','kind','srclang','default','open','download',
|
||||
'hidden','contenteditable',
|
||||
// Bug fix: <select size="4">/<input size> and <meter low/high/optimum>
|
||||
// were still being stripped even though <select>/<meter> are already in
|
||||
// ALLOWED_TAGS -- only these four attribute names were missing here.
|
||||
// Effect: a multi-select rendered at default height instead of the
|
||||
// requested row count, and <meter> lost its threshold-based gauge
|
||||
// colouring. Pure presentation/semantic attributes -- no URL, no
|
||||
// script, no event-handler surface -- so no security weight added.
|
||||
'low','high','optimum',
|
||||
// SVG presentation attributes (explicit route -- see ALLOWED_TAGS
|
||||
// comment on the SVG tag list). Covers the fixture's <svg viewBox
|
||||
// role>/<rect>/<circle>/<text> block plus the common presentation
|
||||
// attributes for the shapes/gradients allowed above. Deliberately
|
||||
// excludes xlink:href (no <use>/<image> allowed, so it has nothing
|
||||
// legitimate to attach to) and the SMIL/animation attributes (begin,
|
||||
// dur, repeatCount, ...) which DOMPurify's own SVG defaults exclude
|
||||
// for the same reason on* handlers are excluded.
|
||||
'viewbox','cx','cy','r','rx','ry','x','y','x1','y1','x2','y2',
|
||||
'points','d','fill','stroke','stroke-width','stroke-linecap',
|
||||
'stroke-linejoin','stroke-dasharray','fill-rule','clip-rule','opacity',
|
||||
'fill-opacity','stroke-opacity','text-anchor','dominant-baseline',
|
||||
'font-family','font-size','font-weight','transform','offset',
|
||||
'stop-color','stop-opacity','gradientunits','gradienttransform',
|
||||
'preserveaspectratio',
|
||||
],
|
||||
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'],
|
||||
// Review fix (Task 24 follow-up): the data:image arm used to sit inside
|
||||
// the group that gets a trailing `:` appended for every alternative
|
||||
// (`(?:https?|mailto|tel|data:image\/[a-z]+;base64,):`), so it required
|
||||
// a SECOND colon after the one already in "base64,figure" -- no real
|
||||
// data URI has that, so the clause could never match. It is now its own
|
||||
// top-level alternative. NOTE: this regex is not the only thing gating
|
||||
// data: URIs -- DOMPurify has its own internal `DATA_URI_TAGS` allow-list
|
||||
// (img/video/audio/source/image/track) that accepts ANY data: URI on
|
||||
// those tag/attribute pairs regardless of declared mimetype, bypassing
|
||||
// this regex entirely. See HtmlBlock.security.test.ts for a regression
|
||||
// test documenting that (acceptable: none of those tags execute their
|
||||
// src as a document in mainstream browsers, and <iframe> -- which would
|
||||
// be dangerous -- is correctly not in that DOMPurify list).
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|data:image\/[a-z]+;base64,|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||
// form/input/button/select/textarea removed from FORBID_TAGS (Task 24) --
|
||||
// they are now deliberately allowed above. script/object/embed/link/meta
|
||||
// stay forbidden. <style> (Task 25) is now allowed too -- see ALLOWED_TAGS
|
||||
// comment above and scopeStyleBlocks() below; it survives sanitisation
|
||||
// here but its CSS gets scoped afterwards, including copies nested inside
|
||||
// the newly-allowed inline <svg> (querySelectorAll('style') in
|
||||
// scopeStyleBlocks() doesn't care about namespace/nesting depth).
|
||||
FORBID_TAGS: ['script','object','embed','link','meta'],
|
||||
FORBID_ATTR: [/^on/i],
|
||||
// NOTE: FORCE_BODY is deliberately NOT set here -- see
|
||||
// needsForceBody()/purifyHtml() below. It's applied conditionally, per
|
||||
// call, only when the input actually has a real <style> tag to rescue.
|
||||
};
|
||||
|
||||
export function purifyHtml(input: string): string {
|
||||
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
|
||||
// Task 25: without FORCE_BODY, DOMPurify parses `input` as a full (mini)
|
||||
// HTML document via DOMParser and only serializes <body>'s contents. Per
|
||||
// the HTML5 parsing algorithm, a tag that can only legally appear in
|
||||
// <head> -- and now that <style> is allowed, that includes <style> --
|
||||
// gets implicitly placed in <head> when it appears before any other real
|
||||
// content, and is silently lost (DOMPurify never looks at <head>). A block
|
||||
// whose entire `code` is `<style>h1{color:red}</style>` -- a very
|
||||
// plausible paste, style-before-markup is a common snippet shape -- would
|
||||
// vanish with no error anywhere, despite <style> sitting right there in
|
||||
// ALLOWED_TAGS. FORCE_BODY prepends an internal element before parsing so
|
||||
// the parser is already in body-insertion-mode by the time it reaches the
|
||||
// customer's first tag, keeping a leading <style> (or anything else) in
|
||||
// <body> where DOMPurify's body-only serialization actually looks.
|
||||
// Confirmed empirically against dompurify+jsdom directly (not just this
|
||||
// app's behavior) -- see the "leading <style> with nothing before it" test
|
||||
// in HtmlBlock.test.ts.
|
||||
//
|
||||
// Review finding (Task 25 follow-up): FORCE_BODY is NOT a no-op for input
|
||||
// that has no <style> tag at all. It also changes how the HTML parser
|
||||
// treats character content sitting between a LEADING comment and the next
|
||||
// real tag -- normal parsing (before <body> is established) silently drops
|
||||
// pure-whitespace text runs there per the HTML5 "before head" insertion
|
||||
// mode rules, while FORCE_BODY (already in body-insertion-mode from the
|
||||
// first token) preserves that whitespace as a real text node. Concretely:
|
||||
// a block starting with a multi-line HTML comment -- this repo's own
|
||||
// ~16KB fixture does exactly that -- gained 2 extra leading bytes (a
|
||||
// preserved newline) once FORCE_BODY was unconditionally on, which
|
||||
// silently broke the "blocks without <style> are byte-identical to
|
||||
// pre-Task-25 output" guarantee (confirmed with a raw diff against
|
||||
// HtmlBlock.tsx@6a9b227 -- the commit immediately before this task -- over
|
||||
// the fixture and a comment-led block; see HtmlBlock.test.ts). Fix: only
|
||||
// ever set FORCE_BODY when the input has a real <style> tag to rescue --
|
||||
// the one and only case that needs it -- so every other input takes
|
||||
// exactly the pre-Task-25 code path, unchanged.
|
||||
//
|
||||
// "Real" deliberately excludes a `<style` substring that only appears
|
||||
// inside an HTML comment (e.g. a customer's own code-sample text
|
||||
// mentioning `<style>`) -- that text can never become an actual <style>
|
||||
// element, but naively substring-matching it would still flip FORCE_BODY
|
||||
// on and reintroduce the exact same whitespace-preservation side effect
|
||||
// for a block that never had, and never needed, real style scoping.
|
||||
const STYLE_TAG_RE = /<style[\s>/]/i;
|
||||
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
||||
function needsForceBody(input: string): boolean {
|
||||
return STYLE_TAG_RE.test(input.replace(HTML_COMMENT_RE, ''));
|
||||
}
|
||||
|
||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', 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');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Task 25: rewrite any surviving `<style>` element(s) in `sanitized` (the
|
||||
* DOMPurify output) so their CSS only matches inside this block's own
|
||||
* wrapper element, then wrap the whole thing in that wrapper.
|
||||
*
|
||||
* Deliberately does the LEAST work possible when there's nothing to scope:
|
||||
* a cheap substring check bails out before touching the DOM at all, so a
|
||||
* block that doesn't use <style> -- i.e. every block saved before this task
|
||||
* -- gets `sanitized` back completely unchanged (same string, no wrapper,
|
||||
* no re-serialization round-trip that could subtly reformat attributes).
|
||||
* That byte-for-byte identity is a hard requirement: published pages
|
||||
* already contain `toHtml()` output with NO wrapper element, and adding one
|
||||
* unconditionally would silently change the DOM/box-model of every
|
||||
* existing customer block. See HtmlBlock.test.ts's
|
||||
* "blocks without <style> are byte-identical" tests, which run this
|
||||
* against real fixture content and diff the exact string.
|
||||
*
|
||||
* Scope identifier: `whp-html-${stableHash(rawCode)}` -- `stableHash` is
|
||||
* the existing djb2 hash from utils/escape.ts (already used for this exact
|
||||
* class of problem, see `scopeId` in that file), applied to `rawCode` --
|
||||
* the block's own `code` prop, nothing else. Pure function of the block's
|
||||
* own content: no Math.random, no Date.now, no counter, and deliberately
|
||||
* NOT the Craft node id (unlike `scopeId`), because a scope identifier that
|
||||
* depends on anything outside `code` would make the editor canvas preview
|
||||
* (which calls purifyHtml(code) on render) and the published output (which
|
||||
* calls the same purifyHtml(code) at publish time) diverge whenever that
|
||||
* outside thing differs between the two call sites, and would make the
|
||||
* stored HTML churn on every save even when the block's own content didn't
|
||||
* change. Hashing `code` guarantees purifyHtml(code) is fully deterministic
|
||||
* on its own -- same code in, byte-identical output out, every time, in
|
||||
* both places it's called.
|
||||
*/
|
||||
const SCOPE_CLASS_RE = /^whp-html-[0-9a-z]+$/;
|
||||
|
||||
/**
|
||||
* Idempotency (review finding, Task 25 follow-up): `purifyHtml()` is not
|
||||
* reachable-with-its-own-output through any CURRENT code path, but nothing
|
||||
* stops a customer from pasting previously-published or exported HTML from
|
||||
* this exact feature into a fresh Custom HTML block -- at which point
|
||||
* `code` already contains our own `<div class="whp-html-OLD">...<style>
|
||||
* .whp-html-OLD h1{...}</style>...</div>` wrapper. Without this check,
|
||||
* `scopeStyleBlocks` would hash the NEW `code` to a NEW scope class, fail
|
||||
* to recognise the embedded selectors as already scoped (they're prefixed
|
||||
* for the OLD class, not the new one `scopeCss`'s own idempotency guard
|
||||
* checks against), and nest a second wrapper div around the first while
|
||||
* re-prefixing every selector under the new class on top of the old one.
|
||||
*
|
||||
* Detects "the sanitized content IS ALREADY exactly one of our own scoped
|
||||
* wrappers": a single root element, a <div>, whose class matches our own
|
||||
* naming convention, and whose `<style>` descendant(s) are each already a
|
||||
* no-op under `scopeCss` for that div's own class -- i.e. re-scoping would
|
||||
* change nothing. That last check reuses `scopeCss`'s own idempotency
|
||||
* guarantee (`scopeCss(scopeCss(x, S), S) === scopeCss(x, S)`, proved in
|
||||
* scope-css.test.ts) rather than re-implementing "is this CSS already
|
||||
* scoped" as a second parser: if scoping again under the div's own class
|
||||
* is a no-op, the CSS is already confined to that div, regardless of
|
||||
* whether this app was the one that put it there -- which is the actual
|
||||
* safety property this function exists to guarantee, not merely a proxy
|
||||
* for it.
|
||||
*/
|
||||
function isAlreadyScoped(container: HTMLElement): boolean {
|
||||
if (container.children.length !== 1) return false;
|
||||
const root = container.children[0];
|
||||
if (root.tagName !== 'DIV') return false;
|
||||
const cls = root.getAttribute('class') || '';
|
||||
if (!SCOPE_CLASS_RE.test(cls)) return false;
|
||||
|
||||
const scopeSelector = `.${cls}`;
|
||||
const styleEls = Array.from(root.querySelectorAll('style'));
|
||||
if (styleEls.length === 0) return false; // matches our naming by coincidence but scopes nothing -- not ours to protect
|
||||
|
||||
return styleEls.every((el) => {
|
||||
const text = el.textContent || '';
|
||||
if (text.trim() === '') return true;
|
||||
return scopeCss(text, scopeSelector) === text;
|
||||
});
|
||||
}
|
||||
|
||||
function scopeStyleBlocks(sanitized: string, rawCode: string): string {
|
||||
if (!sanitized.includes('<style')) return sanitized;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = sanitized;
|
||||
|
||||
if (isAlreadyScoped(container)) return sanitized;
|
||||
|
||||
const styleEls = Array.from(container.querySelectorAll('style'));
|
||||
const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== '');
|
||||
if (nonEmpty.length === 0) return sanitized;
|
||||
|
||||
// Review note (Task 25 follow-up, documented not fixed): `stableHash` is
|
||||
// a 32-bit djb2 hash, so it's brute-forceable in principle -- a customer
|
||||
// could deliberately craft a second block's `code` to collide onto the
|
||||
// same `whp-html-<hash>` class as an existing block on the same page, at
|
||||
// which point the two blocks' <style> rules apply to (and override) each
|
||||
// other, since they'd share one wrapper class. Impact is CSS-only --
|
||||
// visual breakage, never script execution or data exposure -- the same
|
||||
// trust tier as other accepted risks in this file (e.g. remote url() in
|
||||
// style content, or the pre-existing DATA_URI_TAGS mimetype-blindness
|
||||
// documented in HtmlBlock.security.test.ts). Not fixed here: closing it
|
||||
// would mean either a wider hash (cheap, but every existing scope class
|
||||
// set with THIS Task 25 code would silently reshuffle -- a similar
|
||||
// "changing the hash function reshuffles stored HTML" cost the pinned
|
||||
// hash test above already guards against happening BY ACCIDENT) or a
|
||||
// collision-checked/salted scheme, either of which is a bigger design
|
||||
// decision than a follow-up-review fix.
|
||||
const scopeClass = `whp-html-${stableHash(rawCode)}`;
|
||||
for (const el of nonEmpty) {
|
||||
el.textContent = scopeCss(el.textContent || '', `.${scopeClass}`);
|
||||
}
|
||||
|
||||
return `<div class="${scopeClass}">${container.innerHTML}</div>`;
|
||||
}
|
||||
|
||||
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 {
|
||||
const raw = input || '';
|
||||
// See needsForceBody()/the FORCE_BODY comment above PURIFY_CONFIG:
|
||||
// applied only when there's a real <style> tag to rescue, so every
|
||||
// other input takes the exact pre-Task-25 sanitize() call, unchanged.
|
||||
const config = needsForceBody(raw) ? { ...PURIFY_CONFIG, FORCE_BODY: true } : PURIFY_CONFIG;
|
||||
const sanitized = DOMPurify.sanitize(raw, config as any) as unknown as string;
|
||||
return scopeStyleBlocks(sanitized, raw);
|
||||
} finally {
|
||||
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
|
||||
}
|
||||
}
|
||||
|
||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '' }) => {
|
||||
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)); };
|
||||
// The `style` prop is deliberately NOT applied. `toHtml()` emits only the
|
||||
// purified `code`, so anything styled here would show in the editor and
|
||||
// vanish on the live page -- the exact bug this block was reported for.
|
||||
// Wrapper styling belongs in the user's own markup (see the Edit HTML
|
||||
// toolbar's colour control). `style` stays on the props interface so
|
||||
// already-saved sites keep deserializing cleanly.
|
||||
return React.createElement('div', {
|
||||
ref: setRef,
|
||||
style: {
|
||||
minHeight: '40px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
},
|
||||
dangerouslySetInnerHTML: { __html: clean },
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------- 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>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
HtmlBlock.craft = {
|
||||
@@ -132,14 +396,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,36 +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: any): string {
|
||||
str = String(str ?? "");
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
@@ -51,6 +34,7 @@ export const Logo: UserComponent<LogoProps> = ({
|
||||
imageSrc = '',
|
||||
imageWidth = '120px',
|
||||
href = '/',
|
||||
download = false,
|
||||
fontFamily = 'Inter, sans-serif',
|
||||
fontSize = '20px',
|
||||
fontWeight = '700',
|
||||
@@ -68,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',
|
||||
@@ -97,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 = {
|
||||
@@ -374,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 ---------- */
|
||||
@@ -394,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',
|
||||
@@ -402,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({
|
||||
@@ -412,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,12 +41,6 @@ const defaultLinks: MenuLink[] = [
|
||||
{ text: 'Contact', href: '#contact', isCta: true },
|
||||
];
|
||||
|
||||
/* ---------- Helper: escape HTML ---------- */
|
||||
function esc(str: any): string {
|
||||
str = String(str ?? "");
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Menu: UserComponent<MenuProps> = ({
|
||||
@@ -81,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)}
|
||||
@@ -105,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 = {
|
||||
@@ -439,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' };
|
||||
|
||||
@@ -478,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,
|
||||
@@ -494,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,31 +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: any): string {
|
||||
str = String(str ?? "");
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ---------- Component ---------- */
|
||||
|
||||
export const Navbar: UserComponent<NavbarProps> = ({
|
||||
@@ -174,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)}
|
||||
@@ -199,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 = {
|
||||
@@ -814,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',
|
||||
@@ -853,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',
|
||||
@@ -861,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
|
||||
@@ -878,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',
|
||||
@@ -915,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: any) => 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,426 @@
|
||||
<!-- ============================================================
|
||||
HTML test fixture — everything below goes inside <body>
|
||||
Unstyled on purpose. No external assets (SVG/data URIs only)
|
||||
except the media/iframe block, which is intentionally broken
|
||||
so you can see fallback behavior.
|
||||
============================================================ -->
|
||||
|
||||
<a href="#main">Skip to content</a>
|
||||
|
||||
<header>
|
||||
<h1>HTML Test Fixture</h1>
|
||||
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
|
||||
<nav aria-label="Primary">
|
||||
<ul>
|
||||
<li><a href="#text">Text</a></li>
|
||||
<li><a href="#lists">Lists</a></li>
|
||||
<li><a href="#tables">Tables</a></li>
|
||||
<li><a href="#forms">Forms</a></li>
|
||||
<li><a href="#media">Media</a></li>
|
||||
<li><a href="#edge">Edge cases</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
|
||||
<!-- ========== HEADINGS ========== -->
|
||||
<section id="headings">
|
||||
<h2>Headings</h2>
|
||||
<h1>Heading level 1</h1>
|
||||
<h2>Heading level 2</h2>
|
||||
<h3>Heading level 3</h3>
|
||||
<h4>Heading level 4</h4>
|
||||
<h5>Heading level 5</h5>
|
||||
<h6>Heading level 6</h6>
|
||||
<hgroup>
|
||||
<h2>Grouped heading</h2>
|
||||
<p>Subtitle paragraph inside hgroup</p>
|
||||
</hgroup>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- ========== TEXT & INLINE ========== -->
|
||||
<section id="text">
|
||||
<h2>Text and inline elements</h2>
|
||||
|
||||
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
|
||||
|
||||
<p>
|
||||
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
|
||||
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
|
||||
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
|
||||
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
|
||||
<samp>output text</samp>, <var>variable</var>,
|
||||
<abbr title="HyperText Markup Language">HTML</abbr>,
|
||||
<dfn>definition term</dfn>,
|
||||
<time datetime="2026-08-09">August 9, 2026</time>,
|
||||
<data value="42">forty-two</data>,
|
||||
<q>short inline quote</q>,
|
||||
<cite>Cited Work</cite>,
|
||||
<bdi>إسم</bdi>,
|
||||
<bdo dir="rtl">reversed direction</bdo>,
|
||||
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Links:
|
||||
<a href="#top">internal anchor</a> ·
|
||||
<a href="https://example.com">absolute</a> ·
|
||||
<a href="/relative/path">relative</a> ·
|
||||
<a href="mailto:test@example.com">mailto</a> ·
|
||||
<a href="tel:+15555550123">tel</a> ·
|
||||
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
|
||||
<a href="#" download>download attr</a>
|
||||
</p>
|
||||
|
||||
<blockquote cite="https://example.com/source">
|
||||
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
|
||||
<blockquote><p>Nested block quotation.</p></blockquote>
|
||||
<footer>— <cite>Someone, Somewhere</cite></footer>
|
||||
</blockquote>
|
||||
|
||||
<pre><code>#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
for i in {1..3}; do
|
||||
printf 'iteration %d\n' "$i"
|
||||
done
|
||||
|
||||
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
</code></pre>
|
||||
|
||||
<p>Line break here,<br>after the break.</p>
|
||||
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
|
||||
|
||||
<address>
|
||||
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
|
||||
123 Nowhere St, Somewhere
|
||||
</address>
|
||||
|
||||
<p>Entities: & < > " ' © ® ™ — … € 😀</p>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- ========== LISTS ========== -->
|
||||
<section id="lists">
|
||||
<h2>Lists</h2>
|
||||
|
||||
<h3>Unordered, nested</h3>
|
||||
<ul>
|
||||
<li>First item</li>
|
||||
<li>Second item
|
||||
<ul>
|
||||
<li>Nested item
|
||||
<ul><li>Deeply nested item</li></ul>
|
||||
</li>
|
||||
<li>Another nested item</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Ordered variants</h3>
|
||||
<ol>
|
||||
<li>Default numbering</li>
|
||||
<li>Second
|
||||
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
|
||||
</li>
|
||||
</ol>
|
||||
<ol start="5" reversed>
|
||||
<li>Reversed, starting at 5</li>
|
||||
<li>Next</li>
|
||||
<li>Next</li>
|
||||
</ol>
|
||||
|
||||
<h3>Description list</h3>
|
||||
<dl>
|
||||
<dt>Term one</dt>
|
||||
<dd>Definition of the first term.</dd>
|
||||
<dt>Term two</dt>
|
||||
<dt>Term two, alias</dt>
|
||||
<dd>Definition covering both terms above.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Menu</h3>
|
||||
<menu>
|
||||
<li><button type="button">Copy</button></li>
|
||||
<li><button type="button">Paste</button></li>
|
||||
</menu>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- ========== TABLES ========== -->
|
||||
<section id="tables">
|
||||
<h2>Tables</h2>
|
||||
|
||||
<table>
|
||||
<caption>Quarterly figures with spans and a footer</caption>
|
||||
<colgroup>
|
||||
<col span="1">
|
||||
<col span="2">
|
||||
<col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Region</th>
|
||||
<th scope="col">Q1</th>
|
||||
<th scope="col">Q2</th>
|
||||
<th scope="col">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">North</th>
|
||||
<td>1,204</td>
|
||||
<td>1,391</td>
|
||||
<td rowspan="2">Shared note spanning two rows</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">South</th>
|
||||
<td>988</td>
|
||||
<td>1,022</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">East</th>
|
||||
<td colspan="2">Merged across two quarters</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th scope="row">Total</th>
|
||||
<td>2,192</td>
|
||||
<td>2,413</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h3>Wide table (horizontal overflow)</h3>
|
||||
<table>
|
||||
<tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
|
||||
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- ========== FORMS ========== -->
|
||||
<section id="forms">
|
||||
<h2>Forms</h2>
|
||||
|
||||
<form action="#" method="get">
|
||||
<fieldset>
|
||||
<legend>Text inputs</legend>
|
||||
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
|
||||
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
|
||||
<datalist id="suggestions">
|
||||
<option value="alpha"></option>
|
||||
<option value="beta"></option>
|
||||
<option value="gamma"></option>
|
||||
</datalist>
|
||||
<p><label for="f-email">Email</label> <input id="f-email" type="email" required></p>
|
||||
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
|
||||
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
|
||||
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
|
||||
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
|
||||
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
|
||||
content
|
||||
here</textarea></p>
|
||||
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly></p>
|
||||
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Date, time, color, range, file</legend>
|
||||
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
|
||||
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
|
||||
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
|
||||
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
|
||||
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
|
||||
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
|
||||
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
|
||||
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple accept=".txt,.md"></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Choices</legend>
|
||||
<p>
|
||||
<label><input type="checkbox" name="c" value="1" checked> Checked</label>
|
||||
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
|
||||
<label><input type="checkbox" name="c" value="3" disabled> Disabled</label>
|
||||
</p>
|
||||
<p>
|
||||
<label><input type="radio" name="r" value="a" checked> Option A</label>
|
||||
<label><input type="radio" name="r" value="b"> Option B</label>
|
||||
</p>
|
||||
<p>
|
||||
<label for="f-select">Select</label>
|
||||
<select id="f-select" name="select">
|
||||
<option value="">— choose —</option>
|
||||
<optgroup label="Group one">
|
||||
<option value="1" selected>One</option>
|
||||
<option value="2">Two</option>
|
||||
</optgroup>
|
||||
<optgroup label="Group two" disabled>
|
||||
<option value="3">Three</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label for="f-multi">Multi-select</label><br>
|
||||
<select id="f-multi" multiple size="4">
|
||||
<option>Red</option><option selected>Green</option><option>Blue</option><option>Violet</option>
|
||||
</select>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Output and buttons</legend>
|
||||
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
|
||||
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
|
||||
<p><output name="result" for="f-num f-range">Computed output</output></p>
|
||||
<p>
|
||||
<button type="submit">Submit</button>
|
||||
<button type="reset">Reset</button>
|
||||
<button type="button">Plain button</button>
|
||||
<button type="button" disabled>Disabled button</button>
|
||||
<input type="submit" value="Input submit">
|
||||
<input type="button" value="Input button">
|
||||
</p>
|
||||
<input type="hidden" name="csrf" value="hidden-value">
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- ========== MEDIA & EMBEDS ========== -->
|
||||
<section id="media">
|
||||
<h2>Media and embeds</h2>
|
||||
|
||||
<h3>Inline SVG</h3>
|
||||
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
|
||||
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
|
||||
</svg>
|
||||
|
||||
<h3>Figure with data-URI image</h3>
|
||||
<figure>
|
||||
<img alt="Small red square"
|
||||
width="64" height="64"
|
||||
src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
|
||||
<figcaption>Figure caption describing the image above.</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3>Broken image (alt-text fallback test)</h3>
|
||||
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
|
||||
|
||||
<h3>Picture element</h3>
|
||||
<picture>
|
||||
<source media="(min-width: 800px)" srcset="wide.png">
|
||||
<source media="(min-width: 400px)" srcset="medium.png">
|
||||
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
|
||||
</picture>
|
||||
|
||||
<h3>Video and audio (sources intentionally missing)</h3>
|
||||
<video controls width="320" poster="poster.jpg">
|
||||
<source src="clip.webm" type="video/webm">
|
||||
<source src="clip.mp4" type="video/mp4">
|
||||
<track kind="captions" src="captions.vtt" srclang="en" label="English">
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
<audio controls>
|
||||
<source src="tone.ogg" type="audio/ogg">
|
||||
<source src="tone.mp3" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<h3>Canvas and iframe</h3>
|
||||
<canvas width="200" height="60">Canvas fallback text</canvas>
|
||||
<iframe title="Sandboxed iframe" src="about:blank" width="300" height="120" sandbox loading="lazy"></iframe>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- ========== INTERACTIVE / SEMANTIC ========== -->
|
||||
<section id="interactive">
|
||||
<h2>Interactive and semantic containers</h2>
|
||||
|
||||
<details>
|
||||
<summary>Collapsed disclosure</summary>
|
||||
<p>Hidden content revealed on toggle.</p>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>Open disclosure</summary>
|
||||
<ul><li>With a list inside</li><li>Second item</li></ul>
|
||||
</details>
|
||||
|
||||
<dialog id="test-dialog">
|
||||
<p>Non-modal dialog content.</p>
|
||||
<button type="button" onclick="this.closest('dialog').close()">Close</button>
|
||||
</dialog>
|
||||
<button type="button" onclick="document.getElementById('test-dialog').show()">Open dialog</button>
|
||||
|
||||
<article>
|
||||
<header><h3>Article header</h3></header>
|
||||
<p>Article body content.</p>
|
||||
<aside><p>An aside nested inside the article.</p></aside>
|
||||
<footer><p>Article footer.</p></footer>
|
||||
</article>
|
||||
|
||||
<p><span contenteditable="true">Editable inline region</span></p>
|
||||
<p hidden>This paragraph has the hidden attribute and should not render.</p>
|
||||
|
||||
<template id="tpl">
|
||||
<p>Template content — must not render until cloned.</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- ========== EDGE CASES ========== -->
|
||||
<section id="edge">
|
||||
<h2>Edge cases</h2>
|
||||
|
||||
<p>Very long unbroken token (overflow test):</p>
|
||||
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
|
||||
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&another=2&third=3#fragment-identifier</p>
|
||||
|
||||
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
|
||||
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
|
||||
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
|
||||
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
|
||||
<p>Emoji & combining: 👋🏽 👨👩👧👦 🇺🇸 é vs é (precomposed vs combining)</p>
|
||||
<p>Zero-width chars between letters: a​b​c</p>
|
||||
|
||||
<p>Escaped tag text: <script>alert(1)</script></p>
|
||||
<p>Attribute with quotes: <span title='He said "hello"'>hover me</span></p>
|
||||
|
||||
<p>Empty elements follow:</p>
|
||||
<div></div>
|
||||
<p></p>
|
||||
<ul></ul>
|
||||
<table></table>
|
||||
|
||||
<p>Deep nesting:</p>
|
||||
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
|
||||
|
||||
<p>Inline element stress:
|
||||
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
|
||||
</p>
|
||||
|
||||
<p style="color: teal;">Inline style attribute (teal).</p>
|
||||
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
|
||||
</footer>
|
||||
@@ -0,0 +1,415 @@
|
||||
<a href="#main">Skip to content</a>
|
||||
|
||||
<header>
|
||||
<h1>HTML Test Fixture</h1>
|
||||
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
|
||||
<nav aria-label="Primary">
|
||||
<ul>
|
||||
<li><a href="#text">Text</a></li>
|
||||
<li><a href="#lists">Lists</a></li>
|
||||
<li><a href="#tables">Tables</a></li>
|
||||
<li><a href="#forms">Forms</a></li>
|
||||
<li><a href="#media">Media</a></li>
|
||||
<li><a href="#edge">Edge cases</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
|
||||
|
||||
<section id="headings">
|
||||
<h2>Headings</h2>
|
||||
<h1>Heading level 1</h1>
|
||||
<h2>Heading level 2</h2>
|
||||
<h3>Heading level 3</h3>
|
||||
<h4>Heading level 4</h4>
|
||||
<h5>Heading level 5</h5>
|
||||
<h6>Heading level 6</h6>
|
||||
<hgroup>
|
||||
<h2>Grouped heading</h2>
|
||||
<p>Subtitle paragraph inside hgroup</p>
|
||||
</hgroup>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<section id="text">
|
||||
<h2>Text and inline elements</h2>
|
||||
|
||||
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
|
||||
|
||||
<p>
|
||||
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
|
||||
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
|
||||
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
|
||||
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
|
||||
<samp>output text</samp>, <var>variable</var>,
|
||||
<abbr title="HyperText Markup Language">HTML</abbr>,
|
||||
<dfn>definition term</dfn>,
|
||||
<time datetime="2026-08-09">August 9, 2026</time>,
|
||||
<data value="42">forty-two</data>,
|
||||
<q>short inline quote</q>,
|
||||
<cite>Cited Work</cite>,
|
||||
<bdi>إسم</bdi>,
|
||||
<bdo dir="rtl">reversed direction</bdo>,
|
||||
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Links:
|
||||
<a href="#top">internal anchor</a> ·
|
||||
<a href="https://example.com">absolute</a> ·
|
||||
<a href="/relative/path">relative</a> ·
|
||||
<a href="mailto:test@example.com">mailto</a> ·
|
||||
<a href="tel:+15555550123">tel</a> ·
|
||||
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
|
||||
<a href="#" download="">download attr</a>
|
||||
</p>
|
||||
|
||||
<blockquote cite="https://example.com/source">
|
||||
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
|
||||
<blockquote><p>Nested block quotation.</p></blockquote>
|
||||
<footer>— <cite>Someone, Somewhere</cite></footer>
|
||||
</blockquote>
|
||||
|
||||
<pre><code>#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
for i in {1..3}; do
|
||||
printf 'iteration %d\n' "$i"
|
||||
done
|
||||
|
||||
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
</code></pre>
|
||||
|
||||
<p>Line break here,<br>after the break.</p>
|
||||
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
|
||||
|
||||
<address>
|
||||
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
|
||||
123 Nowhere St, Somewhere
|
||||
</address>
|
||||
|
||||
<p>Entities: & < > " ' © ® ™ — … € 😀</p>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<section id="lists">
|
||||
<h2>Lists</h2>
|
||||
|
||||
<h3>Unordered, nested</h3>
|
||||
<ul>
|
||||
<li>First item</li>
|
||||
<li>Second item
|
||||
<ul>
|
||||
<li>Nested item
|
||||
<ul><li>Deeply nested item</li></ul>
|
||||
</li>
|
||||
<li>Another nested item</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Ordered variants</h3>
|
||||
<ol>
|
||||
<li>Default numbering</li>
|
||||
<li>Second
|
||||
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
|
||||
</li>
|
||||
</ol>
|
||||
<ol start="5" reversed="">
|
||||
<li>Reversed, starting at 5</li>
|
||||
<li>Next</li>
|
||||
<li>Next</li>
|
||||
</ol>
|
||||
|
||||
<h3>Description list</h3>
|
||||
<dl>
|
||||
<dt>Term one</dt>
|
||||
<dd>Definition of the first term.</dd>
|
||||
<dt>Term two</dt>
|
||||
<dt>Term two, alias</dt>
|
||||
<dd>Definition covering both terms above.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Menu</h3>
|
||||
<menu>
|
||||
<li><button type="button">Copy</button></li>
|
||||
<li><button type="button">Paste</button></li>
|
||||
</menu>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<section id="tables">
|
||||
<h2>Tables</h2>
|
||||
|
||||
<table>
|
||||
<caption>Quarterly figures with spans and a footer</caption>
|
||||
<colgroup>
|
||||
<col span="1">
|
||||
<col span="2">
|
||||
<col>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Region</th>
|
||||
<th scope="col">Q1</th>
|
||||
<th scope="col">Q2</th>
|
||||
<th scope="col">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">North</th>
|
||||
<td>1,204</td>
|
||||
<td>1,391</td>
|
||||
<td rowspan="2">Shared note spanning two rows</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">South</th>
|
||||
<td>988</td>
|
||||
<td>1,022</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">East</th>
|
||||
<td colspan="2">Merged across two quarters</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th scope="row">Total</th>
|
||||
<td>2,192</td>
|
||||
<td>2,413</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h3>Wide table (horizontal overflow)</h3>
|
||||
<table>
|
||||
<tbody><tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
|
||||
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<section>
|
||||
<h2>Forms</h2>
|
||||
|
||||
<form action="#" method="get">
|
||||
<fieldset>
|
||||
<legend>Text inputs</legend>
|
||||
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
|
||||
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
|
||||
<datalist id="suggestions">
|
||||
<option value="alpha"></option>
|
||||
<option value="beta"></option>
|
||||
<option value="gamma"></option>
|
||||
</datalist>
|
||||
<p><label for="f-email">Email</label> <input id="f-email" type="email" required=""></p>
|
||||
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
|
||||
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
|
||||
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
|
||||
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
|
||||
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
|
||||
content
|
||||
here</textarea></p>
|
||||
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly=""></p>
|
||||
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled=""></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Date, time, color, range, file</legend>
|
||||
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
|
||||
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
|
||||
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
|
||||
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
|
||||
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
|
||||
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
|
||||
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
|
||||
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple="" accept=".txt,.md"></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Choices</legend>
|
||||
<p>
|
||||
<label><input type="checkbox" name="c" value="1" checked=""> Checked</label>
|
||||
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
|
||||
<label><input type="checkbox" name="c" value="3" disabled=""> Disabled</label>
|
||||
</p>
|
||||
<p>
|
||||
<label><input type="radio" name="r" value="a" checked=""> Option A</label>
|
||||
<label><input type="radio" name="r" value="b"> Option B</label>
|
||||
</p>
|
||||
<p>
|
||||
<label for="f-select">Select</label>
|
||||
<select id="f-select" name="select">
|
||||
<option value="">— choose —</option>
|
||||
<optgroup label="Group one">
|
||||
<option value="1" selected="">One</option>
|
||||
<option value="2">Two</option>
|
||||
</optgroup>
|
||||
<optgroup label="Group two" disabled="">
|
||||
<option value="3">Three</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label for="f-multi">Multi-select</label><br>
|
||||
<select id="f-multi" multiple="" size="4">
|
||||
<option>Red</option><option selected="">Green</option><option>Blue</option><option>Violet</option>
|
||||
</select>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Output and buttons</legend>
|
||||
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
|
||||
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
|
||||
<p><output name="result" for="f-num f-range">Computed output</output></p>
|
||||
<p>
|
||||
<button type="submit">Submit</button>
|
||||
<button type="reset">Reset</button>
|
||||
<button type="button">Plain button</button>
|
||||
<button type="button" disabled="">Disabled button</button>
|
||||
<input type="submit" value="Input submit">
|
||||
<input type="button" value="Input button">
|
||||
</p>
|
||||
<input type="hidden" name="csrf" value="hidden-value">
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<section id="media">
|
||||
<h2>Media and embeds</h2>
|
||||
|
||||
<h3>Inline SVG</h3>
|
||||
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
|
||||
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
|
||||
</svg>
|
||||
|
||||
<h3>Figure with data-URI image</h3>
|
||||
<figure>
|
||||
<img alt="Small red square" width="64" height="64" src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
|
||||
<figcaption>Figure caption describing the image above.</figcaption>
|
||||
</figure>
|
||||
|
||||
<h3>Broken image (alt-text fallback test)</h3>
|
||||
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
|
||||
|
||||
<h3>Picture element</h3>
|
||||
<picture>
|
||||
<source media="(min-width: 800px)" srcset="wide.png">
|
||||
<source media="(min-width: 400px)" srcset="medium.png">
|
||||
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
|
||||
</picture>
|
||||
|
||||
<h3>Video and audio (sources intentionally missing)</h3>
|
||||
<video controls="" width="320" poster="poster.jpg">
|
||||
<source src="clip.webm" type="video/webm">
|
||||
<source src="clip.mp4" type="video/mp4">
|
||||
<track kind="captions" src="captions.vtt" srclang="en" label="English">
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
<audio controls="">
|
||||
<source src="tone.ogg" type="audio/ogg">
|
||||
<source src="tone.mp3" type="audio/mpeg">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<h3>Canvas and iframe</h3>
|
||||
<canvas width="200" height="60">Canvas fallback text</canvas>
|
||||
<iframe title="Sandboxed iframe" width="300" height="120" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" loading="lazy" referrerpolicy="no-referrer"></iframe>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<section id="interactive">
|
||||
<h2>Interactive and semantic containers</h2>
|
||||
|
||||
<details>
|
||||
<summary>Collapsed disclosure</summary>
|
||||
<p>Hidden content revealed on toggle.</p>
|
||||
</details>
|
||||
<details open="">
|
||||
<summary>Open disclosure</summary>
|
||||
<ul><li>With a list inside</li><li>Second item</li></ul>
|
||||
</details>
|
||||
|
||||
|
||||
<p>Non-modal dialog content.</p>
|
||||
<button type="button">Close</button>
|
||||
|
||||
<button type="button">Open dialog</button>
|
||||
|
||||
<article>
|
||||
<header><h3>Article header</h3></header>
|
||||
<p>Article body content.</p>
|
||||
<aside><p>An aside nested inside the article.</p></aside>
|
||||
<footer><p>Article footer.</p></footer>
|
||||
</article>
|
||||
|
||||
<p><span contenteditable="true">Editable inline region</span></p>
|
||||
<p hidden="">This paragraph has the hidden attribute and should not render.</p>
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
<section id="edge">
|
||||
<h2>Edge cases</h2>
|
||||
|
||||
<p>Very long unbroken token (overflow test):</p>
|
||||
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
|
||||
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&another=2&third=3#fragment-identifier</p>
|
||||
|
||||
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
|
||||
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
|
||||
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
|
||||
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
|
||||
<p>Emoji & combining: 👋🏽 👨👩👧👦 🇺🇸 é vs é (precomposed vs combining)</p>
|
||||
<p>Zero-width chars between letters: abc</p>
|
||||
|
||||
<p>Escaped tag text: <script>alert(1)</script></p>
|
||||
<p>Attribute with quotes: <span title="He said "hello"">hover me</span></p>
|
||||
|
||||
<p>Empty elements follow:</p>
|
||||
<div></div>
|
||||
<p></p>
|
||||
<ul></ul>
|
||||
<table></table>
|
||||
|
||||
<p>Deep nesting:</p>
|
||||
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
|
||||
|
||||
<p>Inline element stress:
|
||||
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
|
||||
</p>
|
||||
|
||||
<p style="color: teal;">Inline style attribute (teal).</p>
|
||||
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
|
||||
</footer>
|
||||
@@ -6,12 +6,12 @@ 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).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];
|
||||
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
||||
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||
});
|
||||
|
||||
@@ -41,3 +41,248 @@ describe('ContactForm.toHtml relay wiring', () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- Webhook destination (Task 10) ----------
|
||||
This file builds props inline rather than spreading a shared object, so
|
||||
`defaultProps` is introduced here for the destination cases only; every
|
||||
pre-existing test above is untouched. */
|
||||
const defaultProps = { fields: [] as any[], formAction: '#' };
|
||||
|
||||
describe('ContactForm.craft.props includes the destination defaults', () => {
|
||||
// The trap this pins: FormStylePanel renders each destination control behind
|
||||
// `nodeProps.X !== undefined`, so a prop omitted from these defaults yields an
|
||||
// invisible control and the whole feature looks like it does nothing.
|
||||
test('destinationType/webhookUrl/webhookSecretId/webhookAuthMode are all present', () => {
|
||||
expect(ContactForm.craft!.props).toMatchObject({
|
||||
destinationType: 'email',
|
||||
webhookUrl: '',
|
||||
webhookSecretId: '',
|
||||
webhookAuthMode: 'signature',
|
||||
});
|
||||
});
|
||||
|
||||
test('no craft prop holds a raw secret -- only an id', () => {
|
||||
const keys = Object.keys(ContactForm.craft!.props as object);
|
||||
expect(keys).toContain('webhookSecretId');
|
||||
expect(keys.filter((k) => /secret/i.test(k))).toEqual(['webhookSecretId']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContactForm.toHtml destination marker', () => {
|
||||
test('email destination emits the legacy marker unchanged', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'email', recipientEmail: 'a@example.com', thankYouUrl: '' }, '');
|
||||
expect(out.html).toContain('<!--WHP-FORM');
|
||||
expect(out.html).toContain('recipient="a@example.com"');
|
||||
expect(out.html).not.toContain('type="webhook"');
|
||||
});
|
||||
|
||||
test('BYTE-IDENTITY: an email destination emits exactly what a pre-feature form emits', () => {
|
||||
// The guarantee every already-published site depends on: a marker with no
|
||||
// `type` still provisions an email endpoint, so its bytes must not drift by
|
||||
// so much as a space.
|
||||
//
|
||||
// FROZEN LITERAL, not a self-comparison. Comparing two head-revision outputs
|
||||
// to each other only catches a drift that affects ONE of them -- a uniform
|
||||
// change passes it. This string was captured from `071f3447` (the revision
|
||||
// deployed to production before this feature) and is the actual reference:
|
||||
// if it has to be edited, every already-published site's forms have changed
|
||||
// shape and that is the thing to stop, not the test.
|
||||
const FROZEN_LEGACY_MARKER =
|
||||
'<!--WHP-FORM id="F_3hodg" recipient="a@example.com" thankyou="/thx"-->';
|
||||
|
||||
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
|
||||
|
||||
// ...and the new props, set to their defaults, change nothing about it.
|
||||
const explicit = toHtml(
|
||||
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
|
||||
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||
expect(explicit.html).toBe(legacy.html);
|
||||
});
|
||||
|
||||
test('webhook destination emits type, url, secret id and auth mode', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||
webhookSecretId: 'sec-1', webhookAuthMode: 'bearer', recipientEmail: 'fb@example.com' }, '');
|
||||
expect(out.html).toContain('type="webhook"');
|
||||
expect(out.html).toContain('url="https://hooks.example.com/x"');
|
||||
expect(out.html).toContain('secret="sec-1"');
|
||||
expect(out.html).toContain('authmode="bearer"');
|
||||
expect(out.html).toContain('recipient="fb@example.com"');
|
||||
});
|
||||
|
||||
test('a raw secret value is never emitted, only its id', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||
webhookSecretId: 'sec-1', webhookSecret: 'SUPERSECRET' } as any, '');
|
||||
// Non-vacuous: the marker IS emitted (so there is something that could have
|
||||
// carried the secret) and carries the id, but not the value.
|
||||
expect(out.html).toContain('secret="sec-1"');
|
||||
expect(out.html).not.toContain('SUPERSECRET');
|
||||
});
|
||||
|
||||
test('a webhook form with no fallback email still emits a marker (never a bare formAction)', () => {
|
||||
const out = toHtml(
|
||||
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' }, '');
|
||||
expect(out.html).toContain('type="webhook"');
|
||||
expect(out.html).toContain('recipient=""');
|
||||
expect(out.html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||
expect(out.html).toContain('name="_gotcha"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -23,6 +34,21 @@ interface ContactFormProps {
|
||||
inputBorder?: string;
|
||||
recipientEmail?: string;
|
||||
thankYouUrl?: string;
|
||||
/* ---- Submission destination (see utils/form-relay-wiring.ts) ----
|
||||
'email' (default) reproduces the legacy marker exactly. 'webhook' widens it
|
||||
with the url / secret id / auth mode below.
|
||||
There is deliberately NO raw-secret prop: craft props are serialised into
|
||||
the saved project and into published output, so the secret is POSTed to
|
||||
/api/form-webhook-secret.php and only the returned opaque id is kept. */
|
||||
destinationType?: 'email' | 'webhook';
|
||||
webhookUrl?: string;
|
||||
webhookSecretId?: string;
|
||||
webhookAuthMode?: 'signature' | 'bearer';
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultFields: ContactFormField[] = [
|
||||
@@ -135,230 +161,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>
|
||||
|
||||
{/* Relay recipient */}
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Send submissions to (email)</label>
|
||||
<input type="email" value={props.recipientEmail || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.recipientEmail = e.target.value; })}
|
||||
placeholder="you@example.com" style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }} />
|
||||
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
|
||||
Delivered via the site's contact-form relay. Requires the relay to be enabled on this server.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thank-you page URL (optional)</label>
|
||||
<input type="text" value={props.thankYouUrl || ''}
|
||||
onChange={(e) => setProp((p: ContactFormProps) => { p.thankYouUrl = e.target.value; })}
|
||||
placeholder="/thank-you (blank = hosted page)" 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 = {
|
||||
@@ -379,21 +181,28 @@ ContactForm.craft = {
|
||||
inputBorder: '#d1d5db',
|
||||
recipientEmail: '',
|
||||
thankYouUrl: '',
|
||||
// Present (not omitted) so FormStylePanel's `nodeProps.X !== undefined`
|
||||
// gates actually render the destination controls.
|
||||
destinationType: 'email',
|
||||
webhookUrl: '',
|
||||
webhookSecretId: '',
|
||||
webhookAuthMode: 'signature',
|
||||
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: any) => 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',
|
||||
@@ -401,23 +210,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 ');
|
||||
@@ -435,19 +249,28 @@ ContactForm.craft = {
|
||||
alignSelf: 'flex-start',
|
||||
});
|
||||
|
||||
const useRelay = !!props.recipientEmail;
|
||||
const fid = 'F' + Math.random().toString(36).slice(2, 8);
|
||||
const actionAttr = useRelay ? `__WHP_FORM_ACTION__${fid}__` : esc(props.formAction || '#');
|
||||
const honeypot = useRelay
|
||||
? `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`
|
||||
: '';
|
||||
const marker = useRelay
|
||||
? `<!--WHP-FORM id="${fid}" recipient="${esc(props.recipientEmail)}" thankyou="${esc(props.thankYouUrl || '')}"-->`
|
||||
: '';
|
||||
// Only the webhook SECRET ID travels here -- there is no prop holding the raw
|
||||
// secret, by construction (see ContactFormProps).
|
||||
const { marker, actionAttr, honeypot } = relayFormWiring(
|
||||
props.recipientEmail, props.thankYouUrl, props.formAction, nodeId,
|
||||
{
|
||||
type: props.destinationType,
|
||||
url: props.webhookUrl,
|
||||
secretId: props.webhookSecretId,
|
||||
authMode: props.webhookAuthMode,
|
||||
},
|
||||
);
|
||||
|
||||
// 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: `${marker}<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||
${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <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: any) => 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: any) => 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: any) => 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,7 +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 { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface BackgroundSectionProps {
|
||||
bgImage?: string;
|
||||
@@ -69,93 +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' }}>
|
||||
<AnchorIdField />
|
||||
<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 = {
|
||||
@@ -174,15 +87,11 @@ BackgroundSection.craft = {
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: BackgroundSectionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const outerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
@@ -207,7 +116,7 @@ BackgroundSection.craft = {
|
||||
margin: '0 auto',
|
||||
padding: '60px 20px',
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
return {
|
||||
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,8 +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 { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
type SplitOption =
|
||||
| '100'
|
||||
@@ -20,6 +20,11 @@ interface ColumnLayoutProps {
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
anchorId?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
const splitToWidths: Record<string, string[]> = {
|
||||
@@ -94,178 +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' }}>
|
||||
<AnchorIdField />
|
||||
{/* 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 = {
|
||||
@@ -274,24 +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 esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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',
|
||||
@@ -299,8 +148,23 @@ ColumnLayout.craft = {
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
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${idAttr}${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,10 +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 { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
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;
|
||||
@@ -33,6 +43,20 @@ 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',
|
||||
@@ -40,26 +64,37 @@ export const Container: UserComponent<ContainerProps> = ({
|
||||
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: anchorId || undefined,
|
||||
id: idValue,
|
||||
className: cssClass || undefined,
|
||||
},
|
||||
needsBoxedWrapper
|
||||
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
||||
@@ -69,268 +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 }}>
|
||||
<AnchorIdField />
|
||||
{/* 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 esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -340,12 +158,15 @@ Container.craft = {
|
||||
}
|
||||
|
||||
const styleStr = cssPropsToString(outerCss);
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
// 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}${idAttr}${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}${idAttr}${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,7 +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 { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
/* ---------- Shape Divider SVG Paths ---------- */
|
||||
|
||||
@@ -16,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;
|
||||
@@ -29,6 +27,11 @@ interface SectionProps {
|
||||
bottomDividerColor?: string;
|
||||
bottomDividerHeight?: string;
|
||||
anchorId?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
}
|
||||
|
||||
/* ---------- Divider renderer ---------- */
|
||||
@@ -40,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';
|
||||
@@ -93,6 +103,13 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
|
||||
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
|
||||
@@ -102,6 +119,7 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
width: '100%',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
...style,
|
||||
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||
}}
|
||||
>
|
||||
{hasTopDivider && (
|
||||
@@ -133,204 +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' }}>
|
||||
<AnchorIdField />
|
||||
<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',
|
||||
@@ -339,15 +175,17 @@ Section.craft = {
|
||||
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 ---------- */
|
||||
@@ -359,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',
|
||||
@@ -383,14 +225,15 @@ function buildDividerHtml(
|
||||
}
|
||||
|
||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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',
|
||||
@@ -401,7 +244,7 @@ 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="${esc(props.anchorId)}"` : '';
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
return {
|
||||
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,7 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface AccordionItem {
|
||||
title: string;
|
||||
@@ -17,6 +17,11 @@ interface AccordionProps {
|
||||
contentBg?: string;
|
||||
borderColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultItems: AccordionItem[] = [
|
||||
@@ -123,156 +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' }}>
|
||||
<AnchorIdField />
|
||||
<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 = {
|
||||
@@ -285,30 +140,33 @@ Accordion.craft = {
|
||||
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: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
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) => {
|
||||
@@ -318,10 +176,10 @@ 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 ');
|
||||
|
||||
@@ -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,8 +1,8 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
|
||||
interface CTASectionProps {
|
||||
heading?: string;
|
||||
@@ -14,6 +14,11 @@ interface CTASectionProps {
|
||||
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%)';
|
||||
@@ -70,78 +75,6 @@ export const CTASection: UserComponent<CTASectionProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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%)' },
|
||||
];
|
||||
|
||||
const effectiveCtas = normalizeCtas(props);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<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>
|
||||
|
||||
<CtasEditor
|
||||
ctas={effectiveCtas}
|
||||
onChange={(next) => setProp((p: CTASectionProps) => {
|
||||
p.ctas = next;
|
||||
p.buttonText = undefined;
|
||||
p.buttonHref = undefined;
|
||||
})}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
CTASection.craft = {
|
||||
@@ -155,21 +88,22 @@ CTASection.craft = {
|
||||
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: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
background: props.gradient || defaultGradient,
|
||||
padding: '80px 20px',
|
||||
@@ -178,12 +112,12 @@ CTASection.craft = {
|
||||
});
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' });
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
return {
|
||||
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>
|
||||
<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,8 +1,8 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface CallToActionProps {
|
||||
heading?: string;
|
||||
@@ -21,6 +21,11 @@ interface CallToActionProps {
|
||||
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%)';
|
||||
@@ -112,216 +117,6 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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,
|
||||
};
|
||||
|
||||
const effectiveCtas = normalizeCtas(props);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<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>
|
||||
|
||||
<CtasEditor
|
||||
ctas={effectiveCtas}
|
||||
onChange={(next) => setProp((p: CallToActionProps) => {
|
||||
p.ctas = next;
|
||||
p.buttonText = undefined;
|
||||
p.buttonHref = undefined;
|
||||
p.secondaryButtonText = undefined;
|
||||
p.secondaryButtonHref = undefined;
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* 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 = {
|
||||
@@ -341,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: any) => 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';
|
||||
@@ -397,13 +194,13 @@ CallToAction.craft = {
|
||||
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor });
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
|
||||
return {
|
||||
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>
|
||||
<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: any) => 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,7 +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 { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface CountdownProps {
|
||||
targetDate?: string;
|
||||
@@ -11,6 +11,11 @@ interface CountdownProps {
|
||||
labelColor?: string;
|
||||
bgColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
interface TimeLeft {
|
||||
@@ -127,108 +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' }}>
|
||||
<AnchorIdField />
|
||||
{/* 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 = {
|
||||
@@ -241,29 +144,32 @@ Countdown.craft = {
|
||||
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: any) => 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',
|
||||
@@ -271,18 +177,29 @@ Countdown.craft = {
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
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${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
@@ -295,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);
|
||||
@@ -310,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,24 +1,36 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
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> = ({
|
||||
@@ -56,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>
|
||||
@@ -66,101 +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' }}>
|
||||
<AnchorIdField />
|
||||
<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 = {
|
||||
@@ -169,31 +103,38 @@ FeaturesGrid.craft = {
|
||||
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: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
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 ');
|
||||
|
||||
|
||||
@@ -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: any) => 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,8 +1,8 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
interface HeroProps {
|
||||
heading?: string;
|
||||
@@ -162,215 +162,6 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- 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,
|
||||
}));
|
||||
|
||||
const effectiveCtas = normalizeCtas(props);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<AnchorIdField />
|
||||
{/* 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>
|
||||
|
||||
{/* Dynamic CTAs */}
|
||||
<CtasEditor
|
||||
ctas={effectiveCtas}
|
||||
onChange={(next) => setProp((p: HeroProps) => {
|
||||
p.ctas = next;
|
||||
// Once the user touches CTAs, the legacy fields are no longer
|
||||
// authoritative — clear them so the array is the only source.
|
||||
p.buttonText = undefined;
|
||||
p.buttonHref = undefined;
|
||||
p.secondaryButtonText = undefined;
|
||||
p.secondaryButtonHref = undefined;
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* 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 = {
|
||||
@@ -404,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: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||
const bg = buildBackground(props);
|
||||
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
@@ -433,15 +220,22 @@ 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';
|
||||
|
||||
const ctas = normalizeCtas(props);
|
||||
@@ -451,13 +245,14 @@ HeroSimple.craft = {
|
||||
outlineText: props.textColor || '#fff',
|
||||
});
|
||||
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||
const heroTextColor = cssValue(props.textColor) || '#fff';
|
||||
return {
|
||||
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: any) => 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,7 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
@@ -19,6 +19,23 @@ interface PricingTableProps {
|
||||
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> = {
|
||||
@@ -61,6 +78,13 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
featuredBg = '#3b82f6',
|
||||
bulletType = 'check',
|
||||
anchorId,
|
||||
cardBg,
|
||||
textColor,
|
||||
subColor,
|
||||
featColor,
|
||||
checkColor,
|
||||
btnBg,
|
||||
btnColor,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -69,6 +93,14 @@ 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)); }}
|
||||
@@ -95,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',
|
||||
@@ -127,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',
|
||||
}}>
|
||||
@@ -137,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>
|
||||
@@ -161,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>
|
||||
))}
|
||||
@@ -178,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',
|
||||
@@ -197,189 +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' }}>
|
||||
<AnchorIdField />
|
||||
<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 = {
|
||||
@@ -390,44 +239,66 @@ PricingTable.craft = {
|
||||
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: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const bulletType = props.bulletType || 'check';
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
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
|
||||
@@ -436,15 +307,15 @@ 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 ');
|
||||
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
@@ -17,6 +17,11 @@ interface TabsProps {
|
||||
inactiveTabColor?: string;
|
||||
contentBg?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultTabs: TabItem[] = [
|
||||
@@ -101,174 +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' }}>
|
||||
<AnchorIdField />
|
||||
<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 = {
|
||||
@@ -282,42 +119,58 @@ Tabs.craft = {
|
||||
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: any) => String(s ?? "").replace(/</g, '<').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="${esc(props.anchorId)}"` : '';
|
||||
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>
|
||||
@@ -328,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${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,7 +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 { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||
|
||||
interface Testimonial {
|
||||
quote: string;
|
||||
@@ -18,6 +18,11 @@ interface TestimonialsProps {
|
||||
cardBg?: string;
|
||||
starColor?: string;
|
||||
anchorId?: string;
|
||||
animation?: string;
|
||||
animationDelay?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const defaultTestimonials: Testimonial[] = [
|
||||
@@ -41,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> = ({
|
||||
@@ -63,8 +74,6 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
backgroundColor: cardBg,
|
||||
borderRadius: '12px',
|
||||
@@ -103,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>
|
||||
@@ -148,209 +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' }}>
|
||||
<AnchorIdField />
|
||||
{/* 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 = {
|
||||
@@ -363,29 +136,35 @@ Testimonials.craft = {
|
||||
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: any) => 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;
|
||||
|
||||
@@ -394,30 +173,32 @@ Testimonials.craft = {
|
||||
backgroundColor: '#ffffff',
|
||||
...style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
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${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
|
||||
${cards}
|
||||
<div style="max-width:600px;margin:0 auto">
|
||||
${cardHtml(items[0])}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
}
|
||||
|
||||
const cards = items.map(cardHtml).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { CSSProperties } from 'react';
|
||||
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||
|
||||
export type CtaVariant = 'primary' | 'outline' | 'ghost';
|
||||
|
||||
@@ -67,135 +68,28 @@ export function ctaInlineStyle(cta: CtaButton, defaults: CtaStyleDefaults): CSSP
|
||||
|
||||
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:${defaults.outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${defaults.outlineText}`;
|
||||
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:${defaults.outlineText};text-decoration:underline;border-radius:8px;font-weight:600;font-size:16px`;
|
||||
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:${defaults.primaryBg};color:${defaults.primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`;
|
||||
return `display:inline-block;padding:14px 36px;background-color:${primaryBg};color:${primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`;
|
||||
}
|
||||
}
|
||||
|
||||
const esc = (s: any) => String(s ?? '').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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="${esc(c.href || '#')}"${target} style="${ctaCssString(c, defaults)}">${esc(c.text || '')}</a>`;
|
||||
return `<a href="${escapeAttr(safeUrl(c.href || '#'))}"${target} style="${ctaCssString(c, defaults)}">${escapeHtml(c.text || '')}</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ---------- CTAs editor (settings UI) ---------- */
|
||||
|
||||
interface CtasEditorProps {
|
||||
ctas: CtaButton[];
|
||||
/** Called whenever the user mutates the array. Sections wire this via setProp. */
|
||||
onChange: (next: CtaButton[]) => void;
|
||||
/** Max items the user can add. Default 4. */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '6px 8px', background: '#27272a',
|
||||
color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
const labelStyle: CSSProperties = {
|
||||
fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4,
|
||||
};
|
||||
|
||||
export const CtasEditor: React.FC<CtasEditorProps> = ({ ctas, onChange, max = 4 }) => {
|
||||
const update = (i: number, patch: Partial<CtaButton>) => {
|
||||
const next = ctas.slice();
|
||||
next[i] = { ...next[i], ...patch };
|
||||
onChange(next);
|
||||
};
|
||||
const remove = (i: number) => onChange(ctas.filter((_, j) => j !== i));
|
||||
const add = () => {
|
||||
if (ctas.length >= max) return;
|
||||
onChange([...ctas, { text: 'New button', href: '#', variant: ctas.length === 0 ? 'primary' : 'outline' }]);
|
||||
};
|
||||
const move = (i: number, dir: -1 | 1) => {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= ctas.length) return;
|
||||
const next = ctas.slice();
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, color: '#a1a1aa', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Buttons ({ctas.length})
|
||||
</div>
|
||||
{ctas.length === 0 && (
|
||||
<div style={{ fontSize: 11, color: '#71717a', fontStyle: 'italic', padding: '8px 0' }}>
|
||||
No buttons. Click "Add button" to insert one.
|
||||
</div>
|
||||
)}
|
||||
{ctas.map((cta, i) => (
|
||||
<div key={i} style={{
|
||||
background: '#18181b', border: '1px solid #3f3f46', borderRadius: 6,
|
||||
padding: 10, display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 10, color: '#a1a1aa', fontWeight: 600, flex: 1 }}>Button {i + 1}</span>
|
||||
<button onClick={() => move(i, -1)} disabled={i === 0} title="Move up"
|
||||
style={iconBtn(i === 0)}>↑</button>
|
||||
<button onClick={() => move(i, 1)} disabled={i === ctas.length - 1} title="Move down"
|
||||
style={iconBtn(i === ctas.length - 1)}>↓</button>
|
||||
<button onClick={() => remove(i)} title="Remove"
|
||||
style={{ ...iconBtn(false), color: '#fca5a5' }}>✕</button>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Text</label>
|
||||
<input type="text" value={cta.text} onChange={(e) => update(i, { text: e.target.value })} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>URL</label>
|
||||
<input type="text" value={cta.href} onChange={(e) => update(i, { href: e.target.value })}
|
||||
placeholder="https://… or #anchor" style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Style</label>
|
||||
<select value={cta.variant || 'primary'}
|
||||
onChange={(e) => update(i, { variant: e.target.value as CtaVariant })}
|
||||
style={{ ...inputStyle, padding: '5px 6px' }}>
|
||||
<option value="primary">Primary (filled)</option>
|
||||
<option value="outline">Outline</option>
|
||||
<option value="ghost">Ghost (text)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: '0 0 auto', display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
<input type="checkbox" checked={cta.target === '_blank'}
|
||||
onChange={(e) => update(i, { target: e.target.checked ? '_blank' : undefined })} />
|
||||
New tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{ctas.length < max && (
|
||||
<button onClick={add} style={{
|
||||
padding: '8px 12px', fontSize: 12, fontWeight: 600,
|
||||
color: '#3b82f6', background: 'rgba(59,130,246,0.1)',
|
||||
border: '1px dashed #3b82f6', borderRadius: 4, cursor: 'pointer',
|
||||
}}>
|
||||
+ Add button{ctas.length === 0 ? '' : ` (${max - ctas.length} more)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function iconBtn(disabled: boolean): CSSProperties {
|
||||
return {
|
||||
width: 22, height: 22, fontSize: 11,
|
||||
background: '#27272a', color: disabled ? '#52525b' : '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,61 @@
|
||||
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 { LayerFocusProvider } from '../panels/left/LayerFocusContext';
|
||||
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 +78,29 @@ 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>
|
||||
<LayerFocusProvider>
|
||||
<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 +109,7 @@ export const EditorShell: React.FC = () => {
|
||||
onClose={hideMenu}
|
||||
/>
|
||||
</div>
|
||||
</LayerFocusProvider>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user