PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.6.0
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.6.0
3.6.0 3.5.3 3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / src / style-customizer-v2 / store.ts
everest-forms / src / style-customizer-v2 Last commit date
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