Compare commits

...
60 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 4.8 4a426e3513 fix(containers): only flex-convert Container/Section when vertical-align set (avoid blockifying inline-block children)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:55:20 -07:00
shadowdaoandClaude Opus 4.8 9750a6c2bf feat(builder): containers package -- vertical alignment + box-model/anim/vis rollout
- ColumnLayout: exposes style.alignItems on its flex ROW (aligns uneven
  columns) -- render/toHtml already spread `style` onto the row div, so this
  is a craft.props default + panel control addition, no structural change.
- Container/Section: root element is now unconditionally display:flex;
  flex-direction:column (both editor render and toHtml), so the new
  Vertical Alignment control maps to style.justifyContent, paired with a
  Min Height (NumericUnitInput) control on style.minHeight. Default
  justify-content/align-items reproduce ordinary block-flow stacking, so
  this is a visual no-op for existing published content. Works in both
  normal and "boxed" (contentWidth) modes -- the boxed inner wrapper's own
  margin:0-auto horizontal centering is preserved via flex auto-margin
  override semantics.
- ContainerStylePanel (serves Container/Section/Columns) distinguishes the
  Columns case from Container/Section via nodeProps.columns/split presence
  (no typeName plumbing needed) to pick align-items vs justify-content for
  the shared Vertical Alignment control.
- All 3 owned components: added margin/padding (per-side)/border/box-shadow/
  opacity style defaults + AnimationControl/VisibilityControl-backed
  animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props.
  New containerBoxModel.tsx (package-local, not shared.tsx) DRYs the
  box-model + border/effects + animation/visibility panel sections across
  the single shared ContainerStylePanel, mirroring the sibling media
  package's mediaBoxModel.tsx.
- Tests: extended all 3 *.toHtml.test.ts files (align-items/justify-content/
  min-height emission, box-model style emission, craft.props presence).
  673 tests green, tsc + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:44:37 -07:00
jknapp 1d9460c173 Merge PR #13: enhancement foundation 2026-07-14 13:12:22 +00:00
shadowdaoandClaude Opus 4.8 8d69982a0b chore(builder): install CodeMirror deps for the upcoming code-editor feature
Adds @codemirror/state, view, commands, autocomplete, language,
lang-html, lang-javascript, lang-css, and theme-one-dark to package.json
now (unused for now, tree-shaken out of the build) so the code-editor
feature branch doesn't need to touch package.json/package-lock.json itself
and conflict with other enh-batch branches doing the same.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:07:25 -07:00
shadowdaoandClaude Opus 4.8 fb40e3ece4 feat(builder): add reusable StylePanel controls (foundation for enh batch)
Adds NumericUnitInput, SizeControl, AspectRatioControl, FocalPointGrid,
SpacingControl, BorderControl, AnimationControl, and VisibilityControl to
src/panels/right/styles/shared.tsx -- presentational value/onChange controls
that upcoming feature panels will import instead of reinventing size,
spacing, border, and animation/visibility UI per panel.

AnimationControl/VisibilityControl emit the exact prop names
(animation/animationDelay, hideOnDesktop/hideOnTablet/hideOnMobile) already
consumed by html-export.ts's buildDataAttrs(), verified by reading that file
directly. ANIMATIONS matches the export's actual data-animation set
(fade-in/slide-up/slide-left/slide-right/zoom-in/bounce) rather than the
slide-down variant that doesn't exist in the export.

No existing shared.tsx exports were touched, and nothing is wired into any
feature panel yet -- that's the downstream feature branches' job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:07:20 -07:00
shadowdaoandClaude Opus 4.8 9591fcb8a7 feat(builder): add shared style presets for size/aspect-ratio/shadow/typography rollout
ASPECT_RATIOS, SHADOW_PRESETS, LINE_HEIGHTS, LETTER_SPACINGS, OBJECT_FIT,
BORDER_STYLES, and SIZE_PRESETS -- foundation for the new shared StylePanel
controls (SizeControl, AspectRatioControl, BorderControl) so upcoming
feature panels share one preset source instead of each defining their own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:07:14 -07:00
jknapp f80811318b Merge PR #12: real-Editor integration test harness 2026-07-14 00:12:49 +00:00
shadowdaoandClaude Opus 4.8 cb9fea6656 test(builder): real-@craftjs/core integration test harness (duplicate/paste, template load, AI apply-response)
Mocked unit tests for regenerateTreeIds, TemplateModal.addTemplateComponents,
and buildNodeTree each let a real bug ship (DataCloneError on duplicate/paste,
dropped template children, no-op AI section-replace/insert) because their
fake @craftjs/core query/actions never exercised Craft's real node shape
(data.type as a live component reference) or real parseFreshNode validation.

Adds src/test-utils/editorHarness.tsx, which mounts a REAL <Editor>+<Frame>
(no vi.mock('@craftjs/core') anywhere) via react-dom/client + act, plus the
jsdom shims Craft/this component library actually needs (ResizeObserver,
matchMedia, and an HTMLElement.prototype.innerText polyfill -- jsdom has no
native innerText, which Heading/TextBlock rely on to paint their text).

Adds 3 integration suites under src/test-utils/integration/ driving the real
useNodeActions/useKeyboardShortcuts, TemplateModal's real tree-build pipeline,
and the real useApplyAiResponse hook against a live EditorStore, asserting on
both the real rendered DOM and query.serialize()/exportBodyHtml output.

Red-proofed the duplicate/paste suite: temporarily reverted
regenerateTreeIds to structuredClone(oldNode.data) and confirmed both tests
fail with the historical DataCloneError before restoring the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:11:40 -07:00
jknapp b1afdcb585 Merge PR #11: mobile fast-follows 2026-07-13 16:19:53 +00:00
shadowdaoandClaude Opus 4.8 f00e4db3dc test(builder): cover useVisualViewport; fix: disable Select-Parent at ROOT in desktop context menu
- useVisualViewport.test.tsx: covers the visualViewport-undefined fallback,
  the keyboardInset math on a mocked visualViewport resize, and
  listener add/remove (resize + scroll) across mount/unmount.
- ContextMenu.tsx: Select Parent was only disabled at isRoot, so selecting
  a top-level section and choosing Select Parent silently landed on the
  un-editable ROOT (no outline, no toolbar) -- a dead end. Switched the
  guard to useNodeActions' canSelectParent (false whenever the node's
  parent is ROOT or missing), matching the mobile selection toolbar's
  existing identical guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 09:19:00 -07:00
jknapp b9552faa30 Merge PR #10: Mobile Phase B (touch editing) 2026-07-13 15:43:29 +00:00
shadowdaoandClaude Opus 4.8 77f35c4e9e fix(builder): duplicate inserts+selects after source; select-parent/styles-sheet/canvas-pad mobile fixes
Ship-blocking fix (Fable consult): useNodeActions.duplicate() appended the
regenerated tree at the end of the parent while leaving the ORIGINAL
selected, so duplicating a top-level section landed the copy off-screen at
the bottom of the page with no visible change -- shared by both the mobile
selection toolbar and the desktop right-click ContextMenu. Now inserts the
copy immediately after the source (actions.addNodeTree(tree, parentId,
sourceIndex + 1)) and selects it (actions.selectNode(tree.rootNodeId)),
falling back to append-at-end if the source's index can't be resolved.
Mobile also scrolls the new node into view.

Three cheap fast-follows:
- canSelectParent on useNodeActions (false when the node's parent is ROOT
  or missing); MobileSelectionToolbar disables "Select Parent" instead of
  dead-ending on a page-wide ROOT outline with no toolbar of its own.
- Opening the Styles sheet on mobile now scrolls the selected node above
  the 65dvh sheet; a temporary generous bottom-padding class handles the
  case where the node is the last thing on the page and there'd otherwise
  be no room left to scroll it into view.
- Canvas gets bottom padding equal to the fixed selection toolbar's height
  while it's visible, so the last section of a short page isn't stuck
  permanently underneath it.

Desktop duplicate behavior improves (inserts after + selects) via the
shared hook; no toHtml changes. Verified live via Playwright at 375px and
1280px (screenshots in craft/scratchpad/mobileB2/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:41:33 -07:00
shadowdaoandClaude Opus 4.8 2c8425ffb0 feat(builder): mobile-B touch editing -- selection toolbar, tap-to-add, swipe-dismiss
Phase B makes the Craft.js editor genuinely usable by touch on top of Phase
A's responsive shell, gated entirely behind useIsMobile()/<=768px:

- Extract useNodeActions(nodeId) out of ContextMenu.tsx (move/duplicate/
  delete/select-parent), shared by the desktop right-click menu (behavior
  unchanged) and the new mobile MobileSelectionToolbar.
- MobileSelectionToolbar: bottom-fixed selection toolbar (Move Up/Down,
  Duplicate, Select Parent, Edit Styles, two-tap Delete confirm), hidden
  while a sheet is open.
- BlocksPanel: tap-to-add on mobile (insert after selection, close sheet,
  select + scroll the new node into view); desktop drag/double-click
  unchanged.
- LayersPanel rows >=44px on mobile; HeadCodeModal portaled to document.body
  (same fix TemplateModal already had); BottomSheet gets swipe-to-dismiss
  and on-screen-keyboard clearance via a new useVisualViewportInsets hook.

Also fixes two pre-existing bugs surfaced only by driving a real Craft.js
document with Playwright touch input (masked by tests that mock
@craftjs/core): regenerateTreeIds structuredClone'd a live node's whole
data object, including the component function reference in data.type,
throwing DataCloneError and silently breaking Duplicate/Paste for every
node type; and an earlier useNodeActions draft cached canMoveUp/canMoveDown
inside a useEditor collector closed over nodeId, which goes stale for one
render whenever the selection changes without an unrelated store event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:07:51 -07:00
jknapp 2a071bc7ab Merge PR #9: Mobile Phase A 2026-07-13 14:31:23 +00:00
shadowdaoandClaude Opus 4.8 2a8a26687b fix(builder): mobile-A2 hardening -- 16px inputs, shared sheet/modal chrome, z-scale + portal
- Force font-size:16px !important on Styles-sheet/topbar/Sitesmith inputs
  inside the mobile media query so inline 12px/14px styles stop triggering
  iOS zoom-on-focus.
- Lift sheet-open + Templates/Head Code modal-open state out of private
  useState into a shared MobileChromeContext (EditorShell), so Phase B can
  open/close sheets from outside MobilePanelBar.
- Add an explicit z-index layer scale, portal TemplateModal to
  document.body (was trapped under the tab bar inside .topbar's stacking
  context), align Sitesmith to the same --z-modal layer, and make opening
  a sheet close any open modal. Also fix modal backdrops swallowing tab
  bar taps (mirrors the sheet backdrop's existing tab-bar cutout).
- Drop BottomSheet's incorrect aria-modal; mobile-aware AssetsPanel empty
  state copy.
- Tests: useIsMobile (matchMedia mock incl. legacy fallback + cleanup),
  MobileChromeContext invariants (one sheet open, sheet closes modals),
  MobilePanelBar wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 07:30:12 -07:00
shadowdaoandClaude Opus 4.8 979331b12d feat(builder): mobile-responsive editor chrome (Phase A)
Makes the Craft.js editor usable on phones (≤768px) without touching desktop
layout/behavior: a useIsMobile() hook gates a bottom tab bar + sheets (hosting
the existing Blocks/Pages/Layers/Assets/Styles panels unchanged) in place of
the side panels, a collapsed TopBar with a "..." overflow menu, 44px touch
targets, 16px inputs, dvh/safe-area-aware sizing, and small copy/overflow
fixes (empty-canvas hint, Templates modal tabs + close button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:54:15 -07:00
jknapp 329a782052 Merge PR #8: fix templates dropping nested content 2026-07-13 13:28:39 +00:00
shadowdaoandClaude Opus 4.8 da558fd52d fix(builder): render template header/footer nested content in zone preview + export
TemplateModal's addTemplateComponents() built each template component via
React.createElement(Component, comp.props) without ever passing
comp.children, silently dropping every nested children array authored in
templates/definitions.ts (header/footer Container > Logo/Menu/TextBlock,
page Section > Heading/TextBlock/ButtonLink). The resulting Craft.js node had
nodes: [], so the header/footer zone preview (ZonePreview -> exportBodyHtml)
rendered as an empty strip, and published output was affected the same way.

Fix converts each TemplateComponent to a SerializedTreeNode and reuses
craft-tree.ts's buildNodeTree (sanitize -> flatten -> materialize) -- the
same tested tree pipeline already used for AI-generated content -- instead
of hand-rolling a React-element tree, since a naive nested-children fix via
parseReactElement crashes any component with an internal SHELL_INNER linked
canvas (Section/BackgroundSection/FormContainer) or linked columns
(ColumnLayout). Also fixes two latent bugs in buildNodeTree itself, only
surfaced by exercising it against a real Craft.js editor for the first time:
data.type must be the actual resolved component reference (not a string or
{resolvedName} object) for correct rendering, and the synthesized SHELL_INNER
node needs data.name set for actions.addNodeTree's own validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 06:19:26 -07:00
jknapp 8aeadefa88 Merge PR #7: UI polish Phase 2 2026-07-13 04:13:17 +00:00
shadowdao 2066059c35 fix(builder): phase-2 design tweaks (landing home icon, decouple layer-hover from guides, neutral zone separator)
- PagesPanel: add fa-home glyph (8px, inherits badge text color) before
  "Landing" text in the landing-page badge, restoring the icon dropped
  in an earlier polish pass.
- editor.css: split [data-craft-hovered] and [data-layer-hovered] into
  distinct rules. Layer-hover now uses a solid 2px accent outline (vs
  the dashed structural guides) and is excluded from the .guides-off
  suppression list, so the Layers-panel hover->canvas locator still
  works when "Show guides" is off. Structural guides/hover
  ([data-craft-node], [data-craft-hovered]) remain correctly gated.
- Canvas.tsx: move the header/footer ZonePreview separator border to a
  .zone-preview-sep class, recolor it from amber
  (rgba(245,158,11,0.3)) to neutral (rgba(148,163,184,0.25)) to match
  the empty-state variant, and gate it on .guides-off via CSS
  descendant selector so it disappears with the rest of the guides.
2026-07-12 21:12:09 -07:00
shadowdaoandClaude Opus 4.8 473fe8d421 feat(builder): item 16 — context menu FA icon per action
Every menu item now has an FA icon (previously only "Ask Sitesmith" did,
leaving the rest visually misaligned): Duplicate fa-clone, Copy
fa-files-o, Paste fa-clipboard, Move Up/Down fa-arrow-up/down, Select
Parent fa-level-up, Delete fa-trash (kept its danger-red color). The
clipboard-group / structure-group separator already existed via
dividerAfter — no structural change needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:21 -07:00
shadowdaoandClaude Opus 4.8 5d6ae7946b fix(builder): item 15 — default header nav no longer links nonexistent pages
DEFAULT_HEADER_STATE's Navbar linked Home/About/Services/Contact, but a
brand new site only has a Home page — About/Services/Contact were dead
links on first click. Simplified the default to Home + an inert "Get
Started" CTA (href: '#') rather than seeding placeholder pages nobody
asked for. Updated default-header.test.ts's HTML-export assertions to
match the new default (and assert the removed links are gone).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:11 -07:00
shadowdaoandClaude Opus 4.8 1c85ab93eb feat(builder): item 14 — Templates modal close button + category pill hover states
Close button (already an FA fa-times icon-button) gains a hover
background/color; category pills gain the same hover-background
treatment when not active (they were already at the brief's target 12px
font-size). Template card hover-lift (translateY(-2px) + shadow) was
already implemented — no change needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:05 -07:00
shadowdaoandClaude Opus 4.8 67228f24b4 feat(builder): item 11 — Pages panel hierarchy: neutral header/footer rows, outline Landing badge, icon+tooltip actions
Header/Footer zone rows switched from a loud amber zoneButtonStyle to a
compact neutral zoneRowStyle in the same surface language as the page
list (accent border/bg only when active, matching the page list's own
"currently open" treatment). The "Appears on all pages" subtitle moved to
a tooltip; a fa-pencil hint fades in on row hover (`.zone-row-pencil` CSS
already shipped in the previous commit) and swaps to a check icon while
editing that zone.

The "LANDING" badge (loud amber uppercase + house icon) is now a small
outline badge — muted text, 1px border, no fill, no icon.

The page-row rename button's raw `&#9998;` HTML entity is now an FA
`fa-pencil` icon with `data-tooltip`/`aria-label`; the delete button
(already FA fa-trash) got the same tooltip/aria treatment for
consistency. Both dropped their native `title` in favor of `data-tooltip`
to avoid a double tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:56:00 -07:00
shadowdaoandClaude Opus 4.8 613a44c4d4 feat(builder): items 10+12+13 — scope canvas guides, Show Guides toggle, Layers panel hover/icons, topbar unification
Item 10: dashed canvas "guide" outlines were applied via blanket tag
selectors (div/section/header/...), so a finished section showed 3-4
nested dashed boxes. RenderNode.tsx (the <Editor onRender> override) now
tags each Craft.js droppable container's real DOM node with a
`data-craft-node` attribute (node.data.isCanvas, excluding ROOT), and
editor.css's guide rules target that attribute instead — a component's own
internal wrapper markup is no longer mistaken for a drop target. Added a
"Show guides" topbar toggle (default ON, persisted to localStorage),
state lifted in EditorShell.tsx (mirrors how `device` is already lifted),
flips `.guides-off` on Canvas.tsx's `.canvas-device-frame`.

Item 12: Layers panel rows get per-type FA icons (keyed off the same
craft.displayName used for the row label, seeded from BlocksPanel's
choices), indent-guide lines connecting nested rows, and row hover
highlights the matching canvas element (`data-layer-hovered`, written
directly to the node's DOM via `query.node(id).get().dom` — NOT via
`actions.setNodeEvent`, which is stripped from useEditor()'s public
`actions` at runtime, not just in its TS type, and threw when called).

Along the way, Craft.js's own connect() was found to already wire mouse
hover to the same `hovered` node event internally (previously invisible
because the matching CSS was dead) — RenderNode now also mirrors that
onto `data-craft-hovered` for a real-mouse-hover canvas highlight, and
both hover attributes plus the guide attribute are suppressed under
`.guides-off` so the toggle stays airtight.

Item 13: unified the topbar to one button radius (`.topbar-btn` 20px pill
-> var(--radius-md)), demoted Code/Preview to icon-only with
`data-tooltip`. Fixed a latent bug the new tooltips exposed: `[data-tooltip]`
tooltips render above their trigger, but the topbar has no room above it
(`body { overflow: hidden }`) — added a `.topbar` override to render
those below instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:55:52 -07:00
jknapp 5685670895 Merge PR #6: UI polish Phase 1 2026-07-13 03:22:51 +00:00
shadowdao 72f85a97e5 fix(builder): phase-1 polish a11y follow-ups (dropzone keyboard, badge name, icon aria) 2026-07-12 20:22:11 -07:00
shadowdaoandClaude Opus 4.8 ab28ad8f2c Merge assets-panel empty state into one dropzone
The Assets panel used to show a small always-visible dropzone plus a
separate italic "No assets uploaded yet" line stacked underneath it
when there were no assets -- two redundant messages for one state.
Replace both with a single tall dropzone (icon + "Drag images here or
click to upload") that also opens the file picker on click; it
collapses back to the original slim "Drop files here to upload" bar
once assets exist. Upload/drag-drop behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:15:15 -07:00
shadowdaoandClaude Opus 4.8 05e00c572d Show component-indicator selection badge in the canvas
.component-indicator existed in editor.css but was never rendered
anywhere. Add RenderNode.tsx as a Craft.js <Editor onRender> override
and wire it in App.tsx: for the currently-selected node (excluding
ROOT) it portals a floating badge showing the node's displayName plus
a "select parent" chevron wired to actions.selectNode(parentId). Every
other node's render passes through untouched (a Fragment, no extra
DOM), and the badge portals to document.body positioned via
getBoundingClientRect rather than wrapping nodes in extra DOM, so it
can't perturb canvas layout and never appears in toHtml export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:15:08 -07:00
shadowdaoandClaude Opus 4.8 458069afb6 Show empty-canvas hint on a page with no components yet
.empty-canvas-hint existed in editor.css but was never rendered
anywhere. Wire it up in Canvas.tsx: an EmptyCanvasHint component reads
Craft's ROOT node via useEditor and shows the hint once ROOT exists
with zero children, hiding again the instant something is dropped in
or while a drag is in progress. It's absolutely positioned over the
Frame with pointer-events: none so it never intercepts clicks/drops
meant for the underlying (empty) canvas -- scoped to regular page
editing only, not the header/footer editing mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:14:58 -07:00
shadowdaoandClaude Opus 4.8 b3e5009aec Fix preset-grid orphan row for 5/6-item preset sets
PresetButtonGrid rendered every preset set into a fixed 4-column
.preset-grid, so 5-item sets (RADIUS_PRESETS, SPACING_PRESETS,
IMAGE_RADIUS_PRESETS, FONT_WEIGHTS, NavStylePanel's GAP_PRESETS)
wrapped a single lone button onto its own row, and the 6-item
TEXT_SIZES split unevenly (4+2).

PresetButtonGrid now derives a column count from presets.length via
defaultPresetGridColumns() -- 5-item sets get a single row of 5,
6-item sets split into two even rows of 3, and anything else keeps
the classic 4-column grid -- with an optional `columns` prop for
explicit overrides. This fixes every existing call site automatically
rather than threading an explicit count through each one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:14:38 -07:00
shadowdaoandClaude Opus 4.8 eeb0660d83 Replace emoji-as-icons in editor chrome with Font Awesome
Unicode emoji/glyphs (Sitesmith's sparkle, lock, close X) render as
tofu on systems without an emoji font. Swap for the FA4 glyphs the
rest of the chrome already uses:
- SitesmithButton/SitesmithModal: sparkle -> fa-magic, lock -> fa-lock
- ContextMenu "Ask Sitesmith" entry: sparkle -> fa-magic (via new
  optional MenuItem.icon field)
- TemplateModal/SitesmithModal close buttons, PagesPanel delete,
  AssetsPanel delete/cancel: &#10005; -> fa-times / fa-trash

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:14:29 -07:00
shadowdaoandClaude Opus 4.8 138e1a8273 Bump muted/dim text contrast and block label legibility
- --color-text-muted #71717a -> #8b8b96 (~4.9:1 on surface)
- --color-text-dim #52525b -> #6e6e78 (~3.3:1, decorative-only text)
- .block-item-label 10px -> 11px, .block-item-icon 18px -> 20px for
  hierarchy in the Blocks panel tile grid

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:14:10 -07:00
jknapp 1b12b79a0d Merge PR #5: image placeholder + Gallery/safeImageUrl fix 2026-07-13 02:54:24 +00:00
shadowdao 621bb21d52 fix(builder): safeImageUrl for FeaturesGrid/ContentSlider image sinks + tighten data:image allowlist
FeaturesGrid's <img src> and ContentSlider's CSS background-image url()
were still on safeUrl, which blocks data:image/svg+xml -- inconsistent
with other image sinks already swapped to safeImageUrl and a latent
regression for those two components. Swapped both to safeImageUrl;
left their navigation sinks (buttonUrl/buttonHref) on safeUrl.

Also tightened safeImageUrl's data:image allowlist check to require the
slash (dataimage/ not dataimage), so a bogus MIME like
data:imagehtml/... can no longer slip past the prefix check.
2026-07-12 19:53:02 -07:00
shadowdaoandClaude Opus 4.8 3f3c6fb851 security: add safeImageUrl, un-break M-5's over-blocking of image-context SVG data URIs
M-5 made safeUrl() block data:image/svg+xml everywhere, including the
image-only sinks (<img src>, CSS url()) that Gallery's default images and
other SVG placeholders rely on. Loaded as an image, an SVG is rasterized
and never executes an inline <script>/onload= -- that only happens when
it's navigated to or loaded as an <iframe> document -- so M-5 over-blocked
the safe contexts and broke every published Gallery (and other components
using an SVG placeholder) using safeUrl's default images in prod.

Adds safeImageUrl(): identical javascript:/vbscript: handling to safeUrl,
but treats data: as an allowlist of image/* subtypes instead of a
blocklist -- allows all data:image/* (including svg+xml, with or without
base64), still blocks data:text/html and any other non-image data: type.

Swapped to safeImageUrl at IMAGE-src / CSS-image url() sinks only:
- Gallery.tsx img src + lightbox data-lb-src
- ImageBlock.tsx img src (toHtml)
- Logo.tsx / Navbar.tsx logo <img> src (their href/link targets keep safeUrl)
- style-helpers.ts sanitizeCssValue's url(...) handling (background-image
  for HeroSimple/BackgroundSection/Section/CallToAction)

Left on safeUrl (href/iframe/form-action/navigation sinks, where
data:image/svg+xml must stay blocked): ButtonLink, Icon link, SocialLinks,
Menu/Navbar link hrefs, PricingTable buttonHref, _cta-helpers,
ContentSlider buttonHref, FeaturesGrid buttonUrl, FormContainer action
(via form-relay-wiring), MapEmbed/VideoBlock iframe src.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:45:58 -07:00
shadowdaoandClaude Opus 4.8 802938ec1a fix(builder): Image block placeholder no longer overridden by explicit src=""
BlocksPanel dropped a new Image block with an explicit `src=""` prop, which
overrides ImageBlock's `src = PLACEHOLDER_SRC` default parameter (defaults
only apply when a prop is undefined, not when it's an empty string). Craft
then persisted `src:''`, and the canvas rendered a broken-image icon instead
of the placeholder.

- ImageBlock render now falls back to PLACEHOLDER_SRC whenever src is falsy
  (belt-and-braces: also recovers any legacy saved src:'' state).
- BlocksPanel no longer passes src="" when dropping a new Image block, so
  the craft default applies.
- ImageStylePanel now restores the placeholder (instead of blanking to '')
  when the URL field is cleared.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:45:28 -07:00
jknapp 3e43aee6e9 Merge PR #4: head code to published pages + adversarial-review Minors 2026-07-13 01:37:13 +00:00
shadowdaoandClaude Opus 4.8 bf4a9f48eb security: block data:image/svg+xml + sandbox HtmlBlock iframes
M-5: safeUrl() blocked javascript:/vbscript:/data:text/html but
allowed data:image/svg+xml, which can execute inline <script>/onload=
when loaded as a document/navigation target despite its "image" MIME
type (defense in depth -- not currently reachable to execution via
this sink, but closing it). Added `data:image/svg+xml` to the existing
DANGEROUS_SCHEME_PREFIXES check, so it's caught after the same
entity-decode/whitespace-strip/lowercase normalization used for the
other blocked schemes (obfuscated variants included). Other
data:image/* types (png/jpeg/gif/webp, ...) remain allowed unchanged.

M-6: HtmlBlock's purifyHtml() allowed <iframe src> through with no
`sandbox` attribute -- a clickjacking/phishing vector even with
DOMPurify already stripping script/on*=. Added a DOMPurify
afterSanitizeAttributes hook, scoped tightly to each purifyHtml() call
(added right before sanitize(), removed in a finally right after) so
it can't leak onto other DOMPurify uses or accumulate duplicates
across repeated calls, that force-sets a restrictive sandbox
(allow-scripts allow-same-origin allow-popups allow-forms -- no
allow-top-navigation) and referrerpolicy=no-referrer on every iframe
that survives sanitization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:31:25 -07:00
shadowdaoandClaude Opus 4.8 86455413d0 fix: unique addPage ids + collision-free scopeId hashing
M-3: PageContext.addPage minted ids from bare `page_${Date.now()}` --
two adds inside the same millisecond collided on id, so a subsequent
rename/delete/save silently acted on both pages at once. Added a
module-scoped monotonic counter combined with the timestamp
(nextPageId(), exported for direct unit testing) and used it
everywhere an addPage-style id is minted (addPage, replaceAllPages).

M-4: scopeId() lowercased + stripped non-alphanumeric characters from
the node id into a slug, so two node ids differing only by
case/punctuation (e.g. "AbC" vs "abc", or "a-b" vs "ab") collapsed
onto the same scope -- defeating the whole point of scoping ids per
node (M-1/Menu/Tabs/ColumnLayout/Gallery/etc. all rely on it). Now
hashes the raw node id via the existing djb2 stableHash() instead of
slugifying it: still deterministic (same id -> same scope) and a valid
CSS ident, but collision-resistant across case/punctuation. This
changes the exact scope strings Menu/Tabs/ColumnLayout/Gallery/etc.
emit -- expected and fine, since none of their tests pinned an exact
scope value (all already asserted structure/uniqueness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:31:12 -07:00
shadowdaoandClaude Opus 4.8 0cbc58f8d1 a11y/security: scope Navbar ids + hover styles, Gallery lightbox focus trap
M-1: Navbar.toHtml emitted a fixed id="navbar-links" and unscoped
.navbar-link/.navbar-cta :hover selectors -- two Navbars on one page
collided on the duplicate id and cross-applied each other's hover
colors (later <style> block wins in the cascade). Scope both on the
Craft node id via scopeId(), matching the Menu/Tabs pattern: the links
container gets a unique id, aria-controls/the hamburger toggle script
reference it, and the hover rules are prefixed with a per-instance
class on the <nav> root.

M-2: Gallery lightbox had no focus management -- opening it left focus
wherever it was (behind the now-visible overlay) and closing it never
restored it. The inline script now stashes document.activeElement on
open, moves focus to a new accessible close button, traps Tab on the
close button while the dialog is open, and restores the saved focus on
close (Escape, backdrop click, or the close button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:30:40 -07:00
shadowdaoandClaude Opus 4.8 92841e3f35 feat(builder): send + restore site head code in save/load
Extend the save payload with head_code + design so the backend can
inject SiteDesign.headCode into published pages, and restore design
tokens on load() so the editor reflects the last-saved state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:22:38 -07:00
jknappandClaude Opus 4.8 e892ee0e53 Merge PR #3: site builder security & data-loss hardening + asset picker + audit backlog
51 impl commits + 10 adversarial-review fix commits. All 339→502 tests green; final adversarial XSS pass: PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:13:27 +00:00
shadowdaoandClaude Opus 4.8 591a51dcc2 fix(builder): escape/allowlist all attribute-value sinks incl. numeric/enum props (XSS)
An adversarial pass found 5 Critical XSS sinks where props declared number/enum
in TypeScript were interpolated raw into exported HTML attribute values,
trusting the type — but nothing enforces it at runtime (AI update_props only
validates node_id; deserialized saved state is untyped JSON). Fixed all 5
(NumberCounter data-target, StarRating aria-label, FormContainer method,
ContactForm/InputField input type) plus 6 sibling sinks found by an exhaustive
audit of every attribute-value interpolation across src/components: a
JS-source injection into ContentSlider's inline setInterval script, a
prototype-pollution-adjacent allowlist gap in Section's divider-shape lookup,
TextareaField rows, Testimonials rating aria-label, HeroSimple textAlign, and
MapEmbed zoom. Adds shared sanitizeFormMethod/sanitizeInputType allowlist
helpers to utils/escape.ts alongside the existing escapeAttr/safeUrl/cssValue
primitives. Every fix is TDD'd: a malicious-value test reproduces the raw
injection against the pre-fix code, then passes after the fix.

502 tests green (npx vitest run), tsc + vite build green (npm run build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:03:44 -07:00
shadowdaoandClaude Opus 4.8 7ba91d9829 fix(builder): sanitize non-string style values + allowlist element tags (XSS)
Adversarial re-review found the C1 fix incomplete plus an adjacent
same-class XSS, both reachable via the AI update_props path and
deserialized saved state:

- cssPropsToString only ran sanitizeCssValue on typeof-string values, so a
  non-string style value (array/object) with a valid key skipped
  sanitization entirely and was template-coerced raw into style="...",
  e.g. { color: ['red', '"><img src=x onerror=alert(1)>'] }. Now every
  non-number value is coerced with String() and sanitized; numbers stay
  raw. sanitizeBreakoutChars also now escapes < and > (previously only ;
  and ") as defense-in-depth, since values can reach it from non-string
  sources.

- props.tag (Container) and props.level (Heading) were interpolated raw
  into the tag position of exported HTML (`<${tag}`, `<${level}`) with no
  runtime validation, letting a malicious value break out of the tag
  entirely. Both are now allowlisted/clamped against their known-safe sets
  (div/section/article/header/footer/main; h1-h6), falling back to
  div/h2. Applied in Container's live render + toHtml, Heading's live
  render + toHtml, and the typeName==='div' fallback branch in
  html-export.ts's renderNode (hit for unresolved/legacy node types).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:44:59 -07:00
shadowdaoandClaude Opus 4.8 e12fb89ada Fix M-4: punctuation-only page name yields empty slug ('.html')
slugify('!!!') stripped down to '' since punctuation-only names have no
a-z0-9 characters left; buildSavePayload then wrote filename = '' +
'.html'. slugify now falls back to 'page' when the computed slug is
empty, so uniqueSlug's existing dedupe (page, page-2, ...) applies same
as any other collision. Landing page slug stays forced to 'index'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:33:33 -07:00
shadowdaoandClaude Opus 4.8 25507cb57a Fix INT: ContentSlider Slides editor wrote wrong prop key (image vs imageSrc)
MediaStylePanel's Slides array editor guarded on item.image !== undefined
and wrote `image` via AssetPicker's onChange, but ContentSlider (render
+ toHtml) reads slide.imageSrc. Default slides (imageSrc:'', no `image`
key) never showed an image picker at all, and any `image` value written
was a silent no-op on render/export.

Editor now guards/reads/writes `imageSrc` throughout, and the
"add slide" emptyItem matches defaultSlides' exact shape
(type/imageSrc/heading/text/bgColor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:32:47 -07:00
shadowdaoandClaude Opus 4.8 9d3bbc2c46 Fix I-2: dangling parent on shell/ColumnLayout-rooted AI-replace trees
treeToCraftState's ROOT re-key branch reparented nodes['ROOT'].nodes
children to 'ROOT' but not nodes['ROOT'].linkedNodes children. For a
ColumnLayout- or Section/BackgroundSection/FormContainer-rooted AI
`replace`, flattenTreeForCraft puts content in linkedNodes (col-N /
section-inner etc.) whose parent was left pointing at the OLD root id
-- which is then deleted, leaving a dangling parent reference that
breaks select/move/delete of those nodes in the Craft.js editor.

Now the same loop that reparents nodes[] children also reparents
linkedNodes children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:31:01 -07:00
shadowdaoandClaude Opus 4.8 25e674badd Fix I-1: landing-page edits lost when activePageId goes dangling
buildSavePayload matched the active page by page.id === activePageId,
but activePageId defaults to the hardcoded 'home' and load() never
updated it. Deleting the original Home page (its replacement gets id
page_<ts>), reloading, editing, and saving would then match no page --
the live edit only reached the legacy top-level craft_state, while the
authoritative pages[]/pages_craft_state[0] for index.html fell back to
stale state.

Belt-and-suspenders fix:
- buildSavePayload now falls back to pages[0] as the effective active
  page when a real page is active but activePageId matches nothing.
- useWhpApi's load() now points activePageId at the freshly restored
  first page via a new PageContext setActivePageIdDirect setter
  (bookkeeping only -- no re-serialize/deserialize), so activePageId
  stays valid after every load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:29:32 -07:00
shadowdaoandClaude Opus 4.8 e5f30a4a56 Fix C1/C2: XSS via unsanitized style keys and animation attrs
C1: cssPropsToString emitted the camelToKebab'd style-object KEY
unsanitized while only sanitizing the VALUE. A malicious style key
containing a quote (reachable via AI update_props or deserialized
saved state) could close the style="..." attribute and inject a live
element. Now validates each key against a CSS property/custom-prop
allowlist and drops anything that doesn't match.

C2: buildDataAttrs (html-export.ts) interpolated props.animation and
props.animationDelay directly into data-animation="..."/
data-animation-delay="..." with no escaping, for every exported node.
Now routes both through escapeAttr.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:27:22 -07:00
shadowdaoandClaude Opus 4.8 36c3b2f503 fix(builder): sanitize CSS-value sinks to prevent style/<style> breakout XSS
Adds a single cssValue() sanitizer (src/utils/escape.ts) that strips
<>{};"'\ and neutralizes url(), safe for both style="..." attribute and
<style>...</style> element contexts. Applies it at every raw user-prop
CSS-value interpolation sink found via grep across src/components (colors,
sizes, gaps interpolated directly into style strings/<style> blocks),
including the highest-risk <style>-context sinks: ColumnLayout gap,
Menu/Navbar hover and background colors. Also Number()-coerces the
`columns` grid-template-columns sinks in Gallery/Testimonials/NumberCounter
as defense in depth. Regression tests assert </style><script> payloads are
neutralized and normal colors still render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:56:25 -07:00
shadowdaoandClaude Opus 4.8 5acf172511 docs(builder): update CLAUDE.md for hardening branch (remove dead settings/zones, asset picker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:39:16 -07:00
shadowdaoandClaude Opus 4.8 ebb8fe1027 refactor(builder): type the AI-response/patch boundary
applyPatch's `ops` param was typed `any[]`, discarding the SitesmithPatchOp
discriminated union that SitesmithResponse.ops already carried at the call
site. Type it ops: SitesmithPatchOp[] so op.props/op.tree/op.op narrow
correctly per-variant in the switch instead of being `any`, and update the
unit tests' inline op literals to SitesmithPatchOp[] so tsc checks them too.

SerializedTreeNode/SitesmithPatchOp themselves, and buildNodeTree/
sanitizeAiTree's tree params, were already typed from a prior task -- this
closes the one remaining any at the op-handling site. Runtime validation
(protected-key guard, style-merge guard, unknown-resolvedName soft-skip) is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:30:56 -07:00
shadowdaoandClaude Opus 4.8 b88b242b6a refactor(builder): extract shared Modal component for the three editor modals
New src/ui/Modal.tsx ({ open, onClose, title?, children, width?,
closeOnEscape?, closeOnBackdropClick?, backdropStyle?, backdropProps? })
owns the backdrop, Escape-to-close, backdrop-click-to-close, and a new
body-scroll-lock while open. Adopted by TemplateModal, HeadCodeModal, and
SitesmithModal; each keeps its own panel styling/header/footer as children
since those differ per modal.

- TemplateModal: Escape/backdrop-click still close the confirm-template
  sub-dialog first via a wrapped onClose passed to Modal; the header's X
  button keeps using the raw onClose prop (always fully closes), matching
  prior asymmetric behavior.
- HeadCodeModal: straightforward adoption, no prior custom close logic.
- SitesmithModal: previously had no `open` prop, no Escape-to-close, and no
  backdrop-click-to-close. Preserved via open (always mounted-open by its
  parent already), closeOnEscape={false}, closeOnBackdropClick={false}; role
  and aria-modal are passed through via backdropProps to keep them on the
  same backdrop element as before.

Body scroll-lock while a modal is open is a small new addition (requested by
the task) applied uniformly; it has no visible effect since each modal's
opaque fixed-position backdrop already fully covers the viewport.

Added a light Modal.test.tsx (renders children, Escape/backdrop-click close
behavior, scroll-lock) using the existing react-dom/client + act harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:22:05 -07:00
shadowdaoandClaude Opus 4.8 8f51b5144a refactor(builder): extract shared ArrayItemFieldsEditor from SectionTypePanel/GenericPropsEditor
Both panels had a ~90-line near-byte-identical per-item field editor
(boolean->checkbox, number->number, /color/->color, long->textarea, else
text, fields derived from Object.keys(items[0])). Moved into a new
ArrayItemFieldsEditor component in ArrayItemFields.tsx used by both.
SectionTypePanel keeps its own FeaturesGrid special-case (key === 'features'
-> FeaturesEditor) and its empty-array guard ahead of the shared call.
Behavior-preserving (task E4.2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:15:34 -07:00
shadowdaoandClaude Opus 4.8 4733fe84b5 refactor(builder): extract useNodeProp hook, dedup StylePanel setProp boilerplate
Adopted in BackgroundSectionStylePanel, ButtonStylePanel, ContainerStylePanel,
FormStylePanel, GenericPropsEditor, HeroStylePanel, ImageStylePanel,
MediaStylePanel, NavStylePanel, SectionTypePanel, SocialStylePanel,
TextStylePanel — only where the inline setProp/setPropStyle were behaviorally
identical to the hook. PricingStylePanel and FeaturesEditor keep their own
array-mutation logic since it differs. Behavior-preserving (task E4.1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:13:35 -07:00
shadowdaoandClaude Opus 4.8 3cd4ad0154 test(builder): cover Ctrl+C/V shortcut id-regeneration and guards
Regression coverage for the safety-sensitive part of keyboard copy/paste:
nothing previously tested that Ctrl/Cmd+V regenerates node ids via
regenerateTreeIds before actions.addNodeTree -- the exact logic whose
absence caused a duplicate-id corruption bug. Also covers sibling-parent
targeting (with ROOT fallback), the empty-clipboard no-op, and the
existing input/contentEditable focus guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:08:46 -07:00
shadowdaoandClaude Opus 4.8 6421306849 feat(builder): implement Ctrl+C/V copy-paste shortcuts
The context menu advertised Ctrl+C/Ctrl+V hints but useKeyboardShortcuts
only handled undo/redo/delete/duplicate/escape -- pressing them did
nothing. Add a tiny shared module-level clipboard (src/hooks/clipboard.ts)
used by both the keyboard hook and the context menu so copying via one
entry point and pasting via the other stay consistent.

Ctrl/Cmd+C stores the selected node id (skipping ROOT). Ctrl/Cmd+V
inserts a copy as a sibling of the current selection via
regenerateTreeIds, mirroring the existing context-menu paste behavior.
Both respect the existing "disabled while typing" guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:01:30 -07:00
shadowdaoandClaude Opus 4.8 e40d55b2f2 chore(builder): point dev proxy at current test server
192.168.1.105 was decommissioned; local npm run dev save/load now
proxies /api to the current test server (whp-al10-test, .148).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:59:42 -07:00
shadowdaoandClaude Opus 4.8 1958b8e0b5 chore(builder): remove GuidedStyles classification cruft
Drop the computed-but-unused resolvedName/resolverMap, anchor the hero
regex (/^hero/i) so it stops matching any type name containing "hero",
and remove the isContainer alternations for the now-deleted
HeaderZone/FooterZone components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:59:39 -07:00
153 changed files with 9876 additions and 1110 deletions
+58 -46
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Site Builder</title> <title>Site Builder</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+217
View File
@@ -8,6 +8,15 @@
"name": "whp-site-builder", "name": "whp-site-builder",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.6",
"@craftjs/core": "^0.2.10", "@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7", "@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5", "dompurify": "^3.4.5",
@@ -373,6 +382,133 @@
"specificity": "bin/cli.js" "specificity": "bin/cli.js"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@craftjs/core": { "node_modules/@craftjs/core": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@craftjs/core/-/core-0.2.12.tgz",
@@ -1093,6 +1229,69 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz",
"integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"license": "MIT"
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.59.1", "version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
@@ -1873,6 +2072,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/css-color-keywords": { "node_modules/css-color-keywords": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
@@ -2686,6 +2891,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/styled-components": { "node_modules/styled-components": {
"version": "6.3.12", "version": "6.3.12",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz",
@@ -3096,6 +3307,12 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+9
View File
@@ -14,6 +14,15 @@
"test:unit:watch": "vitest" "test:unit:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.6",
"@craftjs/core": "^0.2.10", "@craftjs/core": "^0.2.10",
"@craftjs/layers": "^0.2.7", "@craftjs/layers": "^0.2.7",
"dompurify": "^3.4.5", "dompurify": "^3.4.5",
+2 -1
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Editor } from '@craftjs/core'; import { Editor } from '@craftjs/core';
import { EditorShell } from './editor/EditorShell'; import { EditorShell } from './editor/EditorShell';
import { RenderNode } from './editor/RenderNode';
import { componentResolver } from './components/resolver'; import { componentResolver } from './components/resolver';
import { WhpConfig } from './types'; import { WhpConfig } from './types';
import { EditorConfigProvider } from './state/EditorConfigContext'; import { EditorConfigProvider } from './state/EditorConfigContext';
@@ -23,7 +24,7 @@ export const App: React.FC<AppProps> = ({ whpConfig }) => {
return ( return (
<EditorConfigProvider config={whpConfig}> <EditorConfigProvider config={whpConfig}>
<SiteDesignProvider> <SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true}> <Editor resolver={componentResolver} enabled={true} onRender={RenderNode}>
<PageProvider> <PageProvider>
<SitesmithProvider> <SitesmithProvider>
<EditorShell /> <EditorShell />
@@ -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('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ href: '#', text: 'Tom & Jerry' }, '');
expect(html).toContain('Tom &amp; Jerry');
});
test('a normal text value still renders unchanged', () => {
const { html } = toHtml({ href: '#', text: 'Click Me' }, '');
expect(html).toContain('>Click Me</a>');
});
});
+2 -2
View File
@@ -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 { escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
interface ButtonLinkProps { interface ButtonLinkProps {
text?: string; text?: string;
@@ -78,7 +78,7 @@ ButtonLink.craft = {
textDecoration: 'none', textDecoration: 'none',
...props.style, ...props.style,
}); });
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 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="${escapeAttr(safeUrl(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="/);
});
});
@@ -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('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ text: 'Terms & Conditions' }, '');
expect(html).toContain('Terms &amp; 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.');
});
});
+2 -1
View File
@@ -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;
@@ -107,6 +108,6 @@ Footer.craft = {
textAlign: 'center', textAlign: 'center',
...props.style, ...props.style,
}); });
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 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('&lt;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 &amp; Chips');
});
test('a normal text value still renders unchanged', () => {
const { html } = toHtml({ text: 'Hello world', level: 'h2' }, '');
expect(html).toBe('<h2>Hello world</h2>');
});
});
+15 -3
View File
@@ -1,9 +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 { escapeHtml } from '../../utils/escape';
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;
@@ -30,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);
@@ -59,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));
@@ -98,8 +110,8 @@ Heading.craft = {
}; };
(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, '&lt;').replace(/>/g, '&gt;'); 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}>` };
}; };
@@ -21,3 +21,46 @@ describe('purifyHtml', () => {
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form'); expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
}); });
}); });
describe('purifyHtml iframe sandboxing (M-6)', () => {
test('forces a restrictive sandbox attribute onto every iframe', () => {
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
});
test('sandbox value omits allow-top-navigation (no top-level nav escape)', () => {
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
const sandbox = out.match(/sandbox="([^"]*)"/)![1];
expect(sandbox).not.toMatch(/allow-top-navigation/);
});
test('legitimate embeds (YouTube) still work and get sandboxed too', () => {
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
expect(out).toContain('youtube.com/embed/abc');
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
});
test('adds referrerpolicy=no-referrer to iframes', () => {
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
expect(out).toContain('referrerpolicy="no-referrer"');
});
test('script/on* attributes are still stripped alongside the sandboxed iframe', () => {
const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe><script>alert(2)</script>');
expect(out).not.toContain('onload');
expect(out).not.toContain('<script');
});
test('repeated calls do not leak/accumulate the hook (no duplicate sandbox attr, no cross-call state)', () => {
purifyHtml('<iframe src="https://a.example/"></iframe>');
purifyHtml('<iframe src="https://b.example/"></iframe>');
const out = purifyHtml('<iframe src="https://c.example/"></iframe>');
const sandboxMatches = out.match(/sandbox="/g) || [];
expect(sandboxMatches.length).toBe(1);
});
test('a non-iframe element sanitized alongside an iframe is not touched by the hook', () => {
const out = purifyHtml('<p>hi</p><iframe src="https://example.com/"></iframe>');
expect(out).toContain('<p>hi</p>');
});
});
@@ -10,4 +10,16 @@ describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
expect(html).not.toContain('onclick'); expect(html).not.toContain('onclick');
expect(html).toContain('<p>hi</p>'); 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>');
});
}); });
+29 -1
View File
@@ -24,14 +24,42 @@ const PURIFY_CONFIG = {
'href','src','alt','title','target','rel', 'href','src','alt','title','target','rel',
'width','height','class', 'width','height','class',
'allowfullscreen','allow','frameborder', 'allowfullscreen','allow','frameborder',
'sandbox','referrerpolicy',
], ],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i, ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'], FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
FORBID_ATTR: [/^on/i], FORBID_ATTR: [/^on/i],
}; };
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
// phishing vector (DOMPurify already strips <script>/on*=, but an
// unsandboxed iframe still gets full script execution, same-origin-ish
// access via document.domain tricks, top-level navigation, etc., inside
// itself). This hook force-sets a restrictive sandbox on every iframe that
// survives sanitization, keeping `allow-scripts`/`allow-same-origin`/
// `allow-popups`/`allow-forms` (needed for interactive maps/video/oauth
// popups) but deliberately omitting `allow-top-navigation` so an embedded
// page can never redirect/hijack the parent tab.
const IFRAME_SANDBOX_HOOK = (node: Element): void => {
if (node.nodeName === 'IFRAME') {
node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-forms');
node.setAttribute('referrerpolicy', 'no-referrer');
}
};
export function purifyHtml(input: string): string { export function purifyHtml(input: string): string {
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string; // Hook is added immediately before sanitize() and removed immediately
// after, scoped tightly to this single call -- so it can never leak onto
// (or accumulate duplicate copies across) any other DOMPurify.sanitize()
// call elsewhere in the app, and repeated purifyHtml() calls never stack
// multiple copies of the same hook.
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
try {
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
} finally {
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
}
} }
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => { export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
@@ -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="/);
});
});
@@ -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');
});
});
+2 -2
View File
@@ -2,7 +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 { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -104,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="${escapeAttr(safeUrl(props.imageSrc))}" alt="${escapeAttr(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`; innerHtml = `<img src="${escapeAttr(safeImageUrl(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',
@@ -30,3 +30,17 @@ describe('Menu.toHtml deterministic + unique scope ids (thread node id, no Math.
expect(html1).toBe(html2); 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/);
});
});
+11 -7
View File
@@ -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 { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -125,14 +125,18 @@ Menu.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: 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' };
@@ -5,24 +5,115 @@ const toHtml = (Navbar as any).toHtml;
describe('Navbar.toHtml hamburger accessibility (F2.3)', () => { describe('Navbar.toHtml hamburger accessibility (F2.3)', () => {
test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => { test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => {
const { html } = toHtml({ showMobileMenu: true }, ''); const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/); expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/);
expect(html).toMatch(/aria-expanded="false"/); expect(html).toMatch(/aria-expanded="false"/);
expect(html).toMatch(/aria-controls="navbar-links"/); expect(html).toMatch(/aria-controls="[^"]+"/);
}); });
test('aria-controls target id exists on the links container', () => { test('aria-controls target id exists on the links container', () => {
const { html } = toHtml({ showMobileMenu: true }, ''); const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toContain('id="navbar-links"'); const controls = html.match(/aria-controls="([^"]+)"/)![1];
expect(html).toContain(`id="${controls}"`);
}); });
test('toggle script flips aria-expanded on click', () => { test('toggle script flips aria-expanded on click', () => {
const { html } = toHtml({ showMobileMenu: true }, ''); const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/); expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/);
}); });
test('no mobile menu: no hamburger button emitted', () => { test('no mobile menu: no hamburger button emitted', () => {
const { html } = toHtml({ showMobileMenu: false }, ''); const { html } = toHtml({ showMobileMenu: false }, '', 'node-nav1');
expect(html).not.toContain('navbar-hamburger'); expect(html).not.toContain('navbar-hamburger');
}); });
}); });
describe('Navbar.toHtml node-scoped ids/hover styles (M-1: two navbars must not collide)', () => {
test('no bare unscoped id="navbar-links" is emitted', () => {
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).not.toContain('id="navbar-links"');
});
test('two different node ids produce different links-container ids', () => {
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav2');
const id1 = html1.match(/id="([^"]+)"/)![1];
const id2 = html2.match(/id="([^"]+)"/)![1];
expect(id1).not.toBe(id2);
});
test('aria-controls always equals the actual links-container id', () => {
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const controls = html.match(/aria-controls="([^"]+)"/)![1];
const linksId = html.match(/id="([^"]+)"/)![1];
expect(controls).toBe(linksId);
});
test('hover style selectors are scoped per-instance, not bare .navbar-link/.navbar-cta', () => {
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
// A selector rule that STARTS the line with .navbar-link:hover (i.e. not
// preceded by a per-instance ancestor class) would be the old, unscoped,
// globally-colliding form.
expect(html).not.toMatch(/^\s*\.navbar-link:hover/m);
expect(html).not.toMatch(/^\s*\.navbar-cta:hover/m);
// still present, just scoped under a per-instance ancestor class
expect(html).toMatch(/\.navbar-link:hover/);
expect(html).toMatch(/\.[\w-]+ \.navbar-link:hover/);
});
test('two navbars with different hoverColor do not leak style onto each other (scoped selectors differ)', () => {
const { html: html1 } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
const { html: html2 } = toHtml({ hoverColor: '#00ff00' }, '', 'node-nav2');
const scope1 = html1.match(/<style>\s*\.([\w-]+)\s/)![1];
const scope2 = html2.match(/<style>\s*\.([\w-]+)\s/)![1];
expect(scope1).not.toBe(scope2);
expect(html1).toContain(`.${scope1} .navbar-link:hover`);
expect(html2).toContain(`.${scope2} .navbar-link:hover`);
});
test('a normal single navbar still renders its hover style (visual output preserved)', () => {
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
});
test('same node id -> identical output across calls (deterministic)', () => {
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html1).toBe(html2);
});
});
describe('Navbar.toHtml 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');
});
});
+36 -18
View File
@@ -2,7 +2,7 @@ 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 { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
/* ---------- Types ---------- */ /* ---------- Types ---------- */
@@ -210,17 +210,33 @@ Navbar.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => { (Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string, nodeId?: 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;
const logoUrl = props.logoUrl || '/'; const logoUrl = props.logoUrl || '/';
const links0 = props.links || defaultLinks;
// M-1: deterministic AND unique per-instance scope, keyed on the Craft
// node id. Two Navbars on the same page previously emitted an identical
// fixed id="navbar-links" (invalid duplicate-id HTML, ambiguous
// aria-controls target) and unscoped `.navbar-link:hover`/`.navbar-cta:hover`
// rules in each instance's own <style> block -- since both blocks target
// the SAME global selector, the later one in the DOM silently overrides
// the earlier one's hover color/behavior for BOTH navbars. Scoping the
// links-container id and adding a per-instance class on the <nav> root
// (used to prefix the hover selectors) eliminates both collisions.
const scope = scopeId(nodeId, JSON.stringify(links0) + alignment + pad, 'nav');
const linksId = `${scope}_links`;
const navStyle = cssPropsToString({ const navStyle = cssPropsToString({
display: 'flex', display: 'flex',
@@ -236,7 +252,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="${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>`; logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeImageUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
} else { } else {
const logoStyle = cssPropsToString({ const logoStyle = cssPropsToString({
fontWeight: '700', fontWeight: '700',
@@ -269,21 +285,23 @@ Navbar.craft = {
// via aria-expanded, kept in sync with the .navbar-open class by the // via aria-expanded, kept in sync with the .navbar-open class by the
// inline onclick handler. // inline onclick handler.
const hamburgerHtml = mobile const hamburgerHtml = mobile
? `\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"> ? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="${escapeAttr(linksId)}" onclick="var m=document.getElementById('${linksId}');var open=m.classList.toggle('navbar-open');this.setAttribute('aria-expanded', open ? 'true' : 'false');" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span> <span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span> <span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span> <span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
</button>` </button>`
: ''; : '';
// Hover CSS // Hover CSS -- scoped under `.${scope}` (a class on the <nav> root, added
// below) so it can only ever match THIS instance's links/CTA, never bleed
// into or get overridden by another Navbar instance's rules.
const hoverCss = `<style> const hoverCss = `<style>
.navbar-link:hover { color: ${hoverCol} !important; } .${scope} .navbar-link:hover { color: ${hoverCol} !important; }
.navbar-cta:hover { filter: brightness(1.1); }${mobile ? ` .${scope} .navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
@media (max-width: 768px) { @media (max-width: 768px) {
.navbar-hamburger { display: flex !important; } .${scope} .navbar-hamburger { display: flex !important; }
.navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .${scope} .navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.navbar-links.navbar-open { display: flex !important; } .${scope} .navbar-links.navbar-open { display: flex !important; }
}` : ''} }` : ''}
</style>`; </style>`;
@@ -306,9 +324,9 @@ Navbar.craft = {
return { return {
html: `${hoverCss} html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}> <nav class="${scope}"${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${logoHtml}${hamburgerHtml} ${logoHtml}${hamburgerHtml}
<div class="navbar-links" id="navbar-links" style="display:flex;align-items:center;gap:24px"> <div class="navbar-links" id="${linksId}" style="display:flex;align-items:center;gap:24px">
${linksHtmlWithClass} ${linksHtmlWithClass}
</div> </div>
</nav>`, </nav>`,
@@ -11,3 +11,22 @@ describe('SearchBar.toHtml decorative icons (F2.5)', () => {
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"')); 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)/);
});
});
@@ -14,3 +14,40 @@ describe('SocialLinks.toHtml accessibility (F2.5)', () => {
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/); 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)');
});
});
+5 -4
View File
@@ -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 { escapeAttr, safeUrl } from '../../utils/escape'; import { escapeAttr, safeUrl, cssValue } from '../../utils/escape';
interface SocialLink { interface SocialLink {
platform: string; platform: string;
@@ -163,9 +163,10 @@ SocialLinks.craft = {
(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';
@@ -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>');
});
});
@@ -21,3 +21,57 @@ describe('StarRating.toHtml accessibility (F2.2)', () => {
expect(html).toContain('aria-label="Rating: 2 out of 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"/);
});
});
+24 -6
View File
@@ -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;
@@ -97,11 +98,23 @@ StarRating.craft = {
/* ---------- 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',
@@ -123,7 +136,12 @@ StarRating.craft = {
// The star glyphs convey nothing to assistive tech on their own -- wrap // The star glyphs convey nothing to assistive tech on their own -- wrap
// in role="img" with a textual equivalent, and hide the decorative glyphs // in role="img" with a textual equivalent, and hide the decorative glyphs
// themselves (aria-hidden above) so AT doesn't announce each icon. // 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 role="img" aria-label="Rating: ${rating} out of ${maxStars}"${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('&lt;img');
});
test('ampersand is escaped for well-formed text content (consistency with escapeHtml)', () => {
const { html } = toHtml({ text: 'Tom & Jerry' }, '');
expect(html).toContain('Tom &amp; Jerry');
});
test('a normal text value still renders unchanged', () => {
const { html } = toHtml({ text: 'Hello world' }, '');
expect(html).toBe('<p>Hello world</p>');
});
});
+2 -1
View File
@@ -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 TextBlockProps { interface TextBlockProps {
text?: string; text?: string;
@@ -95,6 +96,6 @@ TextBlock.craft = {
(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, '&lt;').replace(/>/g, '&gt;'); const escapedText = escapeHtml(props.text || '');
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` }; return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
}; };
@@ -110,3 +110,19 @@ describe('ContactForm.toHtml accessibility (F2.1)', () => {
expect(ids1).toEqual(ids2); 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"');
});
});
+7 -5
View File
@@ -2,7 +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 } from '../../utils/escape'; 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';
@@ -175,9 +175,11 @@ 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, i) => { const fieldsHtml = (props.fields || defaultFields).map((field, i) => {
@@ -194,7 +196,7 @@ ContactForm.craft = {
const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join(''); const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`; 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 id="${escapeAttr(fieldId)}" type="${field.type}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(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 ');
@@ -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('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).toContain('&amp;');
expect(html).toContain('&quot;quoted&quot;');
});
});
+2 -1
View File
@@ -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;
@@ -74,7 +75,7 @@ FormButton.craft = {
cursor: 'pointer', cursor: 'pointer',
...props.style, ...props.style,
}); });
const escapedText = (props.text || 'Submit').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 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>`,
}; };
@@ -39,3 +39,16 @@ describe('FormContainer.toHtml relay wiring', () => {
expect(mid1).not.toBe(mid2); 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"');
});
});
+2 -1
View File
@@ -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;
@@ -74,7 +75,7 @@ FormContainer.craft = {
...props.style, ...props.style,
}); });
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId); 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>`,
@@ -58,3 +58,17 @@ describe('InputField.toHtml deterministic + unique ids (thread node id, resolves
expect(id1).toBe(id2); 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"');
});
});
+2 -2
View File
@@ -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 { escapeHtml, escapeAttr, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, scopeId, sanitizeInputType } from '../../utils/escape';
interface InputFieldProps { interface InputFieldProps {
label?: string; label?: string;
@@ -111,7 +111,7 @@ InputField.craft = {
return { return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}> html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml} ${labelHtml}
<input id="${escapeAttr(fieldId)}" type="${props.type || 'text'}" 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" /> <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<');
});
});
@@ -50,3 +50,16 @@ describe('TextareaField.toHtml deterministic + unique ids (thread node id, resol
expect(id1).toBe(id2); 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"');
});
});
+6 -1
View File
@@ -97,6 +97,11 @@ 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 // Deterministic AND unique id: scoped on the Craft node id so the
// <label for> always matches the <textarea id> AND two TextareaField // <label for> always matches the <textarea id> AND two TextareaField
// instances that share the same (often default) `name` -- e.g. two // instances that share the same (often default) `name` -- e.g. two
@@ -113,7 +118,7 @@ TextareaField.craft = {
return { return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}> html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml} ${labelHtml}
<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${props.rows || 4}"${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> <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');
});
});
@@ -59,3 +59,77 @@ describe('ColumnLayout.toHtml deterministic + unique scope ids (thread node id,
expect(html1).toBe(html2); expect(html1).toBe(html2);
}); });
}); });
describe('ColumnLayout.toHtml XSS hardening (gap into <style>)', () => {
test('a gap value containing </style><script> is neutralized in the <style>-context nth-child rule', () => {
const malicious = '0px)}</style><script>alert(1)</script><style>{';
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '<div>A</div><div>B</div>', 'node-xss');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a gap value containing a quote/semicolon breakout is neutralized in the style attribute', () => {
const malicious = '16px" onmouseover="alert(1)';
const { html } = toHtml({ columns: 2, split: '50-50', gap: malicious }, '', 'node-xss2');
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('a normal gap value still renders correctly', () => {
const { html } = toHtml({ columns: 2, split: '50-50', gap: '24px' }, '<div>A</div>', 'node-normal');
expect(html).toContain('gap:24px');
expect(html).toMatch(/calc\(50% - 24px\)/);
});
});
describe('ColumnLayout.toHtml vertical alignment (align-items on the flex row)', () => {
test('style.alignItems flows into the emitted style attribute (aligns uneven columns)', () => {
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px', style: { alignItems: 'center' } }, '<div>A</div><div>B</div>');
expect(html).toContain('align-items:center');
});
});
describe('ColumnLayout.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
columns: 2,
split: '50-50',
gap: '16px',
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'<div>A</div><div>B</div>',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('ColumnLayout.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ColumnLayout as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default alignItems and box-model keys', () => {
const style = (ColumnLayout as any).craft.props.style;
expect(style).toHaveProperty('alignItems');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+24 -3
View File
@@ -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 { escapeAttr, scopeId } from '../../utils/escape'; import { escapeAttr, scopeId, cssValue } from '../../utils/escape';
type SplitOption = type SplitOption =
| '100' | '100'
@@ -20,6 +20,11 @@ interface ColumnLayoutProps {
style?: CSSProperties; style?: CSSProperties;
children?: React.ReactNode; children?: React.ReactNode;
anchorId?: string; anchorId?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
const splitToWidths: Record<string, string[]> = { const splitToWidths: Record<string, string[]> = {
@@ -102,8 +107,20 @@ ColumnLayout.craft = {
columns: 2, columns: 2,
split: '50-50', split: '50-50',
gap: '16px', gap: '16px',
style: {}, style: {
alignItems: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
anchorId: '', anchorId: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -117,7 +134,11 @@ ColumnLayout.craft = {
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => { (ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string, nodeId?: string) => {
const columns = props.columns || 2; const columns = props.columns || 2;
const split = props.split || '50-50'; const split = props.split || '50-50';
const gap = props.gap || '16px'; // 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 widths = getWidths(split, columns);
const outerStyle = cssPropsToString({ const outerStyle = cssPropsToString({
@@ -33,3 +33,118 @@ describe('Container.toHtml cssId/cssClass', () => {
expect(html).toContain('id="anchor-id"'); expect(html).toContain('id="anchor-id"');
}); });
}); });
describe('Container.toHtml tag allowlist (adversarial re-review, same class as C1)', () => {
test('a malicious tag value falls back to div -- no injected <img>, no broken-out attrs', () => {
const { html } = toHtml({ tag: 'div><img src=x onerror=alert(1)' }, 'child');
expect(html).not.toContain('<img');
expect(html).not.toContain('onerror');
expect(html.startsWith('<div')).toBe(true);
expect(html.endsWith('</div>')).toBe(true);
});
test('a tag value outside the known-safe set falls back to div', () => {
const { html } = toHtml({ tag: 'script' }, 'child');
expect(html.startsWith('<div')).toBe(true);
expect(html).not.toContain('<script');
});
test('a valid tag (section) still emits <section', () => {
const { html } = toHtml({ tag: 'section' }, 'child');
expect(html).toContain('<section');
expect(html).toContain('</section>');
});
test('all other allowlisted tags still work', () => {
for (const tag of ['div', 'article', 'header', 'footer', 'main']) {
const { html } = toHtml({ tag }, 'child');
expect(html.startsWith(`<${tag}`)).toBe(true);
expect(html.endsWith(`</${tag}>`)).toBe(true);
}
});
});
describe('Container.toHtml vertical alignment (justify-content + min-height)', () => {
// Regression lock: Container/Section must NOT unconditionally become a
// flex container. Flex-blockifies in-flow children, forcing components
// that deliberately render display:inline-block (ButtonLink, Icon) to
// stack vertically instead of sitting side-by-side -- a real visual
// regression for existing published pages that never touch vertical
// alignment.
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
const { html } = toHtml({}, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
});
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
expect(html).toContain('min-height:400px');
});
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:center');
});
test('style.minHeight flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
expect(html).toContain('min-height:400px');
});
test('justify-content and min-height still flow through in boxed (contentWidth) mode', () => {
const { html } = toHtml({ contentWidth: 'boxed', style: { justifyContent: 'flex-end', minHeight: '500px' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:flex-end');
expect(html).toContain('min-height:500px');
});
});
describe('Container.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'child',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('Container.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Container as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default vertical-alignment and box-model keys', () => {
const style = (Container as any).craft.props.style;
expect(style).toHaveProperty('justifyContent');
expect(style).toHaveProperty('minHeight');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+49 -3
View File
@@ -3,6 +3,19 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers'; import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr } from '../../utils/escape'; import { escapeAttr } from '../../utils/escape';
// The only tag names Container actually supports (matches the TS union
// below and the `tag` default in `.craft.props`). `tag` is settable via the
// AI `update_props` path and from deserialized saved state -- neither is
// type-checked at runtime -- so a malicious value like
// `div><img src=x onerror=alert(1)` must never reach the `<${tag}` template
// position in `toHtml`/the live render. Anything not in this allowlist
// falls back to `'div'`.
const ALLOWED_CONTAINER_TAGS = ['div', 'section', 'article', 'header', 'footer', 'main'] as const;
export type ContainerTag = (typeof ALLOWED_CONTAINER_TAGS)[number];
export const sanitizeContainerTag = (tag: unknown): ContainerTag =>
(ALLOWED_CONTAINER_TAGS as readonly unknown[]).includes(tag) ? (tag as ContainerTag) : 'div';
interface ContainerProps { interface ContainerProps {
style?: CSSProperties; style?: CSSProperties;
tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'; tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main';
@@ -30,6 +43,20 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope
return {}; return {};
}; };
// Container only becomes display:flex/flex-direction:column at its root
// (both in the editor render below and in toHtml) when the user has
// actually set `style.justifyContent` (the Vertical Alignment control,
// paired with `style.minHeight`) -- i.e. the flex conversion is gated on
// vertical-align actually being in use, not unconditional. In-flow children
// of a flex container get CSS-blockified, which would force components that
// deliberately render `display:inline-block` (ButtonLink, Icon) to stack
// vertically instead of sitting side-by-side -- a real visual regression for
// any container/section that never touches vertical alignment, not a no-op.
// So plain block flow (no `display`/`flex-direction` at all) is preserved
// unless vertical-align is set. `flexAlignFromTextAlign` above still
// supplies its own conditional flex conversion (cross-axis alignItems from
// `textAlign`) independently -- unrelated to this gate.
export const Container: UserComponent<ContainerProps> = ({ export const Container: UserComponent<ContainerProps> = ({
style = {}, style = {},
tag = 'div', tag = 'div',
@@ -42,12 +69,15 @@ export const Container: UserComponent<ContainerProps> = ({
}) => { }) => {
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);
const hasVerticalAlign = !!style.justifyContent;
const outerStyle: CSSProperties = { const outerStyle: CSSProperties = {
minHeight: '40px', minHeight: '40px',
...style, ...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
...(needsBoxedWrapper ? {} : flexStyles), ...(needsBoxedWrapper ? {} : flexStyles),
}; };
@@ -58,7 +88,7 @@ export const Container: UserComponent<ContainerProps> = ({
const idValue = cssId || anchorId || undefined; 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,
@@ -79,13 +109,27 @@ export const Container: UserComponent<ContainerProps> = ({
Container.craft = { Container.craft = {
displayName: 'Container', displayName: 'Container',
props: { props: {
style: { padding: '20px', minHeight: '100px' }, style: {
padding: '20px',
minHeight: '100px',
justifyContent: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
tag: 'div', tag: 'div',
fullWidth: false, fullWidth: false,
contentWidth: 'full', contentWidth: 'full',
anchorId: '', anchorId: '',
cssId: '', cssId: '',
cssClass: '', cssClass: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -97,12 +141,14 @@ Container.craft = {
/* ---------- HTML export ---------- */ /* ---------- HTML export ---------- */
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => { (Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
const tag = props.tag || 'div'; const tag = sanitizeContainerTag(props.tag);
const isBoxed = props.contentWidth === 'boxed'; const isBoxed = props.contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign); const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
const hasVerticalAlign = !!props.style?.justifyContent;
const outerCss: CSSProperties = { const outerCss: CSSProperties = {
...props.style, ...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(isBoxed ? {} : flexStyles), ...(isBoxed ? {} : flexStyles),
}; };
@@ -0,0 +1,150 @@
import { describe, test, expect } from 'vitest';
import { Section } from './Section';
const toHtml = (Section as any).toHtml;
describe('Section.toHtml anchorId', () => {
test('escapes a malicious anchorId (attribute breakout attempt)', () => {
const { html } = toHtml({ anchorId: 'x" onmouseover="alert(1)' }, 'child');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a normal anchorId still renders correctly', () => {
const { html } = toHtml({ anchorId: 'my-section' }, 'child');
expect(html).toContain('id="my-section"');
});
});
describe('Section.toHtml childrenHtml passthrough', () => {
test('children are preserved', () => {
const { html } = toHtml({}, '<p>hello</p>');
expect(html).toContain('<p>hello</p>');
});
});
describe('Section.toHtml shape divider color/height XSS hardening', () => {
test('a malicious topDividerColor cannot break out of the SVG style attribute', () => {
const malicious = 'red" onmouseover="alert(1)';
const { html } = toHtml({ topDivider: 'wave', topDividerColor: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a malicious topDividerColor cannot inject a </style><script> breakout', () => {
const malicious = 'red</style><script>alert(1)</script>';
const { html } = toHtml({ topDivider: 'wave', topDividerColor: malicious }, '');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a malicious bottomDividerHeight cannot break out of the wrapper style attribute', () => {
const malicious = '50px" onmouseover="alert(1)';
const { html } = toHtml({ bottomDivider: 'angle', bottomDividerHeight: malicious }, '');
expect(html).not.toContain('onmouseover="alert(1)"');
});
test('a normal divider color/height still renders correctly', () => {
const { html } = toHtml({ topDivider: 'wave', topDividerColor: '#123456', topDividerHeight: '80px' }, '');
expect(html).toContain('fill:#123456');
expect(html).toContain('height:80px');
});
test('divider shape "none" emits no divider markup', () => {
const { html } = toHtml({ topDivider: 'none' }, 'child');
expect(html).not.toContain('<svg');
});
test('an unrecognized divider shape value emits no divider markup and no injected content', () => {
const malicious = 'wave"><script>alert(1)</script>' as any;
const { html } = toHtml({ topDivider: malicious }, 'child');
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('<svg');
});
test('a prototype-property-name divider shape (__proto__) does not leak [object Object]/function source into the SVG path', () => {
const { html } = toHtml({ topDivider: '__proto__' as any }, 'child');
expect(html).not.toContain('[object');
expect(html).not.toContain('native code');
expect(html).not.toContain('<svg');
});
test('a prototype-property-name divider shape (toString) does not leak Object.prototype.toString source into the SVG path', () => {
const { html } = toHtml({ topDivider: 'toString' as any }, 'child');
expect(html).not.toContain('[object');
expect(html).not.toContain('native code');
expect(html).not.toContain('<svg');
});
});
describe('Section.toHtml vertical alignment (justify-content + min-height)', () => {
// Regression lock: same rationale as Container -- see Container.toHtml.test.ts.
// Section must not unconditionally become a flex container, or it
// blockifies inline-block children (ButtonLink, Icon) that are meant to
// sit side-by-side in existing published sections.
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
const { html } = toHtml({}, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
});
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
expect(html).toContain('min-height:600px');
});
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:center');
});
test('style.minHeight flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
expect(html).toContain('min-height:600px');
});
});
describe('Section.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'child',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('Section.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Section as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default vertical-alignment and box-model keys', () => {
const style = (Section as any).craft.props.style;
expect(style).toHaveProperty('justifyContent');
expect(style).toHaveProperty('minHeight');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+46 -5
View File
@@ -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 { escapeAttr } from '../../utils/escape'; import { escapeAttr, cssValue } from '../../utils/escape';
/* ---------- Shape Divider SVG Paths ---------- */ /* ---------- Shape Divider SVG Paths ---------- */
@@ -27,6 +27,11 @@ interface SectionProps {
bottomDividerColor?: string; bottomDividerColor?: string;
bottomDividerHeight?: string; bottomDividerHeight?: string;
anchorId?: string; anchorId?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
/* ---------- Divider renderer ---------- */ /* ---------- Divider renderer ---------- */
@@ -38,7 +43,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';
@@ -91,6 +103,13 @@ export const Section: UserComponent<SectionProps> = ({
const hasTopDivider = topDivider && topDivider !== 'none'; const hasTopDivider = topDivider && topDivider !== 'none';
const hasBottomDivider = bottomDivider && bottomDivider !== 'none'; const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
// Section's root only becomes a column flex container when the user has
// actually set `style.justifyContent` (Vertical Alignment control, paired
// with `style.minHeight`) -- see the matching note in Container.tsx for
// why an unconditional conversion is a real regression (blockifies
// deliberately inline-block children like ButtonLink/Icon) rather than a
// no-op, so plain block flow is preserved unless vertical-align is set.
const hasVerticalAlign = !!style.justifyContent;
return ( return (
<section <section
@@ -100,6 +119,7 @@ export const Section: UserComponent<SectionProps> = ({
width: '100%', width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...style, ...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
}} }}
> >
{hasTopDivider && ( {hasTopDivider && (
@@ -136,7 +156,17 @@ export const Section: UserComponent<SectionProps> = ({
Section.craft = { Section.craft = {
displayName: 'Section', displayName: 'Section',
props: { props: {
style: { padding: '40px 0', backgroundColor: '#ffffff' }, style: {
padding: '40px 0',
backgroundColor: '#ffffff',
minHeight: '',
justifyContent: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
innerMaxWidth: '1200px', innerMaxWidth: '1200px',
topDivider: 'none', topDivider: 'none',
topDividerColor: '#ffffff', topDividerColor: '#ffffff',
@@ -145,6 +175,11 @@ Section.craft = {
bottomDividerColor: '#ffffff', bottomDividerColor: '#ffffff',
bottomDividerHeight: '50px', bottomDividerHeight: '50px',
anchorId: '', anchorId: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -162,12 +197,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',
@@ -188,11 +227,13 @@ function buildDividerHtml(
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => { (Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
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';
const hasVerticalAlign = !!props.style?.justifyContent;
const outerStyle = cssPropsToString({ const outerStyle = cssPropsToString({
width: '100%', width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...props.style, ...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
}); });
const innerStyle = cssPropsToString({ const innerStyle = cssPropsToString({
maxWidth: props.innerMaxWidth || '1200px', maxWidth: props.innerMaxWidth || '1200px',
@@ -0,0 +1,62 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* ImageBlock only needs useNode from @craftjs/core. Mock it following the
DOM-harness pattern in src/components/basic/Footer.editguard.test.tsx (no
@testing-library/react in this repo) so we can render the real component
tree and inspect the emitted <img src> without a real <Editor>. */
vi.mock('@craftjs/core', () => ({
useNode: (collect?: (node: any) => any) => {
const node = { events: { selected: false } };
return {
connectors: { connect: (el: any) => el, drag: (el: any) => el },
actions: { setProp: vi.fn() },
...(collect ? collect(node) : {}),
};
},
}));
import { ImageBlock } from './ImageBlock';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('ImageBlock render falls back to the placeholder for an explicit empty src (Bug 1)', () => {
test('src="" (explicit, overrides the default parameter) still renders a non-empty placeholder src', () => {
render(<ImageBlock src="" alt="Image" />);
const img = container.querySelector('img')!;
expect(img.getAttribute('src')).not.toBe('');
expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/);
container.remove();
});
test('src=undefined (default parameter path) still renders the placeholder (unchanged behavior)', () => {
render(<ImageBlock alt="Image" />);
const img = container.querySelector('img')!;
expect(img.getAttribute('src')).not.toBe('');
expect(img.getAttribute('src')).toMatch(/^data:image\/svg\+xml/);
container.remove();
});
test('a real src is rendered unchanged', () => {
render(<ImageBlock src="https://example.com/photo.jpg" alt="A photo" />);
const img = container.querySelector('img')!;
expect(img.getAttribute('src')).toBe('https://example.com/photo.jpg');
container.remove();
});
});
@@ -0,0 +1,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"');
});
});
+4 -4
View File
@@ -1,9 +1,9 @@
import React, { CSSProperties, useCallback, useRef } 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'; import { escapeAttr, safeImageUrl } from '../../utils/escape';
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E"; export const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
interface ImageBlockProps { interface ImageBlockProps {
src?: string; src?: string;
@@ -66,7 +66,7 @@ export const ImageBlock: UserComponent<ImageBlockProps> = ({
imgRef.current = ref; imgRef.current = ref;
if (ref) connect(drag(ref)); if (ref) connect(drag(ref));
}} }}
src={src} src={src || PLACEHOLDER_SRC}
alt={alt || 'Image'} alt={alt || 'Image'}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
@@ -95,5 +95,5 @@ ImageBlock.craft = {
} }
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style }); const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""'; const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
return { html: `<img src="${escapeAttr(safeUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` }; return { html: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
}; };
@@ -27,3 +27,41 @@ describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)'
expect(srcMatch![1]).not.toMatch(/&(?!amp;)/); 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(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/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');
});
});
+9 -1
View File
@@ -12,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> = ({
@@ -72,6 +72,38 @@ describe('VideoBlock.toHtml iframe accessibility (F2.4)', () => {
}); });
}); });
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)', () => { 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', () => { test('embed params joined with literal & are HTML-entity-encoded in the emitted src attribute', () => {
// autoplay+muted+controls=false forces buildEmbedParams to concatenate // autoplay+muted+controls=false forces buildEmbedParams to concatenate
+7 -5
View File
@@ -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 { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
interface AccordionItem { interface AccordionItem {
title: string; title: string;
@@ -151,10 +151,12 @@ Accordion.craft = {
...props.style, ...props.style,
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(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) => {
@@ -2,7 +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 { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers'; import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr } from '../../utils/escape'; import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape';
interface CallToActionProps { interface CallToActionProps {
heading?: string; heading?: string;
@@ -144,7 +144,9 @@ CallToAction.craft = {
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => { (CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
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';
@@ -91,3 +91,51 @@ describe('ContentSlider.toHtml autoplay silences aria-live and is pausable (F-ex
expect(html).not.toMatch(/visibilitychange/); expect(html).not.toMatch(/visibilitychange/);
}); });
}); });
describe('ContentSlider.toHtml renders slide.imageSrc as a background-image (INT)', () => {
test('a slide with imageSrc set exports a background-image referencing it', () => {
const slidesWithImage = [
{ type: 'image' as const, imageSrc: 'https://example.com/photo.jpg', heading: 'One' },
];
const { html } = toHtml({ slides: slidesWithImage }, '');
expect(html).toContain("background-image:url('https://example.com/photo.jpg')");
});
test('a slide with no imageSrc falls back to bgColor (no broken/empty background-image url)', () => {
const slidesNoImage = [
{ type: 'image' as const, imageSrc: '', heading: 'One', bgColor: '#123456' },
];
const { html } = toHtml({ slides: slidesNoImage }, '');
expect(html).not.toContain('background-image:url(');
expect(html).toContain('background-color:#123456');
});
test('a slide with a data:image/svg+xml imageSrc exports a non-empty background-image url (safeImageUrl, not safeUrl)', () => {
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
const slidesWithSvg = [
{ type: 'image' as const, imageSrc: svgDataUri, heading: 'One' },
];
const { html } = toHtml({ slides: slidesWithSvg }, '');
expect(html).toContain(`background-image:url('${svgDataUri}')`);
});
});
describe('ContentSlider.toHtml interval is NOT runtime-type-checked -- must be coerced before it reaches the inline <script> numeric context', () => {
test('a malicious interval string cannot inject arbitrary JS into the autoplay setInterval call', () => {
const malicious = '5000);alert(document.domain);//';
const { html } = toHtml({ slides, autoplay: true, interval: malicious }, '');
expect(html).not.toContain('alert(document.domain)');
// the setInterval call must still be well-formed with a plain numeral delay
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},\d+\);/);
});
test('a non-numeric interval falls back to a safe default delay', () => {
const { html } = toHtml({ slides, autoplay: true, interval: 'not-a-number' }, '');
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},5000\);/);
});
test('a normal numeric interval still renders as the exact configured delay', () => {
const { html } = toHtml({ slides, autoplay: true, interval: 3000 }, '');
expect(html).toMatch(/setInterval\(function\(\)\{show\(current\+1\);\},3000\);/);
});
});
@@ -1,7 +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 } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
interface Slide { interface Slide {
type: 'image' | 'content'; type: 'image' | 'content';
@@ -250,11 +250,24 @@ ContentSlider.craft = {
} = props; } = props;
const items = slides.length > 0 ? slides : defaultSlides; const items = slides.length > 0 ? slides : defaultSlides;
// 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 // Deterministic AND unique id, scoped on the Craft node id, for this
// slider's slide/dot element ids and inline-script globals -- so two // slider's slide/dot element ids and inline-script globals -- so two
// ContentSlider instances (e.g. both left at default slides) don't // ContentSlider instances (e.g. both left at default slides) don't
// collide and end up driving each other's rotation. // collide and end up driving each other's rotation.
const uid = scopeId(nodeId, JSON.stringify(items) + interval, 'cs'); const uid = scopeId(nodeId, JSON.stringify(items) + safeInterval, 'cs');
const sectionStyle = cssPropsToString({ const sectionStyle = cssPropsToString({
position: 'relative', position: 'relative',
@@ -266,11 +279,14 @@ 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('${escapeAttr(safeUrl(slide.imageSrc!))}');background-size:cover;background-position:center` ? `background-image:url('${escapeAttr(safeImageUrl(slide.imageSrc!))}');background-size:cover;background-position:center`
: slide.bgColor?.startsWith('linear-gradient') : 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) {
@@ -336,7 +352,7 @@ ContentSlider.craft = {
window["${uid}_prev"]=function(){ markManualNav(); show(current-1); }; window["${uid}_prev"]=function(){ markManualNav(); show(current-1); };
${autoplayActive ? ` ${autoplayActive ? `
var timer=null; var timer=null;
function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${interval}); } } function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${safeInterval}); } }
function stop(){ if(timer){ clearInterval(timer); timer=null; } } function stop(){ if(timer){ clearInterval(timer); timer=null; } }
var root=document.getElementById(uid); var root=document.getElementById(uid);
if(root){ if(root){
+5 -3
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties, useEffect, useState } 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 { escapeHtml, escapeAttr, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
interface CountdownProps { interface CountdownProps {
targetDate?: string; targetDate?: string;
@@ -154,10 +154,12 @@ Countdown.craft = {
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',
@@ -0,0 +1,23 @@
import { describe, test, expect } from 'vitest';
import { FeaturesGrid } from './FeaturesGrid';
const toHtml = (FeaturesGrid as any).toHtml;
describe('FeaturesGrid.toHtml image sink uses safeImageUrl (data:image/svg+xml allowed)', () => {
test('feat.image as a data:image/svg+xml value emits a non-empty <img src>', () => {
const svgDataUri = 'data:image/svg+xml,%3Csvg%2F%3E';
const features = [
{ title: 'Feature', description: 'Desc', icon: '⚡', image: svgDataUri, imageAlt: 'alt' },
];
const { html } = toHtml({ features }, '');
expect(html).toContain(`<img src="${svgDataUri}"`);
});
test('feat.buttonUrl stays on safeUrl (data:image/svg+xml blocked as a navigation target)', () => {
const features = [
{ title: 'Feature', description: 'Desc', icon: '⚡', buttonText: 'Go', buttonUrl: 'data:image/svg+xml,<svg onload=alert(1)>' },
];
const { html } = toHtml({ features }, '');
expect(html).toMatch(/<a href=""/);
});
});
@@ -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 { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
interface FeatureItem { interface FeatureItem {
title: string; title: string;
@@ -116,7 +116,7 @@ FeaturesGrid.craft = {
const idAttr = props.anchorId ? ` id="${escapeAttr(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="${escapeAttr(safeUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">` ? `<img src="${escapeAttr(safeImageUrl(feat.image))}" alt="${escapeAttr(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
: `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`; : `<div style="font-size:36px;margin-bottom:16px">${escapeHtml(feat.icon)}</div>`;
const button = feat.buttonText const button = feat.buttonText
? `\n <a href="${escapeAttr(safeUrl(feat.buttonUrl || '#'))}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${escapeHtml(feat.buttonText)}</a>` ? `\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>`
@@ -93,3 +93,59 @@ describe('Gallery.toHtml deterministic + unique scope ids (thread node id, no Ma
expect(html1).toBe(html2); expect(html1).toBe(html2);
}); });
}); });
describe('Gallery.toHtml default SVG placeholder images survive export (Bug 2 regression)', () => {
test('a default data:image/svg+xml image emits a non-empty img src, not src=""', () => {
const { html } = toHtml({}, ''); // no images prop -> component default SVG placeholders
expect(html).not.toContain('src=""');
expect(html).toMatch(/src="data:image\/svg\+xml[^"]*"/);
});
test('an explicit data:image/svg+xml gallery image src is preserved (not stripped to empty)', () => {
const svg = 'data:image/svg+xml,%3Csvg%2F%3E';
const { html } = toHtml({ images: [{ src: svg, alt: 'a' }] }, '');
expect(html).toContain(`src="${svg}"`);
});
test('lightbox data-lb-src also preserves data:image/svg+xml (still an image context)', () => {
const svg = 'data:image/svg+xml,%3Csvg%2F%3E';
const { html } = toHtml({ images: [{ src: svg, alt: 'a' }], lightbox: true }, '');
expect(html).toContain(`data-lb-src="${svg}"`);
});
test('a javascript: gallery image src still yields an empty src (safeImageUrl still blocks it)', () => {
const { html } = toHtml({ images: [{ src: 'javascript:alert(1)', alt: 'a' }] }, '');
expect(html).toContain('src=""');
expect(html).not.toContain('javascript:');
});
});
describe('Gallery.toHtml lightbox focus management (M-2)', () => {
const props = { images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true };
test('overlay includes a focusable close control with an accessible name and tabindex', () => {
const { html } = toHtml(props, '', 'node-gal1');
// A close control: a button (or the dialog container) with an accessible
// name (aria-label) and an explicit tabindex so it's keyboard-focusable.
expect(html).toMatch(/aria-label="[^"]*[Cc]lose[^"]*"[^>]*tabindex="-?\d+"|tabindex="-?\d+"[^>]*aria-label="[^"]*[Cc]lose[^"]*"/);
});
test('script saves document.activeElement on open (for focus restore)', () => {
const { html } = toHtml(props, '', 'node-gal1');
expect(html).toMatch(/document\.activeElement/);
});
test('script moves focus to the close control / dialog on open', () => {
const { html } = toHtml(props, '', 'node-gal1');
expect(html).toMatch(/\.focus\(\)/);
});
test('script restores the previously-saved focus on close', () => {
const { html } = toHtml(props, '', 'node-gal1');
// The close function references a stored "last focused element" variable
// and calls .focus() on it, not just moving focus INTO the dialog.
const closeFnMatch = html.match(/function\s+\w+_close\s*\(\)\s*\{[^}]*\}/);
expect(closeFnMatch).not.toBeNull();
expect(closeFnMatch![0]).toMatch(/\.focus\(\)/);
});
});
+41 -7
View File
@@ -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 { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeImageUrl, scopeId, cssValue } from '../../utils/escape';
interface GalleryImage { interface GalleryImage {
src: string; src: string;
@@ -130,8 +130,14 @@ Gallery.craft = {
...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;
// Deterministic AND unique id, scoped on the Craft node id, for this // Deterministic AND unique id, scoped on the Craft node id, for this
@@ -148,10 +154,10 @@ Gallery.craft = {
// inline onclick with an interpolated src -- a single delegated click // inline onclick with an interpolated src -- a single delegated click
// listener below reads it, so a src containing a quote can't break out // listener below reads it, so a src containing a quote can't break out
// of a per-item event-handler string. // of a per-item event-handler string.
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeUrl(img.src || ''))}" role="button" tabindex="0"` : ''; const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeImageUrl(img.src || ''))}" role="button" tabindex="0"` : '';
const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px'; const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px';
return `<div${lbAttr} style="${itemStyle}"> 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" /> <img src="${escapeAttr(safeImageUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
${caption} ${caption}
</div>`; </div>`;
}).join('\n '); }).join('\n ');
@@ -160,16 +166,37 @@ Gallery.craft = {
let gridIdAttr = ''; let gridIdAttr = '';
if (lightbox) { if (lightbox) {
gridIdAttr = ` id="${galleryId}_grid"`; gridIdAttr = ` id="${galleryId}_grid"`;
// M-2: focus management for the lightbox dialog.
// - OPEN: stash `document.activeElement` (the thumbnail that triggered
// the open) in a module-scoped var, then move focus onto the close
// button -- so a screen-reader/keyboard user lands inside the dialog
// instead of focus staying on (or silently falling back to <body>)
// behind the now-visible overlay.
// - Tab trap: while the overlay is open, every Tab keypress is
// intercepted and refocuses the close button (the dialog's only
// focusable control besides Escape/click-to-close), so focus can
// never wander out into the page content hidden behind the overlay.
// - CLOSE (Escape, backdrop click, or the close button): restore focus
// to the element stashed on open.
lightboxHtml = ` lightboxHtml = `
<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"> <div id="${galleryId}_overlay" role="dialog" aria-modal="true" aria-label="Image preview" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
<button type="button" id="${galleryId}_closebtn" aria-label="Close preview" tabindex="-1" onclick="event.stopPropagation();${galleryId}_close()" style="position:absolute;top:16px;right:16px;width:36px;height:36px;border-radius:50%;border:none;background:rgba(255,255,255,0.15);color:#ffffff;font-size:20px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center">&times;</button>
<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}_close(){document.getElementById('${galleryId}_overlay').style.display='none';} var ${galleryId}_lastFocus = null;
function ${galleryId}_close(){
document.getElementById('${galleryId}_overlay').style.display='none';
if(${galleryId}_lastFocus && ${galleryId}_lastFocus.focus) ${galleryId}_lastFocus.focus();
${galleryId}_lastFocus = null;
}
function ${galleryId}_open(src){ function ${galleryId}_open(src){
${galleryId}_lastFocus = document.activeElement;
var o = document.getElementById('${galleryId}_overlay'); var o = document.getElementById('${galleryId}_overlay');
document.getElementById('${galleryId}_img').src = src; document.getElementById('${galleryId}_img').src = src;
o.style.display = 'flex'; o.style.display = 'flex';
var c = document.getElementById('${galleryId}_closebtn');
if(c) c.focus();
} }
document.getElementById('${galleryId}_grid').addEventListener('click', function(e){ document.getElementById('${galleryId}_grid').addEventListener('click', function(e){
var t = e.target.closest('[data-lb-src]'); var t = e.target.closest('[data-lb-src]');
@@ -184,7 +211,14 @@ document.getElementById('${galleryId}_grid').addEventListener('keydown', functio
${galleryId}_open(t.getAttribute('data-lb-src')); ${galleryId}_open(t.getAttribute('data-lb-src'));
}); });
document.addEventListener('keydown', function(e){ document.addEventListener('keydown', function(e){
if(e.key==='Escape'){ ${galleryId}_close(); } var o = document.getElementById('${galleryId}_overlay');
if(!o || o.style.display==='none') return;
if(e.key==='Escape'){ ${galleryId}_close(); return; }
if(e.key==='Tab'){
e.preventDefault();
var c = document.getElementById('${galleryId}_closebtn');
if(c) c.focus();
}
}); });
</script>`; </script>`;
} }
@@ -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');
});
});
+13 -5
View File
@@ -2,7 +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 { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers'; import { CtaButton, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
interface HeroProps { interface HeroProps {
heading?: string; heading?: string;
@@ -220,7 +220,8 @@ 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 = '';
@@ -228,7 +229,13 @@ HeroSimple.craft = {
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>`; 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);
@@ -239,12 +246,13 @@ HeroSimple.craft = {
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(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">${escapeHtml(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">${escapeHtml(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>`,
@@ -47,3 +47,29 @@ describe('NumberCounter.toHtml deterministic + unique scope ids (thread node id,
expect(wrapId1).not.toBe(wrapId2); 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,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 { escapeHtml, escapeAttr, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
interface Counter { interface Counter {
number: number; number: number;
@@ -112,12 +112,17 @@ NumberCounter.craft = {
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => { (NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
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;
@@ -135,8 +140,20 @@ 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="${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 id="${uid}_n${i}" data-target="${escapeAttr(String(safeNumber))}" data-suffix="${escapeAttr(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${escapeHtml(counter.suffix)}</span>
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${escapeHtml(counter.label)}</span> <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 { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
interface PricingPlan { interface PricingPlan {
name: string; name: string;
@@ -225,7 +225,9 @@ PricingTable.craft = {
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(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';
+10 -6
View File
@@ -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 { escapeHtml, escapeAttr, scopeId } from '../../utils/escape'; import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
interface TabItem { interface TabItem {
label: string; label: string;
@@ -131,11 +131,15 @@ Tabs.craft = {
}); });
const idAttr = props.anchorId ? ` id="${escapeAttr(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';
// tabId scopes the functional wiring (onclick/getElementById) as well as // tabId scopes the functional wiring (onclick/getElementById) as well as
// the ARIA tab<->panel linking ids. It must be BOTH deterministic (so // the ARIA tab<->panel linking ids. It must be BOTH deterministic (so
@@ -46,3 +46,28 @@ describe('Testimonials.toHtml decorative star icons (F2.5)', () => {
stars.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"')); 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"');
});
});
+17 -6
View File
@@ -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 { escapeHtml, escapeAttr } from '../../utils/escape'; 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" aria-hidden="true"></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" role="img" aria-label="Rating: ${count} out of 5">${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> = ({
@@ -139,11 +145,16 @@ Testimonials.craft = {
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;
+11 -4
View File
@@ -1,5 +1,5 @@
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape'; import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
export type CtaVariant = 'primary' | 'outline' | 'ghost'; export type CtaVariant = 'primary' | 'outline' | 'ghost';
@@ -68,14 +68,21 @@ 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`;
} }
} }
+65
View File
@@ -94,3 +94,68 @@ export const DEVICE_WIDTHS: Record<string, string> = {
tablet: '768px', tablet: '768px',
mobile: '375px', mobile: '375px',
}; };
/* ---------- ENH-Foundation shared presets ----------
Consumed by the reusable StylePanel controls in
src/panels/right/styles/shared.tsx (SizeControl, AspectRatioControl,
BorderControl, etc). Kept in the `{ label, value }` shape used throughout
this file so they drop straight into PresetButtonGrid / ColorSwatchGrid. */
// Width/height preset row used by SizeControl (image + video sizing).
export const SIZE_PRESETS = [
{ label: '25%', value: '25%' },
{ label: '50%', value: '50%' },
{ label: '75%', value: '75%' },
{ label: '100%', value: '100%' },
{ label: 'Auto', value: 'auto' },
{ label: 'Full', value: '100vw' },
];
// CSS `aspect-ratio` values. 'Original' (empty string) clears the property.
export const ASPECT_RATIOS = [
{ label: 'Original', value: '' },
{ label: '1:1', value: '1 / 1' },
{ label: '4:3', value: '4 / 3' },
{ label: '3:2', value: '3 / 2' },
{ label: '16:9', value: '16 / 9' },
{ label: '4:5', value: '4 / 5' },
{ label: '9:16', value: '9 / 16' },
];
// box-shadow presets; 'Colored' is the color-aware option (accent-tinted).
export const SHADOW_PRESETS = [
{ label: 'None', value: 'none' },
{ label: 'S', value: '0 1px 2px rgba(0,0,0,0.08)' },
{ label: 'M', value: '0 4px 8px rgba(0,0,0,0.12)' },
{ label: 'L', value: '0 10px 24px rgba(0,0,0,0.18)' },
{ label: 'Colored', value: '0 8px 20px rgba(59,130,246,0.35)' },
];
export const LINE_HEIGHTS = [
{ label: 'Tight', value: '1.1' },
{ label: 'Snug', value: '1.25' },
{ label: 'Normal', value: '1.5' },
{ label: 'Relaxed', value: '1.75' },
{ label: 'Loose', value: '2' },
];
export const LETTER_SPACINGS = [
{ label: 'Tight', value: '-0.02em' },
{ label: 'Normal', value: 'normal' },
{ label: 'Wide', value: '0.05em' },
{ label: 'Wider', value: '0.1em' },
];
export const OBJECT_FIT = [
{ label: 'Cover', value: 'cover' },
{ label: 'Contain', value: 'contain' },
{ label: 'Fill', value: 'fill' },
{ label: 'None', value: 'none' },
];
export const BORDER_STYLES = [
{ label: 'None', value: 'none' },
{ label: 'Solid', value: 'solid' },
{ label: 'Dashed', value: 'dashed' },
{ label: 'Dotted', value: 'dotted' },
];
+94 -14
View File
@@ -1,13 +1,17 @@
import React, { useMemo, useRef, useEffect } from 'react'; import React, { useMemo, useRef, useEffect } from 'react';
import { Frame, Element } from '@craftjs/core'; import { Frame, Element, useEditor } from '@craftjs/core';
import { Container } from '../components/layout/Container'; import { Container } from '../components/layout/Container';
import { usePages } from '../state/PageContext'; import { usePages } from '../state/PageContext';
import { DeviceMode } from '../types'; import { DeviceMode } from '../types';
import { DEVICE_WIDTHS } from '../constants/presets'; import { DEVICE_WIDTHS } from '../constants/presets';
import { exportBodyHtml } from '../utils/html-export'; import { exportBodyHtml } from '../utils/html-export';
import { useIsMobile } from '../hooks/useIsMobile';
import { useMobileChrome } from '../state/MobileChromeContext';
interface CanvasProps { interface CanvasProps {
device: DeviceMode; device: DeviceMode;
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
showGuides: boolean;
} }
/** /**
@@ -81,24 +85,93 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
<div <div
ref={containerRef} ref={containerRef}
data-zone-preview={zone} data-zone-preview={zone}
className="zone-preview-sep"
style={{ style={{
width: '100%', width: '100%',
position: 'relative', position: 'relative',
pointerEvents: 'none', pointerEvents: 'none',
userSelect: 'none', userSelect: 'none',
borderBottom: zone === 'header' ? '1px dashed rgba(245,158,11,0.3)' : 'none', borderBottom: zone === 'header' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
borderTop: zone === 'footer' ? '1px dashed rgba(245,158,11,0.3)' : 'none', borderTop: zone === 'footer' ? '1px dashed rgba(148,163,184,0.25)' : 'none',
}} }}
/> />
); );
}; };
export const Canvas: React.FC<CanvasProps> = ({ device }) => { /**
* First-run hint shown over the canvas drop area once the current page's
* root node exists and has no children yet. Hidden the instant something
* is dropped in, and while a drag is in progress (so it never fights the
* drop-target UI). `pointer-events: none` (see .empty-canvas-hint in
* editor.css) keeps it from intercepting clicks/drops meant for the
* underlying empty canvas.
*/
export const EmptyCanvasHint: React.FC = () => {
const { isEmpty, isDragging } = useEditor((state) => {
const root = state.nodes['ROOT'];
return {
isEmpty: !!root && root.data.nodes.length === 0,
isDragging: state.events.dragged.size > 0,
};
});
const isMobile = useIsMobile();
if (!isEmpty || isDragging) return null;
return (
<div className="empty-canvas-hint">
<i className="fa fa-cubes" aria-hidden />
<span>
{isMobile
? <>Tap <strong>Blocks</strong> below to add content, or pick a Template to start.</>
: 'Drag blocks from the left panel, or pick a Template to start.'}
</span>
</div>
);
};
export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
const width = DEVICE_WIDTHS[device]; const width = DEVICE_WIDTHS[device];
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages(); const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
const isEditingRegularPage = !isEditingHeader && !isEditingFooter; const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
// Fast-follow item 4: while `MobileSelectionToolbar` is up (fixed, ~53px
// tall, just above the tab bar), it covers whatever content was at the
// very bottom of the canvas's own scroll area -- on a short page, the
// last section could sit permanently under the toolbar with no way to
// scroll past it. Mirrors that toolbar's own visibility condition
// (`selectedId && activeSheet === null`) exactly, computed independently
// here since Canvas has no other reason to depend on the toolbar
// component itself.
const isMobile = useIsMobile();
const { activeSheet } = useMobileChrome();
const { selectedId } = useEditor((state) => {
const selected = state.events.selected;
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
return { selectedId: id && id !== 'ROOT' ? id : null };
});
const mobileToolbarVisible = isMobile && !!selectedId && activeSheet === null;
// Fast-follow item 3: `MobileSelectionToolbar`'s "Style" tap scrolls the
// selected node up towards the top of the canvas so it stays visible
// above the Styles sheet (~65dvh tall) -- but that scroll is still bound
// by the canvas's own natural scroll range. For a selected node near the
// END of the content (very plausibly the last section on the page --
// exactly the kind of node someone just added/duplicated and wants to
// style), there may not be enough scrollable distance below it to bring
// its top all the way up to the visible band above the sheet; the browser
// simply clamps at its existing max scrollTop, leaving the node's top
// stuck behind the sheet with nothing anyone can do about it (verified
// live: the last section of a page landed under the sheet even after the
// scroll-into-view ran). Pad the canvas with a full extra viewport's
// worth of scroll room while the sheet is open with a selection, exactly
// as `has-mobile-selection-toolbar` (item 4) already pads it for the
// fixed toolbar -- generous enough that ANY node, including the very
// last one, can always be scrolled with its top reaching the very top of
// the canvas (comfortably within the "top ~30%" target).
const mobileStylesSheetPad = isMobile && !!selectedId && activeSheet === 'styles';
const frameStyle = isEditingHeader const frameStyle = isEditingHeader
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' } ? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
: isEditingFooter : isEditingFooter
@@ -108,9 +181,13 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div'; const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
return ( return (
<div className="editor-canvas"> <div
className={`editor-canvas${mobileToolbarVisible ? ' has-mobile-selection-toolbar' : ''}${
mobileStylesSheetPad ? ' has-mobile-styles-sheet' : ''
}`}
>
<div <div
className="canvas-device-frame" className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
style={{ style={{
width, width,
maxWidth: '100%', maxWidth: '100%',
@@ -140,14 +217,17 @@ export const Canvas: React.FC<CanvasProps> = ({ device }) => {
<ZonePreview craftState={headerPage.craftState} zone="header" /> <ZonePreview craftState={headerPage.craftState} zone="header" />
)} )}
<Frame> <div style={{ position: 'relative' }}>
<Element <Frame>
is={Container} <Element
canvas is={Container}
tag={frameTag} canvas
style={frameStyle} tag={frameTag}
/> style={frameStyle}
</Frame> />
</Frame>
{isEditingRegularPage && <EmptyCanvasHint />}
</div>
{isEditingRegularPage && ( {isEditingRegularPage && (
<ZonePreview craftState={footerPage.craftState} zone="footer" /> <ZonePreview craftState={footerPage.craftState} zone="footer" />
+74 -16
View File
@@ -1,16 +1,60 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { TopBar } from '../panels/topbar/TopBar'; import { TopBar } from '../panels/topbar/TopBar';
import { LeftPanel } from '../panels/left/LeftPanel'; import { LeftPanel } from '../panels/left/LeftPanel';
import { RightPanel } from '../panels/right/RightPanel'; import { RightPanel } from '../panels/right/RightPanel';
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
import { MobileSelectionToolbar } from '../panels/mobile/MobileSelectionToolbar';
import { Canvas } from './Canvas'; import { Canvas } from './Canvas';
import { ContextMenu } from '../panels/context-menu/ContextMenu'; import { ContextMenu } from '../panels/context-menu/ContextMenu';
import { useContextMenu } from '../hooks/useContextMenu'; import { useContextMenu } from '../hooks/useContextMenu';
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
import { useIsMobile } from '../hooks/useIsMobile';
import { MobileChromeProvider } from '../state/MobileChromeContext';
import { DeviceMode } from '../types'; import { DeviceMode } from '../types';
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
function loadShowGuides(): boolean {
try {
const stored = window.localStorage.getItem(SHOW_GUIDES_STORAGE_KEY);
return stored === null ? true : stored === '1';
} catch {
return true;
}
}
export const EditorShell: React.FC = () => { export const EditorShell: React.FC = () => {
const isMobile = useIsMobile();
const [device, setDevice] = useState<DeviceMode>('desktop'); const [device, setDevice] = useState<DeviceMode>('desktop');
// Phase A: default the canvas to the "mobile" preview width the first
// time we detect a mobile viewport, so the frame fits without the user
// having to reach for the device switcher (now tucked in TopBar's
// overflow menu on mobile). Only fires once, and only if the device is
// still at its initial default -- it must not fight a device the user
// has already picked (e.g. from the overflow menu) on a later re-render.
const mobileDeviceAppliedRef = useRef(false);
useEffect(() => {
if (isMobile && !mobileDeviceAppliedRef.current) {
mobileDeviceAppliedRef.current = true;
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
}
}, [isMobile]);
// Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted
// so the choice survives a reload. Lifted here (rather than owned by
// TopBar or Canvas alone) because the toggle button lives in TopBar but
// the `.guides-off` class it drives is applied to Canvas's
// `.canvas-device-frame`, mirroring how `device` is already lifted for
// the same reason.
const [showGuides, setShowGuidesState] = useState<boolean>(loadShowGuides);
const setShowGuides = useCallback((next: boolean) => {
setShowGuidesState(next);
try {
window.localStorage.setItem(SHOW_GUIDES_STORAGE_KEY, next ? '1' : '0');
} catch {
// Storage unavailable (private browsing, etc.) -- in-memory state still works.
}
}, []);
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu(); const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
const { query } = useEditor(); const { query } = useEditor();
@@ -33,22 +77,36 @@ export const EditorShell: React.FC = () => {
}, [query, showMenu]); }, [query, showMenu]);
return ( return (
<div className="editor-app"> // Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) --
<TopBar device={device} onDeviceChange={setDevice} /> // provided around the whole shell so TopBar's Templates/Head Code modal
<div className="editor-container"> // state and MobilePanelBar's sheet state live in one place. Desktop
<LeftPanel /> // doesn't render MobilePanelBar and TopBar's desktop branch behaves
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}> // identically to before (same booleans, just sourced from context).
<Canvas device={device} /> <MobileChromeProvider>
<div className="editor-app">
<TopBar
device={device}
onDeviceChange={setDevice}
showGuides={showGuides}
onToggleGuides={() => setShowGuides(!showGuides)}
/>
<div className="editor-container">
{!isMobile && <LeftPanel />}
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
<Canvas device={device} showGuides={showGuides} />
</div>
{!isMobile && <RightPanel />}
</div> </div>
<RightPanel /> {isMobile && <MobilePanelBar />}
{isMobile && <MobileSelectionToolbar />}
<ContextMenu
visible={menuState.visible}
x={menuState.x}
y={menuState.y}
nodeId={menuState.nodeId}
onClose={hideMenu}
/>
</div> </div>
<ContextMenu </MobileChromeProvider>
visible={menuState.visible}
x={menuState.x}
y={menuState.y}
nodeId={menuState.nodeId}
onClose={hideMenu}
/>
</div>
); );
}; };
+67
View File
@@ -0,0 +1,67 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core's
useEditor so we can drive editor state without a real <Editor> tree. */
let mockNodes: Record<string, { data: { nodes: string[] } }> = {};
let mockDraggedSize = 0;
vi.mock('@craftjs/core', () => ({
useEditor: (collect: (state: any) => any) =>
collect({
nodes: mockNodes,
events: { dragged: { size: mockDraggedSize } },
}),
}));
import { EmptyCanvasHint } from './Canvas';
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
beforeEach(() => {
mockNodes = {};
mockDraggedSize = 0;
});
describe('EmptyCanvasHint', () => {
test('renders nothing before ROOT has mounted (no root node yet)', () => {
render(<EmptyCanvasHint />);
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
container.remove();
});
test('renders the hint once ROOT exists with zero children', () => {
mockNodes = { ROOT: { data: { nodes: [] } } };
render(<EmptyCanvasHint />);
expect(container.querySelector('.empty-canvas-hint')).not.toBeNull();
expect(container.textContent).toContain('Drag blocks from the left panel');
container.remove();
});
test('hides once the page has content', () => {
mockNodes = { ROOT: { data: { nodes: ['node-1'] } } };
render(<EmptyCanvasHint />);
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
container.remove();
});
test('hides while a drag is in progress, even on an empty root', () => {
mockNodes = { ROOT: { data: { nodes: [] } } };
mockDraggedSize = 1;
render(<EmptyCanvasHint />);
expect(container.querySelector('.empty-canvas-hint')).toBeNull();
container.remove();
});
});
+169
View File
@@ -0,0 +1,169 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
/* Same DOM-harness pattern as Footer.editguard.test.tsx: mock @craftjs/core
so RenderNode (the <Editor onRender> override) can be driven without a
real Editor tree. document.body doubles as the portal target, same as
the component itself uses. */
let mockNode: {
id: string;
selected: boolean;
hovered: boolean;
dom: HTMLElement | null;
displayName: string;
parent: string | null;
isCanvas: boolean;
};
const selectNodeSpy = vi.fn();
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { selectNode: selectNodeSpy } }),
useNode: (collect?: (node: any) => any) => {
const node = {
events: { selected: mockNode.selected, hovered: mockNode.hovered },
dom: mockNode.dom,
data: {
custom: {},
displayName: mockNode.displayName,
parent: mockNode.parent,
isCanvas: mockNode.isCanvas,
},
};
return { id: mockNode.id, ...(collect ? collect(node) : {}) };
},
}));
import { RenderNode } from './RenderNode';
let container: HTMLDivElement;
let root: Root;
let nodeDom: HTMLElement;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
beforeEach(() => {
nodeDom = document.createElement('div');
document.body.appendChild(nodeDom);
mockNode = {
id: 'node-1',
selected: false,
hovered: false,
dom: nodeDom,
displayName: 'Heading',
parent: 'ROOT',
isCanvas: false,
};
selectNodeSpy.mockClear();
});
const rendered = <span data-testid="inner">hello</span>;
describe('RenderNode (Editor onRender override)', () => {
test('passes render through untouched when not selected', () => {
render(<RenderNode render={rendered} />);
expect(container.querySelector('[data-testid="inner"]')).not.toBeNull();
expect(document.querySelector('.component-indicator')).toBeNull();
container.remove();
nodeDom.remove();
});
test('shows the badge with the displayName when selected', () => {
mockNode.selected = true;
render(<RenderNode render={rendered} />);
const badge = document.querySelector('.component-indicator');
expect(badge).not.toBeNull();
expect(badge?.textContent).toContain('Heading');
container.remove();
nodeDom.remove();
document.querySelector('.component-indicator')?.remove();
});
test('never shows a badge for ROOT even if "selected"', () => {
mockNode.selected = true;
mockNode.id = 'ROOT';
render(<RenderNode render={rendered} />);
expect(document.querySelector('.component-indicator')).toBeNull();
container.remove();
nodeDom.remove();
});
test('chevron click selects the parent node', () => {
mockNode.selected = true;
mockNode.parent = 'parent-42';
render(<RenderNode render={rendered} />);
const chevron = document.querySelector('.component-indicator-parent-btn') as HTMLElement;
expect(chevron).not.toBeNull();
act(() => {
chevron.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
});
expect(selectNodeSpy).toHaveBeenCalledWith('parent-42');
container.remove();
nodeDom.remove();
document.querySelector('.component-indicator')?.remove();
});
test('no chevron when there is no parent', () => {
mockNode.selected = true;
mockNode.parent = null;
render(<RenderNode render={rendered} />);
expect(document.querySelector('.component-indicator-parent-btn')).toBeNull();
container.remove();
nodeDom.remove();
document.querySelector('.component-indicator')?.remove();
});
test('tags a droppable (isCanvas) node dom with data-craft-node', () => {
mockNode.isCanvas = true;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-node')).toBe(true);
container.remove();
nodeDom.remove();
});
test('does not tag a non-canvas (leaf) node dom with data-craft-node', () => {
mockNode.isCanvas = false;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
container.remove();
nodeDom.remove();
});
test('never tags ROOT with data-craft-node even though ROOT is a canvas', () => {
mockNode.id = 'ROOT';
mockNode.isCanvas = true;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-node')).toBe(false);
container.remove();
nodeDom.remove();
});
test('tags the dom with data-craft-hovered when the Craft hovered event is set (Layers panel hover sync)', () => {
mockNode.hovered = true;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
container.remove();
nodeDom.remove();
});
test('removes data-craft-hovered once the hovered event clears', () => {
mockNode.hovered = true;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(true);
mockNode.hovered = false;
act(() => {
root.render(<RenderNode render={rendered} />);
});
expect(nodeDom.hasAttribute('data-craft-hovered')).toBe(false);
container.remove();
nodeDom.remove();
});
});
+116
View File
@@ -0,0 +1,116 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useEditor, useNode } from '@craftjs/core';
interface RenderNodeProps {
render: React.ReactElement;
}
/**
* Craft.js `<Editor onRender>` override -- wraps every node's render output.
* For the currently-selected node it portals a floating badge (component
* displayName + a "select parent" chevron) positioned over the node's real
* DOM element. Non-selected nodes (the overwhelming majority) and ROOT pass
* straight through as a Fragment, so this never touches layout, never
* appears in `toHtml` export (that walks the Craft node tree, not this
* portal), and doesn't wrap every node in extra DOM.
*
* It also imperatively tags each node's real DOM element with two
* editor-only data attributes (never part of `toHtml` export, which walks
* the Craft node tree, not the live DOM):
* - `data-craft-node`: set on actual Craft.js droppable containers
* (`node.data.isCanvas`, excluding ROOT). `editor.css`'s dashed "guide"
* outlines target this attribute instead of blanket tag selectors
* (div/section/header/...), so a component's own internal wrapper markup
* no longer picks up a guide outline it isn't a real drop target for.
* - `data-craft-hovered`: mirrors `node.events.hovered` (Craft's hover
* event set). Craft's own `connectors.connect()` (called by every
* component) wires a native mouseover/mouseleave listener to this event
* internally, so a plain mouse hover over any connected node sets it --
* this attribute is a real-mouse-hover canvas highlight. (The Layers
* panel's row-hover -> canvas-highlight sync, item 12, uses a sibling
* `data-layer-hovered` attribute written directly by LayersPanel.tsx
* instead of this event, since the action that would drive it here
* -- `actions.setNodeEvent` -- is stripped from the public `useEditor()`
* API at runtime.) editor.css matches both attributes for the same
* outline and suppresses both under `.guides-off`.
*/
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
const { actions } = useEditor();
const { id, isSelected, dom, name, parent, isCanvas, isHovered } = useNode((node) => ({
isSelected: node.events.selected,
isHovered: node.events.hovered,
dom: node.dom,
name: (node.data.props?.aiName as string) || node.data.displayName,
parent: node.data.parent,
isCanvas: node.data.isCanvas,
}));
const badgeRef = useRef<HTMLDivElement>(null);
const active = isSelected && id !== 'ROOT' && !!dom;
const updatePosition = useCallback(() => {
if (!dom || !badgeRef.current) return;
const rect = dom.getBoundingClientRect();
const badgeHeight = 22;
badgeRef.current.style.left = `${Math.max(rect.left, 0)}px`;
badgeRef.current.style.top = `${Math.max(rect.top - badgeHeight, 0)}px`;
}, [dom]);
useEffect(() => {
if (!active) return;
updatePosition();
window.addEventListener('resize', updatePosition);
document.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
document.removeEventListener('scroll', updatePosition, true);
};
}, [active, updatePosition]);
useEffect(() => {
if (!dom) return;
if (isCanvas && id !== 'ROOT') {
dom.setAttribute('data-craft-node', '');
} else {
dom.removeAttribute('data-craft-node');
}
}, [dom, isCanvas, id]);
useEffect(() => {
if (!dom) return;
if (isHovered) {
dom.setAttribute('data-craft-hovered', '');
} else {
dom.removeAttribute('data-craft-hovered');
}
}, [dom, isHovered]);
if (!active) return <>{render}</>;
return (
<>
{render}
{createPortal(
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
<span>{name}</span>
{parent && (
<button
type="button"
className="component-indicator-parent-btn"
title="Select parent"
aria-label={`Select parent of ${name}`}
onMouseDown={(e) => {
e.stopPropagation();
actions.selectNode(parent);
}}
>
<i className="fa fa-chevron-up" aria-hidden />
</button>
)}
</div>,
document.body
)}
</>
);
};
+29
View File
@@ -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();
});
});
+23
View File
@@ -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;
}
+153
View File
@@ -0,0 +1,153 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { useIsMobile } from './useIsMobile';
/**
* Mobile-A2 (review item 5): useIsMobile is the single source of truth every
* mobile-only branch (bottom tab bar, collapsed topbar, bottom sheets) reads
* from, so it needs direct coverage of: (a) it reflects matchMedia's
* `matches`, (b) the `change` listener is registered AND cleaned up on
* unmount (a leaked listener would keep re-rendering an unmounted tree /
* leak the component instance), and (c) the legacy Safari<14
* addListener/removeListener fallback is used when addEventListener isn't
* available.
*
* DOM-harness pattern mirrors PageContext.pure-updaters.test.tsx /
* useKeyboardShortcuts.test.tsx: bare createRoot + act, no @testing-library
* (not a project dependency).
*/
let container: HTMLDivElement;
let root: Root;
let originalMatchMedia: typeof window.matchMedia;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
/** A minimal fake MediaQueryList. `mode` picks which listener API it
* exposes, so tests can force the legacy fallback path. */
function makeMql(matches: boolean, mode: 'modern' | 'legacy' = 'modern') {
const listeners: Array<() => void> = [];
const mql: any = {
matches,
media: '(max-width: 768px)',
};
if (mode === 'modern') {
mql.addEventListener = vi.fn((_type: string, fn: () => void) => listeners.push(fn));
mql.removeEventListener = vi.fn((_type: string, fn: () => void) => {
const i = listeners.indexOf(fn);
if (i !== -1) listeners.splice(i, 1);
});
} else {
mql.addListener = vi.fn((fn: () => void) => listeners.push(fn));
mql.removeListener = vi.fn((fn: () => void) => {
const i = listeners.indexOf(fn);
if (i !== -1) listeners.splice(i, 1);
});
}
return { mql, fire: () => listeners.forEach((fn) => fn()), listenerCount: () => listeners.length };
}
let probedValue: boolean | null = null;
const Probe: React.FC = () => {
probedValue = useIsMobile();
return <span data-value={String(probedValue)} />;
};
beforeEach(() => {
originalMatchMedia = window.matchMedia;
probedValue = null;
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
describe('useIsMobile', () => {
test('returns true when the mobile media query matches', () => {
const { mql } = makeMql(true);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(true);
unmount();
});
test('returns false when the mobile media query does not match', () => {
const { mql } = makeMql(false);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(false);
unmount();
});
test('registers the change listener via addEventListener and updates on change', () => {
const { mql, fire, listenerCount } = makeMql(false);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(false);
expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
expect(listenerCount()).toBe(1);
// Flip the query result and fire the registered 'change' listener --
// the hook must re-read mql.matches, not just toggle blindly.
mql.matches = true;
act(() => {
fire();
});
expect(probedValue).toBe(true);
unmount();
});
test('cleans up the addEventListener listener on unmount', () => {
const { mql, listenerCount } = makeMql(true);
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(listenerCount()).toBe(1);
unmount();
expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
expect(listenerCount()).toBe(0);
});
test('falls back to legacy addListener/removeListener when addEventListener is unavailable', () => {
const { mql, fire, listenerCount } = makeMql(false, 'legacy');
window.matchMedia = vi.fn(() => mql) as any;
render(<Probe />);
expect(probedValue).toBe(false);
expect(mql.addListener).toHaveBeenCalledWith(expect.any(Function));
expect(listenerCount()).toBe(1);
mql.matches = true;
act(() => {
fire();
});
expect(probedValue).toBe(true);
unmount();
expect(mql.removeListener).toHaveBeenCalledWith(expect.any(Function));
expect(listenerCount()).toBe(0);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
/** Mobile breakpoint for the editor chrome (Phase A). Keep in sync with the
* `@media (max-width: 768px)` block in `src/styles/editor.css` -- both the
* CSS and this hook must agree on where mobile chrome kicks in. */
export const MOBILE_BREAKPOINT_PX = 768;
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT_PX}px)`;
function computeIsMobile(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window.matchMedia === 'function') {
try {
return window.matchMedia(MOBILE_QUERY).matches;
} catch {
// Fall through to the width check below (e.g. some older/embedded
// WebViews expose a matchMedia that throws instead of omitting it).
}
}
return window.innerWidth <= MOBILE_BREAKPOINT_PX;
}
/**
* Reports whether the viewport is at or below the mobile editor breakpoint.
* Drives every mobile-only branch introduced in Phase A (bottom tab bar,
* collapsed topbar, bottom sheets) -- desktop rendering must stay identical
* above the breakpoint, so this is the single source of truth both React
* and (via the matching CSS media query) plain CSS use to agree on "mobile".
*
* Falls back to a `resize` listener + `window.innerWidth` when
* `matchMedia` isn't available (e.g. some test/JSDOM environments), so the
* hook degrades gracefully rather than throwing.
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState<boolean>(computeIsMobile);
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window.matchMedia === 'function') {
let mql: MediaQueryList | null = null;
try {
mql = window.matchMedia(MOBILE_QUERY);
} catch {
mql = null;
}
if (mql) {
const handleChange = () => setIsMobile(mql!.matches);
handleChange();
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handleChange);
return () => mql!.removeEventListener('change', handleChange);
}
// Safari < 14 only supports the deprecated addListener/removeListener pair.
const legacyMql = mql as MediaQueryList & {
addListener?: (listener: () => void) => void;
removeListener?: (listener: () => void) => void;
};
legacyMql.addListener?.(handleChange);
return () => legacyMql.removeListener?.(handleChange);
}
}
const handleResize = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT_PX);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return isMobile;
}
@@ -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();
});
});
+40
View File
@@ -2,6 +2,7 @@ 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 { regenerateTreeIds } from '../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
function isInputFocused(): boolean { function isInputFocused(): boolean {
const el = document.activeElement; const el = document.activeElement;
@@ -85,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();
+258
View File
@@ -0,0 +1,258 @@
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 { useNodeActions, type NodeActions } from './useNodeActions';
/**
* Phase B, item 1: `useNodeActions` was extracted out of `ContextMenu.tsx` so
* the desktop right-click menu and the mobile selection toolbar
* (`MobileSelectionToolbar`) share one implementation. This suite exercises
* the hook directly (mocking `@craftjs/core`'s `useEditor`, mirroring the
* pattern in `useKeyboardShortcuts.test.tsx`), covering:
* - moveUp/moveDown call `actions.move` with the right target index, and
* are no-ops at the respective boundary
* - canMoveUp/canMoveDown reflect the node's position among its siblings
* - duplicate regenerates ids, inserts the copy IMMEDIATELY AFTER the
* source node (`actions.addNodeTree(tree, parentId, sourceIndex + 1)`),
* and selects the new copy (`actions.selectNode(tree.rootNodeId)`) --
* Phase B fast-follow: the previous append-at-end behavior left the copy
* off-screen with the ORIGINAL still selected, so users couldn't see
* what "Duplicate" had just done.
* - selectParent calls `actions.selectNode` with the parent id
* - canSelectParent reflects whether the node's parent is itself real
* (not ROOT/missing) -- false at a top-level section, true one level in
* - deleteNode routes through `findDeletableTarget` (also covering the
* "no deletable target" no-op)
* - ROOT/null nodeId disables every action safely (no-ops, all
* canMoveUp/canMoveDown/canDelete/canSelectParent false)
*/
const moveMock = vi.fn();
const addNodeTreeMock = vi.fn();
const selectNodeMock = vi.fn();
const deleteMock = vi.fn();
interface FakeNodeData {
parent: string | null;
nodes: string[];
}
let fakeNodes: Record<string, { data: FakeNodeData }> = {};
const COPIED_TREE: NodeTree = {
rootNodeId: 'child-1',
nodes: {
'child-1': {
id: 'child-1',
data: { props: {}, type: { resolvedName: 'Container' }, name: 'Container', displayName: 'Container', isCanvas: false, parent: 'parent-1', linkedNodes: {}, nodes: [], hidden: false },
info: {},
events: { selected: false, dragged: false, hovered: false },
dom: null,
related: {},
rules: {},
_hydrationTimestamp: 0,
} as unknown as Node,
},
};
function makeQuery() {
return {
node: (id: string) => ({
get: () => fakeNodes[id] ?? null,
toNodeTree: () => {
if (id !== 'child-1') throw new Error(`unexpected toNodeTree() for "${id}"`);
return COPIED_TREE;
},
}),
};
}
vi.mock('@craftjs/core', () => ({
useEditor: (collector?: (state: any, query: any) => any) => {
const state = {
nodes: Object.fromEntries(Object.entries(fakeNodes).map(([id, n]) => [id, n])),
};
const query = makeQuery();
const collected = collector ? collector(state, query) : {};
return {
...collected,
actions: {
move: moveMock,
addNodeTree: addNodeTreeMock,
selectNode: selectNodeMock,
delete: deleteMock,
},
query,
};
},
}));
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;
let captured: NodeActions | null = null;
const Probe: React.FC<{ nodeId: string | null }> = ({ nodeId }) => {
captured = useNodeActions(nodeId);
return null;
};
function render(nodeId: string | null) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<Probe nodeId={nodeId} />);
});
}
function rerender(nodeId: string | null) {
act(() => {
root.render(<Probe nodeId={nodeId} />);
});
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
beforeEach(() => {
moveMock.mockClear();
addNodeTreeMock.mockClear();
selectNodeMock.mockClear();
deleteMock.mockClear();
regenerateTreeIdsMock.mockClear();
captured = null;
fakeNodes = {
ROOT: { data: { parent: null, nodes: ['parent-1'] } },
'parent-1': { data: { parent: 'ROOT', nodes: ['child-0', 'child-1', 'child-2'] } },
'child-0': { data: { parent: 'parent-1', nodes: [] } },
'child-1': { data: { parent: 'parent-1', nodes: [] } },
'child-2': { data: { parent: 'parent-1', nodes: [] } },
};
});
afterEach(() => {
if (root) unmount();
});
describe('useNodeActions', () => {
test('a middle child can move both up and down', () => {
render('child-1');
expect(captured!.canMoveUp).toBe(true);
expect(captured!.canMoveDown).toBe(true);
});
test('the first child cannot move up, but can move down', () => {
render('child-0');
expect(captured!.canMoveUp).toBe(false);
expect(captured!.canMoveDown).toBe(true);
});
test('the last child can move up, but not down', () => {
render('child-2');
expect(captured!.canMoveUp).toBe(true);
expect(captured!.canMoveDown).toBe(false);
});
test('moveUp calls actions.move with idx - 1, moveDown with idx + 2 (Craft.js index semantics)', () => {
render('child-1');
act(() => captured!.moveUp());
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 0);
moveMock.mockClear();
act(() => captured!.moveDown());
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 3);
});
test('moveUp/moveDown at a boundary are no-ops', () => {
render('child-0');
act(() => captured!.moveUp());
expect(moveMock).not.toHaveBeenCalled();
rerender('child-2');
act(() => captured!.moveDown());
expect(moveMock).not.toHaveBeenCalled();
});
test('duplicate inserts the copy immediately after the source (parentId, sourceIndex + 1) and selects it', () => {
render('child-1');
let returned: string | null | undefined;
act(() => {
returned = captured!.duplicate();
});
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [tree, parentId, index] = addNodeTreeMock.mock.calls[0];
// child-1 is at index 1 among ['child-0', 'child-1', 'child-2'] -> insert at 2.
expect(parentId).toBe('parent-1');
expect(index).toBe(2);
expect(tree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
expect(selectNodeMock).toHaveBeenCalledTimes(1);
expect(selectNodeMock).toHaveBeenCalledWith(tree.rootNodeId);
expect(returned).toBe(tree.rootNodeId);
});
test('selectParent calls actions.selectNode with the parent id', () => {
render('child-1');
act(() => captured!.selectParent());
expect(selectNodeMock).toHaveBeenCalledWith('parent-1');
});
test('canSelectParent is false at a top-level node (parent is ROOT), true one level deeper', () => {
render('parent-1'); // parent-1's own parent is 'ROOT'
expect(captured!.canSelectParent).toBe(false);
rerender('child-1'); // child-1's parent is 'parent-1', a real node
expect(captured!.canSelectParent).toBe(true);
});
test('deleteNode calls actions.delete with the node id when deletable', () => {
render('child-1');
expect(captured!.canDelete).toBe(true);
act(() => captured!.deleteNode());
expect(deleteMock).toHaveBeenCalledWith('child-1');
});
test('ROOT nodeId disables every action and is a safe no-op', () => {
render('ROOT');
expect(captured!.canMoveUp).toBe(false);
expect(captured!.canMoveDown).toBe(false);
expect(captured!.canDelete).toBe(false);
expect(captured!.canSelectParent).toBe(false);
act(() => {
captured!.moveUp();
captured!.moveDown();
captured!.duplicate();
captured!.selectParent();
captured!.deleteNode();
});
expect(moveMock).not.toHaveBeenCalled();
expect(addNodeTreeMock).not.toHaveBeenCalled();
expect(selectNodeMock).not.toHaveBeenCalled();
expect(deleteMock).not.toHaveBeenCalled();
});
test('null nodeId disables every action and is a safe no-op', () => {
render(null);
expect(captured!.canMoveUp).toBe(false);
expect(captured!.canMoveDown).toBe(false);
expect(captured!.canDelete).toBe(false);
expect(captured!.canSelectParent).toBe(false);
});
});
+221
View File
@@ -0,0 +1,221 @@
import { useEditor } from '@craftjs/core';
import { regenerateTreeIds } from '../utils/craft-tree';
import { findDeletableTarget } from '../utils/craft-helpers';
export interface NodeActions {
moveUp: () => void;
moveDown: () => void;
/** Duplicates `nodeId`, inserting the copy immediately after the source in
* the parent's children and selecting it. Returns the new node's id (so
* callers -- e.g. `MobileSelectionToolbar` -- can scroll it into view),
* or null if the duplicate could not be performed (no-op for ROOT/null,
* or a caught error). */
duplicate: () => string | null;
deleteNode: () => void;
selectParent: () => void;
/** True if `nodeId` has an earlier sibling under the same parent (so
* `moveUp` would actually move it). False for ROOT/null/no-parent. */
canMoveUp: boolean;
/** True if `nodeId` has a later sibling under the same parent (so
* `moveDown` would actually move it). False for ROOT/null/no-parent. */
canMoveDown: boolean;
/** True if `findDeletableTarget` resolves to a real, deletable node (either
* `nodeId` itself, or an ancestor when `nodeId` is an empty linked-node
* slot whose siblings are all also empty -- see `craft-helpers.ts`). */
canDelete: boolean;
/** True if `nodeId` has a real parent to select -- i.e. the parent is
* neither ROOT nor missing. `selectParent` on a top-level section (whose
* parent IS 'ROOT') would only select the page-wide ROOT node, which has
* no on-canvas outline and no toolbar of its own -- a dead end for a
* mobile user with no way back. False for ROOT/null/no-parent too. */
canSelectParent: boolean;
}
/**
* Shared node-action logic (move up/down, duplicate, delete, select parent)
* extracted from `ContextMenu.tsx` (Phase B) so both the desktop right-click
* menu AND the mobile on-canvas selection toolbar (`MobileSelectionToolbar`)
* drive the exact same behavior from one place instead of two independent
* copies drifting apart.
*
* IMPORTANT gotcha this hook works around: `@craftjs/core`'s `useEditor`
* collector (`@craftjs/utils`' `useCollector`) only synchronously computes
* the collector function ONCE, on this hook's very first mount. After that,
* it updates purely in reaction to the underlying store's OWN change
* notifications, invoking whatever collector closure is current AT THAT
* NOTIFICATION -- so a collector that closes over `nodeId` (an argument that
* changes across renders of the SAME mounted hook instance, e.g. every time
* `MobileSelectionToolbar` re-renders with a newly-selected node) goes stale
* for exactly one render: the cached value from the last store notification
* (computed against the PREVIOUS nodeId) is what gets returned, until some
* unrelated store event happens to trigger a fresh computation. An earlier
* version of this hook computed `canMoveUp`/`canMoveDown`/`canDelete` inside
* such a collector and was caught showing the PREVIOUS selection's move
* boundaries in the mobile toolbar for one render after tapping a new node
* (verified with real Playwright touch taps against a real Craft.js
* document -- a plain mocked `useEditor` in a unit test doesn't reproduce
* this, since a hand-rolled mock has no reason to replicate the real
* library's caching).
*
* The fix: get `actions`/`query` from a collector-FREE `useEditor()` call
* (an always-live reference, same pattern `ContextMenu.tsx` used before this
* extraction) and compute `canMoveUp`/`canMoveDown`/`canDelete` as plain
* synchronous code during render using that live `query` -- never cached.
*
* That still leaves one gap: `nodeId` staying the SAME across renders while
* its position changes (e.g. tapping "Move Up" repeatedly on the same
* still-selected node) needs SOMETHING to trigger a re-render so the boundary
* flags below get recomputed. An earlier version of this hook forced that
* via a second `useEditor((state) => ({ _: state.nodes }))` subscription --
* but subscribing to the WHOLE node map reacts to every `dom` ref
* assignment too (Craft's `connectors.connect(ref)` calls `actions.setDOM`
* synchronously from a React ref callback during COMMIT, i.e. while some
* OTHER component is still mounting), which trips React's "Cannot update a
* component while rendering a different component" warning the moment a
* freshly-added container with children mounts. Callers that need
* `canMoveUp`/`canMoveDown` to refresh after a move they themselves
* triggered (`MobileSelectionToolbar`) should instead bump their OWN local
* state right after calling `moveUp`/`moveDown` -- a plain, local,
* event-handler-triggered re-render, not a store-wide subscription.
*/
export function useNodeActions(nodeId: string | null | undefined): NodeActions {
const { actions, query } = useEditor();
const isRootOrNull = !nodeId || nodeId === 'ROOT';
let canMoveUp = false;
let canMoveDown = false;
let canSelectParent = false;
if (!isRootOrNull) {
try {
const node = query.node(nodeId).get();
const parentId: string | null | undefined = node?.data?.parent;
if (parentId) {
canSelectParent = parentId !== 'ROOT';
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(nodeId);
canMoveUp = idx > 0;
canMoveDown = idx !== -1 && idx < siblings.length - 1;
}
} catch {
// Node no longer exists (e.g. deleted out from under a stale
// reference) -- leave both false.
}
}
const canDelete = !isRootOrNull && !!findDeletableTarget(query, nodeId);
const getParentId = (): string | null => {
if (!nodeId) return null;
try {
const node = query.node(nodeId).get();
return node?.data?.parent || null;
} catch {
return null;
}
};
const duplicate = (): string | null => {
if (!nodeId || nodeId === 'ROOT') return null;
try {
const parentId = getParentId();
if (!parentId) return null;
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
// Insert the copy IMMEDIATELY AFTER the source node, not appended at
// the end of the parent -- on a top-level section, "append at end"
// landed the copy at the bottom of the page, off-screen, with the
// ORIGINAL still selected, so a duplicate looked like nothing had
// happened at all. Resolve the source's index among its siblings so
// the copy lands right next to what it was copied from; if that can't
// be resolved for any reason, fall back to the old append behavior
// rather than guessing at an index (or throwing).
let insertIndex: number | undefined;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const sourceIndex = siblings.indexOf(nodeId);
if (sourceIndex !== -1) insertIndex = sourceIndex + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
if (insertIndex !== undefined) {
actions.addNodeTree(tree, parentId, insertIndex);
} else {
actions.addNodeTree(tree, parentId);
}
// Select the new copy (not the original) so the toolbar/context menu
// and any on-canvas outline immediately reflect what was just created.
actions.selectNode(tree.rootNodeId);
return tree.rootNodeId;
} catch (e) {
console.error('Duplicate failed:', e);
return null;
}
};
const moveUp = () => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx > 0) {
actions.move(nodeId, parentId, idx - 1);
}
} catch (e) {
console.error('Move up failed:', e);
}
};
const moveDown = () => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx < children.length - 1) {
actions.move(nodeId, parentId, idx + 2);
}
} catch (e) {
console.error('Move down failed:', e);
}
};
const selectParent = () => {
if (!nodeId || nodeId === 'ROOT') return;
const parentId = getParentId();
if (parentId) {
actions.selectNode(parentId);
}
};
const deleteNode = () => {
const target = findDeletableTarget(query, nodeId);
if (!target) return;
try {
actions.delete(target);
} catch (e) {
console.error('Delete failed:', e);
}
};
return {
moveUp,
moveDown,
duplicate,
deleteNode,
selectParent,
canMoveUp,
canMoveDown,
canDelete,
canSelectParent,
};
}
+146
View File
@@ -0,0 +1,146 @@
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 { useVisualViewportInsets } from './useVisualViewport';
/**
* Fast-follow coverage for `useVisualViewportInsets` (BottomSheet's
* keyboard-avoidance source of truth). Mirrors the bare createRoot + act
* harness `useIsMobile.test.tsx` uses (no @testing-library/react in this
* repo). Covers:
* - the no-`visualViewport` fallback (older WebViews) returns
* `{ height: window.innerHeight, keyboardInset: 0 }` AND registers no
* listener (there's nothing to listen to).
* - the keyboard-open math: `keyboardInset = innerHeight - (vv.height +
* vv.offsetTop)`, recomputed when the mocked vv fires `resize`.
* - both `resize` and `scroll` listeners are added on mount and the SAME
* handler is removed for both on unmount (a leaked listener would keep
* reacting after the owning component -- e.g. BottomSheet -- is gone).
*/
let container: HTMLDivElement;
let root: Root;
let originalInnerHeight: number;
let originalVisualViewport: VisualViewport | null;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
/** A minimal fake `visualViewport`: just enough surface for the hook
* (`height`, `offsetTop`, `addEventListener`/`removeEventListener`). */
function makeVisualViewport(height: number, offsetTop: number) {
const listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
const vv: any = {
height,
offsetTop,
addEventListener: vi.fn((type: string, fn: () => void) => {
listeners[type].push(fn);
}),
removeEventListener: vi.fn((type: string, fn: () => void) => {
const i = listeners[type].indexOf(fn);
if (i !== -1) listeners[type].splice(i, 1);
}),
};
return {
vv,
fire: (type: 'resize' | 'scroll') => listeners[type].forEach((fn) => fn()),
listenerCount: (type: 'resize' | 'scroll') => listeners[type].length,
};
}
let probed: { height: number; keyboardInset: number } | null = null;
const Probe: React.FC = () => {
probed = useVisualViewportInsets();
return <span data-height={probed.height} data-inset={probed.keyboardInset} />;
};
beforeEach(() => {
originalInnerHeight = window.innerHeight;
originalVisualViewport = window.visualViewport;
probed = null;
});
afterEach(() => {
Object.defineProperty(window, 'innerHeight', {
value: originalInnerHeight,
configurable: true,
writable: true,
});
Object.defineProperty(window, 'visualViewport', {
value: originalVisualViewport,
configurable: true,
});
});
describe('useVisualViewportInsets', () => {
test('falls back to innerHeight/0 and registers no listener when visualViewport is undefined', () => {
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
Object.defineProperty(window, 'visualViewport', { value: undefined, configurable: true });
render(<Probe />);
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
unmount();
});
test('computes keyboardInset from visualViewport and recomputes on resize', () => {
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
const { vv, fire } = makeVisualViewport(800, 0);
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
render(<Probe />);
// No keyboard open: vv fills the layout viewport exactly.
expect(probed).toEqual({ height: 800, keyboardInset: 0 });
// Simulate the on-screen keyboard opening: the visual viewport shrinks.
vv.height = 500;
vv.offsetTop = 0;
act(() => {
fire('resize');
});
expect(probed).toEqual({ height: 500, keyboardInset: 300 });
unmount();
});
test('registers resize and scroll listeners on mount and removes both (same handler) on unmount', () => {
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true });
const { vv, listenerCount } = makeVisualViewport(800, 0);
Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true });
render(<Probe />);
expect(vv.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
expect(vv.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
expect(listenerCount('resize')).toBe(1);
expect(listenerCount('scroll')).toBe(1);
const resizeHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'resize')[1];
const scrollHandler = vv.addEventListener.mock.calls.find((c: any[]) => c[0] === 'scroll')[1];
expect(resizeHandler).toBe(scrollHandler);
unmount();
expect(vv.removeEventListener).toHaveBeenCalledWith('resize', resizeHandler);
expect(vv.removeEventListener).toHaveBeenCalledWith('scroll', scrollHandler);
expect(listenerCount('resize')).toBe(0);
expect(listenerCount('scroll')).toBe(0);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
export interface VisualViewportInsets {
/** The visual viewport's current height (shrinks when the on-screen
* keyboard opens). Falls back to `window.innerHeight` when
* `visualViewport` isn't supported. */
height: number;
/** Extra inset a `position: fixed`, bottom-anchored element should add to
* its own `bottom` offset to stay clear of the on-screen keyboard --
* `window.innerHeight` minus the visual viewport's bottom edge (its
* height + offsetTop). Zero whenever no keyboard is open, or
* `visualViewport` isn't supported (a safe no-op fallback). */
keyboardInset: number;
}
const ZERO_INSETS: VisualViewportInsets = { height: 0, keyboardInset: 0 };
function computeInsets(): VisualViewportInsets {
if (typeof window === 'undefined') return ZERO_INSETS;
const vv = window.visualViewport;
if (!vv) return { height: window.innerHeight, keyboardInset: 0 };
const keyboardInset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
return { height: vv.height, keyboardInset };
}
/**
* Tracks `window.visualViewport`'s height/offset (item 5, Phase B) so
* `BottomSheet` can stay clear of the on-screen keyboard. `position: fixed`
* elements are positioned against the LAYOUT viewport, which does NOT shrink
* when a mobile keyboard opens -- only the visual viewport does -- so a
* bottom-anchored sheet's inputs can otherwise end up hidden underneath the
* keyboard with no visual indication anything is wrong.
*
* Guards for browsers without `visualViewport` (older WebViews): falls back
* to `{ height: window.innerHeight, keyboardInset: 0 }`, i.e. a no-op, so
* the sheet just keeps its existing (keyboard-unaware) sizing there.
*/
export function useVisualViewportInsets(): VisualViewportInsets {
const [insets, setInsets] = useState<VisualViewportInsets>(computeInsets);
useEffect(() => {
if (typeof window === 'undefined' || !window.visualViewport) return;
const vv = window.visualViewport;
const handleChange = () => setInsets(computeInsets());
handleChange();
vv.addEventListener('resize', handleChange);
vv.addEventListener('scroll', handleChange);
return () => {
vv.removeEventListener('resize', handleChange);
vv.removeEventListener('scroll', handleChange);
};
}, []);
return insets;
}
+60
View File
@@ -1,6 +1,7 @@
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { buildSavePayload } from './useWhpApi'; import { buildSavePayload } from './useWhpApi';
import { PageData } from '../types'; import { PageData } from '../types';
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
const pageA: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: 'STORED_HOME' }; 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 pageB: PageData = { id: 'page_2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT' };
@@ -19,6 +20,8 @@ describe('buildSavePayload', () => {
activePageId: '__header__', activePageId: '__header__',
isEditingHeader: true, isEditingHeader: true,
isEditingFooter: false, isEditingFooter: false,
headCode: DEFAULT_SITE_DESIGN.headCode,
design: DEFAULT_SITE_DESIGN,
}); });
// The fresh live canvas must be reflected in header_craft_state, not the stale stored one. // The fresh live canvas must be reflected in header_craft_state, not the stale stored one.
@@ -52,6 +55,8 @@ describe('buildSavePayload', () => {
activePageId: '__footer__', activePageId: '__footer__',
isEditingHeader: false, isEditingHeader: false,
isEditingFooter: true, isEditingFooter: true,
headCode: DEFAULT_SITE_DESIGN.headCode,
design: DEFAULT_SITE_DESIGN,
}); });
expect(payload.footer_craft_state).toBe('LIVE_FOOTER'); expect(payload.footer_craft_state).toBe('LIVE_FOOTER');
@@ -75,6 +80,8 @@ describe('buildSavePayload', () => {
activePageId: 'home', activePageId: 'home',
isEditingHeader: false, isEditingHeader: false,
isEditingFooter: false, isEditingFooter: false,
headCode: DEFAULT_SITE_DESIGN.headCode,
design: DEFAULT_SITE_DESIGN,
}); });
// Live canvas goes to the active page and top-level slots. // Live canvas goes to the active page and top-level slots.
@@ -87,4 +94,57 @@ describe('buildSavePayload', () => {
expect(payload.header_craft_state).toBe('STALE_HEADER'); expect(payload.header_craft_state).toBe('STALE_HEADER');
expect(payload.footer_craft_state).toBe('STALE_FOOTER'); 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,
headCode: DEFAULT_SITE_DESIGN.headCode,
design: DEFAULT_SITE_DESIGN,
});
// 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');
});
test('head code + design tokens are included in the save payload', () => {
const design = { ...DEFAULT_SITE_DESIGN, headCode: '<meta name="x">' };
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_PAGE_HOME',
pages: [pageA, pageB],
headerPage,
footerPage,
activePageId: 'home',
isEditingHeader: false,
isEditingFooter: false,
headCode: '<meta name="x">',
design,
});
expect(payload.head_code).toBe('<meta name="x">');
expect(payload.design).toEqual(design);
expect(payload.design.headCode).toBe('<meta name="x">');
});
}); });
+53 -4
View File
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useEditor } from '@craftjs/core'; 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 { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml } from '../utils/html-export'; import { exportBodyHtml } from '../utils/html-export';
import { PageData } from '../types'; import { PageData } from '../types';
@@ -18,6 +19,10 @@ export interface BuildSavePayloadInput {
isEditingHeader: boolean; isEditingHeader: boolean;
/** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */ /** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */
isEditingFooter: boolean; isEditingFooter: boolean;
/** Site-wide custom `<head>` code (also present on `design.headCode`). */
headCode: string;
/** Full site design tokens object -- lets load() restore colors/fonts/headCode. */
design: SiteDesign;
} }
/** /**
@@ -45,10 +50,24 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
activePageId, activePageId,
isEditingHeader, isEditingHeader,
isEditingFooter, isEditingFooter,
headCode,
design,
} = input; } = input;
const isPageActive = !isEditingHeader && !isEditingFooter; const isPageActive = !isEditingHeader && !isEditingFooter;
// 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 // Fresh header/footer state: the live canvas wins when that zone is the
// one currently being edited; otherwise fall back to the last-committed // one currently being edited; otherwise fall back to the last-committed
// stored state (updated on zone switch by PageContext's saveCurrentState). // stored state (updated on zone switch by PageContext's saveCurrentState).
@@ -115,7 +134,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
const filename = i === 0 ? 'index.html' : page.slug + '.html'; const filename = i === 0 ? 'index.html' : page.slug + '.html';
let pageHtml = ''; let pageHtml = '';
if (isPageActive && 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) {
@@ -146,7 +165,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
// 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: (isPageActive && page.id === activePageId) ? liveCraftState : (page.craftState || null), craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
})); }));
return { return {
@@ -161,6 +180,8 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
header_craft_state: headerCraftState, header_craft_state: headerCraftState,
footer_craft_state: footerCraftState, footer_craft_state: footerCraftState,
pages_craft_state: pagesGrapesjs, pages_craft_state: pagesGrapesjs,
head_code: headCode,
design,
}; };
} }
@@ -177,7 +198,9 @@ export function useWhpApi() {
setHeaderCraftState, setHeaderCraftState,
setFooterCraftState, setFooterCraftState,
setPagesCraftState, setPagesCraftState,
setActivePageIdDirect,
} = usePages(); } = usePages();
const { design, updateDesign } = useSiteDesign();
const save = useCallback(async () => { const save = useCallback(async () => {
if (!isWHP || !whpConfig) return null; if (!isWHP || !whpConfig) return null;
@@ -197,6 +220,8 @@ export function useWhpApi() {
activePageId, activePageId,
isEditingHeader, isEditingHeader,
isEditingFooter, isEditingFooter,
headCode: design.headCode,
design,
}); });
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, { const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
@@ -208,7 +233,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, isEditingHeader, isEditingFooter]); }, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter, design]);
const publish = useCallback(async () => { const publish = useCallback(async () => {
if (!isWHP || !whpConfig) return null; if (!isWHP || !whpConfig) return null;
@@ -242,6 +267,17 @@ export function useWhpApi() {
if (data.success && data.project) { if (data.success && data.project) {
const proj = data.project; const proj = data.project;
// Restore site design tokens (colors/fonts/headCode) so the editor
// reflects what was last saved. Prefer the full `design` object when
// present; fall back to just `head_code` for older project.json files
// saved before this field existed (backward-compatible: defaults for
// everything else).
if (proj.design && typeof proj.design === 'object') {
updateDesign(proj.design);
} else if (typeof proj.head_code === 'string') {
updateDesign({ headCode: proj.head_code });
}
// Restore header craft state // Restore header craft state
if (proj.header_craft_state) { if (proj.header_craft_state) {
setHeaderCraftState(typeof proj.header_craft_state === 'string' setHeaderCraftState(typeof proj.header_craft_state === 'string'
@@ -271,10 +307,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, updateDesign]);
const uploadAsset = useCallback( const uploadAsset = useCallback(
async (file: File) => { async (file: File) => {
+42 -64
View File
@@ -1,9 +1,10 @@
import React, { useEffect, useCallback, useRef } from 'react'; import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
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 { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps { interface ContextMenuProps {
visible: boolean; visible: boolean;
@@ -15,6 +16,8 @@ interface ContextMenuProps {
interface MenuItem { interface MenuItem {
label: string; label: string;
/** Font Awesome icon suffix (e.g. 'magic' for fa-magic), rendered before the label. */
icon?: string;
shortcut?: string; shortcut?: string;
action: () => void; action: () => void;
danger?: boolean; danger?: boolean;
@@ -32,7 +35,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(() => {
@@ -63,24 +65,21 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
} }
}, [nodeId, query]); }, [nodeId, query]);
const duplicate = useCallback(() => { // Shared move/duplicate/delete/select-parent logic (item 1, Phase B) --
if (!nodeId || nodeId === 'ROOT') return; // extracted into `useNodeActions` so the mobile selection toolbar drives
try { // the exact same behavior. Wrapped here purely to also `onClose()` the
const parentId = getParentId(); // menu after each action, same as before the extraction.
if (!parentId) return; const nodeActions = useNodeActions(nodeId);
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree()); const duplicate = useCallback(() => {
actions.addNodeTree(tree, parentId); nodeActions.duplicate();
} catch (e) {
console.error('Duplicate failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, getParentId, onClose]); }, [nodeActions, onClose]);
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);
} }
@@ -88,7 +87,7 @@ 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) { if (!sourceId) {
onClose(); onClose();
return; return;
@@ -116,47 +115,19 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, actions, query, onClose]); }, [nodeId, actions, query, onClose]);
const moveUp = useCallback(() => { const moveUp = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; nodeActions.moveUp();
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx > 0) {
actions.move(nodeId, parentId, idx - 1);
}
} catch (e) {
console.error('Move up failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, getParentId, onClose]); }, [nodeActions, onClose]);
const moveDown = useCallback(() => { const moveDown = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; nodeActions.moveDown();
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx < children.length - 1) {
actions.move(nodeId, parentId, idx + 2);
}
} catch (e) {
console.error('Move down failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, getParentId, onClose]); }, [nodeActions, onClose]);
const selectParent = useCallback(() => { const selectParent = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; nodeActions.selectParent();
const parentId = getParentId();
if (parentId) {
actions.selectNode(parentId);
}
onClose(); onClose();
}, [nodeId, actions, getParentId, onClose]); }, [nodeActions, onClose]);
const askSitesmith = useCallback(() => { const askSitesmith = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
@@ -170,18 +141,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, query, openSitesmith, onClose]); }, [nodeId, query, openSitesmith, onClose]);
const deleteNode = useCallback(() => { const deleteNode = useCallback(() => {
const target = findDeletableTarget(query, nodeId); nodeActions.deleteNode();
if (!target) {
onClose();
return;
}
try {
actions.delete(target);
} catch (e) {
console.error('Delete failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, onClose]); }, [nodeActions, onClose]);
if (!visible) return null; if (!visible) return null;
@@ -189,48 +151,61 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const items: MenuItem[] = [ const items: MenuItem[] = [
{ {
label: '✨ Ask Sitesmith', label: 'Ask Sitesmith',
icon: 'magic',
action: askSitesmith, action: askSitesmith,
disabled: isRoot, disabled: isRoot,
dividerAfter: true, dividerAfter: true,
}, },
{ {
label: 'Duplicate', label: 'Duplicate',
icon: 'clone',
shortcut: 'Ctrl+D', shortcut: 'Ctrl+D',
action: duplicate, action: duplicate,
disabled: isRoot, disabled: isRoot,
}, },
{ {
label: 'Copy', label: 'Copy',
icon: 'files-o',
shortcut: 'Ctrl+C', shortcut: 'Ctrl+C',
action: copyNode, action: copyNode,
disabled: isRoot, disabled: isRoot,
}, },
{ {
label: 'Paste', label: 'Paste',
icon: 'clipboard',
shortcut: 'Ctrl+V', shortcut: 'Ctrl+V',
action: pasteNode, action: pasteNode,
disabled: !clipboardRef.current, disabled: !getClipboardNodeId(),
dividerAfter: true, dividerAfter: true,
}, },
{ {
label: 'Move Up', label: 'Move Up',
icon: 'arrow-up',
action: moveUp, action: moveUp,
disabled: isRoot, disabled: isRoot,
}, },
{ {
label: 'Move Down', label: 'Move Down',
icon: 'arrow-down',
action: moveDown, action: moveDown,
disabled: isRoot, disabled: isRoot,
}, },
{ {
label: 'Select Parent', label: 'Select Parent',
icon: 'level-up',
action: selectParent, action: selectParent,
disabled: isRoot, // Not just `isRoot`: the real dead-end is when nodeId's PARENT is
// ROOT (selecting a top-level section -> Select Parent would only
// select the page-wide, un-editable ROOT -- no outline, no toolbar).
// `canSelectParent` (useNodeActions) already encodes that and matches
// the mobile selection toolbar's identical guard.
disabled: !nodeActions.canSelectParent,
dividerAfter: true, dividerAfter: true,
}, },
{ {
label: 'Delete', label: 'Delete',
icon: 'trash',
shortcut: 'Del', shortcut: 'Del',
action: deleteNode, action: deleteNode,
danger: true, danger: true,
@@ -291,7 +266,10 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
(e.target as HTMLElement).style.background = 'transparent'; (e.target as HTMLElement).style.background = 'transparent';
}} }}
> >
<span>{item.label}</span> <span>
{item.icon && <i className={`fa fa-${item.icon}`} style={{ marginRight: 6, width: 12 }} />}
{item.label}
</span>
{item.shortcut && ( {item.shortcut && (
<span <span
style={{ style={{
+25 -19
View File
@@ -2,12 +2,14 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useAssets } from '../../hooks/useAssets'; import { useAssets } from '../../hooks/useAssets';
import { clickableProps } from '../../utils/a11y'; import { clickableProps } from '../../utils/a11y';
import { copyToClipboard } from '../../utils/clipboard'; import { copyToClipboard } from '../../utils/clipboard';
import { useIsMobile } from '../../hooks/useIsMobile';
/** How long the "Delete?" confirm state stays armed before auto-resetting. */ /** How long the "Delete?" confirm state stays armed before auto-resetting. */
const DELETE_CONFIRM_TIMEOUT_MS = 4000; 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 isMobile = useIsMobile();
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);
@@ -107,23 +109,41 @@ export const AssetsPanel: React.FC = () => {
{loading ? 'Uploading...' : 'Upload File'} {loading ? 'Uploading...' : 'Upload File'}
</button> </button>
{/* Drop zone */} {/* Drop zone -- a single element that doubles as the empty state.
Previously this was a small always-visible dropzone PLUS a
separate italic "No assets uploaded yet" line stacked underneath
it when empty; merged into one tall dropzone (icon + copy,
click-or-drag) so the empty state isn't two redundant messages.
Once assets exist it collapses back to a slim persistent drop
target above the grid. */}
<div <div
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
{...(assets.length === 0 ? clickableProps(() => fileInputRef.current?.click()) : {})}
style={{ style={{
padding: 20, display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: assets.length === 0 ? '36px 20px' : 16,
border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`, border: `2px dashed ${isDragOver ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent', background: isDragOver ? 'var(--color-accent-subtle)' : 'transparent',
textAlign: 'center', textAlign: 'center',
color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)', color: isDragOver ? 'var(--color-accent)' : 'var(--color-text-dim)',
fontSize: 11, fontSize: 11,
cursor: assets.length === 0 ? 'pointer' : 'default',
transition: 'all var(--transition-fast)', transition: 'all var(--transition-fast)',
}} }}
> >
Drop files here to upload {assets.length === 0 && (
<i className="fa fa-cloud-upload" aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
)}
{assets.length === 0
? (isMobile ? 'Tap to upload images' : 'Drag images here or click to upload')
: 'Drop files here to upload'}
</div> </div>
{/* Error message */} {/* Error message */}
@@ -143,20 +163,6 @@ export const AssetsPanel: React.FC = () => {
)} )}
{/* Asset grid */} {/* Asset grid */}
{assets.length === 0 && !loading && (
<div
style={{
textAlign: 'center',
padding: 20,
color: 'var(--color-text-dim)',
fontSize: 12,
fontStyle: 'italic',
}}
>
No assets uploaded yet
</div>
)}
<div <div
style={{ style={{
display: 'grid', display: 'grid',
@@ -296,7 +302,7 @@ export const AssetsPanel: React.FC = () => {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
&#10005; <i className="fa fa-times" aria-hidden />
</button> </button>
</div> </div>
) : ( ) : (
@@ -328,7 +334,7 @@ export const AssetsPanel: React.FC = () => {
onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }} onMouseEnter={(e) => { (e.target as HTMLElement).style.opacity = '1'; }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }} onMouseLeave={(e) => { (e.target as HTMLElement).style.opacity = '0.7'; }}
> >
&#10005; <i className="fa fa-times" aria-hidden />
</button> </button>
)} )}
</div> </div>
+104 -20
View File
@@ -1,5 +1,7 @@
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileChrome } from '../../state/MobileChromeContext';
import { Container } from '../../components/layout/Container'; import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section'; import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout'; import { ColumnLayout } from '../../components/layout/ColumnLayout';
@@ -123,7 +125,7 @@ const categories: CategoryDef[] = [
label: 'Media', label: 'Media',
blocks: [ blocks: [
{ id: 'image', label: 'Image', icon: 'fa-image', { id: 'image', label: 'Image', icon: 'fa-image',
component: <ImageBlock src="" alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> }, component: <ImageBlock alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
{ id: 'video', label: 'Video', icon: 'fa-play-circle', { id: 'video', label: 'Video', icon: 'fa-play-circle',
component: <VideoBlock videoUrl="" isBackground={false} /> }, component: <VideoBlock videoUrl="" isBackground={false} /> },
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker', { id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
@@ -146,6 +148,8 @@ const categories: CategoryDef[] = [
export const BlocksPanel: React.FC = () => { export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor(); const { connectors, actions, query } = useEditor();
const isMobile = useIsMobile();
const { closeSheet } = useMobileChrome();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => { const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {}; const initial: Record<string, boolean> = {};
categories.forEach((cat, index) => { categories.forEach((cat, index) => {
@@ -158,6 +162,101 @@ export const BlocksPanel: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] })); setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
}; };
/** Default insertion target when there's no usable selection to anchor
* to: the first real Craft.js canvas in the document (falls back to
* ROOT). Extracted from the pre-existing onDoubleClick handler so
* tap-to-add (mobile, item 3) and double-click (desktop, unchanged) share
* the exact same fallback. */
const findDefaultCanvasId = useCallback((): string => {
try {
const serialized = JSON.parse(query.serialize());
const nodeIds = Object.keys(serialized);
for (const id of nodeIds) {
if (serialized[id].isCanvas && id !== 'ROOT') return id;
}
} catch {
// Fall through to ROOT below.
}
return 'ROOT';
}, [query]);
/**
* Builds a fresh node tree for `block` and inserts it into the canvas,
* returning the new node's id (or null on failure). Shared by desktop's
* double-click (unchanged behavior/position: always appended to
* `findDefaultCanvasId()`) and mobile's tap-to-add (item 3), which instead
* prefers inserting as a sibling right after the current selection --
* `insertAfterSelection: true` only from the mobile tap handler below.
*/
const addBlockNode = useCallback((block: BlockDef, insertAfterSelection: boolean): string | null => {
try {
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
let inserted = false;
if (insertAfterSelection) {
try {
const selectedIds = query.getEvent('selected').all();
const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
if (selectedId && selectedId !== 'ROOT') {
const selectedNode = query.node(selectedId).get();
const parentId = selectedNode?.data?.parent;
if (parentId) {
const parent = query.node(parentId).get();
const siblings: string[] = parent?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) {
actions.addNodeTree(tree, parentId, idx + 1);
inserted = true;
}
}
}
} catch {
// Selection isn't a valid sibling target (e.g. lives in a
// linkedNodes slot) -- fall through to the default canvas below.
}
}
if (!inserted) {
actions.addNodeTree(tree, findDefaultCanvasId());
}
return tree.rootNodeId;
} catch (e) {
console.error('Failed to add block:', e);
return null;
}
}, [query, actions, findDefaultCanvasId]);
/**
* Mobile tap-to-add (item 3): a single tap on a block tile inserts it,
* closes the Blocks sheet, then selects the new node and scrolls it into
* view so the user immediately sees it (and gets the selection toolbar).
* The select/scroll step is deferred two animation frames past the
* `addNodeTree` call -- Craft.js's own state update (and thus the new
* node's real DOM element) lands asynchronously after this handler
* returns, so `query.node(id).get().dom` isn't populated yet if read
* synchronously here.
*/
const handleTapToAdd = useCallback((block: BlockDef) => {
const newId = addBlockNode(block, true);
closeSheet();
if (!newId) return;
requestAnimationFrame(() => {
try {
actions.selectNode(newId);
} catch {
// Node may have failed to mount -- nothing to select.
}
requestAnimationFrame(() => {
try {
query.node(newId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch {
// Best-effort scroll -- not fatal if the node/DOM isn't found.
}
});
});
}, [addBlockNode, closeSheet, actions, query]);
return ( return (
<div> <div>
{categories.map((category) => { {categories.map((category) => {
@@ -178,24 +277,9 @@ export const BlocksPanel: React.FC = () => {
key={block.id} key={block.id}
className="block-item" className="block-item"
ref={(ref) => { if (ref) connectors.create(ref, block.component); }} ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
onDoubleClick={() => { onDoubleClick={() => addBlockNode(block, false)}
try { onClick={isMobile ? () => handleTapToAdd(block) : undefined}
const serialized = JSON.parse(query.serialize()); title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
const nodeIds = Object.keys(serialized);
let canvasId = 'ROOT';
for (const nodeId of nodeIds) {
if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
canvasId = nodeId;
break;
}
}
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
actions.addNodeTree(tree, canvasId);
} catch (e) {
console.error('Failed to add block:', e);
}
}}
title={`Drag or double-click to add ${block.label}`}
> >
<i className={`fa ${block.icon} block-item-icon`} /> <i className={`fa ${block.icon} block-item-icon`} />
<span className="block-item-label">{block.label}</span> <span className="block-item-label">{block.label}</span>
+156 -24
View File
@@ -1,14 +1,74 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { clickableProps } from '../../utils/a11y'; import { clickableProps } from '../../utils/a11y';
/**
* Per-type icon lookup keyed by the component's `craft.displayName` (the
* same human label `LayerNode` already resolves and shows as the row text).
* Mirrors the icon choices in `BlocksPanel.tsx` where a block exists for the
* type; the remaining entries (Navbar, Container, Section, Columns,
* Background Section, Map, Video, Image, form fields, and the richer
* section blocks not draggable from the Basic/Sections categories) get a
* sensible FA4 icon of their own. Unknown/future types fall back to
* `DEFAULT_ICON`.
*/
const TYPE_ICONS: Record<string, string> = {
// Layout
Container: 'fa-square-o',
Section: 'fa-window-maximize',
Columns: 'fa-th-large',
'Background Section': 'fa-picture-o',
// Basic
Heading: 'fa-header',
Text: 'fa-paragraph',
Button: 'fa-square',
Logo: 'fa-bookmark',
Menu: 'fa-bars',
Navbar: 'fa-bars',
Footer: 'fa-window-minimize',
Divider: 'fa-minus',
Spacer: 'fa-arrows-v',
Icon: 'fa-star',
'Star Rating': 'fa-star-half-o',
'Social Links': 'fa-share-alt',
'Search Bar': 'fa-search',
HTML: 'fa-code',
// Media
Image: 'fa-image',
Video: 'fa-play-circle',
Map: 'fa-map-marker',
// Sections
Hero: 'fa-star-o',
'Features Grid': 'fa-th-large',
'CTA Section': 'fa-bullhorn',
'Call to Action': 'fa-bullhorn',
Countdown: 'fa-clock-o',
Testimonials: 'fa-quote-left',
'Content Slider': 'fa-sliders',
'Number Counter': 'fa-sort-numeric-asc',
Accordion: 'fa-list',
Tabs: 'fa-folder-o',
'Pricing Table': 'fa-usd',
Gallery: 'fa-th',
// Forms
Form: 'fa-wpforms',
Input: 'fa-i-cursor',
Textarea: 'fa-align-left',
'Submit Button': 'fa-paper-plane',
'Contact Form': 'fa-envelope',
'Subscribe Form': 'fa-paper-plane',
};
const DEFAULT_ICON = 'fa-cube';
const ROOT_ICON = 'fa-desktop';
interface LayerNodeProps { interface LayerNodeProps {
nodeId: string; nodeId: string;
depth: number; depth: number;
} }
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => { const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const { node, selectedId, actions } = useEditor((state) => { const { node, selectedId, actions, query } = useEditor((state) => {
const n = state.nodes[nodeId]; const n = state.nodes[nodeId];
const selectedIds = state.events.selected; const selectedIds = state.events.selected;
const selId = selectedIds ? Array.from(selectedIds)[0] : null; const selId = selectedIds ? Array.from(selectedIds)[0] : null;
@@ -18,28 +78,77 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
}; };
}); });
const isSelected = selectedId === nodeId;
const handleActivate = useCallback(() => { const handleActivate = useCallback(() => {
actions.selectNode(nodeId); actions.selectNode(nodeId);
}, [actions, nodeId]); }, [actions, nodeId]);
// Layers-panel <-> canvas hover sync (Item 12): tags the target node's own
// DOM element (found via `query`) with `data-layer-hovered` directly.
// `RenderNode.tsx` mirrors Craft's *own* `hovered` node event onto
// `data-craft-hovered` for the (separate, mouse-driven) canvas hover
// outline -- editor.css's rule matches both attributes. This panel can't
// reuse that path: `setNodeEvent` is a private action that `useEditor()`
// strips from its `actions` return at runtime (see
// @craftjs/core/dist/esm's `xe()` action stripper, not just its TypeScript
// type), so calling it here throws. Writing our own attribute directly is
// a supported, version-stable way to get the same highlight.
const hoveredDomRef = useRef<HTMLElement | null>(null);
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
const dom = query.node(nodeId).get()?.dom;
if (dom) {
dom.setAttribute('data-layer-hovered', '');
hoveredDomRef.current = dom;
}
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
}
},
[query, nodeId, isSelected],
);
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
hoveredDomRef.current = null;
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'transparent';
}
},
[isSelected],
);
// Safety net: if this row unmounts (e.g. the user switches away from the
// Layers tab, or the tree re-shuffles) while still hovered, clear the
// attribute so it doesn't get stuck highlighted on the canvas forever.
useEffect(() => {
return () => {
hoveredDomRef.current?.removeAttribute('data-layer-hovered');
hoveredDomRef.current = null;
};
}, []);
if (!node) return null; if (!node) return null;
const isSelected = selectedId === nodeId;
const nodeType = node.data.type;
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
? (nodeType as any).resolvedName
: typeof nodeType === 'string' ? nodeType : undefined;
const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node'; const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node';
const childNodeIds: string[] = node.data.nodes || []; const childNodeIds: string[] = node.data.nodes || [];
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[]; const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
const allChildren = [...childNodeIds, ...linkedNodeIds]; const allChildren = [...childNodeIds, ...linkedNodeIds];
const isRoot = nodeId === 'ROOT'; const isRoot = nodeId === 'ROOT';
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
return ( return (
<div> <div>
<div <div
{...clickableProps(handleActivate)} {...clickableProps(handleActivate)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="layer-node-row"
style={{ style={{
position: 'relative',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '5px 8px', padding: '5px 8px',
@@ -56,30 +165,53 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
userSelect: 'none', userSelect: 'none',
}} }}
onMouseEnter={(e) => { onFocus={handleMouseEnter}
if (!isSelected) { onBlur={handleMouseLeave}
(e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
(e.currentTarget as HTMLElement).style.background = 'transparent';
}
}}
> >
{/* Indentation indicator */} {/* Indent guides: one vertical line per ancestor depth level,
{allChildren.length > 0 && ( aligned under each ancestor's disclosure/icon column so nested
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)' }}> rows read as a tree instead of a flat, ever-further-indented list. */}
{Array.from({ length: depth }).map((_, level) => (
<span
key={level}
aria-hidden="true"
style={{
position: 'absolute',
left: `${8 + level * 16 + 4}px`,
top: 0,
bottom: 0,
width: 1,
background: 'var(--color-border)',
}}
/>
))}
{/* Indentation/disclosure indicator */}
{allChildren.length > 0 ? (
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
&#9660; &#9660;
</span> </span>
)} ) : (
{allChildren.length === 0 && ( <span style={{ marginRight: 4, fontSize: 8, color: 'transparent', flexShrink: 0 }}>
<span style={{ marginRight: 4, fontSize: 8, color: 'transparent' }}>
&#9660; &#9660;
</span> </span>
)} )}
{/* Component type icon and name */} {/* Component type icon */}
<i
className={`fa ${icon}`}
aria-hidden="true"
style={{
marginRight: 6,
fontSize: 11,
width: 12,
textAlign: 'center',
flexShrink: 0,
color: isSelected ? 'var(--color-accent)' : 'var(--color-text-dim)',
}}
/>
{/* Name */}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{isRoot ? 'Canvas (Root)' : displayName} {isRoot ? 'Canvas (Root)' : displayName}
</span> </span>
+47 -59
View File
@@ -58,18 +58,23 @@ export const PagesPanel: React.FC = () => {
.replace(/-+/g, '-'); .replace(/-+/g, '-');
}; };
/* ---------- Zone button style ---------- */ /* ---------- Zone row style ----------
const zoneButtonStyle = (isActive: boolean): React.CSSProperties => ({ * Same neutral surface language as the page-list rows below (Item 11) --
* header/footer are just two more items in the tree, not a louder,
* differently-colored category. The active/editing state reuses the same
* accent-outline treatment the page list already uses for the active page,
* so there's one consistent "this is what's currently open" affordance. */
const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
width: '100%', width: '100%',
padding: '10px 12px', padding: '7px 10px',
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: isActive ? 600 : 500,
color: isActive ? '#f59e0b' : '#fbbf24', color: isActive ? 'var(--color-accent)' : 'var(--color-text-muted)',
background: isActive ? 'rgba(245, 158, 11, 0.15)' : 'rgba(245, 158, 11, 0.06)', background: isActive ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
border: `1px solid ${isActive ? 'rgba(245, 158, 11, 0.5)' : 'rgba(245, 158, 11, 0.2)'}`, border: `1px solid ${isActive ? 'var(--color-accent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
cursor: 'pointer', cursor: 'pointer',
transition: 'all var(--transition-fast)', transition: 'all var(--transition-fast)',
@@ -78,57 +83,35 @@ export const PagesPanel: React.FC = () => {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Header/Footer zone buttons */} {/* Header/Footer zone rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 8 }}>
<button <button
onClick={editHeader} onClick={editHeader}
style={zoneButtonStyle(isEditingHeader)} className="zone-row"
style={zoneRowStyle(isEditingHeader)}
title="Appears on all pages"
> >
<i className="fa fa-window-maximize" style={{ fontSize: 13 }} /> <i className="fa fa-window-maximize" style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }} />
<div style={{ flex: 1 }}> <span style={{ flex: 1 }}>Header</span>
<div>Edit Header</div> {isEditingHeader ? (
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}> <i className="fa fa-check-circle" style={{ fontSize: 11 }} aria-hidden />
Appears on all pages ) : (
</div> <i className="fa fa-pencil zone-row-pencil" style={{ fontSize: 11 }} aria-hidden />
</div>
{isEditingHeader && (
<span style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
background: 'rgba(245, 158, 11, 0.25)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
Editing
</span>
)} )}
</button> </button>
<button <button
onClick={editFooter} onClick={editFooter}
style={zoneButtonStyle(isEditingFooter)} className="zone-row"
style={zoneRowStyle(isEditingFooter)}
title="Appears on all pages"
> >
<i className="fa fa-window-minimize" style={{ fontSize: 13 }} /> <i className="fa fa-window-minimize" style={{ fontSize: 12, width: 14, textAlign: 'center', flexShrink: 0 }} />
<div style={{ flex: 1 }}> <span style={{ flex: 1 }}>Footer</span>
<div>Edit Footer</div> {isEditingFooter ? (
<div style={{ fontSize: 10, opacity: 0.7, fontWeight: 400, marginTop: 1 }}> <i className="fa fa-check-circle" style={{ fontSize: 11 }} aria-hidden />
Appears on all pages ) : (
</div> <i className="fa fa-pencil zone-row-pencil" style={{ fontSize: 11 }} aria-hidden />
</div>
{isEditingFooter && (
<span style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
background: 'rgba(245, 158, 11, 0.25)',
padding: '2px 6px',
borderRadius: 'var(--radius-sm)',
}}>
Editing
</span>
)} )}
</button> </button>
</div> </div>
@@ -328,18 +311,21 @@ export const PagesPanel: React.FC = () => {
title="This is the landing page — published as the root URL (index.html)" title="This is the landing page — published as the root URL (index.html)"
style={{ style={{
fontSize: 9, fontSize: 9,
fontWeight: 700, fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.3px',
letterSpacing: '0.5px', color: 'var(--color-text-muted)',
color: '#fbbf24', background: 'transparent',
background: 'rgba(245, 158, 11, 0.15)', border: '1px solid var(--color-border-light)',
border: '1px solid rgba(245, 158, 11, 0.35)',
padding: '1px 5px', padding: '1px 5px',
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
flexShrink: 0, flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}} }}
> >
<i className="fa fa-home" style={{ marginRight: 3 }} />Landing <i className="fa fa-home" aria-hidden="true" style={{ fontSize: 8, color: 'inherit' }} />
Landing
</span> </span>
)} )}
</div> </div>
@@ -359,7 +345,8 @@ export const PagesPanel: React.FC = () => {
> >
<button <button
onClick={() => startEditing(page)} onClick={() => startEditing(page)}
title="Rename" data-tooltip="Rename"
aria-label={`Rename ${page.name}`}
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
@@ -374,12 +361,13 @@ export const PagesPanel: React.FC = () => {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
&#9998; <i className="fa fa-pencil" aria-hidden="true" />
</button> </button>
{pages.length > 1 && !isLanding && ( {pages.length > 1 && !isLanding && (
<button <button
onClick={() => setDeleteConfirmId(page.id)} onClick={() => setDeleteConfirmId(page.id)}
title="Delete" data-tooltip="Delete"
aria-label={`Delete ${page.name}`}
style={{ style={{
width: 24, width: 24,
height: 24, height: 24,
@@ -394,7 +382,7 @@ export const PagesPanel: React.FC = () => {
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
&#10005; <i className="fa fa-trash" aria-hidden="true" />
</button> </button>
)} )}
</div> </div>
+184
View File
@@ -0,0 +1,184 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useVisualViewportInsets } from '../../hooks/useVisualViewport';
export interface BottomSheetProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
/** Vertical drag distance (px) past which releasing the handle/header
* commits to closing the sheet, rather than snapping back open. */
const SWIPE_DISMISS_THRESHOLD_PX = 80;
/**
* Mobile-only bottom sheet (Phase A). Slides up from the bottom of the
* viewport to host one of the existing side-panel components
* (BlocksPanel/PagesPanel/LayersPanel/AssetsPanel/GuidedStyles) unchanged,
* as the mobile replacement for the desktop fixed left/right panel columns.
* See `MobilePanelBar`, which owns which sheet (if any) is open.
*
* Deliberately NOT a generic replacement for `src/ui/Modal.tsx` (centered
* dialog chrome) -- this is anchored to the bottom, sized to ~65dvh, and
* has its own drag-handle affordance + internally scrollable body, all of
* which Modal doesn't need for its centered use cases.
*
* Phase B adds two more deferred behaviors on top of Phase A's static sheet:
* - **Swipe-to-dismiss**: a touchstart/move/end drag tracked on the handle
* row + header (not the scrollable body, so it never fights a panel's own
* vertical scroll) that follows the finger while dragging down and either
* commits to `onClose()` past `SWIPE_DISMISS_THRESHOLD_PX` or snaps back
* with a short transition otherwise.
* - **On-screen-keyboard clearance**: `useVisualViewportInsets` reports how
* much the visual viewport has shrunk from the bottom (i.e. the
* keyboard's height); that's surfaced as a `--keyboard-inset` CSS custom
* property (see editor.css) which both lifts the whole backdrop/sheet
* clear of the keyboard AND caps the sheet's own max-height so it never
* extends into the space the keyboard occupies. A `focusin` listener also
* scrolls the newly-focused input into view once the keyboard has had a
* moment to animate in, as a second line of defense for a field that's
* still off-screen after the resize alone (e.g. deep in a long panel).
*/
export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title, children }) => {
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
const { keyboardInset } = useVisualViewportInsets();
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const bodyEl = bodyRef.current;
if (!bodyEl) return;
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable) {
// Give the on-screen keyboard a moment to finish animating in
// before scrolling -- doing it immediately measures the pre-keyboard
// layout and can undershoot.
window.setTimeout(() => {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 300);
}
};
bodyEl.addEventListener('focusin', handleFocusIn);
return () => bodyEl.removeEventListener('focusin', handleFocusIn);
}, [open]);
// Swipe-to-dismiss: tracked only on the handle row + header (never the
// scrollable `.mobile-sheet-body`, which needs its own vertical touch
// scrolling to keep working unimpeded).
//
// The authoritative drag distance lives in a REF (`dragOffsetRef`),
// updated synchronously and imperatively on every touchmove -- NOT solely
// in the `dragOffset` state used for rendering the drag transform. Two
// touchmoves can fire back-to-back inside the same synchronous event
// dispatch (e.g. a fast real swipe, or synthetic touch events fired
// without a yield between them), and React batches their `setDragOffset`
// calls into a single pending update that hasn't committed yet by the
// time `touchend` runs in that same tick -- reading the `dragOffset`
// STATE value directly in `handleTouchEnd` would then see a stale
// (pre-drag) value and wrongly decide the swipe didn't clear the
// threshold. The ref sidesteps that entirely (plain synchronous mutation,
// no batching). Relatedly, `onClose()` mutates an ANCESTOR component's
// state (MobileChromeContext's `closeSheet`) -- it must be called as a
// plain statement in the event handler, never from inside a `setState`
// functional updater (that runs during this component's own render phase
// and trips React's "Cannot update a component while rendering a
// different component" warning).
const dragStartYRef = useRef<number | null>(null);
const dragOffsetRef = useRef(0);
const [dragOffset, setDragOffset] = useState(0);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (e.touches.length !== 1) return;
dragStartYRef.current = e.touches[0].clientY;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (dragStartYRef.current === null) return;
const delta = e.touches[0].clientY - dragStartYRef.current;
// Only follow downward drags -- dragging up shouldn't do anything (the
// sheet is already fully open; there's no "expand further" state).
const next = Math.max(0, delta);
dragOffsetRef.current = next;
setDragOffset(next);
}, []);
const handleTouchEnd = useCallback(() => {
dragStartYRef.current = null;
const finalOffset = dragOffsetRef.current;
dragOffsetRef.current = 0;
setDragOffset(0);
if (finalOffset > SWIPE_DISMISS_THRESHOLD_PX) onClose();
}, [onClose]);
if (!open) return null;
return (
<div
className="mobile-sheet-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{ ['--keyboard-inset' as any]: `${keyboardInset}px` }}
>
{/* Deliberately NOT aria-modal: the tab bar stays reachable/operable
while a sheet is open (tapping another tab swaps sheets directly),
so the rest of the screen must not be marked inert to assistive
tech the way a true modal dialog would be. */}
<div
className="mobile-sheet"
role="dialog"
aria-label={title}
style={{
transform: dragOffset ? `translateY(${dragOffset}px)` : undefined,
transition: dragOffset ? 'none' : 'transform 0.2s ease',
}}
>
<div
className="mobile-sheet-handle-row"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<span className="mobile-sheet-handle" aria-hidden="true" />
</div>
<div
className="mobile-sheet-header"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<span className="mobile-sheet-title">{title}</span>
<button
type="button"
className="mobile-sheet-close"
onClick={onClose}
aria-label={`Close ${title}`}
>
<i className="fa fa-times" aria-hidden="true" />
</button>
</div>
<div className="mobile-sheet-body" ref={bodyRef}>{children}</div>
</div>
</div>
);
};
@@ -0,0 +1,145 @@
import { describe, test, expect, vi } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { MobilePanelBar } from './MobilePanelBar';
import { MobileChromeProvider } from '../../state/MobileChromeContext';
/**
* Mobile-A2 (review item 5): the real BlocksPanel/PagesPanel/LayersPanel/
* AssetsPanel/GuidedStyles all pull in Craft.js's `useEditor` and other
* heavy dependencies not relevant here, so they're stubbed out -- this
* suite is only exercising MobilePanelBar's OWN wiring: tapping a tab opens
* that tab's sheet with only its panel rendered, tapping the active tab
* again closes it, and (mirroring MobileChromeContext.test.tsx) only one
* sheet's content is ever mounted at once.
*/
vi.mock('../left/BlocksPanel', () => ({ BlocksPanel: () => <div data-testid="panel-blocks" /> }));
vi.mock('../left/PagesPanel', () => ({ PagesPanel: () => <div data-testid="panel-pages" /> }));
vi.mock('../left/LayersPanel', () => ({ LayersPanel: () => <div data-testid="panel-layers" /> }));
vi.mock('../left/AssetsPanel', () => ({ AssetsPanel: () => <div data-testid="panel-assets" /> }));
vi.mock('../right/GuidedStyles', () => ({ GuidedStyles: () => <div data-testid="panel-styles" /> }));
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 tabButton(label: string): HTMLButtonElement {
const buttons = Array.from(container.querySelectorAll('.mobile-tab-btn')) as HTMLButtonElement[];
const btn = buttons.find((b) => b.textContent?.includes(label));
if (!btn) throw new Error(`No tab button found for "${label}"`);
return btn;
}
function openPanelIds(): string[] {
return Array.from(container.querySelectorAll('[data-testid^="panel-"]')).map(
(el) => (el as HTMLElement).dataset.testid!,
);
}
describe('MobilePanelBar', () => {
test('no sheet is open initially', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
expect(container.querySelector('.mobile-sheet-backdrop')).toBeNull();
expect(openPanelIds()).toEqual([]);
unmount();
});
test('tapping a tab opens exactly that tab\'s panel', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Blocks').click();
});
expect(openPanelIds()).toEqual(['panel-blocks']);
unmount();
});
test('switching tabs replaces the open sheet -- only one panel mounted at a time', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Blocks').click();
});
expect(openPanelIds()).toEqual(['panel-blocks']);
act(() => {
tabButton('Styles').click();
});
expect(openPanelIds()).toEqual(['panel-styles']);
act(() => {
tabButton('Assets').click();
});
expect(openPanelIds()).toEqual(['panel-assets']);
unmount();
});
test('tapping the active tab again closes the sheet', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Pages').click();
});
expect(openPanelIds()).toEqual(['panel-pages']);
expect(container.querySelector('.mobile-sheet-backdrop')).not.toBeNull();
act(() => {
tabButton('Pages').click();
});
expect(openPanelIds()).toEqual([]);
expect(container.querySelector('.mobile-sheet-backdrop')).toBeNull();
unmount();
});
test('active tab button carries aria-pressed=true only for the open sheet', () => {
render(
<MobileChromeProvider>
<MobilePanelBar />
</MobileChromeProvider>,
);
act(() => {
tabButton('Layers').click();
});
expect(tabButton('Layers').getAttribute('aria-pressed')).toBe('true');
expect(tabButton('Blocks').getAttribute('aria-pressed')).toBe('false');
unmount();
});
});
@@ -0,0 +1,74 @@
import React from 'react';
import { BottomSheet } from './BottomSheet';
import { BlocksPanel } from '../left/BlocksPanel';
import { PagesPanel } from '../left/PagesPanel';
import { LayersPanel } from '../left/LayersPanel';
import { AssetsPanel } from '../left/AssetsPanel';
import { GuidedStyles } from '../right/GuidedStyles';
import { useMobileChrome, MobileSheetTab } from '../../state/MobileChromeContext';
const TABS: { id: MobileSheetTab; label: string; icon: string }[] = [
{ id: 'blocks', label: 'Blocks', icon: 'fa-cube' },
{ id: 'pages', label: 'Pages', icon: 'fa-file-o' },
{ id: 'layers', label: 'Layers', icon: 'fa-sitemap' },
{ id: 'assets', label: 'Assets', icon: 'fa-image' },
{ id: 'styles', label: 'Styles', icon: 'fa-paint-brush' },
];
/**
* Mobile replacement for the desktop LeftPanel/RightPanel column layout
* (Phase A). A fixed bottom tab bar (Blocks/Pages/Layers/Assets/Styles)
* toggles a single `BottomSheet` at a time, hosting the SAME panel
* component the desktop side panels use -- unchanged, so behavior/state
* stays identical, only the chrome around it differs. Tapping the active
* tab again, tapping the backdrop, the sheet's close button, or Escape all
* close the open sheet.
*
* "Styles" hosts `GuidedStyles`, which already falls back to the
* site-design panel when nothing is selected and switches to the
* per-type style panel once something is -- this is the natural place to
* edit a tapped element's styling (Phase B deepens the tap-to-select flow;
* this component only wires up the sheet, per the Phase A brief).
*
* Sheet-open state itself lives in `MobileChromeContext` (Mobile-A2), not
* a private `useState`, so Phase B can open/close a sheet from elsewhere
* (e.g. a canvas selection toolbar). The toggle-to-close-on-repeat-tap
* behavior is local to this click handler -- `openSheet` always opens.
*/
export const MobilePanelBar: React.FC = () => {
const { activeSheet, openSheet, closeSheet } = useMobileChrome();
const handleTabClick = (tab: MobileSheetTab) => {
if (activeSheet === tab) closeSheet();
else openSheet(tab);
};
const activeLabel = TABS.find((t) => t.id === activeSheet)?.label ?? '';
return (
<>
<BottomSheet open={activeSheet !== null} onClose={closeSheet} title={activeLabel}>
{activeSheet === 'blocks' && <BlocksPanel />}
{activeSheet === 'pages' && <PagesPanel />}
{activeSheet === 'layers' && <LayersPanel />}
{activeSheet === 'assets' && <AssetsPanel />}
{activeSheet === 'styles' && <GuidedStyles />}
</BottomSheet>
<nav className="mobile-tab-bar" aria-label="Editor panels">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`mobile-tab-btn${activeSheet === tab.id ? ' active' : ''}`}
onClick={() => handleTabClick(tab.id)}
aria-pressed={activeSheet === tab.id}
>
<i className={`fa ${tab.icon}`} aria-hidden="true" />
<span>{tab.label}</span>
</button>
))}
</nav>
</>
);
};
@@ -0,0 +1,213 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useNodeActions } from '../../hooks/useNodeActions';
import { useMobileChrome } from '../../state/MobileChromeContext';
/**
* Phase B mobile selection toolbar -- the on-canvas action bar that makes a
* tap-selected node actually EDITABLE by touch (reorder/duplicate/delete/
* select-parent/edit-styles), mirroring the desktop right-click ContextMenu.
* Both share the exact same behavior via `useNodeActions` (item 1).
*
* Mounted ONCE (in `EditorShell`'s mobile branch), reading the current
* selection from `useEditor` directly -- NOT per-node -- and positioned
* bottom-fixed just above the tab bar (simplest, robust; a node-anchored
* floating toolbar would have to constantly reposition as the canvas
* scrolls, and risks being clipped at the viewport edge for a node near the
* top or bottom of a tall page). This is a deliberate design call: see the
* report for the alternative (floating over the node) that was passed over.
*
* Hidden whenever a bottom sheet is open (`activeSheet !== null`) -- the
* sheet supersedes it, and the two must never visually stack.
*
* Delete is a two-tap in-app confirm (tap once -> the button becomes
* "Confirm?" for a few seconds; tap again within that window commits the
* delete; selecting something else cancels it) rather than a native
* `confirm()`, matching the project's no-native-dialogs rule.
*/
const DELETE_CONFIRM_TIMEOUT_MS = 3000;
export const MobileSelectionToolbar: React.FC = () => {
const { activeSheet, openSheet } = useMobileChrome();
const { selectedId } = useEditor((state) => {
const selected = state.events.selected;
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
return { selectedId: id && id !== 'ROOT' ? id : null };
});
// Collector-free -- an always-live reference (same pattern as
// GuidedStyles.tsx/BlocksPanel.tsx), used only for imperative DOM lookups
// below (scroll-into-view), never for anything that needs to react to
// store changes on its own.
const { query } = useEditor();
const nodeActions = useNodeActions(selectedId);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const confirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// `useNodeActions` computes canMoveUp/canMoveDown/canDelete synchronously
// from a live (never-cached) Craft.js query -- see that hook's doc comment
// for why it deliberately does NOT hold its own store subscription to force
// a refresh. Moving/duplicating/deleting via THIS toolbar changes the
// selected node's position/siblings WITHOUT changing `selectedId` itself
// (the node stays selected), so nothing else would otherwise trigger a
// re-render of this component to pick up the new boundary flags after,
// say, tapping "Move Up" until the node reaches the top. Bumping a plain
// local tick from these buttons' own click handlers -- an ordinary,
// locally-owned re-render -- covers exactly that, without the broader
// store-wide subscription that was found to trip React's "setState while
// rendering a different component" warning whenever some unrelated
// container mounts (see useNodeActions.ts).
const [, forceRefresh] = useState(0);
const bumpTick = useCallback(() => forceRefresh((n) => n + 1), []);
const clearConfirmTimeout = useCallback(() => {
if (confirmTimeoutRef.current) {
clearTimeout(confirmTimeoutRef.current);
confirmTimeoutRef.current = null;
}
}, []);
// Reset the two-tap delete confirmation whenever the selection changes
// (including being cleared) so a stale "Confirm?" state never lingers onto
// a different node.
const prevSelectedRef = useRef(selectedId);
useEffect(() => {
if (prevSelectedRef.current !== selectedId) {
prevSelectedRef.current = selectedId;
clearConfirmTimeout();
setConfirmingDelete(false);
}
}, [selectedId, clearConfirmTimeout]);
useEffect(() => clearConfirmTimeout, [clearConfirmTimeout]);
// Fast-follow item 3: the Styles bottom sheet covers the bottom ~65dvh of
// the canvas, so a selected node sitting anywhere in that band goes
// invisible the instant the sheet opens -- live style edits then have no
// visible effect until the user closes the sheet to check. Whenever the
// sheet becomes 'styles' (however it got opened -- currently only this
// toolbar's own "Style" button, but this is keyed on the sheet/selection
// state rather than the button tap so it stays correct if another entry
// point is added later) scroll the selected node to the top of the
// canvas's scroll area, into the sliver of screen the sheet leaves clear.
useEffect(() => {
if (activeSheet !== 'styles' || !selectedId) return;
try {
query.node(selectedId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch {
// Best-effort -- not fatal if the node/DOM isn't found.
}
}, [activeSheet, selectedId, query]);
const handleDeleteTap = useCallback(() => {
if (confirmingDelete) {
clearConfirmTimeout();
setConfirmingDelete(false);
nodeActions.deleteNode();
bumpTick();
return;
}
setConfirmingDelete(true);
clearConfirmTimeout();
confirmTimeoutRef.current = setTimeout(() => setConfirmingDelete(false), DELETE_CONFIRM_TIMEOUT_MS);
}, [confirmingDelete, clearConfirmTimeout, nodeActions, bumpTick]);
const handleEditStyles = useCallback(() => {
openSheet('styles');
}, [openSheet]);
const handleMoveUp = useCallback(() => {
nodeActions.moveUp();
bumpTick();
}, [nodeActions, bumpTick]);
const handleMoveDown = useCallback(() => {
nodeActions.moveDown();
bumpTick();
}, [nodeActions, bumpTick]);
const handleDuplicate = useCallback(() => {
const newId = nodeActions.duplicate();
bumpTick();
if (!newId) return;
// Mirror BlocksPanel's tap-to-add pattern: the new node's real DOM
// element isn't attached yet the instant `duplicate()` returns (Craft.js
// mounts it asynchronously after this handler's synchronous state
// update), so defer two animation frames before reading `.dom` off the
// query, same as `handleTapToAdd` does.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
query.node(newId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch {
// Best-effort scroll -- not fatal if the node/DOM isn't found.
}
});
});
}, [nodeActions, bumpTick, query]);
if (!selectedId || activeSheet !== null) return null;
return (
<div className="mobile-selection-toolbar" role="toolbar" aria-label="Selected element actions">
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleMoveUp}
disabled={!nodeActions.canMoveUp}
aria-label="Move up"
>
<i className="fa fa-arrow-up" aria-hidden="true" />
<span>Up</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleMoveDown}
disabled={!nodeActions.canMoveDown}
aria-label="Move down"
>
<i className="fa fa-arrow-down" aria-hidden="true" />
<span>Down</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleDuplicate}
aria-label="Duplicate"
>
<i className="fa fa-clone" aria-hidden="true" />
<span>Copy</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={nodeActions.selectParent}
disabled={!nodeActions.canSelectParent}
aria-label="Select parent"
>
<i className="fa fa-level-up" aria-hidden="true" />
<span>Parent</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleEditStyles}
aria-label="Edit styles"
>
<i className="fa fa-paint-brush" aria-hidden="true" />
<span>Style</span>
</button>
<button
type="button"
className={`mobile-selection-toolbar-btn danger${confirmingDelete ? ' confirming' : ''}`}
onClick={handleDeleteTap}
disabled={!nodeActions.canDelete}
aria-label={confirmingDelete ? 'Tap again to confirm delete' : 'Delete'}
>
<i className="fa fa-trash" aria-hidden="true" />
<span>{confirmingDelete ? 'Confirm?' : 'Delete'}</span>
</button>
</div>
);
};
+3 -11
View File
@@ -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);

Some files were not shown because too many files have changed in this diff Show More