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
store.ts
817 lines
| 1 | /** |
| 2 | * Style Customizer v2 — state store. A tiny external store shared by React (via |
| 3 | * useSyncExternalStore) and the preview bridge; every mutation goes through a guarded method |
| 4 | * so the preview + dirty state stay in sync. `affected` tells the bridge which keys changed |
| 5 | * (`null` = re-apply everything). |
| 6 | */ |
| 7 | import { useSyncExternalStore } from 'react'; |
| 8 | import { clone, deepEqual, mixHex } from './constants'; |
| 9 | import { |
| 10 | BootstrapSettings, |
| 11 | Device, |
| 12 | DeviceBag, |
| 13 | MigrationInfo, |
| 14 | ScalarValue, |
| 15 | StylePayload, |
| 16 | StyleRecord, |
| 17 | Token, |
| 18 | } from './types'; |
| 19 | |
| 20 | interface Snapshot { |
| 21 | tokens: Record< string, DeviceBag >; |
| 22 | palette: string; |
| 23 | customCss: string; |
| 24 | template: string; |
| 25 | applyThemeStyle: boolean; |
| 26 | } |
| 27 | |
| 28 | interface HistoryEntry { |
| 29 | label: string; |
| 30 | snap: Snapshot; |
| 31 | } |
| 32 | |
| 33 | const MAX_HISTORY = 60; |
| 34 | |
| 35 | class StyleStore { |
| 36 | // Static config (from the REST payload) — never mutated. |
| 37 | settings: BootstrapSettings; |
| 38 | schema: Token[]; |
| 39 | byKey: Record< string, Token > = {}; |
| 40 | sections: StylePayload[ 'sections' ]; |
| 41 | palettes: StylePayload[ 'palettes' ]; |
| 42 | templates: StylePayload[ 'templates' ]; |
| 43 | userTemplates: StylePayload[ 'templates' ]; |
| 44 | paletteMap: Record< string, string[] >; |
| 45 | breakpoints: Record< string, number >; |
| 46 | schemaVersion: number; |
| 47 | proActive: boolean; |
| 48 | googleFonts: string[]; |
| 49 | /** Drives the migration notice; never mutated post-init. */ |
| 50 | migration: MigrationInfo; |
| 51 | |
| 52 | // Editable state. |
| 53 | tokens: Record< string, DeviceBag > = {}; |
| 54 | device: Device = 'desktop'; |
| 55 | palette = ''; |
| 56 | customCss = ''; |
| 57 | template = ''; |
| 58 | applyThemeStyle = true; |
| 59 | /** True once {@see setApplyThemeStyle} has been called this session — gates whether `save()` |
| 60 | * includes `apply_theme_style` in its POST body at all (see App.tsx's `save`). */ |
| 61 | applyThemeStyleTouched = false; |
| 62 | baseUpdatedAt = 0; |
| 63 | /** Set from outside React by the (legacy jQuery) Fields tab — drives the unsaved-fields notice. */ |
| 64 | hasUnsavedFieldChanges = false; |
| 65 | /** Bumped on every click inside the preview iframe — lets the AI assistant panel close itself |
| 66 | * before a native <select> there opens its dropdown (which always paints above fixed UI, |
| 67 | * overlapping the panel — see EVF-2698). */ |
| 68 | previewInteractionCount = 0; |
| 69 | |
| 70 | /** Optional UI hook: fired when a manual edit detaches the active palette link. */ |
| 71 | onPaletteUnlinked: ( ( paletteName: string ) => void ) | null = null; |
| 72 | |
| 73 | // Bookkeeping. |
| 74 | affected: string[] | null = null; |
| 75 | private saved: Snapshot; |
| 76 | private version = 0; |
| 77 | private listeners = new Set< () => void >(); |
| 78 | private undoStack: HistoryEntry[] = []; |
| 79 | private redoStack: HistoryEntry[] = []; |
| 80 | private gestureOpen = false; |
| 81 | private gestureTimer: ReturnType< typeof setTimeout > | null = null; |
| 82 | |
| 83 | constructor( payload: StylePayload, settings: BootstrapSettings ) { |
| 84 | this.settings = settings; |
| 85 | this.schema = payload.schema; |
| 86 | this.schema.forEach( ( t ) => ( this.byKey[ t.key ] = t ) ); |
| 87 | this.sections = payload.sections; |
| 88 | this.palettes = payload.palettes; |
| 89 | this.templates = payload.templates || []; |
| 90 | this.userTemplates = payload.user_templates || []; |
| 91 | this.paletteMap = payload.palette_map; |
| 92 | this.breakpoints = payload.breakpoints; |
| 93 | this.schemaVersion = payload.schema_version; |
| 94 | this.proActive = !! payload.pro_active; |
| 95 | this.googleFonts = payload.google_fonts || []; |
| 96 | this.migration = payload.migration || { just_migrated: false }; |
| 97 | |
| 98 | this.hydrate( payload.record ); |
| 99 | // "Apply Theme Style" is a per-form meta (not part of the style record); default on. |
| 100 | this.applyThemeStyle = payload.apply_theme_style !== false; |
| 101 | this.saved = this.snapshot(); |
| 102 | } |
| 103 | |
| 104 | /** Build the full token map from a stored record, filling gaps with schema defaults. */ |
| 105 | private hydrate( record: StyleRecord ) { |
| 106 | const recTokens = record && record.tokens ? record.tokens : {}; |
| 107 | this.tokens = {}; |
| 108 | this.schema.forEach( ( t ) => { |
| 109 | const stored = recTokens[ t.key ]; |
| 110 | this.tokens[ t.key ] = |
| 111 | stored && typeof stored === 'object' && 'desktop' in stored |
| 112 | ? clone( stored ) |
| 113 | : { desktop: clone( t.default ) }; |
| 114 | } ); |
| 115 | this.palette = record && record.palette ? record.palette : ''; |
| 116 | this.customCss = record && record.custom_css ? record.custom_css : ''; |
| 117 | this.template = record && record.template ? record.template : ''; |
| 118 | this.baseUpdatedAt = record && record._updated_at ? record._updated_at : 0; |
| 119 | } |
| 120 | |
| 121 | private snapshot(): Snapshot { |
| 122 | return { |
| 123 | tokens: clone( this.tokens ), |
| 124 | palette: this.palette, |
| 125 | customCss: this.customCss, |
| 126 | template: this.template, |
| 127 | applyThemeStyle: this.applyThemeStyle, |
| 128 | }; |
| 129 | } |
| 130 | |
| 131 | /* ----------------------------------------------------------------- * |
| 132 | * Subscription (useSyncExternalStore) |
| 133 | * ----------------------------------------------------------------- */ |
| 134 | subscribe = ( cb: () => void ): ( () => void ) => { |
| 135 | this.listeners.add( cb ); |
| 136 | return () => this.listeners.delete( cb ); |
| 137 | }; |
| 138 | |
| 139 | getVersion = (): number => this.version; |
| 140 | |
| 141 | private notify( affected: string[] | null ) { |
| 142 | this.affected = affected; |
| 143 | this.version++; |
| 144 | this.listeners.forEach( ( cb ) => cb() ); |
| 145 | } |
| 146 | |
| 147 | /* ----------------------------------------------------------------- * |
| 148 | * Reads |
| 149 | * ----------------------------------------------------------------- */ |
| 150 | /** Which device a write targets — spacing tokens honour the active device, all else desktop. */ |
| 151 | targetDevice( token: Token ): Device { |
| 152 | return token.responsive ? this.device : 'desktop'; |
| 153 | } |
| 154 | |
| 155 | /** The value for the active device: device override, else desktop base, else default. */ |
| 156 | resolve( key: string ): ScalarValue { |
| 157 | const token = this.byKey[ key ]; |
| 158 | const bag = this.tokens[ key ]; |
| 159 | if ( |
| 160 | token.responsive && |
| 161 | this.device !== 'desktop' && |
| 162 | bag && |
| 163 | bag[ this.device ] !== undefined && |
| 164 | bag[ this.device ] !== '' |
| 165 | ) { |
| 166 | return bag[ this.device ] as ScalarValue; |
| 167 | } |
| 168 | return bag && bag.desktop !== undefined && bag.desktop !== '' ? bag.desktop : token.default; |
| 169 | } |
| 170 | |
| 171 | isOverride( key: string ): boolean { |
| 172 | const token = this.byKey[ key ]; |
| 173 | return !! ( |
| 174 | token.responsive && |
| 175 | this.device !== 'desktop' && |
| 176 | this.tokens[ key ] && |
| 177 | this.tokens[ key ][ this.device ] !== undefined |
| 178 | ); |
| 179 | } |
| 180 | |
| 181 | /** Has this token diverged from its default (on the active device view)? */ |
| 182 | isChanged( key: string ): boolean { |
| 183 | const token = this.byKey[ key ]; |
| 184 | if ( token.responsive && this.device !== 'desktop' ) { |
| 185 | return this.isOverride( key ); |
| 186 | } |
| 187 | const bag = this.tokens[ key ]; |
| 188 | return JSON.stringify( bag ? bag.desktop : undefined ) !== JSON.stringify( token.default ); |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * @param overrideFontsTheme When previewing a value that hasn't been committed to the store |
| 193 | * yet (a template hover), pass the value THAT preview would set `fonts.theme` to — otherwise |
| 194 | * this reads the current per-field toggle, which is stale for anything not yet applied. The |
| 195 | * global toggle still wins outright either way. |
| 196 | */ |
| 197 | themeFont( overrideFontsTheme?: boolean ): boolean { |
| 198 | // The global "Apply Theme Style" toggle forces fonts to inherit from the active theme — |
| 199 | // mirrors v1's `show_theme_font` (the only thing that toggle ever actually did) — regardless |
| 200 | // of the per-form Font section's own setting. |
| 201 | if ( this.applyThemeStyle ) { |
| 202 | return true; |
| 203 | } |
| 204 | if ( overrideFontsTheme !== undefined ) { |
| 205 | return overrideFontsTheme; |
| 206 | } |
| 207 | const bag = this.tokens[ 'fonts.theme' ]; |
| 208 | return !! ( bag && bag.desktop === true ); |
| 209 | } |
| 210 | |
| 211 | changedInSection( sectionKey: string ): number { |
| 212 | return this.schema.filter( |
| 213 | ( t ) => |
| 214 | t.section === sectionKey && |
| 215 | JSON.stringify( this.tokens[ t.key ] ) !== JSON.stringify( this.saved.tokens[ t.key ] ) |
| 216 | ).length; |
| 217 | } |
| 218 | |
| 219 | isDirty(): boolean { |
| 220 | return JSON.stringify( this.snapshot() ) !== JSON.stringify( this.saved ); |
| 221 | } |
| 222 | |
| 223 | /* ----------------------------------------------------------------- * |
| 224 | * History (snapshot-based, gesture-coalesced) |
| 225 | * ----------------------------------------------------------------- */ |
| 226 | private push( label: string ) { |
| 227 | this.undoStack.push( { label, snap: this.snapshot() } ); |
| 228 | if ( this.undoStack.length > MAX_HISTORY ) { |
| 229 | this.undoStack.shift(); |
| 230 | } |
| 231 | this.redoStack = []; |
| 232 | } |
| 233 | |
| 234 | /** Coalesce rapid edits (slider drag, typing) into one undo step. */ |
| 235 | private beginGesture( label: string ) { |
| 236 | if ( ! this.gestureOpen ) { |
| 237 | this.push( label ); |
| 238 | this.gestureOpen = true; |
| 239 | } |
| 240 | if ( this.gestureTimer ) { |
| 241 | clearTimeout( this.gestureTimer ); |
| 242 | } |
| 243 | this.gestureTimer = setTimeout( () => ( this.gestureOpen = false ), 450 ); |
| 244 | } |
| 245 | |
| 246 | private discrete( label: string ) { |
| 247 | this.gestureOpen = false; |
| 248 | this.push( label ); |
| 249 | } |
| 250 | |
| 251 | canUndo(): boolean { |
| 252 | return this.undoStack.length > 0; |
| 253 | } |
| 254 | |
| 255 | canRedo(): boolean { |
| 256 | return this.redoStack.length > 0; |
| 257 | } |
| 258 | |
| 259 | /** What Undo would revert, or '' if there's nothing to undo — lets the UI say what a click |
| 260 | * will do before it happens (tooltip, confirmation toast). */ |
| 261 | undoLabel(): string { |
| 262 | return this.canUndo() ? this.undoStack[ this.undoStack.length - 1 ].label : ''; |
| 263 | } |
| 264 | |
| 265 | /** What Redo would reapply, or '' if there's nothing to redo. */ |
| 266 | redoLabel(): string { |
| 267 | return this.canRedo() ? this.redoStack[ this.redoStack.length - 1 ].label : ''; |
| 268 | } |
| 269 | |
| 270 | undo() { |
| 271 | if ( ! this.undoStack.length ) { |
| 272 | return; |
| 273 | } |
| 274 | const top = this.undoStack.pop() as HistoryEntry; |
| 275 | this.redoStack.push( { label: top.label, snap: this.snapshot() } ); |
| 276 | this.applySnapshot( top.snap ); |
| 277 | } |
| 278 | |
| 279 | redo() { |
| 280 | if ( ! this.redoStack.length ) { |
| 281 | return; |
| 282 | } |
| 283 | const top = this.redoStack.pop() as HistoryEntry; |
| 284 | this.undoStack.push( { label: top.label, snap: this.snapshot() } ); |
| 285 | this.applySnapshot( top.snap ); |
| 286 | } |
| 287 | |
| 288 | private applySnapshot( snap: Snapshot ) { |
| 289 | this.tokens = clone( snap.tokens ); |
| 290 | this.palette = snap.palette; |
| 291 | this.customCss = snap.customCss; |
| 292 | this.template = snap.template; |
| 293 | this.applyThemeStyle = snap.applyThemeStyle; |
| 294 | this.notify( null ); |
| 295 | } |
| 296 | |
| 297 | /* ----------------------------------------------------------------- * |
| 298 | * Writes — the guarded mutation path |
| 299 | * ----------------------------------------------------------------- */ |
| 300 | setTokenValue( key: string, value: ScalarValue, gesture = false ) { |
| 301 | const token = this.byKey[ key ]; |
| 302 | if ( ! token ) { |
| 303 | return; |
| 304 | } |
| 305 | if ( gesture ) { |
| 306 | this.beginGesture( `Change ${ token.label }` ); |
| 307 | } else { |
| 308 | this.discrete( `Change ${ token.label }` ); |
| 309 | } |
| 310 | const wasExact = !! this.palette && this.paletteDrivenKeys().has( key ) && this.appliedPaletteId() === this.palette; |
| 311 | if ( ! this.tokens[ key ] ) { |
| 312 | this.tokens[ key ] = { desktop: clone( token.default ) }; |
| 313 | } |
| 314 | this.tokens[ key ][ this.targetDevice( token ) ] = value; |
| 315 | |
| 316 | // A manual edit to a palette-driven token can break the exact match — keep `this.palette` |
| 317 | // as the origin reference (mirrors template's sticky `this.template`, drives the |
| 318 | // "Modified" hint) instead of clearing it, but still toast once, on the actual transition |
| 319 | // away from an exact match. |
| 320 | if ( wasExact && this.appliedPaletteId() !== this.palette ) { |
| 321 | const detached = this.palettes.find( ( p ) => p.id === this.palette ); |
| 322 | if ( detached && this.onPaletteUnlinked ) { |
| 323 | this.onPaletteUnlinked( detached.name ); |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | // Toggling the theme font re-derives the family variable too. |
| 328 | const affected = key === 'fonts.theme' ? [ 'fonts.theme', 'fonts.family' ] : [ key ]; |
| 329 | this.notify( affected ); |
| 330 | } |
| 331 | |
| 332 | removeOverride( key: string, device: Device ) { |
| 333 | const token = this.byKey[ key ]; |
| 334 | this.discrete( `Remove ${ device } override on ${ token ? token.label : key }` ); |
| 335 | if ( this.tokens[ key ] ) { |
| 336 | delete this.tokens[ key ][ device ]; |
| 337 | } |
| 338 | this.notify( [ key ] ); |
| 339 | } |
| 340 | |
| 341 | resetToken( key: string ) { |
| 342 | const token = this.byKey[ key ]; |
| 343 | if ( ! token ) { |
| 344 | return; |
| 345 | } |
| 346 | this.discrete( `Reset ${ token.label }` ); |
| 347 | if ( token.responsive && this.device !== 'desktop' ) { |
| 348 | delete this.tokens[ key ][ this.device ]; |
| 349 | } else { |
| 350 | this.tokens[ key ] = { desktop: clone( token.default ) }; |
| 351 | } |
| 352 | this.notify( [ key ] ); |
| 353 | } |
| 354 | |
| 355 | resetSection( sectionKey: string ) { |
| 356 | const section = this.sections[ sectionKey ]; |
| 357 | this.discrete( `Reset ${ section ? section.title : sectionKey }` ); |
| 358 | const keys: string[] = []; |
| 359 | this.schema |
| 360 | .filter( ( t ) => t.section === sectionKey ) |
| 361 | .forEach( ( t ) => { |
| 362 | this.tokens[ t.key ] = { desktop: clone( t.default ) }; |
| 363 | keys.push( t.key ); |
| 364 | } ); |
| 365 | this.notify( keys ); |
| 366 | } |
| 367 | |
| 368 | resetAll() { |
| 369 | this.discrete( 'Reset all styles' ); |
| 370 | this.schema.forEach( ( t ) => ( this.tokens[ t.key ] = { desktop: clone( t.default ) } ) ); |
| 371 | this.palette = ''; |
| 372 | this.customCss = ''; |
| 373 | this.template = ''; |
| 374 | this.notify( null ); |
| 375 | } |
| 376 | |
| 377 | /** Reset just the 6 palette-slot colours (and their derived tokens) back to schema defaults. */ |
| 378 | resetPalette() { |
| 379 | this.discrete( 'Reset colors' ); |
| 380 | const keys: string[] = []; |
| 381 | this.paletteDrivenKeys().forEach( ( key ) => { |
| 382 | const token = this.byKey[ key ]; |
| 383 | if ( ! token ) { |
| 384 | return; |
| 385 | } |
| 386 | this.tokens[ key ] = { desktop: clone( token.default ) }; |
| 387 | keys.push( key ); |
| 388 | } ); |
| 389 | this.palette = ''; |
| 390 | this.notify( keys ); |
| 391 | } |
| 392 | |
| 393 | /** Reset every element back to default, same as a fresh template — mirrors {@see applyTemplate}'s |
| 394 | * own font preservation so turning this on doesn't silently undo an explicit theme-font choice. */ |
| 395 | resetTemplate() { |
| 396 | this.discrete( 'Reset template' ); |
| 397 | const keepFontKeys = this.applyThemeStyle ? [ 'fonts.theme', 'fonts.family' ] : []; |
| 398 | this.schema.forEach( ( t ) => { |
| 399 | if ( keepFontKeys.indexOf( t.key ) === -1 ) { |
| 400 | this.tokens[ t.key ] = { desktop: clone( t.default ) }; |
| 401 | } |
| 402 | } ); |
| 403 | this.template = ''; |
| 404 | this.palette = ''; |
| 405 | this.notify( null ); |
| 406 | } |
| 407 | |
| 408 | setDevice( device: Device ) { |
| 409 | if ( this.device === device ) { |
| 410 | return; |
| 411 | } |
| 412 | this.device = device; |
| 413 | this.notify( null ); // Re-resolve every token for the new device view. |
| 414 | } |
| 415 | |
| 416 | setCustomCss( css: string ) { |
| 417 | this.customCss = css; |
| 418 | this.notify( [] ); // No token vars change; the App handles the <style> injection. |
| 419 | } |
| 420 | |
| 421 | /** Clears the Custom CSS pane's own textarea — mirrors {@see resetPalette}/{@see resetTemplate}. */ |
| 422 | resetCustomCss() { |
| 423 | this.discrete( 'Reset Custom CSS' ); |
| 424 | this.customCss = ''; |
| 425 | this.notify( [] ); |
| 426 | } |
| 427 | |
| 428 | /** Toggle "Apply Theme Style" (a per-form setting, persisted to the same meta the v1 preview toggle uses). */ |
| 429 | setUnsavedFieldChanges( dirty: boolean ) { |
| 430 | if ( this.hasUnsavedFieldChanges === dirty ) { |
| 431 | return; |
| 432 | } |
| 433 | this.hasUnsavedFieldChanges = dirty; |
| 434 | this.notify( [] ); |
| 435 | } |
| 436 | |
| 437 | notePreviewInteraction() { |
| 438 | this.previewInteractionCount++; |
| 439 | this.notify( [] ); |
| 440 | } |
| 441 | |
| 442 | setApplyThemeStyle( on: boolean ) { |
| 443 | this.applyThemeStyleTouched = true; |
| 444 | if ( this.applyThemeStyle === on ) { |
| 445 | return; |
| 446 | } |
| 447 | this.discrete( on ? 'Apply theme style' : 'Use default form style' ); |
| 448 | this.applyThemeStyle = on; |
| 449 | this.notify( null ); |
| 450 | } |
| 451 | |
| 452 | /** The set of tokens any palette drives (so a manual edit can unlink the active palette). */ |
| 453 | private paletteDrivenKeys(): Set< string > { |
| 454 | const set = new Set< string >(); |
| 455 | Object.values( this.paletteMap ).forEach( ( keys ) => keys.forEach( ( k ) => set.add( k ) ) ); |
| 456 | set.add( 'btn.bgHover' ); |
| 457 | return set; |
| 458 | } |
| 459 | |
| 460 | /** User-authored (custom) palettes — rendered first, with edit/delete affordances. */ |
| 461 | customPalettes(): StylePayload[ 'palettes' ] { |
| 462 | return this.palettes.filter( ( p ) => p.is_custom ); |
| 463 | } |
| 464 | |
| 465 | /** Built-in palettes (the 2 free + 9 Pro presets). */ |
| 466 | builtinPalettes(): StylePayload[ 'palettes' ] { |
| 467 | return this.palettes.filter( ( p ) => ! p.is_custom ); |
| 468 | } |
| 469 | |
| 470 | /** Replace the custom palettes with a fresh server list; detaches the active link if its id is gone. */ |
| 471 | setCustomPalettes( customs: StylePayload[ 'palettes' ] ) { |
| 472 | const list = ( customs || [] ).map( ( p ) => ( { ...p, is_custom: true } ) ); |
| 473 | this.palettes = [ ...list, ...this.builtinPalettes() ]; |
| 474 | if ( this.palette && ! this.palettes.some( ( p ) => p.id === this.palette ) ) { |
| 475 | this.palette = ''; |
| 476 | } |
| 477 | this.notify( [] ); |
| 478 | } |
| 479 | |
| 480 | /** The six palette-slot colours the form currently shows, resolved from live token values. */ |
| 481 | currentPaletteColors(): Record< string, string > { |
| 482 | const out: Record< string, string > = {}; |
| 483 | Object.entries( this.paletteMap ).forEach( ( [ slot, keys ] ) => { |
| 484 | out[ slot ] = keys && keys[ 0 ] ? String( this.resolve( keys[ 0 ] ) ) : '#ffffff'; |
| 485 | } ); |
| 486 | return out; |
| 487 | } |
| 488 | |
| 489 | /** Whether every token a palette SLOT writes to (see {@see palette_map} on the PHP side — |
| 490 | * some slots bundle more than one token, e.g. `field_label` also drives `title.color`) |
| 491 | * itself allows a gradient value. A slot is only gradient-safe if ALL of its tokens are — |
| 492 | * `button_background`, for one, also feeds `input.focusBorder`/`choice.checked`/`file.icon` |
| 493 | * (border/dual-context/SVG-fill tokens a gradient can't reach), so it stays solid-only. */ |
| 494 | slotGradientable( slot: string ): boolean { |
| 495 | const keys = this.paletteMap[ slot ] || []; |
| 496 | return keys.length > 0 && keys.every( ( k ) => !! this.byKey[ k ]?.gradientable ); |
| 497 | } |
| 498 | |
| 499 | /** Whether a palette's 6 named colours exactly match the form's current live colours. */ |
| 500 | private paletteColorsMatch( colors: Record< string, string > ): boolean { |
| 501 | const current = this.currentPaletteColors(); |
| 502 | return Object.keys( this.paletteMap ).every( |
| 503 | ( slot ) => String( colors[ slot ] || '' ).toLowerCase() === String( current[ slot ] || '' ).toLowerCase() |
| 504 | ); |
| 505 | } |
| 506 | |
| 507 | /** The id of the palette the form's current colours exactly match, or '' if none. Mirrors {@see appliedTemplateId}. */ |
| 508 | appliedPaletteId(): string { |
| 509 | const stored = this.palettes.find( ( p ) => p.id === this.palette ); |
| 510 | if ( stored && this.paletteColorsMatch( stored.colors ) ) { |
| 511 | return stored.id; |
| 512 | } |
| 513 | const match = this.palettes.find( ( p ) => this.paletteColorsMatch( p.colors ) ); |
| 514 | return match ? match.id : ''; |
| 515 | } |
| 516 | |
| 517 | /** The palette the form was applied from but has since diverged from; drives the "Modified" hint. Mirrors {@see originTemplateId}. */ |
| 518 | originPaletteId(): string { |
| 519 | if ( ! this.palette || this.appliedPaletteId() === this.palette ) { |
| 520 | return ''; |
| 521 | } |
| 522 | return this.palettes.some( ( p ) => p.id === this.palette ) ? this.palette : ''; |
| 523 | } |
| 524 | |
| 525 | /** Whether every palette-driven colour token is still exactly at its schema default — the |
| 526 | * true "blank slate", independent of any named palette ever having been applied. */ |
| 527 | paletteAtDefault(): boolean { |
| 528 | return Array.from( this.paletteDrivenKeys() ).every( ( key ) => ! this.byKey[ key ] || ! this.isChanged( key ) ); |
| 529 | } |
| 530 | |
| 531 | applyPalette( paletteId: string ) { |
| 532 | const palette = this.palettes.find( ( p ) => p.id === paletteId ); |
| 533 | if ( ! palette ) { |
| 534 | return; |
| 535 | } |
| 536 | this.discrete( `Apply palette “${ palette.name }”` ); |
| 537 | const affected: string[] = []; |
| 538 | Object.entries( this.paletteMap ).forEach( ( [ slot, keys ] ) => { |
| 539 | const color = palette.colors[ slot ]; |
| 540 | if ( color === undefined ) { |
| 541 | return; |
| 542 | } |
| 543 | keys.forEach( ( key ) => { |
| 544 | if ( ! this.byKey[ key ] ) { |
| 545 | return; |
| 546 | } |
| 547 | this.tokens[ key ] = { desktop: color }; |
| 548 | affected.push( key ); |
| 549 | } ); |
| 550 | } ); |
| 551 | // Derive the button hover shade, matching applyPaletteColors() in the prototype. |
| 552 | if ( this.byKey[ 'btn.bgHover' ] && palette.colors.button_background ) { |
| 553 | this.tokens[ 'btn.bgHover' ] = { desktop: mixHex( palette.colors.button_background, '#000000', 0.14 ) }; |
| 554 | affected.push( 'btn.bgHover' ); |
| 555 | } |
| 556 | this.palette = paletteId; |
| 557 | this.notify( affected ); |
| 558 | } |
| 559 | |
| 560 | /** |
| 561 | * Edit ONE palette slot's colour directly — the "Your Palette" section's per-swatch write |
| 562 | * path. Mirrors {@see applyPalette}'s own token-writing logic (same paletteMap keys, same |
| 563 | * btn.bgHover derivation for button_background) but scoped to a single slot. Unlike |
| 564 | * applyPalette(), this never sets `this.palette` to a NEW id — it's a manual edit like any |
| 565 | * other, so `this.palette` stays as the origin reference (mirrors template's sticky |
| 566 | * `this.template`) rather than being cleared; {@see originPaletteId} drives the "Modified" hint. |
| 567 | */ |
| 568 | setPaletteSlotColor( slot: string, color: string, slotLabel: string, gesture = true ) { |
| 569 | const keys = this.paletteMap[ slot ]; |
| 570 | if ( ! keys || ! keys.length ) { |
| 571 | return; |
| 572 | } |
| 573 | const label = `Change ${ slotLabel } color`; |
| 574 | if ( gesture ) { |
| 575 | this.beginGesture( label ); |
| 576 | } else { |
| 577 | this.discrete( label ); |
| 578 | } |
| 579 | const wasExact = !! this.palette && this.appliedPaletteId() === this.palette; |
| 580 | const affected: string[] = []; |
| 581 | keys.forEach( ( key ) => { |
| 582 | if ( ! this.byKey[ key ] ) { |
| 583 | return; |
| 584 | } |
| 585 | this.tokens[ key ] = { desktop: color }; |
| 586 | affected.push( key ); |
| 587 | } ); |
| 588 | if ( slot === 'button_background' && this.byKey[ 'btn.bgHover' ] ) { |
| 589 | this.tokens[ 'btn.bgHover' ] = { desktop: mixHex( color, '#000000', 0.14 ) }; |
| 590 | affected.push( 'btn.bgHover' ); |
| 591 | } |
| 592 | if ( wasExact && this.appliedPaletteId() !== this.palette ) { |
| 593 | const detached = this.palettes.find( ( p ) => p.id === this.palette ); |
| 594 | if ( detached && this.onPaletteUnlinked ) { |
| 595 | this.onPaletteUnlinked( detached.name ); |
| 596 | } |
| 597 | } |
| 598 | this.notify( affected ); |
| 599 | } |
| 600 | |
| 601 | /** Apply a template: reset every token to its default, then overlay the template's token bags. */ |
| 602 | applyTemplate( templateId: string, tokens: Record< string, DeviceBag >, paletteId?: string ) { |
| 603 | this.discrete( 'Apply template' ); |
| 604 | // While "Apply Theme Style" is on, the user has explicitly chosen the theme's font over |
| 605 | // any per-form setting — a template shouldn't silently flip that off behind their back, so |
| 606 | // leave fonts.theme/fonts.family exactly as they are (skip both the reset-to-default below |
| 607 | // and the template's own overlay values for these two keys). |
| 608 | const keepFontKeys = this.applyThemeStyle ? [ 'fonts.theme', 'fonts.family' ] : []; |
| 609 | const keptFonts: Record< string, DeviceBag > = {}; |
| 610 | keepFontKeys.forEach( ( key ) => { |
| 611 | if ( this.tokens[ key ] ) { |
| 612 | keptFonts[ key ] = clone( this.tokens[ key ] ); |
| 613 | } |
| 614 | } ); |
| 615 | this.schema.forEach( ( t ) => ( this.tokens[ t.key ] = { desktop: clone( t.default ) } ) ); |
| 616 | Object.entries( tokens || {} ).forEach( ( [ key, bag ] ) => { |
| 617 | if ( keepFontKeys.indexOf( key ) !== -1 ) { |
| 618 | return; |
| 619 | } |
| 620 | if ( this.byKey[ key ] && bag && typeof bag === 'object' ) { |
| 621 | this.tokens[ key ] = clone( bag ); |
| 622 | } |
| 623 | } ); |
| 624 | Object.entries( keptFonts ).forEach( ( [ key, bag ] ) => ( this.tokens[ key ] = bag ) ); |
| 625 | this.template = templateId; |
| 626 | this.palette = paletteId || ''; |
| 627 | this.notify( null ); |
| 628 | } |
| 629 | |
| 630 | /** |
| 631 | * Apply an AI-generated style intent: an overlay on the current state (unlike |
| 632 | * {@see applyTemplate}, never resets to defaults first). If `paletteId` is set, its colours |
| 633 | * are applied first and `tokens` overlaid on top. |
| 634 | */ |
| 635 | applyAiRecord( tokens: Record< string, DeviceBag >, paletteId?: string ) { |
| 636 | this.discrete( 'Style with AI' ); |
| 637 | const affected: string[] = []; |
| 638 | |
| 639 | if ( paletteId ) { |
| 640 | const palette = this.palettes.find( ( p ) => p.id === paletteId ); |
| 641 | if ( palette ) { |
| 642 | Object.entries( this.paletteMap ).forEach( ( [ slot, keys ] ) => { |
| 643 | const color = palette.colors[ slot ]; |
| 644 | if ( color === undefined ) { |
| 645 | return; |
| 646 | } |
| 647 | keys.forEach( ( key ) => { |
| 648 | if ( ! this.byKey[ key ] ) { |
| 649 | return; |
| 650 | } |
| 651 | this.tokens[ key ] = { desktop: color }; |
| 652 | affected.push( key ); |
| 653 | } ); |
| 654 | } ); |
| 655 | if ( this.byKey[ 'btn.bgHover' ] && palette.colors.button_background ) { |
| 656 | this.tokens[ 'btn.bgHover' ] = { desktop: mixHex( palette.colors.button_background, '#000000', 0.14 ) }; |
| 657 | affected.push( 'btn.bgHover' ); |
| 658 | } |
| 659 | this.palette = paletteId; |
| 660 | } |
| 661 | } |
| 662 | |
| 663 | Object.entries( tokens || {} ).forEach( ( [ key, bag ] ) => { |
| 664 | if ( this.byKey[ key ] && bag && typeof bag === 'object' ) { |
| 665 | this.tokens[ key ] = clone( bag ); |
| 666 | affected.push( key ); |
| 667 | } |
| 668 | } ); |
| 669 | |
| 670 | // `this.template` is deliberately left untouched — the applied/modified badges are value-driven. |
| 671 | this.notify( affected.length ? affected : null ); |
| 672 | } |
| 673 | |
| 674 | /** All templates for display — user-created first (deletable), then the built-ins. */ |
| 675 | allTemplates(): StylePayload[ 'templates' ] { |
| 676 | return this.userTemplates.concat( this.templates ); |
| 677 | } |
| 678 | |
| 679 | /** Whether the current token state is exactly what applying `templateTokens` would produce. |
| 680 | * While "Apply Theme Style" is on, {@see applyTemplate} deliberately leaves fonts.theme/ |
| 681 | * fonts.family exactly as they were rather than overlaying the template's own values for |
| 682 | * those two keys — skip them here too, or a template applied fresh would never register as |
| 683 | * an exact match (always reading "Modified" instead of "Base") purely because of that. */ |
| 684 | private tokensMatchTemplate( templateTokens: Record< string, DeviceBag > ): boolean { |
| 685 | const tpl = templateTokens || {}; |
| 686 | const skip = this.applyThemeStyle ? [ 'fonts.theme', 'fonts.family' ] : []; |
| 687 | return this.schema.every( ( t ) => { |
| 688 | if ( skip.indexOf( t.key ) !== -1 ) { |
| 689 | return true; |
| 690 | } |
| 691 | const expected = tpl[ t.key ] !== undefined ? tpl[ t.key ] : { desktop: t.default }; |
| 692 | return deepEqual( this.tokens[ t.key ], expected ); |
| 693 | } ); |
| 694 | } |
| 695 | |
| 696 | /** Whether two template token maps produce the identical applied result. */ |
| 697 | private templateTokensEqual( a: Record< string, DeviceBag >, b: Record< string, DeviceBag > ): boolean { |
| 698 | const am = a || {}; |
| 699 | const bm = b || {}; |
| 700 | return this.schema.every( ( t ) => { |
| 701 | const av = am[ t.key ] !== undefined ? am[ t.key ] : { desktop: t.default }; |
| 702 | const bv = bm[ t.key ] !== undefined ? bm[ t.key ] : { desktop: t.default }; |
| 703 | return deepEqual( av, bv ); |
| 704 | } ); |
| 705 | } |
| 706 | |
| 707 | /** The id of the template the form's current styles exactly match, or '' if none. Drives the ✓ "Applied" badge. */ |
| 708 | appliedTemplateId(): string { |
| 709 | const all = this.allTemplates(); |
| 710 | const stored = all.find( ( t ) => t.id === this.template ); |
| 711 | if ( stored && this.tokensMatchTemplate( stored.tokens ) ) { |
| 712 | return stored.id; |
| 713 | } |
| 714 | const match = all.find( ( t ) => this.tokensMatchTemplate( t.tokens ) ); |
| 715 | return match ? match.id : ''; |
| 716 | } |
| 717 | |
| 718 | /** The template the form was applied from but has since diverged from; drives the "Modified" hint. */ |
| 719 | originTemplateId(): string { |
| 720 | if ( ! this.template || this.appliedTemplateId() === this.template ) { |
| 721 | return ''; |
| 722 | } |
| 723 | return this.allTemplates().some( ( t ) => t.id === this.template ) ? this.template : ''; |
| 724 | } |
| 725 | |
| 726 | /** Whether every token is still exactly at its schema default — the true "blank slate", |
| 727 | * independent of any named template ever having been applied. */ |
| 728 | isAtSchemaDefault(): boolean { |
| 729 | return this.tokensMatchTemplate( {} ); |
| 730 | } |
| 731 | |
| 732 | /** For a custom template, the id of the built-in template it exactly derives from, or '' if none. */ |
| 733 | templateParentId( tpl: StylePayload[ 'templates' ][ number ] ): string { |
| 734 | if ( ! tpl || ! tpl.custom ) { |
| 735 | return ''; |
| 736 | } |
| 737 | const parent = this.templates.find( ( b ) => this.templateTokensEqual( tpl.tokens, b.tokens ) ); |
| 738 | return parent ? parent.id : ''; |
| 739 | } |
| 740 | |
| 741 | /** Remove a user template from the list. */ |
| 742 | removeUserTemplate( id: string ) { |
| 743 | this.userTemplates = this.userTemplates.filter( ( t ) => t.id !== id ); |
| 744 | if ( this.template === id ) { |
| 745 | this.template = ''; |
| 746 | } |
| 747 | this.notify( [] ); |
| 748 | } |
| 749 | |
| 750 | /** Replace the user templates with a fresh server list (e.g. after an update). Mirrors {@see setCustomPalettes}. */ |
| 751 | setUserTemplates( templates: StylePayload[ 'templates' ] ) { |
| 752 | this.userTemplates = templates || []; |
| 753 | this.notify( [] ); |
| 754 | } |
| 755 | |
| 756 | /** |
| 757 | * Update section/schema visibility — e.g. BuilderSync re-synced after a Settings/Fields-tab |
| 758 | * edit that makes a conditional section or token group newly (in)eligible for this form |
| 759 | * (Multi-Part's "Enable Multi-Part form" toggle, a File Upload field being added/removed). |
| 760 | * Token *values* are untouched; this only changes what the panel currently shows/applies — |
| 761 | * `notify( null )` re-applies every (now current) token, the same as an undo/reset. |
| 762 | */ |
| 763 | setVisibility( sections: StylePayload[ 'sections' ], schema: Token[] ) { |
| 764 | this.sections = sections; |
| 765 | this.schema = schema; |
| 766 | this.byKey = {}; |
| 767 | this.schema.forEach( ( t ) => ( this.byKey[ t.key ] = t ) ); |
| 768 | this.notify( null ); |
| 769 | } |
| 770 | |
| 771 | /** Build the record to POST. Absent-device keys are already pruned by never being set. */ |
| 772 | toRecord(): StyleRecord { |
| 773 | return { |
| 774 | schema_version: this.schemaVersion, |
| 775 | tokens: clone( this.tokens ), |
| 776 | palette: this.palette, |
| 777 | template: this.template, |
| 778 | custom_css: this.customCss, |
| 779 | }; |
| 780 | } |
| 781 | |
| 782 | /** Mark the current state as saved (after a successful POST). */ |
| 783 | markSaved( record: StyleRecord ) { |
| 784 | this.hydrate( record ); |
| 785 | this.saved = this.snapshot(); |
| 786 | // The record is now genuinely persisted as v2 — the migration banner (and App's forced |
| 787 | // first-save allowance) have both done their job, so this one-time flag retires. |
| 788 | if ( this.migration.just_migrated ) { |
| 789 | this.migration = { ...this.migration, just_migrated: false }; |
| 790 | } |
| 791 | this.notify( [] ); |
| 792 | } |
| 793 | } |
| 794 | |
| 795 | let store: StyleStore | null = null; |
| 796 | |
| 797 | export function initStore( payload: StylePayload, settings: BootstrapSettings ): StyleStore { |
| 798 | store = new StyleStore( payload, settings ); |
| 799 | return store; |
| 800 | } |
| 801 | |
| 802 | export function getStore(): StyleStore { |
| 803 | if ( ! store ) { |
| 804 | throw new Error( 'Style store accessed before init.' ); |
| 805 | } |
| 806 | return store; |
| 807 | } |
| 808 | |
| 809 | /** Re-render on any store change. Components then read store fields directly. */ |
| 810 | export function useStore(): StyleStore { |
| 811 | const s = getStore(); |
| 812 | useSyncExternalStore( s.subscribe, s.getVersion ); |
| 813 | return s; |
| 814 | } |
| 815 | |
| 816 | export type { StyleStore }; |
| 817 |