@designcrowd/library.brand-page 5.18.1-undo-26 → 5.18.1-undo-28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-DWzLegt3.js → CreatedOnBrandCrowd-de-de-CmaFdOwA.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-Dr6isTH_.js → CreatedOnBrandCrowd-en-us-DYcx1zJ5.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-hPCTaL-A.js → CreatedOnBrandCrowd-es-es-BW-OyQM7.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-BNDUTNc_.js → CreatedOnBrandCrowd-fr-ca-DDBKibOe.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-BNDUTNc_.js → CreatedOnBrandCrowd-fr-fr-DDBKibOe.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-DKn5U0m5.js → CreatedOnBrandCrowd-pt-br-6OSkxFgf.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-DKn5U0m5.js → CreatedOnBrandCrowd-pt-pt-6OSkxFgf.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-Cxe0RSmZ.js → CreatedOnDcom-de-de-Ds3pSY3b.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-D1D6h8b6.js → CreatedOnDcom-en-us-nu_xhm3W.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-4k5Jz225.js → CreatedOnDcom-es-es-DeWd8xLJ.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-CeB-CL-1.js → CreatedOnDcom-fr-ca-C30UWAc7.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-D7PCXxYg.js → CreatedOnDcom-fr-fr-ZFAi67Ii.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-D89FAdoN.js → CreatedOnDcom-pt-br-Bvtz3TFZ.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-CtJ0lm4O.js → CreatedOnDcom-pt-pt-Dr__mA4b.js} +1 -1
  15. package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +13 -1
  16. package/lib/src/brand-page-maker/stores/history/history-store.d.ts +4 -1
  17. package/lib/src/brand-page-maker/stores/history/history-store.js +90 -393
  18. package/lib/src/brand-page-maker/stores/user/user-store.js +1 -1
  19. package/lib/src/index.mjs +1 -1
  20. package/lib/src/{lib-D_xnR4VH.js → lib-BBItimSg.js} +18133 -18249
  21. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +64 -0
  22. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.js +12 -0
  23. package/package.json +1 -1
  24. package/src/brand-page-maker/components/quick-link-bar/undo-redo-buttons.test.ts +116 -0
  25. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +15 -2
  26. package/src/brand-page-maker/stores/history/history-store.test.ts +152 -790
  27. package/src/brand-page-maker/stores/history/history-store.ts +95 -444
  28. package/src/brand-page-maker/stores/user/user-store.ts +1 -1
  29. package/src/shared/content-settings/components/page-dropdown-field/PageDropdownField.vue +7 -0
  30. package/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.ts +13 -0
  31. package/src/brand-page-maker/components/quick-link-bar/RedoButton.test.ts +0 -166
  32. package/src/brand-page-maker/components/quick-link-bar/UndoButton.test.ts +0 -166
@@ -7,7 +7,6 @@ import { usePageNavigationStore } from '@shared/page-navigation/store/page-navig
7
7
  import { useUiEditableLogoStore } from '@shared/logo-settings/store/editable-logo-store';
8
8
  import { useShopConfigStore } from '@shared/ecommerce-settings/store/shop-settings-store';
9
9
  import { useUiPageSettingsStore } from '@shared/page-settings/store';
10
- import { ESCAPED_NEWLINE_CHARACTER } from '@src/constants/ui';
11
10
  /**
12
11
  * Single-stack history model with a pointer instead of two stacks.
13
12
  *
@@ -52,52 +51,20 @@ const MAX_ENTRY_BYTES = 25 * 1024 * 1024;
52
51
  const BASELINE_SETTLE_MS = 2000;
53
52
  // Content-level wrapper keys whose `paymentConfig` child is a denormalised
54
53
  // cache of `shopConfigStore.paymentConfig` (the user's Stripe Connect
55
- // linkage). The cache is hydrated lazily by
56
- // `PaymentConfigField.mixin.ts:created()` when the user clicks into one of
57
- // these sections — that hydration is not user authoring intent, and Stripe
58
- // Connect linkage state is backend-confirmed (per `project-guardrails.md`).
54
+ // linkage). Stripe Connect linkage state is backend-confirmed (per
55
+ // `project-guardrails.md`) and must NEVER ride along on the undo/redo stack.
59
56
  //
60
- // paymentConfig is kept fully invariant under undo/redo via two layers in
61
- // this file:
62
- // 1. Capture-time strip in `serializeCurrentSnapshot` — snapshots are
63
- // paymentConfig-free, so no entry can carry a stale or absent value.
64
- // 2. Restore-time re-stamp in `applySnapshotString` — after every undo /
65
- // redo apply, every live wrapper's `paymentConfig` is reset from
66
- // `shopConfigStore.paymentConfig`, which is itself never touched by
67
- // undo/redo (see `applyHistorySnapshot` step 6).
68
- //
69
- // Together: the live wrapper always equals the live shop store, regardless
70
- // of what the user does in between captures and restores.
57
+ // paymentConfig is kept invariant under undo/redo by re-stamping every live
58
+ // wrapper from `shopConfigStore.paymentConfig` after every restore — see
59
+ // `restorePaymentConfigsOnLivePages`. The shop store itself is never
60
+ // touched by undo/redo (see `applyHistorySnapshot` step 6), so the live
61
+ // wrapper always equals the live shop store regardless of what's in the
62
+ // snapshot bytes.
71
63
  const PAYMENT_CONFIG_WRAPPER_KEYS = [
72
64
  'supportMeConfig',
73
65
  'requestPaymentConfig',
74
66
  'shopPaymentConfig',
75
67
  ];
76
- /**
77
- * Walk a `pages` array and remove the `paymentConfig` key from every
78
- * payment-config wrapper found inside `pages[*].contents[*]`. Mutates in
79
- * place — caller is responsible for passing a clone if the source must
80
- * remain untouched.
81
- */
82
- function stripPaymentConfigsFromPages(pages) {
83
- if (!Array.isArray(pages))
84
- return;
85
- for (const page of pages) {
86
- const contents = page?.contents;
87
- if (!Array.isArray(contents))
88
- continue;
89
- for (const content of contents) {
90
- if (!content || typeof content !== 'object')
91
- continue;
92
- for (const key of PAYMENT_CONFIG_WRAPPER_KEYS) {
93
- const wrapper = content[key];
94
- if (wrapper && typeof wrapper === 'object' && !Array.isArray(wrapper)) {
95
- delete wrapper.paymentConfig;
96
- }
97
- }
98
- }
99
- }
100
- }
101
68
  export const useHistoryStore = defineStore('history', () => {
102
69
  const eventTracker = useEventTracker();
103
70
  // --- State ---
@@ -131,15 +98,7 @@ export const useHistoryStore = defineStore('history', () => {
131
98
  if (!cachedMakerStore)
132
99
  return null;
133
100
  try {
134
- // JSON round-trip detaches the live reactive proxy, then we strip
135
- // `paymentConfig` from every payment-config wrapper before re-
136
- // stringifying. paymentConfig is the user's Stripe Connect linkage
137
- // (backend-confirmed) and must not enter the undo stack — see
138
- // `PAYMENT_CONFIG_WRAPPER_KEYS` above. The live store is never
139
- // mutated; the strip operates on the detached clone only.
140
- const cloned = JSON.parse(JSON.stringify(toRaw(cachedMakerStore.brandPageData)));
141
- stripPaymentConfigsFromPages(cloned.pages);
142
- return JSON.stringify(cloned);
101
+ return JSON.stringify(toRaw(cachedMakerStore.brandPageData));
143
102
  }
144
103
  catch (error) {
145
104
  console.error('history-store: failed to serialize snapshot', error);
@@ -170,334 +129,31 @@ export const useHistoryStore = defineStore('history', () => {
170
129
  }
171
130
  }
172
131
  /**
173
- * Normalize a snapshot for the dedup byte-compare. Strips/canonicalizes
174
- * fields that get mutated by sidebar-open reactive cascades but aren't
175
- * user-visible edits — without this, opening a content panel produces an
176
- * undo step that "does nothing" when replayed.
177
- *
178
- * Known phantom sources (BP-5506):
179
- * - TipTap RTE writing back its empty-paragraph default on first mount
180
- * (e.g. `""` → `<p style="text-align: center;"></p>`).
181
- * - TipTap RTE writing back its default-style hydration on first mount
182
- * (e.g. plain `"Hello"` → `<p style="..."><span style="..."><mark ...>Hello</mark></span></p>`).
183
- * - TipTap re-emitting whole-content `<strong>`/`<em>`/`<u>`/`<s>` tags
184
- * to mirror the resolved `textStyle.fontWeight / fontStyle /
185
- * textDecoration` (e.g. plain `"text"` →
186
- * `<p>...<span>...<strong>text</strong></span></p>`).
187
- * - TipTap rendering the codebase's URL-encoded newline
188
- * (`ESCAPED_NEWLINE_CHARACTER` = `%0A`) as `<br>` on first hydration
189
- * (e.g. plain `"A%0AB"` → `<p><span>A<br>B</span></p>`). Plain-side
190
- * `%0A` is decoded to `\n` and attributeless `<br>` is collapsed to
191
- * `\n` in `extractCanonicalText`, so the two sides converge.
192
- * - Lazy hydration of `*Style` objects when a content settings panel —
193
- * or section selection on the canvas — resolves `textStyleType` and
194
- * writes the full resolved style (fontFamily, fontSize, color,
195
- * fontWeight, fontStyle, letterSpacing, lineHeight, textDecoration)
196
- * onto the model. The `textStyleType` itself is the authoring
197
- * intent; the resolved keys are just cached values.
198
- * - `isModifiedAfterTemplateApplied` flipping on first edit gesture.
132
+ * One-shot "fold next mutation back into the baseline (or current entry)
133
+ * rather than pushing a new entry" flag.
199
134
  *
200
- * Note: `logoImageBase64Url` (the rendered logo PNG) is NOT stripped here.
201
- * Pre-purchase logo-tab edits (text, support text, logo colors, fonts)
202
- * land in `editableLogoStore.logoEdits` (sidecar — see
203
- * `HistoryEntry.logoEdits`) AND trigger a fabric-render that writes the
204
- * new base64 into `pageHeaderDesktop.logoImageBase64Url` + per-page
205
- * logo content. The base64 IS the canonical signal that a logo edit
206
- * happened; stripping it would dedup real logo edits as no-ops. The
207
- * 2-second `BASELINE_SETTLE_MS` window already collapses initial-load
208
- * base64 churn into the baseline.
135
+ * Hydration write sites (TipTap mount, lazy paymentConfig fill,
136
+ * stale-page-ref clear) all run from a lifecycle hook on first sidebar
137
+ * open / section click. Each site mutates `brandPageData` exactly once
138
+ * with a value the user can't see as a change — but the deep watcher
139
+ * fires anyway, and without this gate the next idle flush would land a
140
+ * phantom undo entry.
209
141
  *
210
- * RTE strings are canonicalized by parsing the HTML with DOMParser and
211
- * walking the tree; only attributeless format wrappers (and a tightly
212
- * scoped set of hydration styles on `<p>` / `<span>` / `<mark>`) are
213
- * collapsed. Real authoring intent (links, custom colors, partial-text
214
- * formatting like `Hello <strong>world</strong>`) survives the walk
215
- * because at least one node falls outside the hydration whitelist,
216
- * and the walker bails to "preserve original" the moment that happens.
142
+ * Each call site sets the flag immediately before the imperative write
143
+ * it knows is a hydration. The watcher's flush consumes the flag (clears
144
+ * it) and merges the resulting snapshot into `entries[currentIndex]`
145
+ * instead of pushing. No TTL — the flag stays armed until either:
146
+ * - the watcher fires and consumes it, or
147
+ * - a snapshot apply (undo/redo) clears it (we don't want a hydration
148
+ * flag set immediately before an apply to swallow the post-apply
149
+ * watcher tick), or
150
+ * - `reset()` clears it on save.
217
151
  *
218
- * Used ONLY for dedup comparison. Stored snapshots remain unchanged so
219
- * `applyHistorySnapshot` always sees the real state.
152
+ * Multiple call sites in the same burst all set the same flag — they
153
+ * collapse together into a single merge into the current head, which is
154
+ * the semantics we want.
220
155
  */
221
- // Tags TipTap re-emits purely to mirror the resolved textStyle on first
222
- // hydration. None carry user authoring intent on their own when they are
223
- // the sole element-child wrapping the entire content:
224
- // - <p>: only its `text-align` style is meaningful, and that's already
225
- // captured in `textStyle.textAlign`.
226
- // - <span>: TipTap wraps content in a span with `font-family`, `font-size`,
227
- // `color`, `letter-spacing`, `line-height` resolved from the
228
- // textStyle. The textStyle object on the same content is the
229
- // authoritative source.
230
- // - <mark>: TipTap's highlight extension default — collapses regardless
231
- // of `data-color` / `style` attrs, mirroring the original
232
- // regex's behavior on a hydrated highlight.
233
- // - <strong>/<em>/<u>/<s>: attributeless tags that mirror
234
- // `fontWeight: bold` / `fontStyle: italic` / `textDecoration:
235
- // underline / line-through`. ONLY recognized as hydration when
236
- // they are the sole child of their parent — partial-content
237
- // usage like `Hello <strong>world</strong>` is a real edit.
238
- // Anything else (`<a>`, `<br>`, `<span style="color: red">`, attributes on
239
- // <strong>/<em>, etc.) is preserved as a real diff.
240
- const STRUCTURAL_HYDRATION_TAGS = new Set(['P', 'SPAN', 'MARK']);
241
- const FORMAT_HYDRATION_TAGS = new Set(['STRONG', 'EM', 'U', 'S']);
242
- // Default style properties TipTap emits on a hydration <span> /
243
- // <mark>. The textStyle resolver writes all of these onto the live
244
- // model, so when TipTap re-emits them as inline style on its
245
- // hydration span/mark there is no user-visible delta.
246
- // Anything outside this whitelist (e.g. `text-decoration` mid-content,
247
- // `text-shadow`, `text-transform`) is treated as a real edit.
248
- const HYDRATION_INLINE_STYLE_PROPS = new Set([
249
- 'font-family',
250
- 'font-size',
251
- 'letter-spacing',
252
- 'line-height',
253
- 'color',
254
- 'background-color',
255
- ]);
256
- // Empty paragraph forms TipTap emits when an editor mounts on an empty
257
- // string. Treated as equivalent to `""` for dedup.
258
- function isEmptyRteHtml(value) {
259
- if (typeof value !== 'string')
260
- return false;
261
- return /^<p(\s[^>]*)?>(<br\s*\/?>)?<\/p>$/.test(value);
262
- }
263
- function isHydrationOnlyAttributes(el) {
264
- // Format tags (<strong>/<em>/<u>/<s>) must be attributeless. Anything
265
- // with class/style/data-*/etc. is a real edit.
266
- if (FORMAT_HYDRATION_TAGS.has(el.tagName))
267
- return el.attributes.length === 0;
268
- for (const attr of Array.from(el.attributes)) {
269
- // <mark> may carry a `data-color` attr (TipTap highlight extension's
270
- // default-and-resolved value). The accompanying `style` is checked
271
- // against the same hydration-prop whitelist as <span>.
272
- if (el.tagName === 'MARK' &&
273
- (attr.name === 'data-color' || attr.name === 'style')) {
274
- if (attr.name === 'data-color')
275
- continue;
276
- }
277
- else if (attr.name !== 'style') {
278
- return false;
279
- }
280
- const decls = attr.value
281
- .split(';')
282
- .map((d) => d.trim())
283
- .filter(Boolean);
284
- if (el.tagName === 'P') {
285
- for (const decl of decls) {
286
- const [prop] = decl.split(':').map((s) => s.trim());
287
- if (prop !== 'text-align')
288
- return false;
289
- }
290
- }
291
- else if (el.tagName === 'SPAN' || el.tagName === 'MARK') {
292
- for (const decl of decls) {
293
- const [prop, value] = decl.split(':').map((s) => s.trim());
294
- // Allow `color: inherit` on <mark> (TipTap stamps it on the
295
- // default highlight to inherit the span's color). Otherwise
296
- // require the prop to be in the hydration whitelist.
297
- if (el.tagName === 'MARK' &&
298
- prop === 'color' &&
299
- value === 'inherit') {
300
- continue;
301
- }
302
- if (!HYDRATION_INLINE_STYLE_PROPS.has(prop))
303
- return false;
304
- }
305
- }
306
- else {
307
- return false;
308
- }
309
- }
310
- return true;
311
- }
312
- /**
313
- * Walk a parsed HTML node tree. Return the joined text content if every
314
- * descendant is hydration noise; return null if any node carries real
315
- * authoring intent (links, partial formatting, custom styles, etc.).
316
- *
317
- * `isFormatTag` differs in how it's allowed to appear: a structural tag
318
- * (P/SPAN/MARK) can have siblings; a format tag (STRONG/EM/U/S) is only
319
- * allowed when it's the SOLE child of its parent. The caller enforces
320
- * that at the iteration site.
321
- */
322
- function extractCanonicalText(node) {
323
- if (node.nodeType === 3 /* TEXT_NODE */)
324
- return node.textContent ?? '';
325
- if (node.nodeType !== 1 /* ELEMENT_NODE */) {
326
- // Comments, CDATA, etc. — treat as opaque (preserve original).
327
- return null;
328
- }
329
- const el = node;
330
- // Attributeless <br> is TipTap's deterministic emission of a hard break.
331
- // Treated as `\n` so a hydrated `<p><span>A<br>B</span></p>` canonicalizes
332
- // to `"A\nB"` and matches the plain-side `"A%0AB"` after newline decoding.
333
- // <br> with any attribute (e.g. `<br data-x>`) bails as before.
334
- if (el.tagName === 'BR') {
335
- return el.attributes.length === 0 ? '\n' : null;
336
- }
337
- const isStructural = STRUCTURAL_HYDRATION_TAGS.has(el.tagName);
338
- const isFormat = FORMAT_HYDRATION_TAGS.has(el.tagName);
339
- if (!isStructural && !isFormat)
340
- return null;
341
- if (!isHydrationOnlyAttributes(el))
342
- return null;
343
- const children = Array.from(el.childNodes);
344
- let out = '';
345
- for (const child of children) {
346
- // Format tags only count as hydration when they wrap the ENTIRE
347
- // content of their parent. If a <strong>/<em>/<u>/<s> appears
348
- // alongside any sibling (text or element), it's partial-content
349
- // formatting — a real edit that can't be expressed via textStyle —
350
- // so bail.
351
- if (child.nodeType === 1 &&
352
- FORMAT_HYDRATION_TAGS.has(child.tagName) &&
353
- children.length > 1) {
354
- return null;
355
- }
356
- const part = extractCanonicalText(child);
357
- if (part === null)
358
- return null;
359
- out += part;
360
- }
361
- return out;
362
- }
363
- /**
364
- * If `value` is TipTap's default-hydration wrapper around plain text
365
- * (only hydration-noise elements wrapping bare text), return that bare
366
- * text. Otherwise return the original string unchanged.
367
- *
368
- * This collapses the BP-5506 phantom where opening a content sidebar —
369
- * or selecting a section on the canvas — mounts every RTE editor at
370
- * once and writes back wrapped HTML with no user-visible delta.
371
- */
372
- function canonicalizeRteString(value) {
373
- // Decode the codebase's URL-encoded newline (`%0A` — see
374
- // `ESCAPED_NEWLINE_CHARACTER`) on the plain-text side. The hydrated side
375
- // emits `<br>` for the same logical newline, and `extractCanonicalText`
376
- // collapses attributeless `<br>` to `\n`, so the two converge.
377
- // `split`/`join` rather than `decodeURIComponent` keeps this narrow —
378
- // we don't want to decode `%20`, `%3C`, etc., which would change real
379
- // user content.
380
- const decodeNewlines = (s) => s.split(ESCAPED_NEWLINE_CHARACTER).join('\n');
381
- // Cheap exit: no tags at all. Still decode `%0A` so a plain-text entry
382
- // matches a hydrated entry whose only diff is `<br>`-vs-newline.
383
- if (!value.includes('<'))
384
- return decodeNewlines(value);
385
- if (isEmptyRteHtml(value))
386
- return '';
387
- // DOMParser is unavailable in some non-browser test environments;
388
- // fall back to leaving the value as-is so the caller produces a
389
- // real diff (i.e. fail open — never falsely collapse).
390
- if (typeof DOMParser === 'undefined')
391
- return decodeNewlines(value);
392
- let doc;
393
- try {
394
- doc = new DOMParser().parseFromString(`<div id="__rte_root__">${value}</div>`, 'text/html');
395
- }
396
- catch {
397
- return decodeNewlines(value);
398
- }
399
- const root = doc.getElementById('__rte_root__');
400
- if (!root)
401
- return decodeNewlines(value);
402
- // Join top-level blocks with `\n`. TipTap renders an Enter keypress as
403
- // a new top-level `<p>` sibling — without the join, `<p>Hello</p>` and
404
- // `<p>Hello</p><p></p>` (or `<p>Hello</p><p>World</p>`) collapse to the
405
- // same canonical text and the Enter is invisible to dedup. The join
406
- // also matches plain-side `%0A` between paragraphs (e.g. stored
407
- // `Hello%0AWorld` ↔ hydrated `<p>Hello</p><p>World</p>`).
408
- const parts = [];
409
- for (const child of Array.from(root.childNodes)) {
410
- const part = extractCanonicalText(child);
411
- if (part === null)
412
- return decodeNewlines(value);
413
- parts.push(part);
414
- }
415
- return parts.join('\n');
416
- }
417
- function normalizeNode(node) {
418
- if (Array.isArray(node))
419
- return node.map(normalizeNode);
420
- if (node === null || typeof node !== 'object')
421
- return node;
422
- const out = {};
423
- for (const key of Object.keys(node)) {
424
- let v = node[key];
425
- // Strip the template-modified flag — it's meta-state that flips on
426
- // first edit gesture (e.g. clicking into a panel) and does not
427
- // represent a user-visible change.
428
- if (key === 'isModifiedAfterTemplateApplied')
429
- continue;
430
- // Collapse RTE default-hydration wrappers down to the plain text
431
- // they wrap. Plain "X" and `<p style="..."><span ...><mark ...>X
432
- // </mark></span></p>` share the same canonical "X" so a sidebar
433
- // open that mounts every editor at once doesn't generate phantom
434
- // undo steps.
435
- if (typeof v === 'string') {
436
- v = canonicalizeRteString(v);
437
- }
438
- // Empty TipTap paragraphs collapse to "" — and an empty string is
439
- // equivalent to the key being absent (JSON.stringify drops `undefined`),
440
- // so skip the key entirely. Otherwise the normalizer would emit
441
- // `"description": ""` here while the baseline snapshot — captured when
442
- // `description` was `undefined` — has no `description` key at all,
443
- // and the two normalized strings would differ.
444
- if (isEmptyRteHtml(v) || v === '')
445
- continue;
446
- // textStyle / descriptionStyle / titleStyle / *Style objects.
447
- //
448
- // When `textStyleType` is set, the live store lazily fills in the
449
- // resolved values (fontFamily, fontSize, color, fontWeight,
450
- // fontStyle, letterSpacing, lineHeight, textDecoration, etc.) the
451
- // first time a settings panel — or section-selection on the canvas
452
- // — resolves `textStyleType` against `styles.textStyles.textStyleOptions`.
453
- // None of those lazy fields represent a real user edit; the
454
- // `textStyleType` itself is the authoring intent.
455
- //
456
- // Real user edits via the inline RTE toolbar set `textStyleType: undefined`
457
- // (see useRichTextEditor.ts `fontStyleChanges = { ..., textStyleType: undefined }`),
458
- // so we leave every key intact in that case — those values ARE the
459
- // real authoring intent.
460
- //
461
- // Per-content style edits via the global Style panel land in
462
- // `snapshot.styles.textStyles` (a separate sub-tree), so they still
463
- // produce a real diff outside this branch.
464
- if (v !== null &&
465
- typeof v === 'object' &&
466
- !Array.isArray(v) &&
467
- /Style$/.test(key)) {
468
- const styleObj = v;
469
- const hasTextStyleType = styleObj.textStyleType !== undefined &&
470
- styleObj.textStyleType !== null;
471
- if (hasTextStyleType) {
472
- // Drop everything except `textStyleType` itself + `textAlign`,
473
- // which is the user's per-instance alignment override (preserved
474
- // on hydration via `globalContentStyle = { ...resolved, textAlign: style?.textAlign }`).
475
- out[key] = {
476
- textStyleType: styleObj.textStyleType,
477
- ...(styleObj.textAlign !== undefined
478
- ? { textAlign: styleObj.textAlign }
479
- : {}),
480
- };
481
- continue;
482
- }
483
- // No textStyleType — these are real per-content overrides. Recurse
484
- // normally so nested objects still get the standard treatment.
485
- out[key] = normalizeNode(styleObj);
486
- continue;
487
- }
488
- out[key] = normalizeNode(v);
489
- }
490
- return out;
491
- }
492
- function normalizeSnapshotForDedup(snapshotStr) {
493
- try {
494
- const parsed = JSON.parse(snapshotStr);
495
- return JSON.stringify(normalizeNode(parsed));
496
- }
497
- catch {
498
- return snapshotStr;
499
- }
500
- }
156
+ const mergeNextFlush = ref(false);
501
157
  /**
502
158
  * Commit a new entry into the stack at the current pointer position.
503
159
  *
@@ -524,19 +180,8 @@ export const useHistoryStore = defineStore('history', () => {
524
180
  */
525
181
  function commitEntry(args) {
526
182
  const current = entries.value[currentIndex.value];
527
- if (current) {
528
- if (current.snapshot === args.snapshot)
529
- return 'noop';
530
- // Phantom-edit dedup: normalize both snapshots to ignore
531
- // sidebar-open reactive cascades (RTE empty-paragraph init, lazy
532
- // textStyle hydration, isModifiedAfterTemplateApplied flips). If
533
- // they're equal after normalization, this capture has no
534
- // user-visible diff vs. the entry at the pointer.
535
- if (normalizeSnapshotForDedup(current.snapshot) ===
536
- normalizeSnapshotForDedup(args.snapshot)) {
537
- return 'noop';
538
- }
539
- }
183
+ if (current && current.snapshot === args.snapshot)
184
+ return 'noop';
540
185
  if (args.snapshot.length > MAX_ENTRY_BYTES) {
541
186
  eventTracker.sendHistoryOversizedEvent({
542
187
  attempted_bytes: args.snapshot.length,
@@ -642,6 +287,17 @@ export const useHistoryStore = defineStore('history', () => {
642
287
  // than the pre-router value captured in `captureBaseline`).
643
288
  const inSettleWindow = currentIndex.value === 0 &&
644
289
  Date.now() - baselineCapturedAt < BASELINE_SETTLE_MS;
290
+ // Merge branch — a hydration write site (TipTap mount, lazy
291
+ // paymentConfig fill, stale-page-ref clear) flagged this flush as
292
+ // "fold into the head, don't push". Replace `entries[currentIndex]`
293
+ // in place so the user-visible undo stack stays exactly as it was
294
+ // before the hydration burst arrived. Consume the flag whether or
295
+ // not we're in the settle window — `inSettleWindow` already merges
296
+ // into the baseline, but past that window the same merge has to
297
+ // happen against the current head for hydrations that fire when the
298
+ // user clicks into a section after the settle elapsed.
299
+ const shouldMerge = mergeNextFlush.value;
300
+ mergeNextFlush.value = false;
645
301
  if (inSettleWindow) {
646
302
  entries.value = [
647
303
  {
@@ -655,6 +311,19 @@ export const useHistoryStore = defineStore('history', () => {
655
311
  ];
656
312
  return;
657
313
  }
314
+ if (shouldMerge && entries.value[currentIndex.value]) {
315
+ const next = entries.value.slice();
316
+ next[currentIndex.value] = {
317
+ ...next[currentIndex.value],
318
+ snapshot,
319
+ timestamp: new Date().toISOString(),
320
+ sizeBytes: snapshot.length,
321
+ activePageId,
322
+ logoEdits,
323
+ };
324
+ entries.value = next;
325
+ return;
326
+ }
658
327
  commitEntry({
659
328
  snapshot,
660
329
  source: 'manual_edit',
@@ -828,16 +497,25 @@ export const useHistoryStore = defineStore('history', () => {
828
497
  }
829
498
  const targetEntry = entries.value[targetIndex];
830
499
  isApplyingSnapshot.value = true;
500
+ // Drop any merge flag a hydration site might have armed but the
501
+ // watcher hasn't yet consumed — otherwise the post-apply watcher
502
+ // tick would silently fold the freshly-restored snapshot into the
503
+ // entry at the new pointer position and erase the user's redo.
504
+ mergeNextFlush.value = false;
505
+ cancelPendingAutoCapture();
831
506
  try {
832
507
  await brandPageMakerStore.applyHistorySnapshot(snapshot, resolvedActivePageId, targetEntry?.logoEdits);
833
508
  await nextTick();
834
509
  // Re-stamp `wrapper.paymentConfig` on every payment-config wrapper in
835
- // the freshly-restored content tree from the live shop store. The
836
- // snapshot is paymentConfig-free (capture-time strip in
837
- // `serializeCurrentSnapshot`), so without this step every wrapper
838
- // would be left with `paymentConfig: undefined` after restore. The
839
- // shop store's value is itself never undoable (see `project-
840
- // guardrails.md` — Stripe Connect linkage is backend-confirmed).
510
+ // the freshly-restored content tree from the live shop store. Stripe
511
+ // Connect linkage is backend-confirmed (per `project-guardrails.md`)
512
+ // and must NEVER come from the snapshot — the captured value can be
513
+ // arbitrarily stale (an entry captured before
514
+ // `PaymentConfigField.mixin.ts:created()` lazily filled in the
515
+ // wrapper carries `paymentConfig: undefined`) and using it would
516
+ // re-surface the "Please enable a payment provider" warning on
517
+ // undo. The shop store's value is itself never undoable (see
518
+ // `applyHistorySnapshot` step 6).
841
519
  restorePaymentConfigsOnLivePages();
842
520
  }
843
521
  finally {
@@ -938,12 +616,30 @@ export const useHistoryStore = defineStore('history', () => {
938
616
  if (!baselineCaptured.value)
939
617
  return;
940
618
  cancelPendingAutoCapture();
619
+ mergeNextFlush.value = false;
941
620
  entries.value = [];
942
621
  currentIndex.value = -1;
943
622
  baselineCaptured.value = false;
944
623
  baselineCapturedAt = 0;
945
624
  captureBaseline();
946
625
  }
626
+ /**
627
+ * Arm the next watcher flush to fold its snapshot into the current head
628
+ * entry instead of pushing a new one. Hydration write sites (TipTap mount,
629
+ * lazy paymentConfig fill, stale-page-ref clear) call this immediately
630
+ * before the imperative `update:modelValue` / `$emit` they know is a
631
+ * hydration mutation. The next flush consumes (clears) the flag.
632
+ *
633
+ * Idempotent — multiple call sites in the same burst all set the same
634
+ * flag and collapse together into a single merge into the current head.
635
+ *
636
+ * No-op when the watcher hasn't yet baselined: hydration writes inside
637
+ * the settle window already merge into the baseline; outside a flagged
638
+ * session the flag has no observable effect.
639
+ */
640
+ function mergeNextMutation() {
641
+ mergeNextFlush.value = true;
642
+ }
947
643
  // --- Instrumentation (NFR-04a) ---
948
644
  /** Sum of all entries' serialized sizes in bytes. */
949
645
  function getCurrentSizeBytes() {
@@ -967,6 +663,7 @@ export const useHistoryStore = defineStore('history', () => {
967
663
  capture,
968
664
  captureTemplateSwitch,
969
665
  captureAiChangeset,
666
+ mergeNextMutation,
970
667
  undo,
971
668
  redo,
972
669
  reset,
@@ -19,7 +19,7 @@ export const useBrandPageMakerUserStore = defineStore('brandPageMakerUser', {
19
19
  canUseGettyVideos: false,
20
20
  canUseGoogleAdsense: false,
21
21
  googleAdsenseEnabled: false,
22
- canUseUndoRedo: false,
22
+ canUseUndoRedo: true,
23
23
  },
24
24
  emailAddress: '',
25
25
  hasPremiumSubscription: false,
package/lib/src/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { A as E, a as T, b as s, c as I, d as S, e as N, f as e, B as A, g as O, h as C, i as G, j as D, k as P, l as t, m as R, n as L, o as M, p as o, q as r, r as b, s as n, t as F, u as i, v as l, w as g, x as B, y as c, z as Y, C as U, D as d, E as u, F as H, G as p, H as y, I as K, J as k, K as m, L as V, M as X, N as h, O as v, P as W, Q as f, R as x, S as z, T as w, U as Z, V as j, W as Q, X as q, Y as J, Z as $, $ as aa, a0 as _a, a1 as Ea, a2 as Ta, a3 as sa, a4 as Ia, a5 as Sa, a6 as Na, a7 as ea, a8 as Aa, a9 as Oa, aa as Ca, ab as Ga, ac as Da, ad as Pa, ae as ta, af as Ra, ag as La, ah as Ma, ai as oa, aj as ra, ak as ba, al as na, am as Fa, an as ia, ao as la, ap as ga, aq as Ba, ar as ca, as as Ya, at as Ua, au as da, av as ua, aw as Ha, ax as pa, ay as ya, az as Ka, aA as ka, aB as ma, aC as Va, aD as Xa, aE as ha, aF as va, aG as Wa, aH as fa, aI as xa, aJ as za, aK as wa, aL as Za, aM as ja, aN as Qa, aO as qa, aP as Ja, aQ as $a, aR as a_, aS as __, aT as E_, aU as T_, aV as s_, aW as I_, aX as S_, aY as N_, aZ as e_, a_ as A_, a$ as O_, b0 as C_, b1 as G_, b2 as D_, b3 as P_, b4 as t_, b5 as R_, b6 as L_, b7 as M_, b8 as o_, b9 as r_, ba as b_, bb as n_, bc as F_, bd as i_, be as l_, bf as g_, bg as B_, bh as c_, bi as Y_, bj as U_, bk as d_, bl as u_, bm as H_, bn as p_, bo as y_, bp as K_, bq as k_, br as m_, bs as V_, bt as X_, bu as h_, bv as v_, bw as W_, bx as f_, by as x_, bz as z_, bA as w_, bB as Z_, bC as j_, bD as Q_, bE as q_, bF as J_, bG as $_, bH as aE, bI as _E, bJ as EE, bK as TE, bL as sE, bM as IE, bN as SE, bO as NE, bP as eE, bQ as AE, bR as OE, bS as CE, bT as GE, bU as DE, bV as PE, bW as tE, bX as RE, bY as LE, bZ as ME, b_ as oE, b$ as rE, c0 as bE, c1 as nE, c2 as FE, c3 as iE, c4 as lE, c5 as gE, c6 as BE, c7 as cE, c8 as YE, c9 as UE, ca as dE, cb as uE, cc as HE, cd as pE, ce as yE, cf as KE, cg as kE, ch as mE, ci as VE, cj as XE, ck as hE, cl as vE, cm as WE, cn as fE, co as xE } from "./lib-D_xnR4VH.js";
1
+ import { A as E, a as T, b as s, c as I, d as S, e as N, f as e, B as A, g as O, h as C, i as G, j as D, k as P, l as t, m as R, n as L, o as M, p as o, q as r, r as b, s as n, t as F, u as i, v as l, w as g, x as B, y as c, z as Y, C as U, D as d, E as u, F as H, G as p, H as y, I as K, J as k, K as m, L as V, M as X, N as h, O as v, P as W, Q as f, R as x, S as z, T as w, U as Z, V as j, W as Q, X as q, Y as J, Z as $, $ as aa, a0 as _a, a1 as Ea, a2 as Ta, a3 as sa, a4 as Ia, a5 as Sa, a6 as Na, a7 as ea, a8 as Aa, a9 as Oa, aa as Ca, ab as Ga, ac as Da, ad as Pa, ae as ta, af as Ra, ag as La, ah as Ma, ai as oa, aj as ra, ak as ba, al as na, am as Fa, an as ia, ao as la, ap as ga, aq as Ba, ar as ca, as as Ya, at as Ua, au as da, av as ua, aw as Ha, ax as pa, ay as ya, az as Ka, aA as ka, aB as ma, aC as Va, aD as Xa, aE as ha, aF as va, aG as Wa, aH as fa, aI as xa, aJ as za, aK as wa, aL as Za, aM as ja, aN as Qa, aO as qa, aP as Ja, aQ as $a, aR as a_, aS as __, aT as E_, aU as T_, aV as s_, aW as I_, aX as S_, aY as N_, aZ as e_, a_ as A_, a$ as O_, b0 as C_, b1 as G_, b2 as D_, b3 as P_, b4 as t_, b5 as R_, b6 as L_, b7 as M_, b8 as o_, b9 as r_, ba as b_, bb as n_, bc as F_, bd as i_, be as l_, bf as g_, bg as B_, bh as c_, bi as Y_, bj as U_, bk as d_, bl as u_, bm as H_, bn as p_, bo as y_, bp as K_, bq as k_, br as m_, bs as V_, bt as X_, bu as h_, bv as v_, bw as W_, bx as f_, by as x_, bz as z_, bA as w_, bB as Z_, bC as j_, bD as Q_, bE as q_, bF as J_, bG as $_, bH as aE, bI as _E, bJ as EE, bK as TE, bL as sE, bM as IE, bN as SE, bO as NE, bP as eE, bQ as AE, bR as OE, bS as CE, bT as GE, bU as DE, bV as PE, bW as tE, bX as RE, bY as LE, bZ as ME, b_ as oE, b$ as rE, c0 as bE, c1 as nE, c2 as FE, c3 as iE, c4 as lE, c5 as gE, c6 as BE, c7 as cE, c8 as YE, c9 as UE, ca as dE, cb as uE, cc as HE, cd as pE, ce as yE, cf as KE, cg as kE, ch as mE, ci as VE, cj as XE, ck as hE, cl as vE, cm as WE, cn as fE, co as xE } from "./lib-BBItimSg.js";
2
2
  import { createPinia as wE, setActivePinia as ZE } from "pinia";
3
3
  export {
4
4
  E as ABOUT_SECTION_SETTINGS_CONFIG,