AiAssistant.tsx
2 days ago
App.tsx
2 days ago
BuilderSync.ts
2 days ago
ControlRenderer.tsx
2 days ago
HoverTip.tsx
2 days ago
Popover.tsx
2 days ago
PreviewBridge.ts
2 days ago
PreviewPane.tsx
2 days ago
constants.ts
2 days ago
cssVars.ts
2 days ago
globals.d.ts
2 days ago
index.tsx
2 days ago
panes.tsx
2 days ago
store.ts
2 days ago
style.scss
2 days ago
types.ts
2 days ago
constants.ts
239 lines
| 1 | /** |
| 2 | * Style Customizer v2 — static UI maps. |
| 3 | * |
| 4 | * Section icons, human labels for state/variant ids, click-to-edit routing, device metadata. |
| 5 | * The token/section DATA all comes from the server; these are pure presentation. |
| 6 | */ |
| 7 | import { Device } from './types'; |
| 8 | |
| 9 | const __ = ( window as any ).wp?.i18n?.__ || ( ( s: string ) => s ); |
| 10 | |
| 11 | export const clone = < T >( v: T ): T => |
| 12 | typeof structuredClone === 'function' ? structuredClone( v ) : JSON.parse( JSON.stringify( v ) ); |
| 13 | |
| 14 | /** |
| 15 | * Structural value-equality for token values / device bags — order-independent for object keys |
| 16 | * (so it doesn't depend on insertion order the way a JSON.stringify compare would). Used to test |
| 17 | * whether a form's current styles EXACTLY equal a given template's (see store.appliedTemplateId). |
| 18 | */ |
| 19 | export function deepEqual( a: unknown, b: unknown ): boolean { |
| 20 | if ( a === b ) { |
| 21 | return true; |
| 22 | } |
| 23 | if ( a === null || b === null || typeof a !== 'object' || typeof b !== 'object' ) { |
| 24 | return false; |
| 25 | } |
| 26 | const aArr = Array.isArray( a ); |
| 27 | if ( aArr !== Array.isArray( b ) ) { |
| 28 | return false; |
| 29 | } |
| 30 | const ak = Object.keys( a as Record< string, unknown > ); |
| 31 | const bk = Object.keys( b as Record< string, unknown > ); |
| 32 | if ( ak.length !== bk.length ) { |
| 33 | return false; |
| 34 | } |
| 35 | return ak.every( |
| 36 | ( k ) => |
| 37 | Object.prototype.hasOwnProperty.call( b, k ) && |
| 38 | deepEqual( ( a as Record< string, unknown > )[ k ], ( b as Record< string, unknown > )[ k ] ) |
| 39 | ); |
| 40 | } |
| 41 | |
| 42 | /** Section icon inner-SVG (rendered inside a 24×24 stroke viewBox). */ |
| 43 | export const SECTION_ICONS: Record<string, string> = { |
| 44 | form: '<rect x="3" y="3" width="18" height="18" rx="3"/>', |
| 45 | text: '<path d="M4 7V5h16v2M9 5v14M9 19h6"/>', |
| 46 | fields: '<rect x="3" y="5" width="18" height="6" rx="2"/><rect x="3" y="14" width="12" height="6" rx="2"/>', |
| 47 | choice: '<circle cx="7" cy="12" r="3"/><rect x="14" y="9" width="6" height="6" rx="1.5"/>', |
| 48 | button: '<rect x="3" y="8" width="18" height="9" rx="4"/>', |
| 49 | messages: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>', |
| 50 | pagination: '<circle cx="5" cy="12" r="2.5"/><circle cx="12" cy="12" r="2.5"/><circle cx="19" cy="12" r="2.5"/><path d="M7.5 12h2M14.5 12h2"/>', |
| 51 | }; |
| 52 | |
| 53 | /** One-line sub-label shown under each element row. */ |
| 54 | export const SECTION_SUBTITLES: Record<string, string> = { |
| 55 | form: __( 'Background, corners, font, width', 'everest-forms' ), |
| 56 | text: __( 'Labels, sublabels, descriptions, section titles', 'everest-forms' ), |
| 57 | fields: __( 'Text boxes, dropdowns, upload area', 'everest-forms' ), |
| 58 | choice: __( 'Radio, checkbox, ratings & more', 'everest-forms' ), |
| 59 | button: __( 'Submit & navigation buttons', 'everest-forms' ), |
| 60 | messages: __( 'Success, error & validation banners', 'everest-forms' ), |
| 61 | pagination: __( 'Progress indicator & part navigation', 'everest-forms' ), |
| 62 | }; |
| 63 | |
| 64 | /** Human labels for the variant/state ids the schema ships. */ |
| 65 | export const STATE_LABELS: Record<string, string> = { |
| 66 | label: 'Label', |
| 67 | sublabel: 'Sublabel', |
| 68 | desc: 'Description', |
| 69 | title: 'Section Title', |
| 70 | normal: 'Normal', |
| 71 | focus: 'Focus', |
| 72 | hover: 'Hover', |
| 73 | success: 'Success', |
| 74 | error: 'Error', |
| 75 | validation: 'Validation', |
| 76 | }; |
| 77 | |
| 78 | /** Preview force-classes the bridge toggles on the iframe wrapper for non-"normal" states. */ |
| 79 | export const STATE_FORCE: Record<string, string> = { |
| 80 | focus: 'force-focus', |
| 81 | hover: 'force-hover', |
| 82 | success: 'force-msg-success', |
| 83 | error: 'force-msg-error', |
| 84 | validation: 'force-msg-validation', |
| 85 | }; |
| 86 | |
| 87 | export const ALL_FORCE_CLASSES = [ |
| 88 | 'force-focus', |
| 89 | 'force-hover', |
| 90 | 'force-msg-success', |
| 91 | 'force-msg-error', |
| 92 | 'force-msg-validation', |
| 93 | ]; |
| 94 | |
| 95 | export const DEVICE_LABELS: Record<Device, string> = { |
| 96 | desktop: 'Desktop', |
| 97 | tablet: 'Tablet', |
| 98 | mobile: 'Mobile', |
| 99 | }; |
| 100 | |
| 101 | /** Preview frame widths per device. Tablet/mobile widths sit just below the breakpoints. */ |
| 102 | export const DEVICE_WIDTHS: Record<Device, number> = { |
| 103 | desktop: 760, |
| 104 | tablet: 760, |
| 105 | mobile: 390, |
| 106 | }; |
| 107 | |
| 108 | export const DEVICE_ICONS: Record<Device, string> = { |
| 109 | desktop: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/>', |
| 110 | tablet: '<rect x="6" y="3" width="12" height="18" rx="2"/>', |
| 111 | mobile: '<rect x="8" y="3" width="8" height="18" rx="2"/>', |
| 112 | }; |
| 113 | |
| 114 | export const ALIGN_ICONS: Record<string, string> = { |
| 115 | left: '<path d="M4 6h16M4 12h10M4 18h13"/>', |
| 116 | center: '<path d="M4 6h16M7 12h10M5 18h14"/>', |
| 117 | right: '<path d="M4 6h16M10 12h10M7 18h13"/>', |
| 118 | }; |
| 119 | |
| 120 | /** Font-style buttons: [flag, glyph-html, title]. */ |
| 121 | export const FONTSTYLE_BUTTONS: Array<[ keyof import('./types').FontStyleValue, string, string ]> = [ |
| 122 | [ 'bold', '<b>B</b>', 'Bold' ], |
| 123 | [ 'italic', '<i>I</i>', 'Italic' ], |
| 124 | [ 'underline', '<u>U</u>', 'Underline' ], |
| 125 | [ 'uppercase', 'TT', 'Uppercase' ], |
| 126 | ]; |
| 127 | |
| 128 | /** Click-to-edit resolver, ordered most-specific → least-specific; the bridge walks up from a |
| 129 | * clicked element to the first matching target. */ |
| 130 | export interface PreviewTarget { |
| 131 | match: string; |
| 132 | section: string; |
| 133 | variant?: string; |
| 134 | label: string; |
| 135 | } |
| 136 | |
| 137 | export const PREVIEW_TARGETS: PreviewTarget[] = [ |
| 138 | { |
| 139 | match: |
| 140 | '.evf-submit-container button, .evf-submit-container input[type="submit"], .everest-forms-multi-part-actions button, .everest-forms-multi-part-actions input[type="submit"], .everest-forms-part-button, .everest-forms-submit-button', |
| 141 | section: 'button', |
| 142 | label: 'Button', |
| 143 | }, |
| 144 | { |
| 145 | match: '.everest-forms-multi-part-indicator', |
| 146 | section: 'pagination', |
| 147 | label: 'Multi step', |
| 148 | }, |
| 149 | { |
| 150 | match: '.everest-forms-notice--success', |
| 151 | section: 'messages', |
| 152 | variant: 'success', |
| 153 | label: 'Success message', |
| 154 | }, |
| 155 | { |
| 156 | match: '.everest-forms-notice--error', |
| 157 | section: 'messages', |
| 158 | variant: 'error', |
| 159 | label: 'Error message', |
| 160 | }, |
| 161 | { |
| 162 | match: '.evf-error', |
| 163 | section: 'messages', |
| 164 | variant: 'validation', |
| 165 | label: 'Validation message', |
| 166 | }, |
| 167 | { match: '.evf-field-title, .evf-field-title h3', section: 'text', variant: 'title', label: 'Section title' }, |
| 168 | { match: '.evf-field-description', section: 'text', variant: 'desc', label: 'Description' }, |
| 169 | { match: 'label.everest-forms-field-sublabel', section: 'text', variant: 'sublabel', label: 'Sublabel' }, |
| 170 | { match: 'label.evf-field-label', section: 'text', variant: 'label', label: 'Label' }, |
| 171 | { match: '.everest-forms-uploader', section: 'fields', label: 'Upload area' }, |
| 172 | { |
| 173 | // Choice-type fields: radio, checkbox, image-choice, likert, ratings, payment pickers, yes/no, privacy policy, subscription plan. |
| 174 | match: |
| 175 | '.evf-field-radio, .evf-field-checkbox, .evf-field-payment-multiple, .evf-field-payment-checkbox, .evf-field-privacy-policy, .evf-field-image-choice, .evf-field-likert, .evf-field-rating, .evf-field-scale-rating, .evf-field-payment-single, .evf-field-yes-no, .evf-field-payment-subscription-plan', |
| 176 | section: 'choice', |
| 177 | label: 'Choice field', |
| 178 | }, |
| 179 | { |
| 180 | match: |
| 181 | 'input[type="text"], input[type="email"], input[type="url"], input[type="tel"], input[type="number"], input[type="password"], input[type="date"], input[type="time"], input[type="datetime-local"], textarea, select, .StripeElement, canvas.evf-signature-canvas', |
| 182 | section: 'fields', |
| 183 | label: 'Input field', |
| 184 | }, |
| 185 | { match: '.evf-field, .evf-frontend-row', section: 'fields', label: 'Field' }, |
| 186 | { match: '.evf-container', section: 'form', label: 'Form container' }, |
| 187 | ]; |
| 188 | |
| 189 | /** |
| 190 | * Best-effort #rrggbb for any CSS colour a "color" token might hold. Token values are usually |
| 191 | * plain hex, but {@see Sanitizer::sanitize_color()} also accepts (and a couple of schema defaults, |
| 192 | * e.g. `file.bg`, actually use) `rgba()` — kept for legacy v1 alpha borders/backgrounds. Neither the |
| 193 | * native `<input type="color">` swatch nor a hex textbox understands rgba, so callers use this to |
| 194 | * get something displayable instead of silently rendering black; alpha is dropped since there's no |
| 195 | * alpha control here. Returns null only for a genuinely unparseable value (a CSS keyword/var). |
| 196 | */ |
| 197 | export function toDisplayHex( value: string ): string | null { |
| 198 | const v = String( value ).trim(); |
| 199 | if ( /^#[0-9a-f]{3}$/i.test( v ) ) { |
| 200 | return ( '#' + v.slice( 1 ).split( '' ).map( ( c ) => c + c ).join( '' ) ).toLowerCase(); |
| 201 | } |
| 202 | if ( /^#[0-9a-f]{6}$/i.test( v ) ) { |
| 203 | return v.toLowerCase(); |
| 204 | } |
| 205 | if ( /^#[0-9a-f]{8}$/i.test( v ) ) { |
| 206 | return v.slice( 0, 7 ).toLowerCase(); |
| 207 | } |
| 208 | const m = v.match( /^rgba?\(\s*([\d.]+%?)\s*[,\s]+\s*([\d.]+%?)\s*[,\s]+\s*([\d.]+%?)/i ); |
| 209 | if ( m ) { |
| 210 | const hex = m.slice( 1, 4 ).map( ( raw ) => { |
| 211 | const num = parseFloat( raw ); |
| 212 | const c = Math.max( 0, Math.min( 255, Math.round( raw.endsWith( '%' ) ? num * 2.55 : num ) ) ); |
| 213 | return c.toString( 16 ).padStart( 2, '0' ); |
| 214 | } ); |
| 215 | return '#' + hex.join( '' ); |
| 216 | } |
| 217 | return null; |
| 218 | } |
| 219 | |
| 220 | /** Mix two hex colours by ratio t (0..1). Used to derive the button hover from a palette. */ |
| 221 | export function mixHex( a: string, b: string, t: number ): string { |
| 222 | const parse = ( h: string ): number[] => { |
| 223 | let s = String( h ).replace( '#', '' ); |
| 224 | if ( s.length === 3 ) { |
| 225 | s = s |
| 226 | .split( '' ) |
| 227 | .map( ( c ) => c + c ) |
| 228 | .join( '' ); |
| 229 | } |
| 230 | return [ parseInt( s.slice( 0, 2 ), 16 ), parseInt( s.slice( 2, 4 ), 16 ), parseInt( s.slice( 4, 6 ), 16 ) ]; |
| 231 | }; |
| 232 | const A = parse( a ); |
| 233 | const B = parse( b ); |
| 234 | return ( |
| 235 | '#' + |
| 236 | A.map( ( x, i ) => Math.round( x + ( B[ i ] - x ) * t ).toString( 16 ).padStart( 2, '0' ) ).join( '' ) |
| 237 | ); |
| 238 | } |
| 239 |