@designcrowd/library.brand-page 5.18.1-undo-27 → 5.18.1-undo-29

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 (34) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-COk1RbBS.js → CreatedOnBrandCrowd-de-de-BxL6mTJW.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-CB3iIEMt.js → CreatedOnBrandCrowd-en-us-BPbjqfkj.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-CRU0kUB6.js → CreatedOnBrandCrowd-es-es-DDsoe143.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-C5DdVGnT.js → CreatedOnBrandCrowd-fr-ca-Dak7ppy_.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-C5DdVGnT.js → CreatedOnBrandCrowd-fr-fr-Dak7ppy_.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-2L9tw0-m.js → CreatedOnBrandCrowd-pt-br-BUAneEVD.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-2L9tw0-m.js → CreatedOnBrandCrowd-pt-pt-BUAneEVD.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-BDHBukLE.js → CreatedOnDcom-de-de-BJ00bTz_.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-D0vKhTnI.js → CreatedOnDcom-en-us-B0V1M9Hs.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-CCYUHgfO.js → CreatedOnDcom-es-es-YDMLB7u6.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-DnPc5h-w.js → CreatedOnDcom-fr-ca-DppQ26s1.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-CvWbnzbp.js → CreatedOnDcom-fr-fr-0Xi-3slB.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-DdMZYZog.js → CreatedOnDcom-pt-br-De-wHqSu.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-BPvriH9Y.js → CreatedOnDcom-pt-pt-CQGAsn5D.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/brandpage-maker/brandpage-maker-store.js +37 -4
  17. package/lib/src/brand-page-maker/stores/history/history-store.d.ts +4 -1
  18. package/lib/src/brand-page-maker/stores/history/history-store.js +103 -443
  19. package/lib/src/brand-page-maker/stores/user/user-store.js +1 -1
  20. package/lib/src/index.mjs +1 -1
  21. package/lib/src/{lib-CcYKXNUY.js → lib-YRlMax_i.js} +21157 -21284
  22. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.d.ts +64 -0
  23. package/lib/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.js +12 -0
  24. package/package.json +1 -1
  25. package/src/brand-page-maker/components/quick-link-bar/undo-redo-buttons.test.ts +116 -0
  26. package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +15 -2
  27. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +46 -4
  28. package/src/brand-page-maker/stores/history/history-store.test.ts +184 -894
  29. package/src/brand-page-maker/stores/history/history-store.ts +108 -521
  30. package/src/brand-page-maker/stores/user/user-store.ts +1 -1
  31. package/src/shared/content-settings/components/page-dropdown-field/PageDropdownField.vue +7 -0
  32. package/src/shared/content-settings/components/payment-config-field/PaymentConfigField.mixin.ts +13 -0
  33. package/src/brand-page-maker/components/quick-link-bar/RedoButton.test.ts +0 -166
  34. package/src/brand-page-maker/components/quick-link-bar/UndoButton.test.ts +0 -166
@@ -5,8 +5,8 @@ import { useAiWebsiteBuilderThreadStore } from '../ai-thread/ai-website-builder-
5
5
  import { useEventTracker } from '../../composables/useEventTracker';
6
6
  import { usePageNavigationStore } from '@shared/page-navigation/store/page-navigation-store';
7
7
  import { useUiEditableLogoStore } from '@shared/logo-settings/store/editable-logo-store';
8
+ import { useShopConfigStore } from '@shared/ecommerce-settings/store/shop-settings-store';
8
9
  import { useUiPageSettingsStore } from '@shared/page-settings/store';
9
- import { ESCAPED_NEWLINE_CHARACTER } from '@src/constants/ui';
10
10
  /**
11
11
  * Single-stack history model with a pointer instead of two stacks.
12
12
  *
@@ -49,33 +49,22 @@ const MAX_ENTRY_BYTES = 25 * 1024 * 1024;
49
49
  // reactive cascades (template loadPageNo, router afterEach, layout settle)
50
50
  // that would otherwise make undo light up before the user has done anything.
51
51
  const BASELINE_SETTLE_MS = 2000;
52
- // Content-level wrapper keys whose `paymentConfig` child is the user's
53
- // Stripe Connect linkage — backend-confirmed state, not native authoring
54
- // intent (per `project-guardrails.md`). It must be invariant under undo /
55
- // redo regardless of what other edits the user makes.
52
+ // Content-level wrapper keys whose `paymentConfig` child is a denormalised
53
+ // cache of `shopConfigStore.paymentConfig` (the user's Stripe Connect
54
+ // linkage). Stripe Connect linkage state is backend-confirmed (per
55
+ // `project-guardrails.md`) and must NEVER ride along on the undo/redo stack.
56
56
  //
57
- // paymentConfig is kept invariant by `applySnapshotString`:
58
- // - Before applying the target snapshot, harvest each live
59
- // wrapper.paymentConfig keyed by content id.
60
- // - applyHistorySnapshot rebuilds page contents from the snapshot
61
- // (which may carry a stale or absent paymentConfig).
62
- // - After the apply resolves, walk the rebuilt live pages and re-stamp
63
- // wrapper.paymentConfig from the harvest, so each wrapper holds the
64
- // same paymentConfig it had immediately before the apply.
65
- //
66
- // Snapshots themselves still carry paymentConfig (we don't strip at
67
- // capture time) — there's nothing to gain from stripping when the live
68
- // value is preserved across the apply, and keeping snapshots whole avoids
69
- // any data-loss risk for any future code path that might read paymentConfig
70
- // from a snapshot directly. The dedup-side strip below ignores
71
- // `paymentConfig` for the byte-compare so a click-without-edit doesn't
72
- // generate a phantom entry.
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.
73
63
  const PAYMENT_CONFIG_WRAPPER_KEYS = [
74
64
  'supportMeConfig',
75
65
  'requestPaymentConfig',
76
66
  'shopPaymentConfig',
77
67
  ];
78
- const PAYMENT_CONFIG_WRAPPER_KEY_SET = new Set(PAYMENT_CONFIG_WRAPPER_KEYS);
79
68
  export const useHistoryStore = defineStore('history', () => {
80
69
  const eventTracker = useEventTracker();
81
70
  // --- State ---
@@ -140,357 +129,31 @@ export const useHistoryStore = defineStore('history', () => {
140
129
  }
141
130
  }
142
131
  /**
143
- * Normalize a snapshot for the dedup byte-compare. Strips/canonicalizes
144
- * fields that get mutated by sidebar-open reactive cascades but aren't
145
- * user-visible edits — without this, opening a content panel produces an
146
- * undo step that "does nothing" when replayed.
147
- *
148
- * Known phantom sources (BP-5506):
149
- * - TipTap RTE writing back its empty-paragraph default on first mount
150
- * (e.g. `""` → `<p style="text-align: center;"></p>`).
151
- * - TipTap RTE writing back its default-style hydration on first mount
152
- * (e.g. plain `"Hello"` → `<p style="..."><span style="..."><mark ...>Hello</mark></span></p>`).
153
- * - TipTap re-emitting whole-content `<strong>`/`<em>`/`<u>`/`<s>` tags
154
- * to mirror the resolved `textStyle.fontWeight / fontStyle /
155
- * textDecoration` (e.g. plain `"text"` →
156
- * `<p>...<span>...<strong>text</strong></span></p>`).
157
- * - TipTap rendering the codebase's URL-encoded newline
158
- * (`ESCAPED_NEWLINE_CHARACTER` = `%0A`) as `<br>` on first hydration
159
- * (e.g. plain `"A%0AB"` → `<p><span>A<br>B</span></p>`). Plain-side
160
- * `%0A` is decoded to `\n` and attributeless `<br>` is collapsed to
161
- * `\n` in `extractCanonicalText`, so the two sides converge.
162
- * - Lazy hydration of `*Style` objects when a content settings panel —
163
- * or section selection on the canvas — resolves `textStyleType` and
164
- * writes the full resolved style (fontFamily, fontSize, color,
165
- * fontWeight, fontStyle, letterSpacing, lineHeight, textDecoration)
166
- * onto the model. The `textStyleType` itself is the authoring
167
- * intent; the resolved keys are just cached values.
168
- * - `isModifiedAfterTemplateApplied` flipping on first edit gesture.
169
- *
170
- * Note: `logoImageBase64Url` (the rendered logo PNG) is NOT stripped here.
171
- * Pre-purchase logo-tab edits (text, support text, logo colors, fonts)
172
- * land in `editableLogoStore.logoEdits` (sidecar — see
173
- * `HistoryEntry.logoEdits`) AND trigger a fabric-render that writes the
174
- * new base64 into `pageHeaderDesktop.logoImageBase64Url` + per-page
175
- * logo content. The base64 IS the canonical signal that a logo edit
176
- * happened; stripping it would dedup real logo edits as no-ops. The
177
- * 2-second `BASELINE_SETTLE_MS` window already collapses initial-load
178
- * base64 churn into the baseline.
132
+ * One-shot "fold next mutation back into the baseline (or current entry)
133
+ * rather than pushing a new entry" flag.
179
134
  *
180
- * RTE strings are canonicalized by parsing the HTML with DOMParser and
181
- * walking the tree; only attributeless format wrappers (and a tightly
182
- * scoped set of hydration styles on `<p>` / `<span>` / `<mark>`) are
183
- * collapsed. Real authoring intent (links, custom colors, partial-text
184
- * formatting like `Hello <strong>world</strong>`) survives the walk
185
- * because at least one node falls outside the hydration whitelist,
186
- * and the walker bails to "preserve original" the moment that happens.
187
- *
188
- * Used ONLY for dedup comparison. Stored snapshots remain unchanged so
189
- * `applyHistorySnapshot` always sees the real state.
190
- */
191
- // Tags TipTap re-emits purely to mirror the resolved textStyle on first
192
- // hydration. None carry user authoring intent on their own when they are
193
- // the sole element-child wrapping the entire content:
194
- // - <p>: only its `text-align` style is meaningful, and that's already
195
- // captured in `textStyle.textAlign`.
196
- // - <span>: TipTap wraps content in a span with `font-family`, `font-size`,
197
- // `color`, `letter-spacing`, `line-height` resolved from the
198
- // textStyle. The textStyle object on the same content is the
199
- // authoritative source.
200
- // - <mark>: TipTap's highlight extension default — collapses regardless
201
- // of `data-color` / `style` attrs, mirroring the original
202
- // regex's behavior on a hydrated highlight.
203
- // - <strong>/<em>/<u>/<s>: attributeless tags that mirror
204
- // `fontWeight: bold` / `fontStyle: italic` / `textDecoration:
205
- // underline / line-through`. ONLY recognized as hydration when
206
- // they are the sole child of their parent — partial-content
207
- // usage like `Hello <strong>world</strong>` is a real edit.
208
- // Anything else (`<a>`, `<br>`, `<span style="color: red">`, attributes on
209
- // <strong>/<em>, etc.) is preserved as a real diff.
210
- const STRUCTURAL_HYDRATION_TAGS = new Set(['P', 'SPAN', 'MARK']);
211
- const FORMAT_HYDRATION_TAGS = new Set(['STRONG', 'EM', 'U', 'S']);
212
- // Default style properties TipTap emits on a hydration <span> /
213
- // <mark>. The textStyle resolver writes all of these onto the live
214
- // model, so when TipTap re-emits them as inline style on its
215
- // hydration span/mark there is no user-visible delta.
216
- // Anything outside this whitelist (e.g. `text-decoration` mid-content,
217
- // `text-shadow`, `text-transform`) is treated as a real edit.
218
- const HYDRATION_INLINE_STYLE_PROPS = new Set([
219
- 'font-family',
220
- 'font-size',
221
- 'letter-spacing',
222
- 'line-height',
223
- 'color',
224
- 'background-color',
225
- ]);
226
- // Empty paragraph forms TipTap emits when an editor mounts on an empty
227
- // string. Treated as equivalent to `""` for dedup.
228
- function isEmptyRteHtml(value) {
229
- if (typeof value !== 'string')
230
- return false;
231
- return /^<p(\s[^>]*)?>(<br\s*\/?>)?<\/p>$/.test(value);
232
- }
233
- function isHydrationOnlyAttributes(el) {
234
- // Format tags (<strong>/<em>/<u>/<s>) must be attributeless. Anything
235
- // with class/style/data-*/etc. is a real edit.
236
- if (FORMAT_HYDRATION_TAGS.has(el.tagName))
237
- return el.attributes.length === 0;
238
- for (const attr of Array.from(el.attributes)) {
239
- // <mark> may carry a `data-color` attr (TipTap highlight extension's
240
- // default-and-resolved value). The accompanying `style` is checked
241
- // against the same hydration-prop whitelist as <span>.
242
- if (el.tagName === 'MARK' &&
243
- (attr.name === 'data-color' || attr.name === 'style')) {
244
- if (attr.name === 'data-color')
245
- continue;
246
- }
247
- else if (attr.name !== 'style') {
248
- return false;
249
- }
250
- const decls = attr.value
251
- .split(';')
252
- .map((d) => d.trim())
253
- .filter(Boolean);
254
- if (el.tagName === 'P') {
255
- for (const decl of decls) {
256
- const [prop] = decl.split(':').map((s) => s.trim());
257
- if (prop !== 'text-align')
258
- return false;
259
- }
260
- }
261
- else if (el.tagName === 'SPAN' || el.tagName === 'MARK') {
262
- for (const decl of decls) {
263
- const [prop, value] = decl.split(':').map((s) => s.trim());
264
- // Allow `color: inherit` on <mark> (TipTap stamps it on the
265
- // default highlight to inherit the span's color). Otherwise
266
- // require the prop to be in the hydration whitelist.
267
- if (el.tagName === 'MARK' &&
268
- prop === 'color' &&
269
- value === 'inherit') {
270
- continue;
271
- }
272
- if (!HYDRATION_INLINE_STYLE_PROPS.has(prop))
273
- return false;
274
- }
275
- }
276
- else {
277
- return false;
278
- }
279
- }
280
- return true;
281
- }
282
- /**
283
- * Walk a parsed HTML node tree. Return the joined text content if every
284
- * descendant is hydration noise; return null if any node carries real
285
- * authoring intent (links, partial formatting, custom styles, etc.).
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.
286
141
  *
287
- * `isFormatTag` differs in how it's allowed to appear: a structural tag
288
- * (P/SPAN/MARK) can have siblings; a format tag (STRONG/EM/U/S) is only
289
- * allowed when it's the SOLE child of its parent. The caller enforces
290
- * that at the iteration site.
291
- */
292
- function extractCanonicalText(node) {
293
- if (node.nodeType === 3 /* TEXT_NODE */)
294
- return node.textContent ?? '';
295
- if (node.nodeType !== 1 /* ELEMENT_NODE */) {
296
- // Comments, CDATA, etc. — treat as opaque (preserve original).
297
- return null;
298
- }
299
- const el = node;
300
- // Attributeless <br> is TipTap's deterministic emission of a hard break.
301
- // Treated as `\n` so a hydrated `<p><span>A<br>B</span></p>` canonicalizes
302
- // to `"A\nB"` and matches the plain-side `"A%0AB"` after newline decoding.
303
- // <br> with any attribute (e.g. `<br data-x>`) bails as before.
304
- if (el.tagName === 'BR') {
305
- return el.attributes.length === 0 ? '\n' : null;
306
- }
307
- const isStructural = STRUCTURAL_HYDRATION_TAGS.has(el.tagName);
308
- const isFormat = FORMAT_HYDRATION_TAGS.has(el.tagName);
309
- if (!isStructural && !isFormat)
310
- return null;
311
- if (!isHydrationOnlyAttributes(el))
312
- return null;
313
- const children = Array.from(el.childNodes);
314
- let out = '';
315
- for (const child of children) {
316
- // Format tags only count as hydration when they wrap the ENTIRE
317
- // content of their parent. If a <strong>/<em>/<u>/<s> appears
318
- // alongside any sibling (text or element), it's partial-content
319
- // formatting — a real edit that can't be expressed via textStyle —
320
- // so bail.
321
- if (child.nodeType === 1 &&
322
- FORMAT_HYDRATION_TAGS.has(child.tagName) &&
323
- children.length > 1) {
324
- return null;
325
- }
326
- const part = extractCanonicalText(child);
327
- if (part === null)
328
- return null;
329
- out += part;
330
- }
331
- return out;
332
- }
333
- /**
334
- * If `value` is TipTap's default-hydration wrapper around plain text
335
- * (only hydration-noise elements wrapping bare text), return that bare
336
- * text. Otherwise return the original string unchanged.
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.
337
151
  *
338
- * This collapses the BP-5506 phantom where opening a content sidebar —
339
- * or selecting a section on the canvas — mounts every RTE editor at
340
- * once and writes back wrapped HTML with no user-visible delta.
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.
341
155
  */
342
- function canonicalizeRteString(value) {
343
- // Decode the codebase's URL-encoded newline (`%0A` — see
344
- // `ESCAPED_NEWLINE_CHARACTER`) on the plain-text side. The hydrated side
345
- // emits `<br>` for the same logical newline, and `extractCanonicalText`
346
- // collapses attributeless `<br>` to `\n`, so the two converge.
347
- // `split`/`join` rather than `decodeURIComponent` keeps this narrow —
348
- // we don't want to decode `%20`, `%3C`, etc., which would change real
349
- // user content.
350
- const decodeNewlines = (s) => s.split(ESCAPED_NEWLINE_CHARACTER).join('\n');
351
- // Cheap exit: no tags at all. Still decode `%0A` so a plain-text entry
352
- // matches a hydrated entry whose only diff is `<br>`-vs-newline.
353
- if (!value.includes('<'))
354
- return decodeNewlines(value);
355
- if (isEmptyRteHtml(value))
356
- return '';
357
- // DOMParser is unavailable in some non-browser test environments;
358
- // fall back to leaving the value as-is so the caller produces a
359
- // real diff (i.e. fail open — never falsely collapse).
360
- if (typeof DOMParser === 'undefined')
361
- return decodeNewlines(value);
362
- let doc;
363
- try {
364
- doc = new DOMParser().parseFromString(`<div id="__rte_root__">${value}</div>`, 'text/html');
365
- }
366
- catch {
367
- return decodeNewlines(value);
368
- }
369
- const root = doc.getElementById('__rte_root__');
370
- if (!root)
371
- return decodeNewlines(value);
372
- // Join top-level blocks with `\n`. TipTap renders an Enter keypress as
373
- // a new top-level `<p>` sibling — without the join, `<p>Hello</p>` and
374
- // `<p>Hello</p><p></p>` (or `<p>Hello</p><p>World</p>`) collapse to the
375
- // same canonical text and the Enter is invisible to dedup. The join
376
- // also matches plain-side `%0A` between paragraphs (e.g. stored
377
- // `Hello%0AWorld` ↔ hydrated `<p>Hello</p><p>World</p>`).
378
- const parts = [];
379
- for (const child of Array.from(root.childNodes)) {
380
- const part = extractCanonicalText(child);
381
- if (part === null)
382
- return decodeNewlines(value);
383
- parts.push(part);
384
- }
385
- return parts.join('\n');
386
- }
387
- function normalizeNode(node) {
388
- if (Array.isArray(node))
389
- return node.map(normalizeNode);
390
- if (node === null || typeof node !== 'object')
391
- return node;
392
- const out = {};
393
- for (const key of Object.keys(node)) {
394
- let v = node[key];
395
- // Strip the template-modified flag — it's meta-state that flips on
396
- // first edit gesture (e.g. clicking into a panel) and does not
397
- // represent a user-visible change.
398
- if (key === 'isModifiedAfterTemplateApplied')
399
- continue;
400
- // Strip lazily-hydrated `paymentConfig` from payment-config wrappers
401
- // (`supportMeConfig` / `requestPaymentConfig` / `shopPaymentConfig`)
402
- // for the dedup byte-compare. paymentConfig is preserved across
403
- // every undo/redo by `applySnapshotString`'s harvest+re-stamp pair,
404
- // so capturing a wrapper-only diff (e.g. lazy hydration on first
405
- // section click) would generate a phantom undo entry that, on
406
- // playback, has no user-visible effect. Same reasoning as the
407
- // `*Style` / RTE hydration carve-outs above. See
408
- // `PAYMENT_CONFIG_WRAPPER_KEYS`.
409
- if (PAYMENT_CONFIG_WRAPPER_KEY_SET.has(key) &&
410
- v !== null &&
411
- typeof v === 'object' &&
412
- !Array.isArray(v) &&
413
- 'paymentConfig' in v) {
414
- const wrapper = v;
415
- const stripped = {};
416
- for (const k of Object.keys(wrapper)) {
417
- if (k === 'paymentConfig')
418
- continue;
419
- stripped[k] = wrapper[k];
420
- }
421
- v = stripped;
422
- }
423
- // Collapse RTE default-hydration wrappers down to the plain text
424
- // they wrap. Plain "X" and `<p style="..."><span ...><mark ...>X
425
- // </mark></span></p>` share the same canonical "X" so a sidebar
426
- // open that mounts every editor at once doesn't generate phantom
427
- // undo steps.
428
- if (typeof v === 'string') {
429
- v = canonicalizeRteString(v);
430
- }
431
- // Empty TipTap paragraphs collapse to "" — and an empty string is
432
- // equivalent to the key being absent (JSON.stringify drops `undefined`),
433
- // so skip the key entirely. Otherwise the normalizer would emit
434
- // `"description": ""` here while the baseline snapshot — captured when
435
- // `description` was `undefined` — has no `description` key at all,
436
- // and the two normalized strings would differ.
437
- if (isEmptyRteHtml(v) || v === '')
438
- continue;
439
- // textStyle / descriptionStyle / titleStyle / *Style objects.
440
- //
441
- // When `textStyleType` is set, the live store lazily fills in the
442
- // resolved values (fontFamily, fontSize, color, fontWeight,
443
- // fontStyle, letterSpacing, lineHeight, textDecoration, etc.) the
444
- // first time a settings panel — or section-selection on the canvas
445
- // — resolves `textStyleType` against `styles.textStyles.textStyleOptions`.
446
- // None of those lazy fields represent a real user edit; the
447
- // `textStyleType` itself is the authoring intent.
448
- //
449
- // Real user edits via the inline RTE toolbar set `textStyleType: undefined`
450
- // (see useRichTextEditor.ts `fontStyleChanges = { ..., textStyleType: undefined }`),
451
- // so we leave every key intact in that case — those values ARE the
452
- // real authoring intent.
453
- //
454
- // Per-content style edits via the global Style panel land in
455
- // `snapshot.styles.textStyles` (a separate sub-tree), so they still
456
- // produce a real diff outside this branch.
457
- if (v !== null &&
458
- typeof v === 'object' &&
459
- !Array.isArray(v) &&
460
- /Style$/.test(key)) {
461
- const styleObj = v;
462
- const hasTextStyleType = styleObj.textStyleType !== undefined &&
463
- styleObj.textStyleType !== null;
464
- if (hasTextStyleType) {
465
- // Drop everything except `textStyleType` itself + `textAlign`,
466
- // which is the user's per-instance alignment override (preserved
467
- // on hydration via `globalContentStyle = { ...resolved, textAlign: style?.textAlign }`).
468
- out[key] = {
469
- textStyleType: styleObj.textStyleType,
470
- ...(styleObj.textAlign !== undefined
471
- ? { textAlign: styleObj.textAlign }
472
- : {}),
473
- };
474
- continue;
475
- }
476
- // No textStyleType — these are real per-content overrides. Recurse
477
- // normally so nested objects still get the standard treatment.
478
- out[key] = normalizeNode(styleObj);
479
- continue;
480
- }
481
- out[key] = normalizeNode(v);
482
- }
483
- return out;
484
- }
485
- function normalizeSnapshotForDedup(snapshotStr) {
486
- try {
487
- const parsed = JSON.parse(snapshotStr);
488
- return JSON.stringify(normalizeNode(parsed));
489
- }
490
- catch {
491
- return snapshotStr;
492
- }
493
- }
156
+ const mergeNextFlush = ref(false);
494
157
  /**
495
158
  * Commit a new entry into the stack at the current pointer position.
496
159
  *
@@ -517,19 +180,8 @@ export const useHistoryStore = defineStore('history', () => {
517
180
  */
518
181
  function commitEntry(args) {
519
182
  const current = entries.value[currentIndex.value];
520
- if (current) {
521
- if (current.snapshot === args.snapshot)
522
- return 'noop';
523
- // Phantom-edit dedup: normalize both snapshots to ignore
524
- // sidebar-open reactive cascades (RTE empty-paragraph init, lazy
525
- // textStyle hydration, isModifiedAfterTemplateApplied flips). If
526
- // they're equal after normalization, this capture has no
527
- // user-visible diff vs. the entry at the pointer.
528
- if (normalizeSnapshotForDedup(current.snapshot) ===
529
- normalizeSnapshotForDedup(args.snapshot)) {
530
- return 'noop';
531
- }
532
- }
183
+ if (current && current.snapshot === args.snapshot)
184
+ return 'noop';
533
185
  if (args.snapshot.length > MAX_ENTRY_BYTES) {
534
186
  eventTracker.sendHistoryOversizedEvent({
535
187
  attempted_bytes: args.snapshot.length,
@@ -635,6 +287,17 @@ export const useHistoryStore = defineStore('history', () => {
635
287
  // than the pre-router value captured in `captureBaseline`).
636
288
  const inSettleWindow = currentIndex.value === 0 &&
637
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;
638
301
  if (inSettleWindow) {
639
302
  entries.value = [
640
303
  {
@@ -648,6 +311,19 @@ export const useHistoryStore = defineStore('history', () => {
648
311
  ];
649
312
  return;
650
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
+ }
651
327
  commitEntry({
652
328
  snapshot,
653
329
  source: 'manual_edit',
@@ -820,95 +496,60 @@ export const useHistoryStore = defineStore('history', () => {
820
496
  }
821
497
  }
822
498
  const targetEntry = entries.value[targetIndex];
823
- // Harvest the LIVE `wrapper.paymentConfig` from every payment-config
824
- // wrapper BEFORE applying the snapshot. The snapshot may carry a
825
- // stale or absent paymentConfig (e.g. baseline captured before the
826
- // wrapper was hydrated), but the live value reflects the current
827
- // truth — wherever it came from (server bootstrap, Stripe Connect
828
- // onboarding via PaymentConfigField, or the shop store's lazy hydration).
829
- // We re-stamp this harvest onto the rebuilt live wrappers after the
830
- // apply so paymentConfig is invariant under undo/redo regardless of
831
- // what other content edits the user made. See `PAYMENT_CONFIG_WRAPPER_KEYS`.
832
- const paymentConfigHarvest = harvestLivePaymentConfigsByContentId();
833
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();
834
506
  try {
835
507
  await brandPageMakerStore.applyHistorySnapshot(snapshot, resolvedActivePageId, targetEntry?.logoEdits);
836
508
  await nextTick();
837
- restorePaymentConfigsFromHarvest(paymentConfigHarvest);
509
+ // Re-stamp `wrapper.paymentConfig` on every payment-config wrapper in
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).
519
+ restorePaymentConfigsOnLivePages();
838
520
  }
839
521
  finally {
840
522
  isApplyingSnapshot.value = false;
841
523
  }
842
524
  }
843
- function harvestLivePaymentConfigsByContentId() {
844
- const out = new Map();
845
- try {
846
- const pageSettingsStore = useUiPageSettingsStore();
847
- for (const page of pageSettingsStore.pages) {
848
- for (const item of page.contents) {
849
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
850
- const content = item.settings.content;
851
- const id = content?.id;
852
- if (!id)
853
- continue;
854
- let entryRecord = undefined;
855
- for (const key of PAYMENT_CONFIG_WRAPPER_KEYS) {
856
- const wrapper = content?.[key];
857
- if (wrapper &&
858
- typeof wrapper === 'object' &&
859
- !Array.isArray(wrapper) &&
860
- 'paymentConfig' in wrapper) {
861
- if (!entryRecord)
862
- entryRecord = {};
863
- entryRecord[key] = wrapper.paymentConfig;
864
- }
865
- }
866
- if (entryRecord)
867
- out.set(id, entryRecord);
868
- }
869
- }
870
- }
871
- catch (error) {
872
- console.error('history-store: failed to harvest live paymentConfig before apply', error);
873
- }
874
- return out;
875
- }
876
525
  /**
877
- * Re-stamp `wrapper.paymentConfig` onto every matching live wrapper from
878
- * the pre-apply harvest. Wrappers whose content id is missing from the
879
- * harvest are left as-is (e.g. a content item that didn't exist before
880
- * this restore — there's nothing to preserve).
526
+ * Walk the live `pageSettingsStore.pages` and stamp
527
+ * `wrapper.paymentConfig = shopConfigStore.paymentConfig` on every
528
+ * payment-config wrapper. Called from `applySnapshotString` after
529
+ * `applyHistorySnapshot` rebuilds page contents.
881
530
  */
882
- function restorePaymentConfigsFromHarvest(harvest) {
883
- if (harvest.size === 0)
884
- return;
531
+ function restorePaymentConfigsOnLivePages() {
885
532
  try {
533
+ const shopStore = useShopConfigStore();
886
534
  const pageSettingsStore = useUiPageSettingsStore();
535
+ const livePaymentConfig = shopStore.paymentConfig;
887
536
  for (const page of pageSettingsStore.pages) {
888
537
  for (const item of page.contents) {
889
538
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
890
539
  const content = item.settings.content;
891
- const id = content?.id;
892
- if (!id)
893
- continue;
894
- const harvestedRecord = harvest.get(id);
895
- if (!harvestedRecord)
896
- continue;
897
540
  for (const key of PAYMENT_CONFIG_WRAPPER_KEYS) {
898
- if (!(key in harvestedRecord))
899
- continue;
900
541
  const wrapper = content?.[key];
901
542
  if (wrapper &&
902
543
  typeof wrapper === 'object' &&
903
544
  !Array.isArray(wrapper)) {
904
- wrapper.paymentConfig = harvestedRecord[key];
545
+ wrapper.paymentConfig = livePaymentConfig;
905
546
  }
906
547
  }
907
548
  }
908
549
  }
909
550
  }
910
551
  catch (error) {
911
- console.error('history-store: failed to restore paymentConfig from harvest', error);
552
+ console.error('history-store: failed to restore paymentConfig on live pages', error);
912
553
  }
913
554
  }
914
555
  /** Move the pointer back one step and apply the entry at the new position. */
@@ -975,12 +616,30 @@ export const useHistoryStore = defineStore('history', () => {
975
616
  if (!baselineCaptured.value)
976
617
  return;
977
618
  cancelPendingAutoCapture();
619
+ mergeNextFlush.value = false;
978
620
  entries.value = [];
979
621
  currentIndex.value = -1;
980
622
  baselineCaptured.value = false;
981
623
  baselineCapturedAt = 0;
982
624
  captureBaseline();
983
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
+ }
984
643
  // --- Instrumentation (NFR-04a) ---
985
644
  /** Sum of all entries' serialized sizes in bytes. */
986
645
  function getCurrentSizeBytes() {
@@ -1004,6 +663,7 @@ export const useHistoryStore = defineStore('history', () => {
1004
663
  capture,
1005
664
  captureTemplateSwitch,
1006
665
  captureAiChangeset,
666
+ mergeNextMutation,
1007
667
  undo,
1008
668
  redo,
1009
669
  reset,