Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
+58
-46
@@ -41,14 +41,12 @@ craft/
|
|||||||
│ │ └── Canvas.tsx # Craft.js <Frame> with device-width switching
|
│ │ └── Canvas.tsx # Craft.js <Frame> with device-width switching
|
||||||
│ │
|
│ │
|
||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── resolver.ts # Component map for Craft.js serialization (20 components)
|
│ │ ├── resolver.ts # Component map for Craft.js serialization (39 components)
|
||||||
│ │ ├── layout/
|
│ │ ├── layout/
|
||||||
│ │ │ ├── Container.tsx # Generic container (div/section/article/header/footer/main)
|
│ │ │ ├── Container.tsx # Generic container (div/section/article/header/footer/main)
|
||||||
│ │ │ ├── Section.tsx # Full-width section with centered inner container
|
│ │ │ ├── Section.tsx # Full-width section with centered inner container
|
||||||
│ │ │ ├── ColumnLayout.tsx # Flex columns (1-6, with split ratios)
|
│ │ │ ├── ColumnLayout.tsx # Flex columns (1-6, with split ratios)
|
||||||
│ │ │ ├── BackgroundSection.tsx # Section with background image/gradient overlay
|
│ │ │ └── BackgroundSection.tsx # Section with background image/gradient overlay
|
||||||
│ │ │ ├── HeaderZone.tsx # Page-level header zone wrapper
|
|
||||||
│ │ │ └── FooterZone.tsx # Page-level footer zone wrapper
|
|
||||||
│ │ ├── basic/
|
│ │ ├── basic/
|
||||||
│ │ │ ├── Heading.tsx # Inline-editable heading (h1-h6)
|
│ │ │ ├── Heading.tsx # Inline-editable heading (h1-h6)
|
||||||
│ │ │ ├── TextBlock.tsx # Inline-editable paragraph
|
│ │ │ ├── TextBlock.tsx # Inline-editable paragraph
|
||||||
@@ -73,7 +71,8 @@ craft/
|
|||||||
│ ├── panels/
|
│ ├── panels/
|
||||||
│ │ ├── topbar/
|
│ │ ├── topbar/
|
||||||
│ │ │ ├── TopBar.tsx # Back button, domain badge, device switcher, undo/redo, save, templates
|
│ │ │ ├── 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/
|
│ │ ├── left/
|
||||||
│ │ │ ├── LeftPanel.tsx # Tabs: Blocks | Pages | Layers | Assets
|
│ │ │ ├── LeftPanel.tsx # Tabs: Blocks | Pages | Layers | Assets
|
||||||
│ │ │ ├── BlocksPanel.tsx # Draggable block toolbox with categories
|
│ │ │ ├── BlocksPanel.tsx # Draggable block toolbox with categories
|
||||||
@@ -81,9 +80,10 @@ craft/
|
|||||||
│ │ │ ├── LayersPanel.tsx # Component hierarchy tree view
|
│ │ │ ├── LayersPanel.tsx # Component hierarchy tree view
|
||||||
│ │ │ └── AssetsPanel.tsx # Asset browser with upload, drag-drop, thumbnails
|
│ │ │ └── AssetsPanel.tsx # Asset browser with upload, drag-drop, thumbnails
|
||||||
│ │ ├── right/
|
│ │ ├── right/
|
||||||
│ │ │ ├── RightPanel.tsx # Tabs: Styles | Settings | Head
|
│ │ │ ├── RightPanel.tsx # Single "Styles" tab -- renders GuidedStyles only
|
||||||
│ │ │ ├── GuidedStyles.tsx # Context-aware style panel (shows selected type)
|
│ │ │ ├── GuidedStyles.tsx # Context-aware dispatcher: picks a StylePanel by selected type
|
||||||
│ │ │ └── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs)
|
│ │ │ ├── SiteDesignPanel.tsx # Site-wide design tokens editor (Basic/Advanced tabs)
|
||||||
|
│ │ │ └── styles/ # Per-type StylePanels (Text, Button, Image, Container, Nav, Form, etc.)
|
||||||
│ │ └── context-menu/
|
│ │ └── context-menu/
|
||||||
│ │ └── ContextMenu.tsx # Right-click context menu (duplicate, copy, paste, delete, etc.)
|
│ │ └── ContextMenu.tsx # Right-click context menu (duplicate, copy, paste, delete, etc.)
|
||||||
│ │
|
│ │
|
||||||
@@ -98,7 +98,8 @@ craft/
|
|||||||
│ │ └── definitions.ts # 16 template definitions across 4 categories
|
│ │ └── definitions.ts # 16 template definitions across 4 categories
|
||||||
│ │
|
│ │
|
||||||
│ ├── ui/
|
│ ├── 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/
|
│ ├── constants/
|
||||||
│ │ └── presets.ts # Color, font, spacing, radius, gradient, device width presets
|
│ │ └── 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.
|
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.
|
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>;
|
return <div ref={(r) => { if (r) connect(drag(r)); }} style={style}>{text}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Settings panel (rendered in right panel when selected)
|
// 3. Craft config (displayName, default props, rules)
|
||||||
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)
|
|
||||||
MyComponent.craft = {
|
MyComponent.craft = {
|
||||||
displayName: 'My Component',
|
displayName: 'My Component',
|
||||||
props: { text: 'Default text', style: {} },
|
props: { text: 'Default text', style: {} },
|
||||||
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
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) => {
|
(MyComponent as any).toHtml = (props: MyComponentProps, childrenHtml: string) => {
|
||||||
return { html: `<div style="...">${childrenHtml}</div>` };
|
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
|
### 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.
|
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)
|
- `whpConfig` - The full config object (or null in standalone mode)
|
||||||
- `isWHP` - Boolean shorthand for whether we're running inside WHP
|
- `isWHP` - Boolean shorthand for whether we're running inside WHP
|
||||||
|
|
||||||
## All Components (22)
|
## All Components (39)
|
||||||
|
|
||||||
| # | Component | Type | File | Features |
|
| # | 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 5 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align |
|
||||||
| 6 | FooterZone | Layout | `layout/FooterZone.tsx` | Page-level footer wrapper zone, used by PageContext |
|
| 6 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height |
|
||||||
| 7 | Heading | Basic | `basic/Heading.tsx` | Inline-editable, h1-h6 level, color, font family/size/weight, text align |
|
| 7 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size |
|
||||||
| 8 | TextBlock | Basic | `basic/TextBlock.tsx` | Inline-editable paragraph, color, font family/size/weight, text align, line height |
|
| 8 | Logo | Basic | `basic/Logo.tsx` | Text or image logo, link href, font family/size/weight, image width |
|
||||||
| 9 | ButtonLink | Basic | `basic/ButtonLink.tsx` | Link text/URL/target, 8 color presets (auto text contrast), radius, padding, font size |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 14 | Icon | Basic | `basic/Icon.tsx` | Font Awesome icon, size/color, background shape, optional link |
|
||||||
| 15 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop |
|
| 15 | ImageBlock | Media | `media/ImageBlock.tsx` | SVG placeholder, URL input, upload, browse assets, alt text, width/height, object-fit, radius |
|
||||||
| 16 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background |
|
| 16 | VideoBlock | Media | `media/VideoBlock.tsx` | YouTube, Vimeo, direct files (.mp4/.webm/.ogg), background mode, autoplay, loop |
|
||||||
| 17 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions |
|
| 17 | MapEmbed | Media | `media/MapEmbed.tsx` | Embedded map by address, zoom level, height |
|
||||||
| 18 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button |
|
| 18 | HeroSimple | Section | `sections/HeroSimple.tsx` | Pre-built hero with heading, subtext, CTA button, gradient/color background |
|
||||||
| 19 | FormContainer | Form | `forms/FormContainer.tsx` | Form wrapper with action URL and method |
|
| 19 | FeaturesGrid | Section | `sections/FeaturesGrid.tsx` | 3-column feature cards with icons, titles, descriptions |
|
||||||
| 20 | InputField | Form | `forms/InputField.tsx` | Text input with label, placeholder, type (text/email/tel/password/number) |
|
| 20 | CTASection | Section | `sections/CTASection.tsx` | Call-to-action banner with heading, text, button |
|
||||||
| 21 | TextareaField | Form | `forms/TextareaField.tsx` | Textarea with label and placeholder |
|
| 21 | Countdown | Section | `sections/Countdown.tsx` | Countdown timer to a target date, heading, digit/label colors |
|
||||||
| 22 | FormButton | Form | `forms/FormButton.tsx` | Submit button with color and style controls |
|
| 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
|
## Site Design Tokens
|
||||||
|
|
||||||
@@ -351,7 +362,7 @@ Templates are loaded via the Template Modal (opened from TopBar). Loading a temp
|
|||||||
## Multi-Page System
|
## Multi-Page System
|
||||||
|
|
||||||
Pages are managed through `PageContext`:
|
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__`)
|
- 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
|
- Page switching serializes the current canvas, stores it, then deserializes the target page
|
||||||
- Header/Footer editing puts the canvas in a distinct mode
|
- Header/Footer editing puts the canvas in a distinct mode
|
||||||
@@ -392,21 +403,22 @@ The Assets panel (`AssetsPanel.tsx`) provides:
|
|||||||
- Delete asset
|
- Delete asset
|
||||||
- Integration with WHP API for server-side storage
|
- 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
|
## Adding New Components
|
||||||
|
|
||||||
1. Create `src/components/<category>/<ComponentName>.tsx` following the pattern above
|
1. Create `src/components/<category>/<ComponentName>.tsx` following the pattern above
|
||||||
2. Add the component to `src/components/resolver.ts`
|
2. Add the component to `src/components/resolver.ts`
|
||||||
3. Add a block entry in `src/panels/left/BlocksPanel.tsx` under the appropriate category
|
3. Add a block entry in `src/panels/left/BlocksPanel.tsx` under the appropriate category
|
||||||
4. Implement the `toHtml` static for 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. Build and test: `npm run dev`, drag the block onto the canvas, verify settings panel, verify HTML export
|
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:
|
### Checklist for a new component:
|
||||||
- [ ] Props interface with `style?: CSSProperties`
|
- [ ] Props interface with `style?: CSSProperties`
|
||||||
- [ ] `useNode()` with `connect(drag(ref))` on the root element
|
- [ ] `useNode()` with `connect(drag(ref))` on the root element
|
||||||
- [ ] Settings panel using `useNode()` with `setProp()`
|
- [ ] `StylePanel` entry (new or reused) wired into `GuidedStyles.tsx`
|
||||||
- [ ] `.craft` config with `displayName`, default `props`, `rules`, `related.settings`
|
- [ ] `.craft` config with `displayName`, default `props`, `rules`
|
||||||
- [ ] `.toHtml()` static method using `cssPropsToString()`
|
- [ ] `.toHtml()` static method using `cssPropsToString()`
|
||||||
- [ ] Registered in `resolver.ts`
|
- [ ] Registered in `resolver.ts`
|
||||||
- [ ] Block added to `BlocksPanel.tsx`
|
- [ ] 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`
|
- **Border:** `#2d2d3a`
|
||||||
- **Font:** Inter
|
- **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
|
## Presets
|
||||||
|
|
||||||
@@ -446,7 +458,7 @@ Every component has a static `toHtml(props, childrenHtml)` method. The `html-exp
|
|||||||
|
|
||||||
## Testing Approach
|
## 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)
|
- **Type checking:** `tsc --noEmit` (part of build step)
|
||||||
- **HTML export:** Verify `toHtml()` output matches expected HTML structure
|
- **HTML export:** Verify `toHtml()` output matches expected HTML structure
|
||||||
- **Device preview:** Switch between desktop/tablet/mobile and verify responsive behavior
|
- **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
|
## Development Notes
|
||||||
|
|
||||||
- Path alias `@/` maps to `./src/` (configured in both tsconfig.json and vite.config.ts)
|
- 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
|
- Text components (Heading, TextBlock) use `contentEditable` for inline editing when selected
|
||||||
- Button/link navigation is prevented in the editor via `e.preventDefault()`
|
- 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
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { ButtonLink } from './ButtonLink';
|
||||||
|
|
||||||
|
const toHtml = (ButtonLink as any).toHtml;
|
||||||
|
|
||||||
|
describe('ButtonLink.toHtml href sanitization (attacker-controlled `href` prop)', () => {
|
||||||
|
test('a javascript: URL is neutralized', () => {
|
||||||
|
const { html } = toHtml({ href: 'javascript:alert(1)', text: 'Click' }, '');
|
||||||
|
expect(html).not.toContain('javascript:alert');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a quote-breakout href does not escape the href attribute', () => {
|
||||||
|
const malicious = '"><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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
interface ButtonLinkProps {
|
interface ButtonLinkProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -44,150 +45,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
ButtonLink.craft = {
|
ButtonLink.craft = {
|
||||||
@@ -211,9 +68,6 @@ ButtonLink.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ButtonLinkSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
@@ -224,9 +78,9 @@ ButtonLink.craft = {
|
|||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
...props.style,
|
...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"' : '';
|
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
return {
|
return {
|
||||||
html: `<a href="${props.href || '#'}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
|
html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Divider.craft = {
|
Divider.craft = {
|
||||||
@@ -101,9 +48,6 @@ Divider.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: DividerSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- 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,22 @@
|
|||||||
|
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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml } from '../../utils/escape';
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -20,16 +21,33 @@ export const Footer: UserComponent<FooterProps> = ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const elRef = useRef<HTMLElement | null>(null);
|
const elRef = useRef<HTMLElement | null>(null);
|
||||||
|
const editedTextRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const commitText = useCallback(() => {
|
||||||
if (elRef.current) {
|
if (elRef.current) {
|
||||||
const newText = elRef.current.innerText;
|
const newText = elRef.current.innerText;
|
||||||
|
editedTextRef.current = newText;
|
||||||
setProp((p: FooterProps) => { p.text = newText; }, 500);
|
setProp((p: FooterProps) => { p.text = newText; }, 500);
|
||||||
}
|
}
|
||||||
}, [setProp]);
|
}, [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(() => {
|
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 || '';
|
elRef.current.innerText = text || '';
|
||||||
}
|
}
|
||||||
}, [text, selected]);
|
}, [text, selected]);
|
||||||
@@ -43,6 +61,12 @@ export const Footer: UserComponent<FooterProps> = ({
|
|||||||
contentEditable={selected}
|
contentEditable={selected}
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
|
onInput={() => {
|
||||||
|
// Track that we have unsaved edits
|
||||||
|
if (elRef.current) {
|
||||||
|
editedTextRef.current = elRef.current.innerText;
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '24px 20px',
|
padding: '24px 20px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
@@ -56,67 +80,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Footer.craft = {
|
Footer.craft = {
|
||||||
@@ -135,9 +98,6 @@ Footer.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: FooterSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
@@ -148,6 +108,6 @@ Footer.craft = {
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const escapedText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
const escapedText = escapeHtml(props.text || '');
|
||||||
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` };
|
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
import { escapeHtml } from '../../utils/escape';
|
||||||
import { TypographyControl } from '../../ui/TypographyControl';
|
|
||||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
|
||||||
|
|
||||||
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
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 {
|
interface HeadingProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
level?: HeadingLevel;
|
level?: HeadingLevel;
|
||||||
@@ -33,6 +41,7 @@ export const Heading: UserComponent<HeadingProps> = ({
|
|||||||
selected: node.events.selected,
|
selected: node.events.selected,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const safeLevel = sanitizeHeadingLevel(level);
|
||||||
const elRef = useRef<HTMLElement | null>(null);
|
const elRef = useRef<HTMLElement | null>(null);
|
||||||
const editedTextRef = useRef<string | null>(null);
|
const editedTextRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -62,7 +71,7 @@ export const Heading: UserComponent<HeadingProps> = ({
|
|||||||
}
|
}
|
||||||
}, [text, selected]);
|
}, [text, selected]);
|
||||||
|
|
||||||
return React.createElement(level, {
|
return React.createElement(safeLevel, {
|
||||||
ref: (ref: HTMLElement | null): void => {
|
ref: (ref: HTMLElement | null): void => {
|
||||||
elRef.current = ref;
|
elRef.current = ref;
|
||||||
if (ref) connect(drag(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 = {
|
Heading.craft = {
|
||||||
displayName: 'Heading',
|
displayName: 'Heading',
|
||||||
props: {
|
props: {
|
||||||
@@ -168,14 +107,11 @@ Heading.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: HeadingSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
|
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
|
||||||
const tag = props.level || 'h2';
|
const tag = sanitizeHeadingLevel(props.level);
|
||||||
const safeText = (props.text || '').replace(/</g, '<').replace(/>/g, '>');
|
const safeText = escapeHtml(props.text || '');
|
||||||
const styleStr = cssPropsToString(props.style);
|
const styleStr = cssPropsToString(props.style);
|
||||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
|
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { HtmlBlock } from './HtmlBlock';
|
||||||
|
|
||||||
|
const toHtml = (HtmlBlock as any).toHtml;
|
||||||
|
|
||||||
|
describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
|
||||||
|
test('strips <script> and on-handlers from exported output', () => {
|
||||||
|
const { html } = toHtml({ code: '<script>alert(1)</script><p onclick="x">hi</p>' }, '');
|
||||||
|
expect(html).not.toContain('<script');
|
||||||
|
expect(html).not.toContain('onclick');
|
||||||
|
expect(html).toContain('<p>hi</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not wrap output in an unsanitized element carrying the style prop raw', () => {
|
||||||
|
// toHtml only ever returns the sanitized `code` blob -- there is no
|
||||||
|
// wrapper <div style="..."> in the exported HTML, so a malicious
|
||||||
|
// `style` prop (e.g. an attacker-controlled object with a breakout
|
||||||
|
// toString()) has nothing to splice into.
|
||||||
|
const malicious = { toString: () => 'color:red" onmouseover="alert(1)' } as any;
|
||||||
|
const { html } = toHtml({ code: '<p>hi</p>', style: malicious }, '');
|
||||||
|
expect(html).not.toMatch(/onmouseover/);
|
||||||
|
expect(html).not.toMatch(/<div/);
|
||||||
|
expect(html).toBe('<p>hi</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,76 +49,6 @@ export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {}
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- 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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
HtmlBlock.craft = {
|
HtmlBlock.craft = {
|
||||||
@@ -132,14 +62,12 @@ HtmlBlock.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: HtmlBlockSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => {
|
(HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => {
|
||||||
// Output the raw code as-is
|
// Run through the same DOMPurify config used for the live editor preview
|
||||||
return { html: props.code || '' };
|
// so exported pages can't carry <script>/on*= payloads either.
|
||||||
|
return { html: purifyHtml(props.code || '') };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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="/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
interface IconProps {
|
interface IconProps {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -13,15 +14,6 @@ interface IconProps {
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
function getBgBorderRadius(shape: string): string {
|
||||||
if (shape === 'circle') return '50%';
|
if (shape === 'circle') return '50%';
|
||||||
if (shape === 'rounded') return '8px';
|
if (shape === 'rounded') return '8px';
|
||||||
@@ -91,176 +83,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Icon.craft = {
|
Icon.craft = {
|
||||||
@@ -280,9 +102,6 @@ Icon.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: IconSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
@@ -300,7 +119,7 @@ Icon.craft = {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
|
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';
|
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
|
||||||
if (hasBg) {
|
if (hasBg) {
|
||||||
@@ -317,7 +136,7 @@ Icon.craft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (link) {
|
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 });
|
const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style });
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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 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 { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||||
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
/* ---------- Types ---------- */
|
/* ---------- Types ---------- */
|
||||||
|
|
||||||
@@ -18,31 +19,6 @@ interface LogoProps {
|
|||||||
style?: CSSProperties;
|
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, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Component ---------- */
|
/* ---------- Component ---------- */
|
||||||
|
|
||||||
export const Logo: UserComponent<LogoProps> = ({
|
export const Logo: UserComponent<LogoProps> = ({
|
||||||
@@ -97,269 +73,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Logo.craft = {
|
Logo.craft = {
|
||||||
@@ -381,9 +94,6 @@ Logo.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: LogoSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
@@ -394,7 +104,7 @@ Logo.craft = {
|
|||||||
let innerHtml: string;
|
let innerHtml: string;
|
||||||
if (props.type === 'image' && props.imageSrc) {
|
if (props.type === 'image' && props.imageSrc) {
|
||||||
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
|
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(safeUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
|
||||||
} else {
|
} else {
|
||||||
const spanStyle = cssPropsToString({
|
const spanStyle = cssPropsToString({
|
||||||
fontWeight: props.fontWeight || '700',
|
fontWeight: props.fontWeight || '700',
|
||||||
@@ -402,7 +112,7 @@ Logo.craft = {
|
|||||||
fontFamily: props.fontFamily || 'Inter, sans-serif',
|
fontFamily: props.fontFamily || 'Inter, sans-serif',
|
||||||
color: props.color || '#1f2937',
|
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({
|
const aStyle = cssPropsToString({
|
||||||
@@ -414,6 +124,6 @@ Logo.craft = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<a href="${esc(href)}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
html: `<a href="${escapeAttr(safeUrl(href))}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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 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 React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { usePages } from '../../state/PageContext';
|
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
/* ---------- Types ---------- */
|
/* ---------- Types ---------- */
|
||||||
|
|
||||||
@@ -34,12 +34,6 @@ const defaultLinks: MenuLink[] = [
|
|||||||
{ text: 'Contact', href: '#contact', isCta: true },
|
{ 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 ---------- */
|
/* ---------- Component ---------- */
|
||||||
|
|
||||||
export const Menu: UserComponent<MenuProps> = ({
|
export const Menu: UserComponent<MenuProps> = ({
|
||||||
@@ -105,325 +99,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Menu.craft = {
|
Menu.craft = {
|
||||||
@@ -445,22 +120,23 @@ Menu.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: MenuSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string) => {
|
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const linkCol = props.linkColor || '#3f3f46';
|
// Sanitized once here -- linkCol/hoverCol/ctaBg/ctaText/gap/fSize are raw
|
||||||
const hoverCol = props.linkHoverColor || '#3b82f6';
|
// string-interpolation sinks below (hoverCol goes into a <style> block,
|
||||||
const ctaBg = props.ctaBgColor || '#3b82f6';
|
// the worst case: </style> breakout -> arbitrary <script>), see
|
||||||
const ctaText = props.ctaTextColor || '#ffffff';
|
// task-cssxss-brief.md.
|
||||||
const gap = props.gap || '24px';
|
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 orientation = props.orientation || 'horizontal';
|
||||||
const alignment = props.alignment || 'right';
|
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' };
|
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
|
||||||
|
|
||||||
@@ -478,12 +154,15 @@ Menu.craft = {
|
|||||||
|
|
||||||
const links = props.links || defaultLinks;
|
const links = props.links || defaultLinks;
|
||||||
|
|
||||||
// Unique ID suffix for scoped CSS
|
// Scope for the hover CSS classes below. Deterministic AND unique: scoped
|
||||||
const scopeId = `menu-${Math.random().toString(36).slice(2, 8)}`;
|
// 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 linksHtml = links.map((link) => {
|
||||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
const cls = link.isCta ? `${scopeId}-cta` : `${scopeId}-link`;
|
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
|
||||||
const linkStyle = cssPropsToString({
|
const linkStyle = cssPropsToString({
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontSize: fSize,
|
fontSize: fSize,
|
||||||
@@ -494,12 +173,12 @@ Menu.craft = {
|
|||||||
borderRadius: link.isCta ? '6px' : '0',
|
borderRadius: link.isCta ? '6px' : '0',
|
||||||
transition: 'color 0.15s, background-color 0.15s',
|
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}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
const hoverCss = `<style>
|
const hoverCss = `<style>
|
||||||
.${scopeId}-link:hover { color: ${hoverCol} !important; }
|
.${scope}-link:hover { color: ${hoverCol} !important; }
|
||||||
.${scopeId}-cta:hover { filter: brightness(1.1); }
|
.${scope}-cta:hover { filter: brightness(1.1); }
|
||||||
</style>`;
|
</style>`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 }, '');
|
||||||
|
expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/);
|
||||||
|
expect(html).toMatch(/aria-expanded="false"/);
|
||||||
|
expect(html).toMatch(/aria-controls="navbar-links"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aria-controls target id exists on the links container', () => {
|
||||||
|
const { html } = toHtml({ showMobileMenu: true }, '');
|
||||||
|
expect(html).toContain('id="navbar-links"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle script flips aria-expanded on click', () => {
|
||||||
|
const { html } = toHtml({ showMobileMenu: true }, '');
|
||||||
|
expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no mobile menu: no hamburger button emitted', () => {
|
||||||
|
const { html } = toHtml({ showMobileMenu: false }, '');
|
||||||
|
expect(html).not.toContain('navbar-hamburger');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { usePages } from '../../state/PageContext';
|
|
||||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||||
|
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
/* ---------- Types ---------- */
|
/* ---------- Types ---------- */
|
||||||
|
|
||||||
@@ -51,31 +51,6 @@ const PADDING_PRESETS = [
|
|||||||
{ label: 'Spacious', value: '24px 48px' },
|
{ 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 ---------- */
|
/* ---------- Component ---------- */
|
||||||
|
|
||||||
export const Navbar: UserComponent<NavbarProps> = ({
|
export const Navbar: UserComponent<NavbarProps> = ({
|
||||||
@@ -199,595 +174,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Navbar.craft = {
|
Navbar.craft = {
|
||||||
@@ -820,20 +206,20 @@ Navbar.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: NavbarSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => {
|
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => {
|
||||||
const bgColor = props.backgroundColor || '#ffffff';
|
// Sanitized once here -- these are raw string-interpolation sinks below
|
||||||
const textCol = props.textColor || '#3f3f46';
|
// (hoverCol/bgColor go into a <style> block, the worst case: </style>
|
||||||
const hoverCol = props.hoverColor || '#3b82f6';
|
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
|
||||||
const ctaCol = props.ctaColor || '#3b82f6';
|
const bgColor = cssValue(props.backgroundColor) || '#ffffff';
|
||||||
const ctaTextCol = props.ctaTextColor || '#ffffff';
|
const textCol = cssValue(props.textColor) || '#3f3f46';
|
||||||
const pad = props.padding || '16px 24px';
|
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 alignment = props.navAlignment || 'space-between';
|
||||||
const sticky = props.isSticky;
|
const sticky = props.isSticky;
|
||||||
const mobile = props.showMobileMenu;
|
const mobile = props.showMobileMenu;
|
||||||
@@ -853,7 +239,7 @@ Navbar.craft = {
|
|||||||
let logoHtml: string;
|
let logoHtml: string;
|
||||||
if (props.logoType === 'image' && props.logoImage) {
|
if (props.logoType === 'image' && props.logoImage) {
|
||||||
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
|
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(safeUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
|
||||||
} else {
|
} else {
|
||||||
const logoStyle = cssPropsToString({
|
const logoStyle = cssPropsToString({
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
@@ -861,7 +247,7 @@ Navbar.craft = {
|
|||||||
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
|
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
|
||||||
color: props.logoColor || textCol,
|
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
|
// Links HTML
|
||||||
@@ -878,15 +264,18 @@ Navbar.craft = {
|
|||||||
borderRadius: link.isCta ? '6px' : '0',
|
borderRadius: link.isCta ? '6px' : '0',
|
||||||
transition: 'color 0.15s, background-color 0.15s',
|
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 ');
|
}).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
|
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">
|
? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="navbar-links" onclick="var m=this.parentElement.querySelector('.navbar-links');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:${esc(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:${esc(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:${esc(textCol)}"></span>
|
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
||||||
</button>`
|
</button>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -915,14 +304,14 @@ Navbar.craft = {
|
|||||||
borderRadius: link.isCta ? '6px' : '0',
|
borderRadius: link.isCta ? '6px' : '0',
|
||||||
transition: 'color 0.15s, background-color 0.15s',
|
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}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `${hoverCss}
|
html: `${hoverCss}
|
||||||
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
|
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
|
||||||
${logoHtml}${hamburgerHtml}
|
${logoHtml}${hamburgerHtml}
|
||||||
<div class="navbar-links" style="display:flex;align-items:center;gap:24px">
|
<div class="navbar-links" id="navbar-links" style="display:flex;align-items:center;gap:24px">
|
||||||
${linksHtmlWithClass}
|
${linksHtmlWithClass}
|
||||||
</div>
|
</div>
|
||||||
</nav>`,
|
</nav>`,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -92,62 +93,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
SearchBar.craft = {
|
SearchBar.craft = {
|
||||||
@@ -163,15 +108,11 @@ SearchBar.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SearchBarSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
|
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const {
|
const {
|
||||||
placeholder = 'Search...',
|
placeholder = 'Search...',
|
||||||
buttonText = 'Search',
|
buttonText = 'Search',
|
||||||
@@ -189,14 +130,14 @@ 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 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
|
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>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
|
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||||
<div style="position:relative;flex:1">
|
<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>
|
<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" placeholder="${esc(placeholder)}" style="${inputStyleStr}" />
|
<input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
|
||||||
</div>
|
</div>
|
||||||
${btnHtml}
|
${btnHtml}
|
||||||
</form>`,
|
</form>`,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface SocialLink {
|
interface SocialLink {
|
||||||
platform: string;
|
platform: string;
|
||||||
@@ -48,8 +49,6 @@ const platformLabels: Record<string, string> = {
|
|||||||
twitch: 'Twitch',
|
twitch: 'Twitch',
|
||||||
};
|
};
|
||||||
|
|
||||||
const allPlatforms = Object.keys(platformIcons);
|
|
||||||
|
|
||||||
const defaultLinks: SocialLink[] = [
|
const defaultLinks: SocialLink[] = [
|
||||||
{ platform: 'facebook', url: '#' },
|
{ platform: 'facebook', url: '#' },
|
||||||
{ platform: 'twitter', url: '#' },
|
{ platform: 'twitter', url: '#' },
|
||||||
@@ -139,235 +138,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
SocialLinks.craft = {
|
SocialLinks.craft = {
|
||||||
@@ -387,18 +157,16 @@ SocialLinks.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SocialLinksSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
|
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
|
||||||
const links = props.links || defaultLinks;
|
const links = props.links || defaultLinks;
|
||||||
const iconSize = props.iconSize || '20px';
|
// Sanitized -- raw string-interpolation sinks in aStyle/getShapeStr below.
|
||||||
const iconColor = props.iconColor || '#ffffff';
|
const iconSize = cssValue(props.iconSize) || '20px';
|
||||||
const iconBgColor = props.iconBgColor || '#374151';
|
const iconColor = cssValue(props.iconColor) || '#ffffff';
|
||||||
|
const iconBgColor = cssValue(props.iconBgColor) || '#374151';
|
||||||
const iconShape = props.iconShape || 'circle';
|
const iconShape = props.iconShape || 'circle';
|
||||||
const gap = props.gap || '10px';
|
const gap = props.gap || '10px';
|
||||||
const alignment = props.alignment || 'center';
|
const alignment = props.alignment || 'center';
|
||||||
@@ -437,7 +205,11 @@ SocialLinks.craft = {
|
|||||||
if (hasBg) {
|
if (hasBg) {
|
||||||
aStyle += `;${getShapeStr()}`;
|
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 ');
|
}).join('\n ');
|
||||||
|
|
||||||
return {
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Spacer.craft = {
|
Spacer.craft = {
|
||||||
@@ -91,9 +46,6 @@ Spacer.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SpacerSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { cssValue, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface StarRatingProps {
|
interface StarRatingProps {
|
||||||
rating?: number;
|
rating?: number;
|
||||||
@@ -75,107 +76,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
StarRating.craft = {
|
StarRating.craft = {
|
||||||
@@ -193,19 +93,28 @@ StarRating.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: StarRatingSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
|
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
|
||||||
const rating = props.rating ?? 4.5;
|
// `rating`/`maxStars` are declared `number` in TS but arrive unchecked at
|
||||||
const maxStars = props.maxStars || 5;
|
// runtime (AI update_props only validates node_id; deserialized saved
|
||||||
const size = props.size || '24px';
|
// state is untyped JSON) -- a string like `5" onmouseover="alert(1)`
|
||||||
const filledColor = props.filledColor || '#f59e0b';
|
// breaks out of the aria-label attribute below, and an uncoerced/unclamped
|
||||||
const emptyColor = props.emptyColor || '#d1d5db';
|
// 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({
|
const wrapperStyle = cssPropsToString({
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -216,15 +125,23 @@ StarRating.craft = {
|
|||||||
let starsHtml = '';
|
let starsHtml = '';
|
||||||
for (let i = 1; i <= maxStars; i++) {
|
for (let i = 1; i <= maxStars; i++) {
|
||||||
if (i <= Math.floor(rating)) {
|
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) {
|
} 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 {
|
} 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 {
|
return {
|
||||||
html: `<span${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
|
html: `<span role="img" aria-label="${ariaLabel}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
import { escapeHtml } from '../../utils/escape';
|
||||||
import { TypographyControl } from '../../ui/TypographyControl';
|
|
||||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
|
||||||
|
|
||||||
interface TextBlockProps {
|
interface TextBlockProps {
|
||||||
text?: string;
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
TextBlock.craft = {
|
TextBlock.craft = {
|
||||||
@@ -144,15 +90,12 @@ TextBlock.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: TextBlockSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
|
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
|
||||||
const styleStr = cssPropsToString(props.style);
|
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>` };
|
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ const toHtml = (ContactForm as any).toHtml;
|
|||||||
describe('ContactForm.toHtml relay wiring', () => {
|
describe('ContactForm.toHtml relay wiring', () => {
|
||||||
test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
|
test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
|
||||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '');
|
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(/<!--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(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||||
expect(html).toContain('method="POST"');
|
expect(html).toContain('method="POST"');
|
||||||
expect(html).toContain('name="_gotcha"');
|
expect(html).toContain('name="_gotcha"');
|
||||||
// marker id and action id match
|
// 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}__`);
|
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,3 +41,88 @@ describe('ContactForm.toHtml relay wiring', () => {
|
|||||||
expect(html).toContain('Name');
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
|
|||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||||
|
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
|
||||||
|
|
||||||
interface ContactFormField {
|
interface ContactFormField {
|
||||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
||||||
@@ -136,230 +137,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
ContactForm.craft = {
|
ContactForm.craft = {
|
||||||
@@ -386,15 +163,11 @@ ContactForm.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ContactFormSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
|
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const formStyle = cssPropsToString({
|
const formStyle = cssPropsToString({
|
||||||
padding: '32px',
|
padding: '32px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -402,23 +175,28 @@ ContactForm.craft = {
|
|||||||
gap: '20px',
|
gap: '20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const labelColor = props.labelColor || '#374151';
|
// Sanitized -- raw string-interpolation sinks in inputStyleStr/labelHtml
|
||||||
const inputBg = props.inputBg || '#ffffff';
|
// below.
|
||||||
const inputBorder = props.inputBorder || '#d1d5db';
|
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 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 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' : '';
|
const reqAttr = field.required ? ' required' : '';
|
||||||
let inputHtml = '';
|
let inputHtml = '';
|
||||||
if (field.type === 'textarea') {
|
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') {
|
} else if (field.type === 'select') {
|
||||||
const opts = (field.options || []).map((o) => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
|
const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
|
||||||
inputHtml = `<select name="${esc(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${esc(field.placeholder || 'Select...')}</option>${opts}</select>`;
|
inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
|
||||||
} else {
|
} 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>`;
|
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
@@ -436,11 +214,18 @@ ContactForm.craft = {
|
|||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction);
|
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
|
||||||
|
|
||||||
|
// The form-sender relay delivers success via a full-page 303 redirect
|
||||||
|
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
|
||||||
|
// that reveals an inline success element today. Emit successMessage as a
|
||||||
|
// forward-compatible data attribute so a future AJAX/JS submission mode
|
||||||
|
// can read it, without implying a live mechanism that doesn't exist yet.
|
||||||
|
const successAttr = props.successMessage ? ` data-whp-success-message="${escapeAttr(props.successMessage)}"` : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `${marker}<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
html: `${marker}<form action="${actionAttr}" method="POST"${successAttr}${formStyle ? ` style="${formStyle}"` : ''}>
|
||||||
${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
|
${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(props.submitText || 'Send Message')}</button>
|
||||||
</form>`,
|
</form>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml } from '../../utils/escape';
|
||||||
|
|
||||||
interface FormButtonProps {
|
interface FormButtonProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -42,101 +43,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
FormButton.craft = {
|
FormButton.craft = {
|
||||||
@@ -158,9 +64,6 @@ FormButton.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: FormButtonSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
@@ -172,7 +75,7 @@ FormButton.craft = {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const escapedText = (props.text || 'Submit').replace(/</g, '<').replace(/>/g, '>');
|
const escapedText = escapeHtml(props.text || 'Submit');
|
||||||
return {
|
return {
|
||||||
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
|
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ const toHtml = (FormContainer as any).toHtml;
|
|||||||
describe('FormContainer.toHtml relay wiring', () => {
|
describe('FormContainer.toHtml relay wiring', () => {
|
||||||
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
|
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
|
||||||
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
|
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(/<!--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(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||||
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
|
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
|
||||||
expect(html).toContain('name="_gotcha"');
|
expect(html).toContain('name="_gotcha"');
|
||||||
// honeypot precedes the form's children
|
// honeypot precedes the form's children
|
||||||
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
|
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
|
||||||
// marker id === action id
|
// marker id === action id
|
||||||
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}__`);
|
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,4 +24,31 @@ describe('FormContainer.toHtml relay wiring', () => {
|
|||||||
expect(html).toContain('action="/legacy"');
|
expect(html).toContain('action="/legacy"');
|
||||||
expect(html).toContain('<input name="email">');
|
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"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNode, Element, UserComponent } from '@craftjs/core';
|
|||||||
import { Container } from '../layout/Container';
|
import { Container } from '../layout/Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||||
|
import { sanitizeFormMethod } from '../../utils/escape';
|
||||||
|
|
||||||
interface FormContainerProps {
|
interface FormContainerProps {
|
||||||
action?: string;
|
action?: string;
|
||||||
@@ -43,94 +44,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 }}>Send submissions to (email)</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={props.recipientEmail || ''}
|
|
||||||
onChange={(e) => setProp((p: FormContainerProps) => { p.recipientEmail = e.target.value; })}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, 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. Leave blank to use the Form Action URL below instead.
|
|
||||||
</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: FormContainerProps) => { p.thankYouUrl = e.target.value; })}
|
|
||||||
placeholder="/thank-you (blank = hosted page)"
|
|
||||||
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 }}>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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
FormContainer.craft = {
|
FormContainer.craft = {
|
||||||
@@ -152,20 +65,17 @@ FormContainer.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: FormContainerSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string) => {
|
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string, nodeId?: string) => {
|
||||||
const styleStr = cssPropsToString({
|
const styleStr = cssPropsToString({
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action);
|
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
|
||||||
const method = useRelay ? 'POST' : (props.method || 'POST'); // relay requires POST
|
const method = useRelay ? 'POST' : sanitizeFormMethod(props.method); // relay requires POST
|
||||||
const body = honeypot + childrenHtml; // honeypot as first child
|
const body = honeypot + childrenHtml; // honeypot as first child
|
||||||
return {
|
return {
|
||||||
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
|
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml, escapeAttr, scopeId, sanitizeInputType } from '../../utils/escape';
|
||||||
|
|
||||||
interface InputFieldProps {
|
interface InputFieldProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -65,81 +66,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
InputField.craft = {
|
InputField.craft = {
|
||||||
@@ -157,15 +83,11 @@ InputField.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: InputFieldSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => {
|
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const wrapStyle = cssPropsToString({
|
const wrapStyle = cssPropsToString({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -173,13 +95,23 @@ InputField.craft = {
|
|||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const reqAttr = props.required ? ' required' : '';
|
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
|
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 {
|
return {
|
||||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||||
${labelHtml}
|
${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>`,
|
</div>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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).toContain('<form 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<');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface SubscribeFormProps {
|
interface SubscribeFormProps {
|
||||||
heading?: string;
|
heading?: string;
|
||||||
@@ -98,132 +99,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
SubscribeForm.craft = {
|
SubscribeForm.craft = {
|
||||||
@@ -241,15 +116,11 @@ SubscribeForm.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SubscribeFormSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const {
|
const {
|
||||||
heading = 'Subscribe to our newsletter',
|
heading = 'Subscribe to our newsletter',
|
||||||
placeholder = 'Enter your email',
|
placeholder = 'Enter your email',
|
||||||
@@ -268,7 +139,7 @@ SubscribeForm.craft = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const headingHtml = heading
|
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({
|
const formStyle = cssPropsToString({
|
||||||
@@ -299,8 +170,8 @@ SubscribeForm.craft = {
|
|||||||
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
|
||||||
${headingHtml}
|
${headingHtml}
|
||||||
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||||
<input type="email" name="email" placeholder="${esc(placeholder)}" required style="${inputStyleStr}" />
|
<input type="email" name="email" placeholder="${escapeAttr(placeholder)}" required style="${inputStyleStr}" />
|
||||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(buttonText)}</button>
|
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(buttonText)}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>`,
|
</div>`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
|
||||||
|
|
||||||
interface TextareaFieldProps {
|
interface TextareaFieldProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -67,81 +68,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
TextareaField.craft = {
|
TextareaField.craft = {
|
||||||
@@ -159,15 +85,11 @@ TextareaField.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: TextareaFieldSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
|
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const wrapStyle = cssPropsToString({
|
const wrapStyle = cssPropsToString({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -175,13 +97,28 @@ TextareaField.craft = {
|
|||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const reqAttr = props.required ? ' required' : '';
|
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
|
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 {
|
return {
|
||||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||||
${labelHtml}
|
${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>`,
|
</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 { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from './Container';
|
import { Container } from './Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface BackgroundSectionProps {
|
interface BackgroundSectionProps {
|
||||||
bgImage?: string;
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
BackgroundSection.craft = {
|
BackgroundSection.craft = {
|
||||||
@@ -174,15 +87,11 @@ BackgroundSection.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: BackgroundSectionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
|
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const outerStyle = cssPropsToString({
|
const outerStyle = cssPropsToString({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -207,7 +116,7 @@ BackgroundSection.craft = {
|
|||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
});
|
});
|
||||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||||
return {
|
return {
|
||||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from './Container';
|
import { Container } from './Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
type SplitOption =
|
type SplitOption =
|
||||||
| '100'
|
| '100'
|
||||||
@@ -94,178 +94,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
ColumnLayout.craft = {
|
ColumnLayout.craft = {
|
||||||
@@ -282,16 +110,20 @@ ColumnLayout.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ColumnLayoutSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
|
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const columns = props.columns || 2;
|
||||||
const gap = props.gap || '16px';
|
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({
|
const outerStyle = cssPropsToString({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
@@ -299,8 +131,23 @@ ColumnLayout.craft = {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
...props.style,
|
...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 {
|
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,65 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
import { escapeAttr } from '../../utils/escape';
|
||||||
import { BorderControl } from '../../ui/BorderControl';
|
|
||||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
// The only tag names Container actually supports (matches the TS union
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
// 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 {
|
interface ContainerProps {
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
@@ -40,9 +50,12 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
contentWidth = 'full',
|
contentWidth = 'full',
|
||||||
anchorId,
|
anchorId,
|
||||||
|
cssId,
|
||||||
|
cssClass,
|
||||||
}) => {
|
}) => {
|
||||||
const { connectors: { connect, drag } } = useNode();
|
const { connectors: { connect, drag } } = useNode();
|
||||||
|
|
||||||
|
const safeTag = sanitizeContainerTag(tag);
|
||||||
const needsBoxedWrapper = contentWidth === 'boxed';
|
const needsBoxedWrapper = contentWidth === 'boxed';
|
||||||
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
||||||
|
|
||||||
@@ -53,13 +66,19 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
...(needsBoxedWrapper ? {} : flexStyles),
|
...(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(
|
const el = React.createElement(
|
||||||
tag,
|
safeTag,
|
||||||
{
|
{
|
||||||
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
||||||
style: outerStyle,
|
style: outerStyle,
|
||||||
'data-craft-container': 'true',
|
'data-craft-container': 'true',
|
||||||
id: anchorId || undefined,
|
id: idValue,
|
||||||
|
className: cssClass || undefined,
|
||||||
},
|
},
|
||||||
needsBoxedWrapper
|
needsBoxedWrapper
|
||||||
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
||||||
@@ -69,237 +88,6 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
return el;
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Container.craft = {
|
Container.craft = {
|
||||||
@@ -310,22 +98,20 @@ Container.craft = {
|
|||||||
fullWidth: false,
|
fullWidth: false,
|
||||||
contentWidth: 'full',
|
contentWidth: 'full',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
cssId: '',
|
||||||
|
cssClass: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
canMoveIn: () => true,
|
canMoveIn: () => true,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ContainerSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const tag = sanitizeContainerTag(props.tag);
|
||||||
const tag = props.tag || 'div';
|
|
||||||
const isBoxed = props.contentWidth === 'boxed';
|
const isBoxed = props.contentWidth === 'boxed';
|
||||||
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
||||||
|
|
||||||
@@ -340,12 +126,15 @@ Container.craft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styleStr = cssPropsToString(outerCss);
|
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) {
|
if (isBoxed) {
|
||||||
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
|
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,75 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
|
|||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { Container } from './Container';
|
import { Container } from './Container';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeAttr, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
/* ---------- Shape Divider SVG Paths ---------- */
|
/* ---------- 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',
|
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 {
|
interface SectionProps {
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
innerMaxWidth?: string;
|
innerMaxWidth?: string;
|
||||||
@@ -40,7 +38,14 @@ const ShapeDivider: React.FC<{
|
|||||||
position: 'top' | 'bottom';
|
position: 'top' | 'bottom';
|
||||||
}> = ({ shape, color, height, position }) => {
|
}> = ({ shape, color, height, position }) => {
|
||||||
if (!shape || shape === 'none') return null;
|
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;
|
if (!path) return null;
|
||||||
|
|
||||||
const isTop = position === 'top';
|
const isTop = position === 'top';
|
||||||
@@ -133,198 +138,6 @@ 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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Section.craft = {
|
Section.craft = {
|
||||||
@@ -345,9 +158,6 @@ Section.craft = {
|
|||||||
canMoveIn: () => true,
|
canMoveIn: () => true,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: SectionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
@@ -359,12 +169,16 @@ function buildDividerHtml(
|
|||||||
position: 'top' | 'bottom',
|
position: 'top' | 'bottom',
|
||||||
): string {
|
): string {
|
||||||
if (!shape || shape === 'none') return '';
|
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 '';
|
if (!path) return '';
|
||||||
|
|
||||||
const isTop = position === 'top';
|
const isTop = position === 'top';
|
||||||
const h = height || '50px';
|
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({
|
const wrapperStyle = cssPropsToString({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -383,7 +197,6 @@ function buildDividerHtml(
|
|||||||
}
|
}
|
||||||
|
|
||||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
(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 hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
||||||
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
||||||
|
|
||||||
@@ -401,7 +214,7 @@ function buildDividerHtml(
|
|||||||
|
|
||||||
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
|
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
|
||||||
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
|
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 {
|
return {
|
||||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { ImageBlock } 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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
import React, { CSSProperties, useCallback, useRef } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeAttr, safeUrl } 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";
|
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";
|
||||||
|
|
||||||
@@ -80,392 +81,10 @@ 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 = {
|
ImageBlock.craft = {
|
||||||
displayName: 'Image',
|
displayName: 'Image',
|
||||||
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
||||||
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
||||||
related: { settings: ImageBlockSettings },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
|
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
|
||||||
@@ -475,6 +94,6 @@ ImageBlock.craft = {
|
|||||||
return { html: '' };
|
return { html: '' };
|
||||||
}
|
}
|
||||||
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
||||||
const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '"')}"` : ' alt=""';
|
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
|
||||||
return { html: `<img src="${src}"${alt}${s ? ` style="${s}"` : ''} />` };
|
return { html: `<img src="${escapeAttr(safeUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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 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,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
interface MapEmbedProps {
|
interface MapEmbedProps {
|
||||||
address?: string;
|
address?: string;
|
||||||
@@ -11,7 +12,15 @@ interface MapEmbedProps {
|
|||||||
|
|
||||||
function buildMapUrl(address: string, zoom: number): string {
|
function buildMapUrl(address: string, zoom: number): string {
|
||||||
const encoded = encodeURIComponent(address);
|
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> = ({
|
export const MapEmbed: UserComponent<MapEmbedProps> = ({
|
||||||
@@ -53,79 +62,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
MapEmbed.craft = {
|
MapEmbed.craft = {
|
||||||
@@ -141,9 +77,6 @@ MapEmbed.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: MapEmbedSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
@@ -168,6 +101,6 @@ MapEmbed.craft = {
|
|||||||
const src = buildMapUrl(address, zoom);
|
const src = buildMapUrl(address, zoom);
|
||||||
|
|
||||||
return {
|
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,120 @@
|
|||||||
|
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;)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from '../layout/Container';
|
import { Container } from '../layout/Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
/* ---------- Types ---------- */
|
/* ---------- Types ---------- */
|
||||||
|
|
||||||
@@ -25,18 +26,43 @@ interface VideoBlockProps {
|
|||||||
|
|
||||||
/* ---------- URL detection ---------- */
|
/* ---------- 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 } {
|
function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
|
||||||
if (!url) return { type: 'none', embedUrl: '' };
|
if (!url) return { type: 'none', embedUrl: '' };
|
||||||
|
|
||||||
// YouTube
|
// YouTube
|
||||||
const ytMatch = url.match(
|
const ytId = extractYouTubeId(url);
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/
|
if (ytId) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytId}?rel=0` };
|
||||||
);
|
|
||||||
if (ytMatch) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}?rel=0` };
|
|
||||||
|
|
||||||
// Vimeo
|
// Vimeo: vimeo.com/ID, or vimeo.com/ID/HASH for unlisted/private videos
|
||||||
const vmMatch = url.match(/vimeo\.com\/(\d+)/);
|
// (the hash becomes the player's `h` query param).
|
||||||
if (vmMatch) return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}` };
|
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
|
// Direct file
|
||||||
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
|
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
|
||||||
@@ -60,28 +86,6 @@ function buildEmbedParams(
|
|||||||
return url.toString();
|
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 ---------- */
|
/* ---------- Placeholder ---------- */
|
||||||
|
|
||||||
const VIDEO_PLACEHOLDER = (
|
const VIDEO_PLACEHOLDER = (
|
||||||
@@ -296,342 +300,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
|
Craft Config
|
||||||
======================================================================== */
|
======================================================================== */
|
||||||
@@ -657,9 +325,6 @@ VideoBlock.craft = {
|
|||||||
canMoveIn: () => true,
|
canMoveIn: () => true,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: VideoBlockSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ========================================================================
|
/* ========================================================================
|
||||||
@@ -722,7 +387,7 @@ VideoBlock.craft = {
|
|||||||
zIndex: '0',
|
zIndex: '0',
|
||||||
pointerEvents: 'none',
|
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) {
|
} else if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
|
||||||
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
|
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
|
||||||
const ifrStyle = cssPropsToString({
|
const ifrStyle = cssPropsToString({
|
||||||
@@ -738,7 +403,7 @@ VideoBlock.craft = {
|
|||||||
zIndex: '0',
|
zIndex: '0',
|
||||||
pointerEvents: 'none',
|
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 {
|
return {
|
||||||
@@ -771,7 +436,7 @@ VideoBlock.craft = {
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
});
|
});
|
||||||
return {
|
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>`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +454,6 @@ VideoBlock.craft = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
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))}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface AccordionItem {
|
interface AccordionItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -123,156 +123,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Accordion.craft = {
|
Accordion.craft = {
|
||||||
@@ -291,24 +141,22 @@ Accordion.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: AccordionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||||
const headerBg = props.headerBg || '#f8fafc';
|
// Sanitized -- raw string-interpolation sinks in the <details>/<summary>
|
||||||
const headerColor = props.headerColor || '#18181b';
|
// style attributes below.
|
||||||
const contentBg = props.contentBg || '#ffffff';
|
const headerBg = cssValue(props.headerBg) || '#f8fafc';
|
||||||
const borderColor = props.borderColor || '#e2e8f0';
|
const headerColor = cssValue(props.headerColor) || '#18181b';
|
||||||
|
const contentBg = cssValue(props.contentBg) || '#ffffff';
|
||||||
|
const borderColor = cssValue(props.borderColor) || '#e2e8f0';
|
||||||
const items = props.items || defaultItems;
|
const items = props.items || defaultItems;
|
||||||
|
|
||||||
const panels = items.map((item, i) => {
|
const panels = items.map((item, i) => {
|
||||||
@@ -318,10 +166,10 @@ Accordion.craft = {
|
|||||||
const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`;
|
const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`;
|
||||||
return `<details${openAttr} style="${borderBottom}${topRadius}${bottomRadius}">
|
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">
|
<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>
|
</summary>
|
||||||
<div style="padding:16px 20px;background-color:${contentBg};color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}">
|
<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>
|
</div>
|
||||||
</details>`;
|
</details>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||||
|
|
||||||
interface CTASectionProps {
|
interface CTASectionProps {
|
||||||
heading?: string;
|
heading?: string;
|
||||||
@@ -70,78 +70,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
CTASection.craft = {
|
CTASection.craft = {
|
||||||
@@ -161,15 +89,11 @@ CTASection.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: CTASectionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
|
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
background: props.gradient || defaultGradient,
|
background: props.gradient || defaultGradient,
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
@@ -178,12 +102,12 @@ CTASection.craft = {
|
|||||||
});
|
});
|
||||||
const ctas = normalizeCtas(props);
|
const ctas = normalizeCtas(props);
|
||||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' });
|
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 {
|
return {
|
||||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
<div style="max-width:700px;margin:0 auto">
|
<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>
|
<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">${esc(props.description || '')}</p>
|
<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 style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>`,
|
</section>`,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface CallToActionProps {
|
interface CallToActionProps {
|
||||||
heading?: string;
|
heading?: string;
|
||||||
@@ -112,216 +112,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
CallToAction.craft = {
|
CallToAction.craft = {
|
||||||
@@ -347,19 +137,16 @@ CallToAction.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: CallToActionSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
|
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
|
||||||
const bgType = props.bgType || 'gradient';
|
const bgType = props.bgType || 'gradient';
|
||||||
const bgValue = props.bgValue || defaultGradient;
|
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 buttonColor = props.buttonColor || '#ffffff';
|
||||||
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
||||||
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
||||||
@@ -397,13 +184,13 @@ CallToAction.craft = {
|
|||||||
|
|
||||||
const ctas = normalizeCtas(props);
|
const ctas = normalizeCtas(props);
|
||||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor });
|
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 {
|
return {
|
||||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
${overlayHtml}<div style="max-width:700px;margin:0 auto;position:relative;z-index:1">
|
${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>
|
<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">${esc(props.description || '')}</p>
|
<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 style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>`,
|
</section>`,
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
|
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface Slide {
|
interface Slide {
|
||||||
type: 'image' | 'content';
|
type: 'image' | 'content';
|
||||||
@@ -215,208 +216,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
ContentSlider.craft = {
|
ContentSlider.craft = {
|
||||||
@@ -435,15 +234,11 @@ ContentSlider.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: ContentSliderSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
|
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const {
|
const {
|
||||||
slides = defaultSlides,
|
slides = defaultSlides,
|
||||||
autoplay = true,
|
autoplay = true,
|
||||||
@@ -455,7 +250,24 @@ ContentSlider.craft = {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const items = slides.length > 0 ? slides : defaultSlides;
|
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({
|
const sectionStyle = cssPropsToString({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -467,21 +279,24 @@ ContentSlider.craft = {
|
|||||||
|
|
||||||
const slidesHtml = items.map((slide, i) => {
|
const slidesHtml = items.map((slide, i) => {
|
||||||
const hasBgImage = slide.imageSrc;
|
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
|
const bgStyle = hasBgImage
|
||||||
? `background-image:url(${esc(slide.imageSrc!)});background-size:cover;background-position:center`
|
? `background-image:url('${escapeAttr(safeUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
|
||||||
: slide.bgColor?.startsWith('linear-gradient')
|
: slide.bgColor?.startsWith('linear-gradient')
|
||||||
? `background-image:${slide.bgColor}`
|
? `background-image:${safeBgColor}`
|
||||||
: `background-color:${slide.bgColor || '#3b82f6'}`;
|
: `background-color:${safeBgColor}`;
|
||||||
|
|
||||||
const contentParts: string[] = [];
|
const contentParts: string[] = [];
|
||||||
if (slide.heading) {
|
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) {
|
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) {
|
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
|
const innerHtml = contentParts.length > 0
|
||||||
@@ -494,24 +309,37 @@ ContentSlider.craft = {
|
|||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
const arrowsHtml = showArrows && items.length > 1
|
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}_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()" 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}_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
|
const dotsHtml = showDots && items.length > 1
|
||||||
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2">
|
? `<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>`
|
</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 {
|
return {
|
||||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section id="${uid}"${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
|
<div id="${uid}_live" aria-live="${liveAttr}">
|
||||||
${slidesHtml}
|
${slidesHtml}
|
||||||
|
</div>
|
||||||
${arrowsHtml}
|
${arrowsHtml}
|
||||||
${dotsHtml}
|
${dotsHtml}
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var current=0, total=${items.length}, uid="${uid}";
|
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){
|
function show(idx){
|
||||||
document.getElementById(uid+"_s"+current).style.opacity="0";
|
document.getElementById(uid+"_s"+current).style.opacity="0";
|
||||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
|
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
|
||||||
@@ -519,10 +347,23 @@ ContentSlider.craft = {
|
|||||||
document.getElementById(uid+"_s"+current).style.opacity="1";
|
document.getElementById(uid+"_s"+current).style.opacity="1";
|
||||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
|
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
|
||||||
}
|
}
|
||||||
window["${uid}_go"]=show;
|
window["${uid}_go"]=function(idx){ markManualNav(); show(idx); };
|
||||||
window["${uid}_next"]=function(){show(current+1);};
|
window["${uid}_next"]=function(){ markManualNav(); show(current+1); };
|
||||||
window["${uid}_prev"]=function(){show(current-1);};
|
window["${uid}_prev"]=function(){ markManualNav(); show(current-1); };
|
||||||
${autoplay && items.length > 1 ? `setInterval(function(){show(current+1);},${interval});` : ''}
|
${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>
|
</script>
|
||||||
</section>`,
|
</section>`,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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*\{/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface CountdownProps {
|
interface CountdownProps {
|
||||||
targetDate?: string;
|
targetDate?: string;
|
||||||
@@ -127,108 +127,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Countdown.craft = {
|
Countdown.craft = {
|
||||||
@@ -247,23 +145,21 @@ Countdown.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: CountdownSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
|
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const {
|
const {
|
||||||
targetDate = DEFAULT_TARGET,
|
targetDate = DEFAULT_TARGET,
|
||||||
heading = 'Coming Soon',
|
heading = 'Coming Soon',
|
||||||
style = {},
|
style = {},
|
||||||
digitColor = '#ffffff',
|
|
||||||
labelColor = 'rgba(255,255,255,0.7)',
|
|
||||||
bgColor = '#18181b',
|
bgColor = '#18181b',
|
||||||
} = props;
|
} = 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({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
@@ -271,18 +167,29 @@ Countdown.craft = {
|
|||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
...style,
|
...style,
|
||||||
});
|
});
|
||||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||||
|
|
||||||
const headingHtml = heading
|
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 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 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`;
|
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
|
// Deterministic AND unique id for this countdown instance's span ids and
|
||||||
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
|
// 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 {
|
return {
|
||||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
@@ -295,11 +202,15 @@ Countdown.craft = {
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var target = new Date("${targetDate}").getTime();
|
var target = ${dateExpr}.getTime();
|
||||||
|
var timer = null;
|
||||||
function pad(n){ return String(n).padStart(2,'0'); }
|
function pad(n){ return String(n).padStart(2,'0'); }
|
||||||
function update(){
|
function update(){
|
||||||
var diff = target - Date.now();
|
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 d = Math.floor(diff/(1000*60*60*24));
|
||||||
var h = Math.floor((diff/(1000*60*60))%24);
|
var h = Math.floor((diff/(1000*60*60))%24);
|
||||||
var m = Math.floor((diff/(1000*60))%60);
|
var m = Math.floor((diff/(1000*60))%60);
|
||||||
@@ -310,7 +221,9 @@ Countdown.craft = {
|
|||||||
document.getElementById("${uid}_s").textContent = pad(s);
|
document.getElementById("${uid}_s").textContent = pad(s);
|
||||||
}
|
}
|
||||||
update();
|
update();
|
||||||
setInterval(update,1000);
|
if(target - Date.now() > 0){
|
||||||
|
timer = setInterval(update,1000);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</section>`,
|
</section>`,
|
||||||
|
|||||||
@@ -1,38 +1,18 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
interface FeatureItem {
|
interface FeatureItem {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
mediaType?: 'icon' | 'image'; // default behaves as 'icon' when undefined
|
|
||||||
image?: string;
|
image?: string;
|
||||||
imageAlt?: string;
|
imageAlt?: string;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
buttonUrl?: string;
|
buttonUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Image upload helper (same as Navbar/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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeaturesGridProps {
|
interface FeaturesGridProps {
|
||||||
features?: FeatureItem[];
|
features?: FeatureItem[];
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
@@ -110,152 +90,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 handleImageUpload = async (index: number, file: File) => {
|
|
||||||
const url = await uploadToWhp(file);
|
|
||||||
if (url) updateFeature(index, 'image', url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addFeature = () => {
|
|
||||||
setProp((p: FeaturesGridProps) => {
|
|
||||||
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
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' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Icon / Image toggle */}
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
{(['icon', 'image'] as const).map((mt) => {
|
|
||||||
const active = (feat.mediaType || 'icon') === mt;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={mt}
|
|
||||||
onClick={() => updateFeature(i, 'mediaType', mt)}
|
|
||||||
style={{
|
|
||||||
flex: 1, padding: '3px 6px', fontSize: 11, cursor: 'pointer',
|
|
||||||
background: active ? '#3b82f6' : '#27272a',
|
|
||||||
color: active ? '#fff' : '#e4e4e7',
|
|
||||||
border: '1px solid #3f3f46', borderRadius: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{mt === 'icon' ? 'Icon' : 'Image'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(feat.mediaType || 'icon') === 'image' ? (
|
|
||||||
<>
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<label
|
|
||||||
style={{ padding: '3px 6px', fontSize: 11, cursor: 'pointer', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, flex: 'none', whiteSpace: 'nowrap' }}
|
|
||||||
>
|
|
||||||
Upload
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleImageUpload(i, f); }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<input type="text" value={feat.image || ''} onChange={(e) => updateFeature(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
|
|
||||||
</div>
|
|
||||||
<input type="text" value={feat.imageAlt || ''} onChange={(e) => updateFeature(i, 'imageAlt', e.target.value)} placeholder="Alt text (optional)" style={inputStyle} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Button (optional) */}
|
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginTop: 2 }}>Button (optional)</label>
|
|
||||||
<input type="text" value={feat.buttonText || ''} onChange={(e) => updateFeature(i, 'buttonText', e.target.value)} placeholder="Button text" style={inputStyle} />
|
|
||||||
<input type="text" value={feat.buttonUrl || ''} onChange={(e) => updateFeature(i, 'buttonUrl', e.target.value)} placeholder="https://... or /page" style={inputStyle} />
|
|
||||||
</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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
FeaturesGrid.craft = {
|
FeaturesGrid.craft = {
|
||||||
@@ -270,31 +104,27 @@ FeaturesGrid.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: FeaturesGridSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
|
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
...props.style,
|
...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 cards = (props.features || defaultFeatures).map((feat) => {
|
||||||
const media = feat.image
|
const media = feat.image
|
||||||
? `<img src="${esc(feat.image)}" alt="${esc(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
|
? `<img src="${escapeAttr(safeUrl(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">${esc(feat.icon)}</div>`;
|
: `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
|
||||||
const button = feat.buttonText
|
const button = feat.buttonText
|
||||||
? `\n <a href="${esc(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">${esc(feat.buttonText)}</a>`
|
? `\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">
|
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
|
||||||
${media}
|
${media}
|
||||||
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
|
<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">${esc(feat.description)}</p>${button}
|
<p style="font-size:14px;color:#64748b;line-height:1.6">${escapeHtml(feat.description)}</p>${button}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { Gallery } from './Gallery';
|
||||||
|
|
||||||
|
const toHtml = (Gallery as any).toHtml;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface GalleryImage {
|
interface GalleryImage {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -103,156 +104,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Gallery.craft = {
|
Gallery.craft = {
|
||||||
@@ -269,52 +120,84 @@ Gallery.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: GallerySettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
|
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const images = props.images || defaultImages;
|
const images = props.images || defaultImages;
|
||||||
const columns = props.columns || 3;
|
// Number() coercion: `columns` is a raw string-interpolation sink into the
|
||||||
const gap = props.gap || '16px';
|
// 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 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 items = images.map((img) => {
|
||||||
const caption = img.caption
|
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"';
|
// Lightbox items carry the image URL as a data attribute rather than an
|
||||||
return `<div${clickAttr}>
|
// inline onclick with an interpolated src -- a single delegated click
|
||||||
<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" />
|
// 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(safeUrl(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(safeUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
||||||
${caption}
|
${caption}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
let lightboxHtml = '';
|
let lightboxHtml = '';
|
||||||
|
let gridIdAttr = '';
|
||||||
if (lightbox) {
|
if (lightbox) {
|
||||||
|
gridIdAttr = ` id="${galleryId}_grid"`;
|
||||||
lightboxHtml = `
|
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">
|
||||||
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
|
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<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';}
|
function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';}
|
||||||
|
function ${galleryId}_open(src){
|
||||||
|
var o = document.getElementById('${galleryId}_overlay');
|
||||||
|
document.getElementById('${galleryId}_img').src = src;
|
||||||
|
o.style.display = 'flex';
|
||||||
|
}
|
||||||
|
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){
|
||||||
|
if(e.key==='Escape'){ ${galleryId}_close(); }
|
||||||
|
});
|
||||||
</script>`;
|
</script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
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}
|
${items}
|
||||||
</div>${lightboxHtml}
|
</div>${lightboxHtml}
|
||||||
</section>`,
|
</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 React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
heading?: string;
|
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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
HeroSimple.craft = {
|
HeroSimple.craft = {
|
||||||
@@ -404,15 +195,11 @@ HeroSimple.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: HeroSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
|
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
const bg = buildBackground(props);
|
const bg = buildBackground(props);
|
||||||
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||||
|
|
||||||
@@ -433,15 +220,22 @@ HeroSimple.craft = {
|
|||||||
|
|
||||||
let overlayHtml = '';
|
let overlayHtml = '';
|
||||||
if ((props.overlayOpacity || 0) > 0) {
|
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 = '';
|
let videoHtml = '';
|
||||||
if (props.bgType === 'video' && props.bgVideo) {
|
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 justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
|
||||||
|
|
||||||
const ctas = normalizeCtas(props);
|
const ctas = normalizeCtas(props);
|
||||||
@@ -451,13 +245,14 @@ HeroSimple.craft = {
|
|||||||
outlineText: props.textColor || '#fff',
|
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 {
|
return {
|
||||||
html: `<section${idAttr} style="${sectionStyle}">
|
html: `<section${idAttr} style="${sectionStyle}">
|
||||||
${videoHtml}${overlayHtml}
|
${videoHtml}${overlayHtml}
|
||||||
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
|
<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>
|
<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:${props.textColor || '#fff'};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${esc(props.subtitle || '')}</p>
|
<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 style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>`,
|
</section>`,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface Counter {
|
interface Counter {
|
||||||
number: number;
|
number: number;
|
||||||
@@ -87,199 +88,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
NumberCounter.craft = {
|
NumberCounter.craft = {
|
||||||
@@ -297,26 +105,33 @@ NumberCounter.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: NumberCounterSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
|
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const {
|
const {
|
||||||
counters = defaultCounters,
|
counters = defaultCounters,
|
||||||
columns = 4,
|
|
||||||
numberColor = '#3b82f6',
|
|
||||||
labelColor = '#6b7280',
|
|
||||||
numberSize = '48px',
|
|
||||||
style = {},
|
style = {},
|
||||||
} = props;
|
} = 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 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({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
@@ -325,9 +140,21 @@ NumberCounter.craft = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const countersHtml = items.map((counter, i) => {
|
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">
|
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 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">${esc(counter.label)}</span>
|
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${escapeHtml(counter.label)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface PricingPlan {
|
interface PricingPlan {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -197,189 +197,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
PricingTable.craft = {
|
PricingTable.craft = {
|
||||||
@@ -396,23 +213,21 @@ PricingTable.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: PricingTableSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
|
(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 bulletType = props.bulletType || 'check';
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||||
const plans = props.plans || defaultPlans;
|
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';
|
||||||
|
|
||||||
const cards = plans.map((plan) => {
|
const cards = plans.map((plan) => {
|
||||||
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
|
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
|
||||||
@@ -427,7 +242,7 @@ PricingTable.craft = {
|
|||||||
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 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) =>
|
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 ');
|
).join('\n ');
|
||||||
|
|
||||||
const badge = plan.isFeatured
|
const badge = plan.isFeatured
|
||||||
@@ -436,15 +251,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}">
|
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}
|
${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">
|
<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:48px;font-weight:700;color:${textColor};line-height:1">${escapeHtml(plan.price)}</span>
|
||||||
<span style="font-size:16px;color:${subColor}">${esc(plan.period)}</span>
|
<span style="font-size:16px;color:${subColor}">${escapeHtml(plan.period)}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
|
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
|
||||||
${featuresHtml}
|
${featuresHtml}
|
||||||
</ul>
|
</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>`;
|
</div>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface TabItem {
|
interface TabItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -101,174 +101,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Tabs.craft = {
|
Tabs.craft = {
|
||||||
@@ -288,36 +120,47 @@ Tabs.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: TabsSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
|
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string, nodeId?: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
||||||
const tabs = props.tabs || defaultTabs;
|
const tabs = props.tabs || defaultTabs;
|
||||||
const activeTabBg = props.activeTabBg || '#3b82f6';
|
// Sanitized -- raw string-interpolation sinks below, both into style="..."
|
||||||
const activeTabColor = props.activeTabColor || '#ffffff';
|
// attributes AND into an inline <script> as single-quoted JS string
|
||||||
const inactiveTabBg = props.inactiveTabBg || '#f1f5f9';
|
// literals (a stray `'` there breaks out of the JS string, not just CSS);
|
||||||
const inactiveTabColor = props.inactiveTabColor || '#64748b';
|
// cssValue strips quotes too so it neutralizes both contexts at once.
|
||||||
const contentBg = props.contentBg || '#ffffff';
|
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 tabButtons = tabs.map((tab, i) => {
|
||||||
const isActive = i === 0;
|
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 ');
|
}).join('\n ');
|
||||||
|
|
||||||
const tabPanels = tabs.map((tab, i) => {
|
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 ');
|
}).join('\n ');
|
||||||
|
|
||||||
const switchScript = `<script>
|
const switchScript = `<script>
|
||||||
@@ -328,14 +171,36 @@ function ${tabId}_switch(idx){
|
|||||||
var btn=document.getElementById('${tabId}_btn_'+i);
|
var btn=document.getElementById('${tabId}_btn_'+i);
|
||||||
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
|
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
|
||||||
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
|
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>`;
|
</script>`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
<div style="max-width:800px;margin:0 auto">
|
<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}
|
${tabButtons}
|
||||||
</div>
|
</div>
|
||||||
${tabPanels}
|
${tabPanels}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
|
||||||
|
|
||||||
interface Testimonial {
|
interface Testimonial {
|
||||||
quote: string;
|
quote: string;
|
||||||
@@ -41,10 +41,16 @@ function renderStars(count: number, color: string): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function starsHtml(count: number, color: string): string {
|
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) =>
|
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('');
|
).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> = ({
|
export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||||
@@ -63,8 +69,6 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
|||||||
selected: node.events.selected,
|
selected: node.events.selected,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
|
|
||||||
const cardStyle: CSSProperties = {
|
const cardStyle: CSSProperties = {
|
||||||
backgroundColor: cardBg,
|
backgroundColor: cardBg,
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
@@ -103,44 +107,11 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
|||||||
{items.map((t, i) => renderCard(t, i))}
|
{items.map((t, i) => renderCard(t, i))}
|
||||||
</div>
|
</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' }}>
|
<div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}>
|
||||||
{renderCard(items[currentIndex] || items[0], currentIndex)}
|
{renderCard(items[0], 0)}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -148,209 +119,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 ---------- */
|
/* ---------- Craft config ---------- */
|
||||||
|
|
||||||
Testimonials.craft = {
|
Testimonials.craft = {
|
||||||
@@ -369,23 +137,24 @@ Testimonials.craft = {
|
|||||||
canMoveIn: () => false,
|
canMoveIn: () => false,
|
||||||
canMoveOut: () => true,
|
canMoveOut: () => true,
|
||||||
},
|
},
|
||||||
related: {
|
|
||||||
settings: TestimonialsSettings,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
|
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
|
||||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
const {
|
const {
|
||||||
testimonials = defaultTestimonials,
|
testimonials = defaultTestimonials,
|
||||||
layout = 'grid',
|
layout = 'grid',
|
||||||
columns = 3,
|
|
||||||
style = {},
|
style = {},
|
||||||
cardBg = '#f8fafc',
|
|
||||||
starColor = '#f59e0b',
|
|
||||||
} = props;
|
} = 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;
|
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
|
||||||
|
|
||||||
@@ -394,30 +163,32 @@ Testimonials.craft = {
|
|||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
...style,
|
...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 cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
|
||||||
|
|
||||||
const cards = items.map((t) => {
|
const cardHtml = (t: Testimonial): string => `<div style="${cardCss}">
|
||||||
return `<div style="${cardCss}">
|
|
||||||
${starsHtml(t.rating, starColor)}
|
${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>
|
<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">${esc(t.name)}</div>
|
<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">${esc(t.title)}</div>
|
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${escapeHtml(t.title)}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('\n ');
|
|
||||||
|
|
||||||
if (layout === 'single') {
|
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 {
|
return {
|
||||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
|
<div style="max-width:600px;margin:0 auto">
|
||||||
${cards}
|
${cardHtml(items[0])}
|
||||||
</div>
|
</div>
|
||||||
</section>`,
|
</section>`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cards = items.map(cardHtml).join('\n ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
|
<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';
|
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 {
|
export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string {
|
||||||
const variant = cta.variant || 'primary';
|
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) {
|
switch (variant) {
|
||||||
case 'outline':
|
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':
|
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':
|
case 'primary':
|
||||||
default:
|
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 {
|
export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string {
|
||||||
return ctas.map((c) => {
|
return ctas.map((c) => {
|
||||||
const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
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('');
|
}).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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, test, expect, afterEach } from 'vitest';
|
||||||
|
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||||
|
|
||||||
|
describe('clipboard', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
setClipboardNodeId(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts empty', () => {
|
||||||
|
expect(getClipboardNodeId()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set then get returns the stored node id', () => {
|
||||||
|
setClipboardNodeId('node-123');
|
||||||
|
expect(getClipboardNodeId()).toBe('node-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a shared module-level store -- overwriting replaces the previous value', () => {
|
||||||
|
setClipboardNodeId('first');
|
||||||
|
setClipboardNodeId('second');
|
||||||
|
expect(getClipboardNodeId()).toBe('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can be cleared back to null', () => {
|
||||||
|
setClipboardNodeId('node-123');
|
||||||
|
setClipboardNodeId(null);
|
||||||
|
expect(getClipboardNodeId()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Tiny shared clipboard for canvas node copy/paste.
|
||||||
|
*
|
||||||
|
* Both the context menu (right-click Copy/Paste) and the keyboard shortcuts
|
||||||
|
* hook (Ctrl/Cmd+C / Ctrl/Cmd+V) read and write this single module-level
|
||||||
|
* store, so copying a node via one entry point and pasting via the other
|
||||||
|
* behaves consistently instead of each maintaining its own clipboard.
|
||||||
|
*
|
||||||
|
* Deliberately not React state -- nothing in the UI needs to re-render
|
||||||
|
* reactively when the clipboard changes; consumers just read the current
|
||||||
|
* value at the moment they need it (on paste, or when a menu opens).
|
||||||
|
*/
|
||||||
|
let clipboardNodeId: string | null = null;
|
||||||
|
|
||||||
|
/** Returns the id of the node currently on the clipboard, or null if empty. */
|
||||||
|
export function getClipboardNodeId(): string | null {
|
||||||
|
return clipboardNodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets (or clears, with `null`) the node id on the clipboard. */
|
||||||
|
export function setClipboardNodeId(nodeId: string | null): void {
|
||||||
|
clipboardNodeId = nodeId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import type { NodeTree, Node } from '@craftjs/core';
|
||||||
|
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
|
||||||
|
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
|
||||||
|
* `regenerateTreeIds` BEFORE handing it to `actions.addNodeTree`. Pasting
|
||||||
|
* with the source node's ORIGINAL ids re-inserted them into the live Craft.js
|
||||||
|
* node map, producing duplicate ids and corrupting the tree -- that bug is
|
||||||
|
* exactly what this suite guards against. Also covers the sibling-parent /
|
||||||
|
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
|
||||||
|
* input-focus guard.
|
||||||
|
*
|
||||||
|
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
|
||||||
|
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
|
||||||
|
* mounted via a bare consumer component, with REAL `keydown` events
|
||||||
|
* dispatched on `document` (where useKeyboardShortcuts attaches its
|
||||||
|
* listener) to exercise the actual handler rather than calling it directly.
|
||||||
|
*
|
||||||
|
* `regenerateTreeIds` itself is NOT mocked -- we partially mock
|
||||||
|
* `../utils/craft-tree` to wrap the real implementation in a `vi.fn()` so we
|
||||||
|
* can assert it was called, while still getting genuinely fresh ids back.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const addNodeTreeMock = vi.fn();
|
||||||
|
let selectedIds: string[] = [];
|
||||||
|
|
||||||
|
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
props: {},
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
name: 'Container',
|
||||||
|
displayName: 'Container',
|
||||||
|
isCanvas: false,
|
||||||
|
parent,
|
||||||
|
linkedNodes: {},
|
||||||
|
nodes: children,
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
info: {},
|
||||||
|
events: { selected: false, dragged: false, hovered: false },
|
||||||
|
dom: null,
|
||||||
|
related: {},
|
||||||
|
rules: {},
|
||||||
|
_hydrationTimestamp: 0,
|
||||||
|
} as unknown as Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tree `toNodeTree()` returns for the node that was Ctrl+C'd -- a root
|
||||||
|
// with one child, so regeneration has more than one id to remap.
|
||||||
|
const COPIED_TREE: NodeTree = {
|
||||||
|
rootNodeId: 'copied-root-1',
|
||||||
|
nodes: {
|
||||||
|
'copied-root-1': makeNode('copied-root-1', null, ['copied-child-1']),
|
||||||
|
'copied-child-1': makeNode('copied-child-1', 'copied-root-1'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeStore: Record<string, { data: { parent: string | null } }> = {
|
||||||
|
'selected-1': { data: { parent: 'parent-container-1' } },
|
||||||
|
ROOT: { data: { parent: null } },
|
||||||
|
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeQueryNode(id: string) {
|
||||||
|
return {
|
||||||
|
get: () => nodeStore[id] ?? null,
|
||||||
|
toNodeTree: () => {
|
||||||
|
if (id !== 'copied-root-1') {
|
||||||
|
throw new Error(`unexpected toNodeTree() call for "${id}"`);
|
||||||
|
}
|
||||||
|
return COPIED_TREE;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
query: {
|
||||||
|
getEvent: () => ({ all: () => selectedIds }),
|
||||||
|
node: (id: string) => makeQueryNode(id),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
addNodeTree: addNodeTreeMock,
|
||||||
|
history: { undo: vi.fn(), redo: vi.fn() },
|
||||||
|
delete: vi.fn(),
|
||||||
|
clearEvents: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Partial mock: keep the real `regenerateTreeIds` implementation (so pasted
|
||||||
|
// trees genuinely get fresh ids) but wrap it in a spy so we can assert the
|
||||||
|
// handler actually calls it, rather than only inferring that from the
|
||||||
|
// output.
|
||||||
|
vi.mock('../utils/craft-tree', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../utils/craft-tree')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
regenerateTreeIds: vi.fn(actual.regenerateTreeIds),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { regenerateTreeIds } from '../utils/craft-tree';
|
||||||
|
const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds);
|
||||||
|
|
||||||
|
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 unmount() {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressKey(key: string, opts: Partial<KeyboardEventInit> = {}) {
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key, ctrlKey: true, bubbles: true, cancelable: true, ...opts }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
selectedIds = [];
|
||||||
|
addNodeTreeMock.mockClear();
|
||||||
|
regenerateTreeIdsMock.mockClear();
|
||||||
|
setClipboardNodeId(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setClipboardNodeId(null);
|
||||||
|
if (root) unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||||
|
test('Ctrl+C copies the selected node id to the clipboard', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
|
pressKey('c');
|
||||||
|
|
||||||
|
expect(getClipboardNodeId()).toBe('selected-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
|
||||||
|
selectedIds = ['copied-root-1'];
|
||||||
|
pressKey('c');
|
||||||
|
expect(getClipboardNodeId()).toBe('copied-root-1');
|
||||||
|
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
pressKey('v');
|
||||||
|
|
||||||
|
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
|
||||||
|
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE);
|
||||||
|
|
||||||
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0];
|
||||||
|
|
||||||
|
// Sibling of the current selection: selected-1's data.parent.
|
||||||
|
expect(targetParent).toBe('parent-container-1');
|
||||||
|
|
||||||
|
// The regression this guards: pasted ids must be fresh, never reuse the
|
||||||
|
// ids the copied node already occupies in the live Craft.js tree.
|
||||||
|
expect(pastedTree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
|
||||||
|
const originalIds = new Set(Object.keys(COPIED_TREE.nodes));
|
||||||
|
const pastedIds = new Set(Object.keys(pastedTree.nodes));
|
||||||
|
expect(pastedIds.size).toBe(originalIds.size);
|
||||||
|
for (const id of pastedIds) {
|
||||||
|
expect(originalIds.has(id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
|
||||||
|
selectedIds = ['copied-root-1'];
|
||||||
|
pressKey('c');
|
||||||
|
|
||||||
|
selectedIds = ['ROOT'];
|
||||||
|
pressKey('v');
|
||||||
|
|
||||||
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [, targetParent] = addNodeTreeMock.mock.calls[0];
|
||||||
|
expect(targetParent).toBe('ROOT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+V with an empty clipboard is a no-op', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
|
pressKey('v');
|
||||||
|
|
||||||
|
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
||||||
|
expect(regenerateTreeIdsMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortcuts are ignored while focus is in an input element', () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
|
||||||
|
render(<Consumer />);
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
|
pressKey('c');
|
||||||
|
expect(getClipboardNodeId()).toBeNull();
|
||||||
|
|
||||||
|
setClipboardNodeId('copied-root-1');
|
||||||
|
pressKey('v');
|
||||||
|
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortcuts are ignored while focus is in a contentEditable element', () => {
|
||||||
|
// jsdom does not implement `HTMLElement.isContentEditable` (it always
|
||||||
|
// reports false regardless of the contentEditable attribute -- a known
|
||||||
|
// jsdom limitation), so a real contentEditable + focus() can't exercise
|
||||||
|
// this branch of the guard. Stub `document.activeElement` directly with
|
||||||
|
// a fake element that reports isContentEditable: true, matching what
|
||||||
|
// the guard (`isInputFocused` in useKeyboardShortcuts.ts) actually reads.
|
||||||
|
const fakeEditable = { tagName: 'DIV', isContentEditable: true } as unknown as Element;
|
||||||
|
const activeElementSpy = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(fakeEditable);
|
||||||
|
|
||||||
|
render(<Consumer />);
|
||||||
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
|
pressKey('c');
|
||||||
|
expect(getClipboardNodeId()).toBeNull();
|
||||||
|
|
||||||
|
activeElementSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { findDeletableTarget } from '../utils/craft-helpers';
|
import { findDeletableTarget } from '../utils/craft-helpers';
|
||||||
|
import { regenerateTreeIds } from '../utils/craft-tree';
|
||||||
|
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
||||||
|
|
||||||
function isInputFocused(): boolean {
|
function isInputFocused(): boolean {
|
||||||
const el = document.activeElement;
|
const el = document.activeElement;
|
||||||
@@ -73,7 +75,7 @@ export function useKeyboardShortcuts() {
|
|||||||
const node = query.node(nodeId).get();
|
const node = query.node(nodeId).get();
|
||||||
const parentId = node?.data?.parent;
|
const parentId = node?.data?.parent;
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
const tree = query.node(nodeId).toNodeTree();
|
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
|
||||||
actions.addNodeTree(tree, parentId);
|
actions.addNodeTree(tree, parentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,6 +86,45 @@ export function useKeyboardShortcuts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+C: copy selected node id to the shared clipboard
|
||||||
|
if (ctrl && (e.key === 'c' || e.key === 'C')) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const selected = query.getEvent('selected').all();
|
||||||
|
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
||||||
|
setClipboardNodeId(selected[0]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed:', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+V: paste the clipboard node as a sibling of the current selection
|
||||||
|
if (ctrl && (e.key === 'v' || e.key === 'V')) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const sourceId = getClipboardNodeId();
|
||||||
|
if (!sourceId || !query.node(sourceId).get()) return;
|
||||||
|
|
||||||
|
const selected = query.getEvent('selected').all();
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
const selectedId = selected[0];
|
||||||
|
|
||||||
|
let targetParent = 'ROOT';
|
||||||
|
if (selectedId !== 'ROOT') {
|
||||||
|
const node = query.node(selectedId).get();
|
||||||
|
targetParent = node?.data?.parent || 'ROOT';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
|
||||||
|
actions.addNodeTree(tree, targetParent);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Paste failed:', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Escape: deselect all
|
// Escape: deselect all
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { buildSavePayload } from './useWhpApi';
|
||||||
|
import { PageData } from '../types';
|
||||||
|
|
||||||
|
const pageA: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: 'STORED_HOME' };
|
||||||
|
const pageB: PageData = { id: 'page_2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT' };
|
||||||
|
const headerPage: PageData = { id: '__header__', name: 'Header', slug: '__header__', craftState: 'STALE_HEADER' };
|
||||||
|
const footerPage: PageData = { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: 'STALE_FOOTER' };
|
||||||
|
|
||||||
|
describe('buildSavePayload', () => {
|
||||||
|
test('editing header: live serialize lands in header_craft_state, not in any page slot', () => {
|
||||||
|
const payload = buildSavePayload({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Test Site',
|
||||||
|
liveCraftState: 'LIVE_HEADER',
|
||||||
|
pages: [pageA, pageB],
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId: '__header__',
|
||||||
|
isEditingHeader: true,
|
||||||
|
isEditingFooter: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fresh live canvas must be reflected in header_craft_state, not the stale stored one.
|
||||||
|
expect(payload.header_craft_state).toBe('LIVE_HEADER');
|
||||||
|
expect(payload.header_craft_state).not.toBe('STALE_HEADER');
|
||||||
|
|
||||||
|
// Footer must remain untouched (stored state, since we're not editing it).
|
||||||
|
expect(payload.footer_craft_state).toBe('STALE_FOOTER');
|
||||||
|
|
||||||
|
// Live header content must never leak into a page slot or the top-level page fields.
|
||||||
|
expect(payload.craft_state).not.toBe('LIVE_HEADER');
|
||||||
|
for (const p of payload.pages_craft_state) {
|
||||||
|
expect(p.craftState).not.toBe('LIVE_HEADER');
|
||||||
|
}
|
||||||
|
// Top-level page fields should fall back to the landing page's own stored state.
|
||||||
|
expect(payload.craft_state).toBe('STORED_HOME');
|
||||||
|
|
||||||
|
// Per-page slots must reflect each page's own stored state, untouched.
|
||||||
|
expect(payload.pages_craft_state.find((p) => p.id === 'home')?.craftState).toBe('STORED_HOME');
|
||||||
|
expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.craftState).toBe('STORED_ABOUT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing footer: live serialize lands in footer_craft_state, not in any page slot', () => {
|
||||||
|
const payload = buildSavePayload({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Test Site',
|
||||||
|
liveCraftState: 'LIVE_FOOTER',
|
||||||
|
pages: [pageA, pageB],
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId: '__footer__',
|
||||||
|
isEditingHeader: false,
|
||||||
|
isEditingFooter: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.footer_craft_state).toBe('LIVE_FOOTER');
|
||||||
|
expect(payload.footer_craft_state).not.toBe('STALE_FOOTER');
|
||||||
|
expect(payload.header_craft_state).toBe('STALE_HEADER');
|
||||||
|
|
||||||
|
expect(payload.craft_state).not.toBe('LIVE_FOOTER');
|
||||||
|
for (const p of payload.pages_craft_state) {
|
||||||
|
expect(p.craftState).not.toBe('LIVE_FOOTER');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing a real page: behavior unchanged — live serialize goes to that page + top-level, header/footer come from stored state', () => {
|
||||||
|
const payload = buildSavePayload({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Test Site',
|
||||||
|
liveCraftState: 'LIVE_PAGE_HOME',
|
||||||
|
pages: [pageA, pageB],
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId: 'home',
|
||||||
|
isEditingHeader: false,
|
||||||
|
isEditingFooter: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live canvas goes to the active page and top-level slots.
|
||||||
|
expect(payload.craft_state).toBe('LIVE_PAGE_HOME');
|
||||||
|
expect(payload.pages_craft_state.find((p) => p.id === 'home')?.craftState).toBe('LIVE_PAGE_HOME');
|
||||||
|
// Other pages keep their stored state.
|
||||||
|
expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.craftState).toBe('STORED_ABOUT');
|
||||||
|
|
||||||
|
// Header/footer come from stored (stale-but-correct, since we're not editing them) state.
|
||||||
|
expect(payload.header_craft_state).toBe('STALE_HEADER');
|
||||||
|
expect(payload.footer_craft_state).toBe('STALE_FOOTER');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('I-1: activePageId matches no page (dangling, e.g. original Home deleted then reload reset it to "home") -- live serialize still lands in pages_craft_state[0] / index.html, not lost', () => {
|
||||||
|
// pages[0] has id 'page_x' (the replacement landing page after the
|
||||||
|
// original 'home' was deleted); activePageId is stuck at the stale
|
||||||
|
// default 'home', which matches no entry in `pages`.
|
||||||
|
const pageX: PageData = { id: 'page_x', name: 'Home', slug: 'index', craftState: 'STORED_X' };
|
||||||
|
const p2: PageData = { id: 'p2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT_2' };
|
||||||
|
|
||||||
|
const payload = buildSavePayload({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Test Site',
|
||||||
|
liveCraftState: 'LIVE_EDIT',
|
||||||
|
pages: [pageX, p2],
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId: 'home',
|
||||||
|
isEditingHeader: false,
|
||||||
|
isEditingFooter: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The live edit must land in pages_craft_state[0] (index.html slot),
|
||||||
|
// not be silently dropped to only the legacy top-level fields.
|
||||||
|
expect(payload.pages_craft_state[0].craftState).toBe('LIVE_EDIT');
|
||||||
|
expect(payload.pages[0].filename).toBe('index.html');
|
||||||
|
// Top-level fields (legacy) should also reflect the live edit.
|
||||||
|
expect(payload.craft_state).toBe('LIVE_EDIT');
|
||||||
|
// The other page is untouched.
|
||||||
|
expect(payload.pages_craft_state.find((p) => p.id === 'p2')?.craftState).toBe('STORED_ABOUT_2');
|
||||||
|
});
|
||||||
|
});
|
||||||
+165
-44
@@ -3,21 +3,99 @@ import { useEditor } from '@craftjs/core';
|
|||||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||||
import { usePages } from '../state/PageContext';
|
import { usePages } from '../state/PageContext';
|
||||||
import { exportBodyHtml } from '../utils/html-export';
|
import { exportBodyHtml } from '../utils/html-export';
|
||||||
|
import { PageData } from '../types';
|
||||||
|
|
||||||
export function useWhpApi() {
|
export interface BuildSavePayloadInput {
|
||||||
const { query, actions } = useEditor();
|
siteId: number;
|
||||||
const { whpConfig, isWHP } = useEditorConfig();
|
siteName: string;
|
||||||
const { pages, headerPage, footerPage, activePageId, setHeaderCraftState, setFooterCraftState, setPagesCraftState } = usePages();
|
/** query.serialize() of whatever is currently on the live canvas. */
|
||||||
|
liveCraftState: string;
|
||||||
|
pages: PageData[];
|
||||||
|
headerPage: PageData;
|
||||||
|
footerPage: PageData;
|
||||||
|
activePageId: string;
|
||||||
|
/** True when the live canvas is showing the header zone (activePageId === '__header__'). */
|
||||||
|
isEditingHeader: boolean;
|
||||||
|
/** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */
|
||||||
|
isEditingFooter: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
/**
|
||||||
if (!isWHP || !whpConfig) return null;
|
* Pure payload builder for the save() API call. Extracted so the
|
||||||
|
* header/footer-vs-page routing logic can be unit-tested without mounting
|
||||||
|
* React/Craft.js.
|
||||||
|
*
|
||||||
|
* Bug this fixes: when the user is editing the Header or Footer,
|
||||||
|
* `activePageId` is `'__header__'`/`'__footer__'` — which matches no entry
|
||||||
|
* in `pages`. The live canvas serialization must be routed into
|
||||||
|
* `header_craft_state`/`footer_craft_state` in that case, NOT into a page
|
||||||
|
* slot or the top-level `craft_state`/`html` (which must always represent
|
||||||
|
* an actual page). Conversely, header/footer must be sourced from the FRESH
|
||||||
|
* live state when that zone is being edited, not from the stale stored
|
||||||
|
* `headerPage.craftState`/`footerPage.craftState`.
|
||||||
|
*/
|
||||||
|
export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||||
|
const {
|
||||||
|
siteId,
|
||||||
|
siteName,
|
||||||
|
liveCraftState,
|
||||||
|
pages,
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId,
|
||||||
|
isEditingHeader,
|
||||||
|
isEditingFooter,
|
||||||
|
} = input;
|
||||||
|
|
||||||
// Serialize the current canvas state (whatever page is active)
|
const isPageActive = !isEditingHeader && !isEditingFooter;
|
||||||
const currentCraftState = query.serialize();
|
|
||||||
|
|
||||||
// Export body HTML for the current page
|
// I-1 (data-loss): `activePageId` can go dangling -- e.g. the original
|
||||||
|
// Home page (id 'home') is deleted, its replacement gets a fresh id
|
||||||
|
// (`page_<ts>`), and a reload re-initializes `activePageId` back to the
|
||||||
|
// hardcoded default `'home'` (see PageContext's `useState('home')`) before
|
||||||
|
// `load()` has a chance to point it at the actually-restored page. If a
|
||||||
|
// real page IS active but matches no entry in `pages`, treat `pages[0]`
|
||||||
|
// (the landing page) as the active one so the live canvas serialization
|
||||||
|
// still reaches the index.html page slot / `pages_craft_state[0]` instead
|
||||||
|
// of only the legacy top-level `craft_state`/`html` fields.
|
||||||
|
const activePageIndex = isPageActive ? pages.findIndex((p) => p.id === activePageId) : -1;
|
||||||
|
const effectiveActivePageId = activePageIndex !== -1 ? activePageId : pages[0]?.id;
|
||||||
|
|
||||||
|
// Fresh header/footer state: the live canvas wins when that zone is the
|
||||||
|
// one currently being edited; otherwise fall back to the last-committed
|
||||||
|
// stored state (updated on zone switch by PageContext's saveCurrentState).
|
||||||
|
const headerCraftState = isEditingHeader ? liveCraftState : (headerPage.craftState || null);
|
||||||
|
const footerCraftState = isEditingFooter ? liveCraftState : (footerPage.craftState || null);
|
||||||
|
|
||||||
|
let headerHtml = '';
|
||||||
|
try {
|
||||||
|
if (headerCraftState) {
|
||||||
|
headerHtml = exportBodyHtml(headerCraftState).html;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Header HTML export failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let footerHtml = '';
|
||||||
|
try {
|
||||||
|
if (footerCraftState) {
|
||||||
|
footerHtml = exportBodyHtml(footerCraftState).html;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Footer HTML export failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The top-level `craft_state`/`html` fields (and the matching per-page
|
||||||
|
// slot below) must always represent an actual PAGE. When editing the
|
||||||
|
// header/footer, activePageId matches no page — use the landing page's
|
||||||
|
// own stored state instead of leaking the live header/footer canvas into
|
||||||
|
// a page slot or mislabeling it as page content.
|
||||||
|
let currentCraftState: string | null;
|
||||||
let currentHtml = '';
|
let currentHtml = '';
|
||||||
let css = '';
|
let css = '';
|
||||||
|
|
||||||
|
if (isPageActive) {
|
||||||
|
currentCraftState = liveCraftState;
|
||||||
try {
|
try {
|
||||||
const result = exportBodyHtml(currentCraftState);
|
const result = exportBodyHtml(currentCraftState);
|
||||||
currentHtml = result.html;
|
currentHtml = result.html;
|
||||||
@@ -25,32 +103,23 @@ export function useWhpApi() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('HTML export failed, saving state only:', e);
|
console.error('HTML export failed, saving state only:', e);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Export header HTML from its craft state
|
const landingPage = pages[0] ?? null;
|
||||||
let headerHtml = '';
|
currentCraftState = landingPage?.craftState ?? null;
|
||||||
try {
|
try {
|
||||||
if (headerPage.craftState) {
|
if (currentCraftState) {
|
||||||
const hResult = exportBodyHtml(headerPage.craftState);
|
const result = exportBodyHtml(currentCraftState);
|
||||||
headerHtml = hResult.html;
|
currentHtml = result.html;
|
||||||
|
css = result.css;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Header HTML export failed:', e);
|
console.error('HTML export failed, saving state only:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export footer HTML from its craft state
|
// Build the pages array with HTML for each page. For the active page (only
|
||||||
let footerHtml = '';
|
// possible when a real page is active), use the freshly exported HTML from
|
||||||
try {
|
// the canvas; for others, export from their stored craft state.
|
||||||
if (footerPage.craftState) {
|
|
||||||
const fResult = exportBodyHtml(footerPage.craftState);
|
|
||||||
footerHtml = fResult.html;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Footer HTML export failed:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the pages array with HTML for each page
|
|
||||||
// For the active page, use the freshly exported HTML from the canvas;
|
|
||||||
// for others, export from their stored craft state
|
|
||||||
const pagesPayload = pages.map((page, i) => {
|
const pagesPayload = pages.map((page, i) => {
|
||||||
// The first page is ALWAYS the landing page → publishes to index.html
|
// The first page is ALWAYS the landing page → publishes to index.html
|
||||||
// regardless of the page name/slug. Apache serves '/' from index.html,
|
// regardless of the page name/slug. Apache serves '/' from index.html,
|
||||||
@@ -58,13 +127,12 @@ export function useWhpApi() {
|
|||||||
const filename = i === 0 ? 'index.html' : page.slug + '.html';
|
const filename = i === 0 ? 'index.html' : page.slug + '.html';
|
||||||
let pageHtml = '';
|
let pageHtml = '';
|
||||||
|
|
||||||
if (page.id === activePageId) {
|
if (isPageActive && page.id === effectiveActivePageId) {
|
||||||
// Active page: use the current canvas HTML (already exported above)
|
// Active page: use the current canvas HTML (already exported above)
|
||||||
pageHtml = currentHtml;
|
pageHtml = currentHtml;
|
||||||
} else if (page.craftState) {
|
} else if (page.craftState) {
|
||||||
try {
|
try {
|
||||||
const pResult = exportBodyHtml(page.craftState);
|
pageHtml = exportBodyHtml(page.craftState).html;
|
||||||
pageHtml = pResult.html;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`HTML export failed for page ${page.name}:`, e);
|
console.error(`HTML export failed for page ${page.name}:`, e);
|
||||||
}
|
}
|
||||||
@@ -77,9 +145,12 @@ export function useWhpApi() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build pages_craft_state array: for each page, store its craft state
|
// Build pages_craft_state array: for each page, store its craft state.
|
||||||
// For the currently active page, always use the fresh canvas state (currentCraftState)
|
// For the currently active page (only when a real page is active), always
|
||||||
// since page.craftState may be stale (not updated until page switch)
|
// use the fresh canvas state since page.craftState may be stale (not
|
||||||
|
// updated until page switch). When editing header/footer, activePageId
|
||||||
|
// matches no page, so every page correctly falls back to its own stored
|
||||||
|
// state below.
|
||||||
const pagesGrapesjs = pages.map((page, i) => ({
|
const pagesGrapesjs = pages.map((page, i) => ({
|
||||||
id: page.id,
|
id: page.id,
|
||||||
name: page.name,
|
name: page.name,
|
||||||
@@ -87,22 +158,59 @@ export function useWhpApi() {
|
|||||||
// reload the editor's clean-URL routing (.htaccess rewrite of /name →
|
// reload the editor's clean-URL routing (.htaccess rewrite of /name →
|
||||||
// name.html) lines up with the file we just wrote (index.html).
|
// name.html) lines up with the file we just wrote (index.html).
|
||||||
slug: i === 0 ? 'index' : page.slug,
|
slug: i === 0 ? 'index' : page.slug,
|
||||||
craftState: page.id === activePageId ? currentCraftState : (page.craftState || null),
|
craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const payload = {
|
return {
|
||||||
site_id: whpConfig.siteId,
|
site_id: siteId,
|
||||||
name: whpConfig.siteName,
|
name: siteName,
|
||||||
html: currentHtml,
|
html: currentHtml,
|
||||||
css,
|
css,
|
||||||
pages: pagesPayload,
|
pages: pagesPayload,
|
||||||
header_html: headerHtml,
|
header_html: headerHtml,
|
||||||
footer_html: footerHtml,
|
footer_html: footerHtml,
|
||||||
craft_state: currentCraftState,
|
craft_state: currentCraftState,
|
||||||
header_craft_state: headerPage.craftState || null,
|
header_craft_state: headerCraftState,
|
||||||
footer_craft_state: footerPage.craftState || null,
|
footer_craft_state: footerCraftState,
|
||||||
pages_craft_state: pagesGrapesjs,
|
pages_craft_state: pagesGrapesjs,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWhpApi() {
|
||||||
|
const { query, actions } = useEditor();
|
||||||
|
const { whpConfig, isWHP } = useEditorConfig();
|
||||||
|
const {
|
||||||
|
pages,
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId,
|
||||||
|
isEditingHeader,
|
||||||
|
isEditingFooter,
|
||||||
|
setHeaderCraftState,
|
||||||
|
setFooterCraftState,
|
||||||
|
setPagesCraftState,
|
||||||
|
setActivePageIdDirect,
|
||||||
|
} = usePages();
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
if (!isWHP || !whpConfig) return null;
|
||||||
|
|
||||||
|
// Serialize whatever is currently on the live canvas (a page, the
|
||||||
|
// header, or the footer — depending on activePageId/isEditingHeader/
|
||||||
|
// isEditingFooter).
|
||||||
|
const liveCraftState = query.serialize();
|
||||||
|
|
||||||
|
const payload = buildSavePayload({
|
||||||
|
siteId: whpConfig.siteId,
|
||||||
|
siteName: whpConfig.siteName,
|
||||||
|
liveCraftState,
|
||||||
|
pages,
|
||||||
|
headerPage,
|
||||||
|
footerPage,
|
||||||
|
activePageId,
|
||||||
|
isEditingHeader,
|
||||||
|
isEditingFooter,
|
||||||
|
});
|
||||||
|
|
||||||
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
|
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -113,7 +221,7 @@ export function useWhpApi() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
return resp.json();
|
return resp.json();
|
||||||
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage]);
|
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter]);
|
||||||
|
|
||||||
const publish = useCallback(async () => {
|
const publish = useCallback(async () => {
|
||||||
if (!isWHP || !whpConfig) return null;
|
if (!isWHP || !whpConfig) return null;
|
||||||
@@ -176,10 +284,23 @@ export function useWhpApi() {
|
|||||||
console.warn('Failed to load page state:', e);
|
console.warn('Failed to load page state:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// I-1 (data-loss): point activePageId at the page we just loaded
|
||||||
|
// into the canvas. Without this, activePageId stays at whatever it
|
||||||
|
// was initialized to (the hardcoded default 'home'), which goes
|
||||||
|
// dangling the moment the original Home page has been deleted and
|
||||||
|
// replaced (its replacement gets a fresh `page_<ts>` id) -- the next
|
||||||
|
// edit+save would then only reach the legacy top-level fields
|
||||||
|
// instead of the actual page slot. Only do this when a real page is
|
||||||
|
// being loaded, i.e. we're not currently mid-edit of the header/
|
||||||
|
// footer zone (switching zones is handled separately by switchPage).
|
||||||
|
if (!isEditingHeader && !isEditingFooter) {
|
||||||
|
setActivePageIdDirect(firstPage.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState]);
|
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter]);
|
||||||
|
|
||||||
const uploadAsset = useCallback(
|
const uploadAsset = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useEditor } from '@craftjs/core';
|
|||||||
import { findDeletableTarget } from '../../utils/craft-helpers';
|
import { findDeletableTarget } from '../../utils/craft-helpers';
|
||||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
||||||
|
import { regenerateTreeIds } from '../../utils/craft-tree';
|
||||||
|
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -31,7 +33,6 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
const { actions, query } = useEditor();
|
const { actions, query } = useEditor();
|
||||||
const { open: openSitesmith } = useSitesmithModal();
|
const { open: openSitesmith } = useSitesmithModal();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const clipboardRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Close on click outside
|
// Close on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,15 +66,11 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
const duplicate = useCallback(() => {
|
const duplicate = useCallback(() => {
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
if (!nodeId || nodeId === 'ROOT') return;
|
||||||
try {
|
try {
|
||||||
const tree = query.node(nodeId).toSerializedNode();
|
|
||||||
const parentId = getParentId();
|
const parentId = getParentId();
|
||||||
if (!parentId) return;
|
if (!parentId) return;
|
||||||
|
|
||||||
// Get the full subtree
|
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
|
||||||
const freshTree = query.node(nodeId).toNodeTree();
|
actions.addNodeTree(tree, parentId);
|
||||||
const clonedTree = query.parseSerializedNode(freshTree.nodes[freshTree.rootNodeId].data).toNode();
|
|
||||||
|
|
||||||
actions.addNodeTree(freshTree, parentId);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Duplicate failed:', e);
|
console.error('Duplicate failed:', e);
|
||||||
}
|
}
|
||||||
@@ -83,7 +80,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
const copyNode = useCallback(() => {
|
const copyNode = useCallback(() => {
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
if (!nodeId || nodeId === 'ROOT') return;
|
||||||
try {
|
try {
|
||||||
clipboardRef.current = nodeId;
|
setClipboardNodeId(nodeId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Copy failed:', e);
|
console.error('Copy failed:', e);
|
||||||
}
|
}
|
||||||
@@ -91,11 +88,26 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
}, [nodeId, onClose]);
|
}, [nodeId, onClose]);
|
||||||
|
|
||||||
const pasteNode = useCallback(() => {
|
const pasteNode = useCallback(() => {
|
||||||
const sourceId = clipboardRef.current;
|
const sourceId = getClipboardNodeId();
|
||||||
if (!sourceId) return;
|
if (!sourceId) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const targetParent = nodeId || 'ROOT';
|
if (!query.node(sourceId).get()) {
|
||||||
const tree = query.node(sourceId).toNodeTree();
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste as a SIBLING of the right-clicked node, not as its child --
|
||||||
|
// using the clicked node itself as the parent throws when it's a leaf.
|
||||||
|
let targetParent = 'ROOT';
|
||||||
|
if (nodeId && nodeId !== 'ROOT') {
|
||||||
|
const clickedNode = query.node(nodeId).get();
|
||||||
|
targetParent = clickedNode?.data?.parent || 'ROOT';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
|
||||||
actions.addNodeTree(tree, targetParent);
|
actions.addNodeTree(tree, targetParent);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Paste failed:', e);
|
console.error('Paste failed:', e);
|
||||||
@@ -198,7 +210,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
label: 'Paste',
|
label: 'Paste',
|
||||||
shortcut: 'Ctrl+V',
|
shortcut: 'Ctrl+V',
|
||||||
action: pasteNode,
|
action: pasteNode,
|
||||||
disabled: !clipboardRef.current,
|
disabled: !getClipboardNodeId(),
|
||||||
dividerAfter: true,
|
dividerAfter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useAssets } from '../../hooks/useAssets';
|
import { useAssets } from '../../hooks/useAssets';
|
||||||
|
import { clickableProps } from '../../utils/a11y';
|
||||||
|
import { copyToClipboard } from '../../utils/clipboard';
|
||||||
|
|
||||||
|
/** How long the "Delete?" confirm state stays armed before auto-resetting. */
|
||||||
|
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
export const AssetsPanel: React.FC = () => {
|
export const AssetsPanel: React.FC = () => {
|
||||||
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
|
const { assets, loading, error, loadAssets, uploadAsset, deleteAsset } = useAssets();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||||
|
const [copyErrorUrl, setCopyErrorUrl] = useState<string | null>(null);
|
||||||
|
const [confirmDeleteName, setConfirmDeleteName] = useState<string | null>(null);
|
||||||
|
const deleteConfirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetDeleteConfirm = useCallback(() => {
|
||||||
|
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
|
||||||
|
setConfirmDeleteName(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const armDeleteConfirm = useCallback((name: string) => {
|
||||||
|
setConfirmDeleteName(name);
|
||||||
|
if (deleteConfirmTimeoutRef.current) clearTimeout(deleteConfirmTimeoutRef.current);
|
||||||
|
deleteConfirmTimeoutRef.current = setTimeout(() => setConfirmDeleteName(null), DELETE_CONFIRM_TIMEOUT_MS);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAssets();
|
loadAssets();
|
||||||
@@ -37,11 +62,17 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const copyUrl = useCallback((url: string) => {
|
const copyUrl = useCallback(async (url: string) => {
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
const ok = await copyToClipboard(url);
|
||||||
|
if (ok) {
|
||||||
|
setCopyErrorUrl(null);
|
||||||
setCopiedUrl(url);
|
setCopiedUrl(url);
|
||||||
setTimeout(() => setCopiedUrl(null), 2000);
|
setTimeout(() => setCopiedUrl(null), 2000);
|
||||||
});
|
} else {
|
||||||
|
setCopiedUrl(null);
|
||||||
|
setCopyErrorUrl(url);
|
||||||
|
setTimeout(() => setCopyErrorUrl(null), 2500);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isImage = (type: string) =>
|
const isImage = (type: string) =>
|
||||||
@@ -133,19 +164,30 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
gap: 6,
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{assets.map((asset) => (
|
{assets.map((asset) => {
|
||||||
|
const isConfirmingDelete = confirmDeleteName === asset.name;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={asset.name}
|
key={asset.name}
|
||||||
|
{...clickableProps(() => {
|
||||||
|
// While armed, clicking anywhere on the tile (other than the
|
||||||
|
// delete control itself) counts as "click elsewhere" and
|
||||||
|
// disarms the confirm instead of copying the URL.
|
||||||
|
if (isConfirmingDelete) {
|
||||||
|
resetDeleteConfirm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyUrl(asset.url);
|
||||||
|
})}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
background: 'var(--color-bg-elevated)',
|
background: 'var(--color-bg-elevated)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
overflow: 'hidden',
|
overflow: 'visible',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'border-color var(--transition-fast)',
|
transition: 'border-color var(--transition-fast)',
|
||||||
}}
|
}}
|
||||||
onClick={() => copyUrl(asset.url)}
|
|
||||||
title={`Click to copy URL: ${asset.url}`}
|
title={`Click to copy URL: ${asset.url}`}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
@@ -158,6 +200,7 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: 'var(--color-bg-base)',
|
background: 'var(--color-bg-base)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
borderRadius: 'var(--radius-md) var(--radius-md) 0 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isImage(asset.type) ? (
|
{isImage(asset.type) ? (
|
||||||
@@ -183,27 +226,87 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name */}
|
{/* Name / status */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 6px',
|
padding: '4px 6px',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: 'var(--color-text-muted)',
|
color: copyErrorUrl === asset.url ? 'var(--color-danger)' : 'var(--color-text-muted)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{copiedUrl === asset.url ? 'Copied!' : asset.name}
|
{copyErrorUrl === asset.url ? 'Copy failed' : copiedUrl === asset.url ? 'Copied!' : asset.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete button */}
|
{/* Delete control: idle icon, or an in-app two-step confirm */}
|
||||||
|
{isConfirmingDelete ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 3,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
resetDeleteConfirm();
|
||||||
deleteAsset(asset.name);
|
deleteAsset(asset.name);
|
||||||
}}
|
}}
|
||||||
|
title="Click again to permanently delete"
|
||||||
|
aria-label={`Confirm delete ${asset.name}`}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#fff',
|
||||||
|
background: 'var(--color-danger)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete?
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
resetDeleteConfirm();
|
||||||
|
}}
|
||||||
|
title="Cancel"
|
||||||
|
aria-label={`Cancel delete ${asset.name}`}
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#fff',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
armDeleteConfirm(asset.name);
|
||||||
|
}}
|
||||||
title="Delete asset"
|
title="Delete asset"
|
||||||
|
aria-label={`Delete ${asset.name}`}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 4,
|
top: 4,
|
||||||
@@ -227,8 +330,10 @@ export const AssetsPanel: React.FC = () => {
|
|||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { clickableProps } from '../../utils/a11y';
|
||||||
|
|
||||||
interface LayerNodeProps {
|
interface LayerNodeProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@@ -17,8 +18,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
const handleActivate = useCallback(() => {
|
||||||
e.stopPropagation();
|
|
||||||
actions.selectNode(nodeId);
|
actions.selectNode(nodeId);
|
||||||
}, [actions, nodeId]);
|
}, [actions, nodeId]);
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
onClick={handleClick}
|
{...clickableProps(handleActivate)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { usePages } from '../../state/PageContext';
|
import { usePages } from '../../state/PageContext';
|
||||||
|
import { clickableProps } from '../../utils/a11y';
|
||||||
|
|
||||||
export const PagesPanel: React.FC = () => {
|
export const PagesPanel: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -286,7 +287,7 @@ export const PagesPanel: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
/* Normal page item */
|
/* Normal page item */
|
||||||
<div
|
<div
|
||||||
onClick={() => switchPage(page.id)}
|
{...clickableProps(() => switchPage(page.id))}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { componentResolver } from '../../components/resolver';
|
|
||||||
import { SiteDesignPanel } from './SiteDesignPanel';
|
import { SiteDesignPanel } from './SiteDesignPanel';
|
||||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
||||||
@@ -31,27 +30,21 @@ import {
|
|||||||
================================================================ */
|
================================================================ */
|
||||||
|
|
||||||
export const GuidedStyles: React.FC = () => {
|
export const GuidedStyles: React.FC = () => {
|
||||||
const resolverMap = componentResolver as Record<string, any>;
|
|
||||||
const { open: openSitesmith } = useSitesmithModal();
|
const { open: openSitesmith } = useSitesmithModal();
|
||||||
const { query } = useEditor();
|
const { query } = useEditor();
|
||||||
|
|
||||||
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
|
const { selected, selectedType, nodeProps } = useEditor((state) => {
|
||||||
const currentNodeId = state.events.selected
|
const currentNodeId = state.events.selected
|
||||||
? Array.from(state.events.selected)[0]
|
? Array.from(state.events.selected)[0]
|
||||||
: undefined;
|
: undefined;
|
||||||
let selectedType: string | null = null;
|
let selectedType: string | null = null;
|
||||||
let nodeProps: Record<string, any> = {};
|
let nodeProps: Record<string, any> = {};
|
||||||
let resolvedName: string | null = null;
|
|
||||||
|
|
||||||
if (currentNodeId) {
|
if (currentNodeId) {
|
||||||
const node = state.nodes[currentNodeId];
|
const node = state.nodes[currentNodeId];
|
||||||
if (node) {
|
if (node) {
|
||||||
selectedType = node.data.displayName || node.data.name || null;
|
selectedType = node.data.displayName || node.data.name || null;
|
||||||
nodeProps = node.data.props || {};
|
nodeProps = node.data.props || {};
|
||||||
const nodeType = node.data.type as any;
|
|
||||||
if (nodeType && typeof nodeType === 'object' && nodeType.resolvedName) {
|
|
||||||
resolvedName = nodeType.resolvedName;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +52,6 @@ export const GuidedStyles: React.FC = () => {
|
|||||||
selected: currentNodeId || null,
|
selected: currentNodeId || null,
|
||||||
selectedType,
|
selectedType,
|
||||||
nodeProps,
|
nodeProps,
|
||||||
resolvedName,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,8 +67,8 @@ export const GuidedStyles: React.FC = () => {
|
|||||||
const isButton = /^button$/i.test(typeName);
|
const isButton = /^button$/i.test(typeName);
|
||||||
const isImage = /^image$/i.test(typeName);
|
const isImage = /^image$/i.test(typeName);
|
||||||
const isBgSection = /^background section$/i.test(typeName);
|
const isBgSection = /^background section$/i.test(typeName);
|
||||||
const isContainer = /^container$|^section$|^columns$|^header zone$|^footer zone$/i.test(typeName);
|
const isContainer = /^container$|^section$|^columns$/i.test(typeName);
|
||||||
const isHero = /hero/i.test(typeName);
|
const isHero = /^hero/i.test(typeName);
|
||||||
const isNav = /^menu$|^logo$|^navbar$|^footer$/i.test(typeName);
|
const isNav = /^menu$|^logo$|^navbar$|^footer$/i.test(typeName);
|
||||||
const isMedia = /^video$|^gallery$|^map$|^content slider$/i.test(typeName);
|
const isMedia = /^video$|^gallery$|^map$|^content slider$/i.test(typeName);
|
||||||
const isForm = /^form$|^input$|^textarea$|^subscribe|^contact form$|^submit button$|^search bar$/i.test(typeName);
|
const isForm = /^form$|^input$|^textarea$|^subscribe|^contact form$|^submit button$|^search bar$/i.test(typeName);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { CollapsibleSection, ArrayPropEditor, smallInputStyle } from './shared';
|
||||||
|
|
||||||
|
/* ---------- Shared array-item field editor ----------
|
||||||
|
Extracted from SectionTypePanel and GenericPropsEditor, which both had a
|
||||||
|
near-byte-identical per-item field renderer for generic array props
|
||||||
|
(features/items/plans/testimonials/etc.). Infers an input type per field:
|
||||||
|
boolean -> checkbox, number -> number, /color/ -> color swatch, long
|
||||||
|
string -> textarea, else text. Fields are derived from Object.keys(items[0]).
|
||||||
|
Callers keep their own special-casing (e.g. SectionTypePanel routes
|
||||||
|
key === 'features' to FeaturesEditor instead of using this component). */
|
||||||
|
export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: string; items: any[] }> = ({
|
||||||
|
selectedId, propKey, items,
|
||||||
|
}) => {
|
||||||
|
const { actions } = useEditor();
|
||||||
|
const arrayItems = items;
|
||||||
|
const sampleItem = arrayItems[0] || {};
|
||||||
|
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleSection title={propKey.replace(/([A-Z])/g, ' $1').trim()}>
|
||||||
|
<ArrayPropEditor
|
||||||
|
selectedId={selectedId}
|
||||||
|
propKey={propKey}
|
||||||
|
items={arrayItems}
|
||||||
|
renderItem={(item: any, index: number) => {
|
||||||
|
if (typeof item !== 'object' || item === null) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={String(item)}
|
||||||
|
onChange={(e) => {
|
||||||
|
actions.setProp(selectedId, (props: any) => {
|
||||||
|
const updated = [...(props[propKey] || [])];
|
||||||
|
updated[index] = e.target.value;
|
||||||
|
props[propKey] = updated;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={smallInputStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{itemFields.map((field) => {
|
||||||
|
const fieldVal = item[field];
|
||||||
|
if (typeof fieldVal === 'boolean') {
|
||||||
|
return (
|
||||||
|
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={fieldVal} onChange={(e) => {
|
||||||
|
actions.setProp(selectedId, (props: any) => {
|
||||||
|
const updated = [...(props[propKey] || [])];
|
||||||
|
updated[index] = { ...updated[index], [field]: e.target.checked };
|
||||||
|
props[propKey] = updated;
|
||||||
|
});
|
||||||
|
}} />
|
||||||
|
{field}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof fieldVal === 'number') {
|
||||||
|
return (
|
||||||
|
<div key={field}>
|
||||||
|
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||||
|
<input type="number" value={fieldVal} onChange={(e) => {
|
||||||
|
actions.setProp(selectedId, (props: any) => {
|
||||||
|
const updated = [...(props[propKey] || [])];
|
||||||
|
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
|
||||||
|
props[propKey] = updated;
|
||||||
|
});
|
||||||
|
}} style={smallInputStyle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// color fields
|
||||||
|
if (/color/i.test(field) && typeof fieldVal === 'string') {
|
||||||
|
return (
|
||||||
|
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
|
||||||
|
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
|
||||||
|
actions.setProp(selectedId, (props: any) => {
|
||||||
|
const updated = [...(props[propKey] || [])];
|
||||||
|
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||||
|
props[propKey] = updated;
|
||||||
|
});
|
||||||
|
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// long text
|
||||||
|
const strVal = String(fieldVal ?? '');
|
||||||
|
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
|
||||||
|
return (
|
||||||
|
<div key={field}>
|
||||||
|
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
||||||
|
{isLongField ? (
|
||||||
|
<textarea value={strVal} onChange={(e) => {
|
||||||
|
actions.setProp(selectedId, (props: any) => {
|
||||||
|
const updated = [...(props[propKey] || [])];
|
||||||
|
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||||
|
props[propKey] = updated;
|
||||||
|
});
|
||||||
|
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
||||||
|
) : (
|
||||||
|
<input type="text" value={strVal} onChange={(e) => {
|
||||||
|
actions.setProp(selectedId, (props: any) => {
|
||||||
|
const updated = [...(props[propKey] || [])];
|
||||||
|
updated[index] = { ...updated[index], [field]: e.target.value };
|
||||||
|
props[propKey] = updated;
|
||||||
|
});
|
||||||
|
}} style={smallInputStyle} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
|
||||||
|
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback, useRef } from 'react';
|
import React from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
|
||||||
import {
|
import {
|
||||||
BG_COLORS,
|
BG_COLORS,
|
||||||
SPACING_PRESETS,
|
SPACING_PRESETS,
|
||||||
@@ -12,33 +11,16 @@ import {
|
|||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
ColorPickerField,
|
ColorPickerField,
|
||||||
uploadToWhp,
|
|
||||||
labelStyle,
|
labelStyle,
|
||||||
inputStyle,
|
inputStyle,
|
||||||
smallInputStyle,
|
|
||||||
btnActiveStyle,
|
|
||||||
sectionGap,
|
sectionGap,
|
||||||
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
|
|
||||||
/* ---------- BACKGROUND SECTION ---------- */
|
/* ---------- BACKGROUND SECTION ---------- */
|
||||||
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const setProp = useCallback((key: string, value: any) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
|
||||||
}, [actions, selectedId]);
|
|
||||||
|
|
||||||
const setPropStyle = useCallback((key: string, value: string) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
props.style = { ...props.style, [key]: value };
|
|
||||||
});
|
|
||||||
}, [actions, selectedId]);
|
|
||||||
|
|
||||||
const handleUpload = useCallback(async (file: File) => {
|
|
||||||
const url = await uploadToWhp(file);
|
|
||||||
if (url) setProp('bgImage', url);
|
|
||||||
}, [setProp]);
|
|
||||||
|
|
||||||
const style = nodeProps.style || {};
|
const style = nodeProps.style || {};
|
||||||
|
|
||||||
@@ -46,23 +28,7 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
|
|||||||
<>
|
<>
|
||||||
{/* Background Image */}
|
{/* Background Image */}
|
||||||
<CollapsibleSection title="Background Image">
|
<CollapsibleSection title="Background Image">
|
||||||
{nodeProps.bgImage && (
|
<AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
|
||||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
|
||||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
|
||||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, 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' }}>
|
|
||||||
<i className="fa fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
|
||||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f); e.target.value = ''; }} />
|
|
||||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste image URL..."
|
|
||||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* Background Color */}
|
{/* Background Color */}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
TextInputField,
|
TextInputField,
|
||||||
autoTextColor,
|
autoTextColor,
|
||||||
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
/* ---------- BUTTON ---------- */
|
/* ---------- BUTTON ---------- */
|
||||||
@@ -19,14 +20,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
|||||||
const { actions } = useEditor();
|
const { actions } = useEditor();
|
||||||
const style: CSSProperties = nodeProps.style || {};
|
const style: CSSProperties = nodeProps.style || {};
|
||||||
|
|
||||||
const setPropStyle = useCallback(
|
const { setPropStyle } = useNodeProp(selectedId);
|
||||||
(property: string, value: string) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
props.style = { ...props.style, [property]: value };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[actions, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setButtonColor = useCallback(
|
const setButtonColor = useCallback(
|
||||||
(bgColor: string) => {
|
(bgColor: string) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback, CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
|
||||||
import {
|
import {
|
||||||
BG_COLORS,
|
BG_COLORS,
|
||||||
SPACING_PRESETS,
|
SPACING_PRESETS,
|
||||||
@@ -11,24 +10,44 @@ import {
|
|||||||
ColorSwatchGrid,
|
ColorSwatchGrid,
|
||||||
GradientSwatchGrid,
|
GradientSwatchGrid,
|
||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
|
labelStyle,
|
||||||
|
inputStyle,
|
||||||
|
sectionGap,
|
||||||
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
/* ---------- CONTAINER / SECTION ---------- */
|
/* ---------- CONTAINER / SECTION ---------- */
|
||||||
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
|
||||||
const style: CSSProperties = nodeProps.style || {};
|
const style: CSSProperties = nodeProps.style || {};
|
||||||
|
|
||||||
const setPropStyle = useCallback(
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||||
(property: string, value: string) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
props.style = { ...props.style, [property]: value };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[actions, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{nodeProps.cssId !== undefined && (
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>CSS ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nodeProps.cssId || ''}
|
||||||
|
onChange={(e) => setProp('cssId', e.target.value)}
|
||||||
|
placeholder="my-element-id"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nodeProps.cssClass !== undefined && (
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>CSS Class</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nodeProps.cssClass || ''}
|
||||||
|
onChange={(e) => setProp('cssClass', e.target.value)}
|
||||||
|
placeholder="my-custom-class"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="guided-section">
|
<div className="guided-section">
|
||||||
<SectionLabel>Background Color</SectionLabel>
|
<SectionLabel>Background Color</SectionLabel>
|
||||||
<ColorSwatchGrid
|
<ColorSwatchGrid
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
import React, { useRef } from 'react';
|
import React from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { labelStyle, inputStyle, sectionGap } from './shared';
|
import { labelStyle, inputStyle, sectionGap } from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
/* Upload helper (same contract as ImageBlock/Navbar): uploads to WHP if a
|
|
||||||
WHP_CONFIG is present, otherwise falls back to a local blob URL. */
|
|
||||||
async function uploadToWhp(file: File): Promise<string | null> {
|
|
||||||
const cfg = (window as any).WHP_CONFIG;
|
|
||||||
if (!cfg) return URL.createObjectURL(file);
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.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: fd,
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
return data.success && data.url ? data.url : null;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Feature {
|
interface Feature {
|
||||||
title?: string; description?: string; icon?: string;
|
title?: string; description?: string; icon?: string;
|
||||||
@@ -33,7 +18,6 @@ interface Feature {
|
|||||||
export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }> = ({ selectedId, features }) => {
|
export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }> = ({ selectedId, features }) => {
|
||||||
const { actions } = useEditor();
|
const { actions } = useEditor();
|
||||||
const list: Feature[] = Array.isArray(features) ? (features as Feature[]) : [];
|
const list: Feature[] = Array.isArray(features) ? (features as Feature[]) : [];
|
||||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
|
||||||
|
|
||||||
const mutate = (fn: (arr: Feature[]) => Feature[]) =>
|
const mutate = (fn: (arr: Feature[]) => Feature[]) =>
|
||||||
actions.setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
|
actions.setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
|
||||||
@@ -42,9 +26,6 @@ export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }>
|
|||||||
const add = () =>
|
const add = () =>
|
||||||
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
|
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
|
||||||
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
|
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
|
||||||
const onUpload = async (i: number, file: File) => { const url = await uploadToWhp(file); if (url) update(i, 'image', url); };
|
|
||||||
|
|
||||||
const smallBtn: React.CSSProperties = { padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#27272a', color: '#e4e4e7' };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
@@ -57,21 +38,12 @@ export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }>
|
|||||||
<textarea value={feat.description || ''} onChange={(e) => update(i, 'description', e.target.value)} placeholder="Description" rows={2} style={{ ...inputStyle, resize: 'vertical' }} />
|
<textarea value={feat.description || ''} onChange={(e) => update(i, 'description', e.target.value)} placeholder="Description" rows={2} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||||
|
|
||||||
{/* Media: image (upload/URL) takes precedence over the icon when set */}
|
{/* Media: image (upload/URL) takes precedence over the icon when set */}
|
||||||
{feat.image ? (
|
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
||||||
<img src={feat.image} alt="" style={{ width: 28, height: 28, objectFit: 'cover', borderRadius: 4, border: '1px solid #3f3f46' }} />
|
|
||||||
<input type="text" value={feat.image} onChange={(e) => update(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
|
|
||||||
<button onClick={() => update(i, 'image', '')} title="Use icon instead" style={smallBtn}>Icon</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<input type="text" value={feat.icon || ''} onChange={(e) => update(i, 'icon', e.target.value)} placeholder="Icon (emoji)" style={{ ...inputStyle, width: 60, flex: 'none', textAlign: 'center' }} />
|
<input type="text" value={feat.icon || ''} onChange={(e) => update(i, 'icon', e.target.value)} placeholder="Icon (emoji)" style={{ ...inputStyle, width: 60, flex: 'none', textAlign: 'center' }} />
|
||||||
<button onClick={() => fileRefs.current[i]?.click()} style={{ ...smallBtn, flex: 1 }}> Upload image</button>
|
<div style={{ flex: 1 }}>
|
||||||
<input type="text" value={feat.image || ''} onChange={(e) => update(i, 'image', e.target.value)} placeholder="or image URL" style={{ ...inputStyle, flex: 1 }} />
|
<AssetPicker variant="compact" value={feat.image || ''} onChange={(url) => update(i, 'image', url)} placeholder="or image URL" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<input ref={(el) => { fileRefs.current[i] = el; }} type="file" accept="image/*" style={{ display: 'none' }}
|
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(i, f); e.target.value = ''; }} />
|
|
||||||
|
|
||||||
{/* Optional button */}
|
{/* Optional button */}
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
|
||||||
import {
|
import {
|
||||||
BG_COLORS,
|
BG_COLORS,
|
||||||
SPACING_PRESETS,
|
SPACING_PRESETS,
|
||||||
@@ -15,21 +14,12 @@ import {
|
|||||||
inputStyle,
|
inputStyle,
|
||||||
btnActiveStyle,
|
btnActiveStyle,
|
||||||
sectionGap,
|
sectionGap,
|
||||||
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
/* ---------- FORM ---------- */
|
/* ---------- FORM ---------- */
|
||||||
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||||
|
|
||||||
const setProp = useCallback((key: string, value: any) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
|
||||||
}, [actions, selectedId]);
|
|
||||||
|
|
||||||
const setPropStyle = useCallback((key: string, value: string) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
props.style = { ...props.style, [key]: value };
|
|
||||||
});
|
|
||||||
}, [actions, selectedId]);
|
|
||||||
|
|
||||||
const style = nodeProps.style || {};
|
const style = nodeProps.style || {};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
|
||||||
import {
|
import {
|
||||||
TEXT_COLORS,
|
TEXT_COLORS,
|
||||||
BG_COLORS,
|
BG_COLORS,
|
||||||
@@ -12,30 +11,20 @@ import {
|
|||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
ColorPickerField,
|
ColorPickerField,
|
||||||
ArrayPropEditor,
|
|
||||||
labelStyle,
|
labelStyle,
|
||||||
inputStyle,
|
inputStyle,
|
||||||
smallInputStyle,
|
|
||||||
sectionGap,
|
sectionGap,
|
||||||
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { ArrayItemFieldsEditor } from './ArrayItemFields';
|
||||||
|
|
||||||
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
||||||
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
||||||
selectedId, nodeProps, typeName,
|
selectedId, nodeProps, typeName,
|
||||||
}) => {
|
}) => {
|
||||||
const { actions } = useEditor();
|
|
||||||
|
|
||||||
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass']);
|
||||||
|
|
||||||
const setPropValue = useCallback((key: string, value: any) => {
|
const { setProp: setPropValue, setPropStyle: setStyleValue } = useNodeProp(selectedId);
|
||||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
|
||||||
}, [actions, selectedId]);
|
|
||||||
|
|
||||||
const setStyleValue = useCallback((key: string, value: string) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
props.style = { ...props.style, [key]: value };
|
|
||||||
});
|
|
||||||
}, [actions, selectedId]);
|
|
||||||
|
|
||||||
// Categorize all props
|
// Categorize all props
|
||||||
const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key));
|
const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key));
|
||||||
@@ -105,116 +94,9 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Array props */}
|
{/* Array props */}
|
||||||
{arrayProps.map(([key, items]) => {
|
{arrayProps.map(([key, items]) => (
|
||||||
const arrayItems = items as any[];
|
<ArrayItemFieldsEditor key={key} selectedId={selectedId} propKey={key} items={items as any[]} />
|
||||||
const sampleItem = arrayItems[0] || {};
|
))}
|
||||||
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CollapsibleSection key={key} title={key.replace(/([A-Z])/g, ' $1').trim()}>
|
|
||||||
<ArrayPropEditor
|
|
||||||
selectedId={selectedId}
|
|
||||||
propKey={key}
|
|
||||||
items={arrayItems}
|
|
||||||
renderItem={(item: any, index: number) => {
|
|
||||||
if (typeof item !== 'object' || item === null) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={String(item)}
|
|
||||||
onChange={(e) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
const updated = [...(props[key] || [])];
|
|
||||||
updated[index] = e.target.value;
|
|
||||||
props[key] = updated;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
style={smallInputStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
{itemFields.map((field) => {
|
|
||||||
const fieldVal = item[field];
|
|
||||||
if (typeof fieldVal === 'boolean') {
|
|
||||||
return (
|
|
||||||
<label key={field} style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
|
||||||
<input type="checkbox" checked={fieldVal} onChange={(e) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
const updated = [...(props[key] || [])];
|
|
||||||
updated[index] = { ...updated[index], [field]: e.target.checked };
|
|
||||||
props[key] = updated;
|
|
||||||
});
|
|
||||||
}} />
|
|
||||||
{field}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (typeof fieldVal === 'number') {
|
|
||||||
return (
|
|
||||||
<div key={field}>
|
|
||||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
|
||||||
<input type="number" value={fieldVal} onChange={(e) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
const updated = [...(props[key] || [])];
|
|
||||||
updated[index] = { ...updated[index], [field]: parseFloat(e.target.value) || 0 };
|
|
||||||
props[key] = updated;
|
|
||||||
});
|
|
||||||
}} style={smallInputStyle} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (/color/i.test(field) && typeof fieldVal === 'string') {
|
|
||||||
return (
|
|
||||||
<div key={field} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize', width: 50 }}>{field}</label>
|
|
||||||
<input type="color" value={fieldVal || '#000000'} onChange={(e) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
const updated = [...(props[key] || [])];
|
|
||||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
|
||||||
props[key] = updated;
|
|
||||||
});
|
|
||||||
}} style={{ width: 24, height: 20, border: 'none', cursor: 'pointer', background: 'none', padding: 0 }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const strVal = String(fieldVal ?? '');
|
|
||||||
const isLongField = strVal.length > 50 || field === 'description' || field === 'text' || field === 'content';
|
|
||||||
return (
|
|
||||||
<div key={field}>
|
|
||||||
<label style={{ fontSize: 9, color: '#52525b', textTransform: 'capitalize' }}>{field}</label>
|
|
||||||
{isLongField ? (
|
|
||||||
<textarea value={strVal} onChange={(e) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
const updated = [...(props[key] || [])];
|
|
||||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
|
||||||
props[key] = updated;
|
|
||||||
});
|
|
||||||
}} rows={2} style={{ ...smallInputStyle, resize: 'vertical' }} />
|
|
||||||
) : (
|
|
||||||
<input type="text" value={strVal} onChange={(e) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
const updated = [...(props[key] || [])];
|
|
||||||
updated[index] = { ...updated[index], [field]: e.target.value };
|
|
||||||
props[key] = updated;
|
|
||||||
});
|
|
||||||
}} style={smallInputStyle} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
emptyItem={typeof sampleItem === 'object' && sampleItem !== null
|
|
||||||
? Object.fromEntries(itemFields.map((f) => [f, typeof sampleItem[f] === 'number' ? 0 : typeof sampleItem[f] === 'boolean' ? false : '']))
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Style controls */}
|
{/* Style controls */}
|
||||||
<CollapsibleSection title="Style">
|
<CollapsibleSection title="Style">
|
||||||
|
|||||||
@@ -1,100 +1,19 @@
|
|||||||
import React, { useCallback, useState, useRef } from 'react';
|
import React from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
|
||||||
import {
|
import {
|
||||||
StylePanelProps,
|
StylePanelProps,
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
ColorPickerField,
|
ColorPickerField,
|
||||||
uploadToWhp,
|
|
||||||
labelStyle,
|
labelStyle,
|
||||||
inputStyle,
|
inputStyle,
|
||||||
smallInputStyle,
|
|
||||||
btnActiveStyle,
|
btnActiveStyle,
|
||||||
sectionGap,
|
sectionGap,
|
||||||
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
/* ---------- Asset Browser Inline ---------- */
|
|
||||||
const AssetBrowser: React.FC<{
|
|
||||||
filter: 'image' | 'video' | 'all';
|
|
||||||
onSelect: (url: string) => void;
|
|
||||||
}> = ({ filter, onSelect }) => {
|
|
||||||
const [assets, setAssets] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const loadAssets = useCallback(async () => {
|
|
||||||
const cfg = (window as any).WHP_CONFIG;
|
|
||||||
if (!cfg) return;
|
|
||||||
setLoading(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 filtered = filter === 'all' ? data.assets : data.assets.filter((a: any) => {
|
|
||||||
const t = (a.type || '').toLowerCase();
|
|
||||||
return filter === 'image' ? t.startsWith('image') : t.startsWith('video');
|
|
||||||
});
|
|
||||||
setAssets(filtered);
|
|
||||||
}
|
|
||||||
} catch (e) { console.error('Load assets failed:', e); }
|
|
||||||
setLoading(false);
|
|
||||||
}, [filter]);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
|
||||||
if (!open) loadAssets();
|
|
||||||
setOpen(!open);
|
|
||||||
}, [open, loadAssets]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button onClick={handleToggle} style={{
|
|
||||||
...btnActiveStyle(open), width: '100%', marginTop: 4,
|
|
||||||
}}>
|
|
||||||
<i className={`fa ${loading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} />
|
|
||||||
{open ? 'Close' : 'Browse Assets'}
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div style={{ maxHeight: 160, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
|
||||||
{assets.map((asset, i) => (
|
|
||||||
<div key={i} onClick={() => { onSelect(asset.url); setOpen(false); }}
|
|
||||||
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s', background: '#27272a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}>
|
|
||||||
{(asset.type || '').startsWith('image') ? (
|
|
||||||
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
|
||||||
) : (
|
|
||||||
<div style={{ textAlign: 'center', padding: 4 }}>
|
|
||||||
<i className="fa fa-film" style={{ fontSize: 20, color: '#71717a' }} />
|
|
||||||
<div style={{ fontSize: 8, color: '#71717a', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 70 }}>
|
|
||||||
{asset.name?.replace(/^\d+_[a-f0-9]+_/, '')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{assets.length === 0 && (
|
|
||||||
<p style={{ gridColumn: '1/-1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: 12, margin: 0 }}>
|
|
||||||
No {filter} assets uploaded yet
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- HERO STYLE PANEL ---------- */
|
/* ---------- HERO STYLE PANEL ---------- */
|
||||||
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
const { setProp } = useNodeProp(selectedId);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const setProp = useCallback((key: string, value: any) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => { props[key] = value; });
|
|
||||||
}, [actions, selectedId]);
|
|
||||||
|
|
||||||
const handleUpload = useCallback(async (file: File, propKey: string) => {
|
|
||||||
const url = await uploadToWhp(file);
|
|
||||||
if (url) setProp(propKey, url);
|
|
||||||
}, [setProp]);
|
|
||||||
|
|
||||||
const bgType = nodeProps.bgType || 'color';
|
const bgType = nodeProps.bgType || 'color';
|
||||||
|
|
||||||
@@ -161,55 +80,20 @@ export const HeroStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
|||||||
{bgType === 'image' && (
|
{bgType === 'image' && (
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
<label style={labelStyle}>Background Image</label>
|
<label style={labelStyle}>Background Image</label>
|
||||||
{nodeProps.bgImage && (
|
<AssetPicker value={nodeProps.bgImage || ''} onChange={(url) => setProp('bgImage', url)} variant="full" />
|
||||||
<div style={{ marginBottom: 6, borderRadius: 4, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
|
||||||
<img src={nodeProps.bgImage} alt="" style={{ width: '100%', height: 80, objectFit: 'cover', display: 'block' }} />
|
|
||||||
<button onClick={() => setProp('bgImage', '')} style={{ position: 'absolute', top: 2, right: 2, 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' }}>
|
|
||||||
<i className="fa fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<button onClick={() => fileInputRef.current?.click()} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
|
||||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<AssetBrowser filter="image" onSelect={(url) => setProp('bgImage', url)} />
|
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleUpload(f, 'bgImage'); e.target.value = ''; }} />
|
|
||||||
<input type="text" value={nodeProps.bgImage || ''} placeholder="Or paste URL..."
|
|
||||||
onChange={(e) => setProp('bgImage', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bgType === 'video' && (
|
{bgType === 'video' && (
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
<label style={labelStyle}>Background Video</label>
|
<label style={labelStyle}>Background Video</label>
|
||||||
{nodeProps.bgVideo && (
|
<AssetPicker
|
||||||
<div style={{ marginBottom: 6, padding: 8, background: '#18181b', borderRadius: 4, border: '1px solid #3f3f46', display: 'flex', alignItems: 'center', gap: 6 }}>
|
mediaType="video"
|
||||||
<i className="fa fa-film" style={{ color: '#3b82f6' }} />
|
variant="full"
|
||||||
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
value={nodeProps.bgVideo || ''}
|
||||||
{nodeProps.bgVideo.replace(/.*filename=/, '').replace(/^\d+_[a-f0-9]+_/, '') || nodeProps.bgVideo.split('/').pop()}
|
onChange={(url) => setProp('bgVideo', url)}
|
||||||
</span>
|
placeholder="YouTube, Vimeo, or .mp4 URL"
|
||||||
<button onClick={() => setProp('bgVideo', '')} style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: 12 }}>
|
/>
|
||||||
<i className="fa fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<button onClick={() => {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = 'video/*';
|
|
||||||
input.onchange = () => { const f = input.files?.[0]; if (f) handleUpload(f, 'bgVideo'); };
|
|
||||||
input.click();
|
|
||||||
}} style={{ ...btnActiveStyle(false), flex: 1 }}>
|
|
||||||
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload Video
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<AssetBrowser filter="video" onSelect={(url) => setProp('bgVideo', url)} />
|
|
||||||
<input type="text" value={nodeProps.bgVideo || ''} placeholder="YouTube, Vimeo, or .mp4 URL"
|
|
||||||
onChange={(e) => setProp('bgVideo', e.target.value)} style={{ ...smallInputStyle, width: '100%', marginTop: 4 }} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback, useRef, CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import {
|
import {
|
||||||
IMAGE_RADIUS_PRESETS,
|
IMAGE_RADIUS_PRESETS,
|
||||||
@@ -8,58 +8,16 @@ import {
|
|||||||
SectionLabel,
|
SectionLabel,
|
||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
TextInputField,
|
TextInputField,
|
||||||
uploadToWhp,
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
|
|
||||||
/* ---------- IMAGE (with upload/browse/drop) ---------- */
|
/* ---------- IMAGE (with upload/browse/drop) ---------- */
|
||||||
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
const { actions } = useEditor();
|
||||||
const style: CSSProperties = nodeProps.style || {};
|
const style: CSSProperties = nodeProps.style || {};
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [showBrowser, setShowBrowser] = useState(false);
|
|
||||||
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
|
||||||
const [browserLoading, setBrowserLoading] = useState(false);
|
|
||||||
const [imgUrl, setImgUrl] = useState(nodeProps.src || '');
|
|
||||||
|
|
||||||
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg";
|
const { setPropStyle } = useNodeProp(selectedId);
|
||||||
const isPlaceholder = !nodeProps.src || nodeProps.src.startsWith('data:image/svg');
|
|
||||||
|
|
||||||
const setPropStyle = useCallback(
|
|
||||||
(property: string, value: string) => {
|
|
||||||
actions.setProp(selectedId, (props: any) => {
|
|
||||||
props.style = { ...props.style, [property]: value };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[actions, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUpload = useCallback(async (file: File) => {
|
|
||||||
const url = await uploadToWhp(file);
|
|
||||||
if (url) {
|
|
||||||
actions.setProp(selectedId, (props: any) => { props.src = url; });
|
|
||||||
setImgUrl(url);
|
|
||||||
}
|
|
||||||
}, [actions, selectedId]);
|
|
||||||
|
|
||||||
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 maxWidthPresets = [
|
const maxWidthPresets = [
|
||||||
{ label: '25%', value: '25%' },
|
{ label: '25%', value: '25%' },
|
||||||
@@ -68,117 +26,16 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
{ label: '100%', value: '100%' },
|
{ label: '100%', value: '100%' },
|
||||||
];
|
];
|
||||||
|
|
||||||
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';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Image source with upload/browse */}
|
{/* Image source */}
|
||||||
<div className="guided-section">
|
<div className="guided-section">
|
||||||
<SectionLabel>Image Source</SectionLabel>
|
<SectionLabel>Image Source</SectionLabel>
|
||||||
|
<AssetPicker
|
||||||
{!isPlaceholder && nodeProps.src ? (
|
value={nodeProps.src || ''}
|
||||||
<>
|
onChange={(url) => actions.setProp(selectedId, (props: any) => { props.src = url; })}
|
||||||
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
variant="full"
|
||||||
<img src={nodeProps.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
|
||||||
<button
|
|
||||||
onClick={() => { actions.setProp(selectedId, (props: any) => { props.src = ''; }); setImgUrl(''); }}
|
|
||||||
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(nodeProps.src || '')}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload + Browse buttons */}
|
|
||||||
<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={() => {
|
|
||||||
actions.setProp(selectedId, (props: any) => { props.src = asset.url; });
|
|
||||||
setImgUrl(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 for advanced users */}
|
|
||||||
<div style={{ marginTop: 6 }}>
|
|
||||||
<div className="guided-input-row">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="guided-input"
|
|
||||||
value={imgUrl}
|
|
||||||
placeholder="Or paste image URL..."
|
|
||||||
onChange={(e) => setImgUrl(e.target.value)}
|
|
||||||
style={{ fontSize: 11 }}
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
className="preset-btn apply-btn"
|
|
||||||
onClick={() => {
|
|
||||||
actions.setProp(selectedId, (props: any) => { props.src = imgUrl; });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Alt Text */}
|
{/* Alt Text */}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
/* MediaStylePanel (via useNodeProp/ArrayPropEditor in ./shared) only needs
|
||||||
|
useEditor from @craftjs/core. Mock it following the DOM-harness pattern
|
||||||
|
used in Footer.editguard.test.tsx / AssetPicker.test.tsx (no
|
||||||
|
@testing-library/react in this repo) so setProp calls can be observed
|
||||||
|
without mounting a real <Editor> tree. */
|
||||||
|
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
|
||||||
|
updater(lastProps);
|
||||||
|
});
|
||||||
|
let lastProps: any;
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({ actions: { setProp: setPropSpy } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../utils/assets', () => ({
|
||||||
|
uploadAsset: vi.fn(),
|
||||||
|
listAssets: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MediaStylePanel } from './MediaStylePanel';
|
||||||
|
|
||||||
|
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 unmount() {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function q<T extends Element = Element>(testId: string): T | null {
|
||||||
|
return container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(input: HTMLInputElement, value: string) {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||||
|
setter.call(input, value);
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setPropSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INT: ContentSlider (src/components/sections/ContentSlider.tsx) reads
|
||||||
|
* `slide.imageSrc` in both render and toHtml, but the Slides array editor
|
||||||
|
* here guarded on `item.image !== undefined` and wrote `image` via
|
||||||
|
* AssetPicker's onChange -- a key that ContentSlider never reads. Default
|
||||||
|
* slides (`imageSrc:''`, no `image` key) therefore showed NO image picker at
|
||||||
|
* all (the guard was never true), and even a manually-added `image` value
|
||||||
|
* was a silent no-op on export/render.
|
||||||
|
*/
|
||||||
|
describe('MediaStylePanel Slides editor uses imageSrc (INT)', () => {
|
||||||
|
test('a slide shaped like defaultSlides (imageSrc present, no `image` key) renders the image AssetPicker', () => {
|
||||||
|
lastProps = { slides: [{ type: 'image', imageSrc: '', heading: 'First Slide', text: '', bgColor: '' }] };
|
||||||
|
render(<MediaStylePanel selectedId="cs-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
// Before the fix, the guard `item.image !== undefined` was false for
|
||||||
|
// this default-shaped slide, so no AssetPicker rendered at all.
|
||||||
|
expect(q('asset-picker-compact')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing the slide image writes imageSrc (not image) via setProp', () => {
|
||||||
|
lastProps = { slides: [{ type: 'image', imageSrc: '', heading: 'First Slide', text: '', bgColor: '' }] };
|
||||||
|
render(<MediaStylePanel selectedId="cs-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
const urlInput = q<HTMLInputElement>('asset-picker-url-input')!;
|
||||||
|
expect(urlInput).not.toBeNull();
|
||||||
|
setValue(urlInput, 'https://example.com/slide.jpg');
|
||||||
|
act(() => {
|
||||||
|
urlInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setPropSpy).toHaveBeenCalled();
|
||||||
|
const updatedSlide = lastProps.slides[0];
|
||||||
|
expect(updatedSlide.imageSrc).toBe('https://example.com/slide.jpg');
|
||||||
|
expect(updatedSlide.image).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adding a new slide (emptyItem) includes imageSrc, matching defaultSlides shape', () => {
|
||||||
|
lastProps = { slides: [] };
|
||||||
|
render(<MediaStylePanel selectedId="cs-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
// The "+ Add Item" button for the Slides ArrayPropEditor.
|
||||||
|
const addButtons = Array.from(container.querySelectorAll('button')).filter(
|
||||||
|
(b) => b.textContent?.includes('Add Item'),
|
||||||
|
);
|
||||||
|
expect(addButtons.length).toBeGreaterThan(0);
|
||||||
|
act(() => {
|
||||||
|
addButtons[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setPropSpy).toHaveBeenCalled();
|
||||||
|
expect(lastProps.slides).toHaveLength(1);
|
||||||
|
expect(lastProps.slides[0]).toHaveProperty('imageSrc');
|
||||||
|
expect(lastProps.slides[0].image).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user