@designcrowd/library.brand-page 5.17.25-undo-7 → 5.17.25-undo-9

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-Bj3SnIcG.js → CreatedOnBrandCrowd-de-de-DmDSspcZ.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-Cctc4swb.js → CreatedOnBrandCrowd-en-us-B5Lh1tFl.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-BEH9gTNh.js → CreatedOnBrandCrowd-es-es-BOiOyh5Y.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-BgWoNZ-T.js → CreatedOnBrandCrowd-fr-ca-BbC8s_pY.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-BgWoNZ-T.js → CreatedOnBrandCrowd-fr-fr-BbC8s_pY.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-VI26X7xu.js → CreatedOnBrandCrowd-pt-br-B9PQ72m4.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-VI26X7xu.js → CreatedOnBrandCrowd-pt-pt-B9PQ72m4.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-CQKXLChO.js → CreatedOnDcom-de-de-C7NzbKIg.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-DTjoj2Iv.js → CreatedOnDcom-en-us-DbVi6Q9C.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-C7ZOaeD4.js → CreatedOnDcom-es-es-C4TPAk-c.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-CaODnIN8.js → CreatedOnDcom-fr-ca-xqzYFaKy.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-CrF0dPzz.js → CreatedOnDcom-fr-fr-Bh-7lY7W.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-51WgNOhj.js → CreatedOnDcom-pt-br-D3J8peaF.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-Bkn3fggv.js → CreatedOnDcom-pt-pt-101mCRJX.js} +1 -1
  15. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +1 -0
  16. package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.js +6 -0
  17. package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +14 -0
  18. package/lib/src/brand-page-maker/stores/history/history-store.js +193 -19
  19. package/lib/src/css/library.brand-page-brandCrowd.min.css +1 -1
  20. package/lib/src/css/library.brand-page-brandPage.min.css +1 -1
  21. package/lib/src/css/library.brand-page-designCom.min.css +1 -1
  22. package/lib/src/css/library.brand-page-preview.min.css +1 -1
  23. package/lib/src/index.mjs +1 -1
  24. package/lib/src/{lib-rp5REB2a.js → lib-MGJszuw2.js} +5870 -5807
  25. package/package.json +1 -1
  26. package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.ts +7 -0
  27. package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.vue +1 -1
  28. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.test.ts +121 -0
  29. package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +14 -0
  30. package/src/brand-page-maker/stores/history/history-store.test.ts +172 -3
  31. package/src/brand-page-maker/stores/history/history-store.ts +217 -22
  32. package/src/shared/content-settings/components/logo-selection-field/LogoSelection.vue +16 -23
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@designcrowd/library.brand-page",
3
3
  "description": "A collection of Maker/Designer applications",
4
- "version": "5.17.25-undo-7",
4
+ "version": "5.17.25-undo-9",
5
5
  "author": "Design.com Eng",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
@@ -37,6 +37,13 @@ export default defineComponent({
37
37
  return this.brandPageContainerStore.isPublished;
38
38
  },
39
39
 
40
+ // Only render the bar when at least one inner cluster has something to
41
+ // show. Otherwise we'd render an empty white strip — e.g. on an
42
+ // unpublished page with the undo flag off.
43
+ shouldShowBar(): boolean {
44
+ return Boolean(this.undoRedoEnabled) || this.isPublished;
45
+ },
46
+
40
47
  brandPageUrl(): string {
41
48
  if (
42
49
  this.brandPageContainerStore.isCustomDomainEnabled &&
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <div
3
- v-if="undoRedoEnabled || isPublished"
3
+ v-if="shouldShowBar"
4
4
  data-component="quick-link-bar"
5
5
  class="c-quick-links tw-w-full tw-flex-none tw-bg-white md:tw-h-12 tw-border-b tw-border-grayscale-500 tw-px-2 lg:tw-px-4 tw-box-border tw-flex tw-items-center tw-text-grayscale-800 tw-fixed lg:tw-static"
6
6
  >
@@ -40,6 +40,13 @@ import { useUiBackgroundSettingsStore } from '@shared/background-settings/store'
40
40
  import { useBrandPageMakerUserStore } from '../user';
41
41
  import { useBrandPageContainerStore } from '../brandpage-container';
42
42
  import { useUiTemplateSettingsStore } from '@shared/template-settings/store';
43
+ import { useUiNavigationStyleSettingsStore } from '@shared/navigation-style-settings/store';
44
+ import {
45
+ CONTENT_SETTINGS_MODES,
46
+ useUiContentSettingsStore,
47
+ } from '@shared/content-settings/store';
48
+ import { useUiSideBarStore } from '@shared/side-bar/store';
49
+ import { SIDE_BAR_TAB_NAMES } from '@brand-page-maker/constants';
43
50
  import { PAGE_SECTIONS } from '@shared/page-settings/models';
44
51
  import { FabricRenderClient } from '../../../clients/fabric-render-client';
45
52
  import { setLocaleAsync } from '@src/composables/useTranslate';
@@ -560,4 +567,118 @@ describe('brand page maker store module', () => {
560
567
  // Assert
561
568
  expect(setDesignTemplateTokenSpy).not.toHaveBeenCalled();
562
569
  });
570
+
571
+ // BP-5506 regression: applyHistorySnapshot must replace nested navigation
572
+ // style refs as a whole, not deep-merge them. The previous bug was that
573
+ // object-form $patch would leave keys present in the live state but absent
574
+ // from the snapshot in place — e.g. switching nav button styles then
575
+ // undoing would leave the new style's borderColor / borderRadius applied.
576
+ test('applyHistorySnapshot replaces navigation styles wholesale (no key bleed across undo)', async () => {
577
+ // Arrange — initialise the maker so `applyHistorySnapshot` has a
578
+ // populated set of dependent stores to write into.
579
+ const makerStore = useBrandPageMakerStore();
580
+ const navigationStyleSettingsStore = useUiNavigationStyleSettingsStore();
581
+
582
+ await makerStore.initialise(INITIALISE_PAYLOAD);
583
+
584
+ // Build snapshot A — the "older" state we'll undo back to. Keep the
585
+ // navigation simple: only borderRadius is set on selectedButtonStyle.
586
+ const snapshotA = {
587
+ ...makerStore.brandPageData,
588
+ pageHeaderDesktop: {
589
+ ...makerStore.brandPageData.pageHeaderDesktop,
590
+ navigation: {
591
+ ...makerStore.brandPageData.pageHeaderDesktop?.navigation,
592
+ selectedButtonStyle: {
593
+ borderRadius: '0px',
594
+ },
595
+ },
596
+ },
597
+ } as BrandPageModel;
598
+
599
+ // Build snapshot B — the "newer" state with extra keys that the
600
+ // older snapshot does NOT have. If $patch deep-merged, these would
601
+ // survive an undo back to A.
602
+ const snapshotB = {
603
+ ...snapshotA,
604
+ pageHeaderDesktop: {
605
+ ...snapshotA.pageHeaderDesktop,
606
+ navigation: {
607
+ ...snapshotA.pageHeaderDesktop?.navigation,
608
+ selectedButtonStyle: {
609
+ borderRadius: '12px',
610
+ borderColor: 'rgb(255, 0, 0)',
611
+ borderStyle: 'dashed',
612
+ borderWidth: '4px',
613
+ },
614
+ },
615
+ },
616
+ } as BrandPageModel;
617
+
618
+ // Act — apply newer first (simulates the user making the edit), then
619
+ // older (simulates the undo). NavigationDesktop's constructor folds
620
+ // its input over a defaults object, so we read the resulting state
621
+ // and assert against the keys that came from snapshotB but NOT from
622
+ // snapshotA.
623
+ await makerStore.applyHistorySnapshot(snapshotB);
624
+ await makerStore.applyHistorySnapshot(snapshotA);
625
+
626
+ // Assert — after the undo, no keys from B should bleed through onto
627
+ // A's selectedButtonStyle. Specifically the values that are unique to
628
+ // B (red dashed border) must not be present.
629
+ const restored =
630
+ navigationStyleSettingsStore.navigation[PAGE_SECTIONS.header][
631
+ VIEWPORTS.desktop
632
+ ];
633
+ const restoredSelected = restored.selectedButtonStyle;
634
+
635
+ expect(restoredSelected.borderRadius).toBe('0px');
636
+ // The B-only values must not survive the undo. NavigationStyles'
637
+ // constructor seeds defaults for selectedButtonStyle, so we assert
638
+ // the *exact* B-only values are gone rather than a strict superset.
639
+ expect(restoredSelected.borderColor).not.toBe('rgb(255, 0, 0)');
640
+ expect(restoredSelected.borderStyle).not.toBe('dashed');
641
+ expect(restoredSelected.borderWidth).not.toBe('4px');
642
+ });
643
+
644
+ // BP-5506 regression: when undoing a content add, the selected item
645
+ // disappears. The fallback must restore the Content tab's natural
646
+ // landing state (mode = edit, selectedItemId = null), not just switch
647
+ // tabs — otherwise `<FocusedContentItemSettings>` keeps rendering for
648
+ // a missing item and paints a blank panel.
649
+ test('applyHistorySnapshot resets sidebar to default Content tab when selected item is gone', async () => {
650
+ const makerStore = useBrandPageMakerStore();
651
+ const contentSettingsStore = useUiContentSettingsStore();
652
+ const sideBarStore = useUiSideBarStore();
653
+
654
+ await makerStore.initialise(INITIALISE_PAYLOAD);
655
+
656
+ // Simulate the user opening a content item's focused settings.
657
+ const orphanItemId = 'orphan-content-id-not-in-snapshot';
658
+
659
+ contentSettingsStore.pushMode({
660
+ mode: CONTENT_SETTINGS_MODES.focusedContentItem,
661
+ selectedItemId: orphanItemId,
662
+ });
663
+ contentSettingsStore.selectedItemId = orphanItemId;
664
+ sideBarStore.setSelectedTabName({
665
+ tabName: SIDE_BAR_TAB_NAMES.CONTENT_SETTINGS,
666
+ });
667
+
668
+ // Pre-condition: panel is showing the focused content item.
669
+ expect(contentSettingsStore.mode).toBe(
670
+ CONTENT_SETTINGS_MODES.focusedContentItem,
671
+ );
672
+ expect(contentSettingsStore.selectedItemId).toBe(orphanItemId);
673
+
674
+ // Snapshot does NOT contain orphanItemId — simulates undo of an add.
675
+ await makerStore.applyHistorySnapshot(makerStore.brandPageData);
676
+
677
+ // Post-condition: same shape as a fresh Content tab click.
678
+ expect(contentSettingsStore.mode).toBe(CONTENT_SETTINGS_MODES.edit);
679
+ expect(contentSettingsStore.selectedItemId).toBeNull();
680
+ expect(sideBarStore.selectedTabName).toBe(
681
+ SIDE_BAR_TAB_NAMES.CONTENT_SETTINGS,
682
+ );
683
+ });
563
684
  });
@@ -928,9 +928,23 @@ export const useBrandPageMakerStore = defineStore('brandPageMaker', {
928
928
 
929
929
  if (existingContentIds.has(selectedItemId)) return;
930
930
 
931
+ // The previously-selected content was undone away. Restore the
932
+ // Content tab's natural landing state — the same shape produced
933
+ // when the user clicks the Content tab fresh — instead of leaving
934
+ // the sidebar stuck rendering `<FocusedContentItemSettings>` for
935
+ // a missing item, which paints a blank panel.
936
+ //
937
+ // `resetMode()` pops the modeStack back to the base `edit` entry
938
+ // (the default landing mode); clearing `selectedItemId` releases
939
+ // the pointer to the now-deleted item.
931
940
  sideBarStore.setSelectedTabName({
932
941
  tabName: SIDE_BAR_TAB_NAMES.CONTENT_SETTINGS,
933
942
  });
943
+ contentSettingsStore.resetMode();
944
+ contentSettingsStore.selectedItemId = null;
945
+ contentSettingsStore.selectedItemFieldName = null;
946
+ contentSettingsStore.selectedItemIndex = null;
947
+ contentSettingsStore.selectedItemValue = null;
934
948
  },
935
949
  export(): BrandPageModel {
936
950
  const data = this.brandPageData;
@@ -297,6 +297,76 @@ describe('history-store (single stack + JSON-string snapshots)', () => {
297
297
  expect(store.entries.length).toBe(2);
298
298
  });
299
299
 
300
+ // BP-5506 follow-up: editing logo text via the Logo sidebar mutates
301
+ // `editableLogoStore.logoEdits` (not in brandPageData), then re-renders
302
+ // the result into `logoImageBase64Url` on every header/footer +
303
+ // per-page logo content. If history captured that base64, undo would
304
+ // revert the canvas without touching the sidebar input — the user
305
+ // sees the canvas snap back but the sidebar still shows their typed
306
+ // value. V1 excludes logo property edits from undo, so dedup must
307
+ // collapse base64-only diffs.
308
+ it('treats isolated logoImageBase64Url changes as a no-op', async () => {
309
+ const store = useHistoryStore();
310
+
311
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
312
+ (mockMakerStore.brandPageData as any).pageHeaderDesktop = {
313
+ logoImageBase64Url: 'data:image/png;base64,AAAA',
314
+ };
315
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
316
+ (mockMakerStore.brandPageData as any).pageHeaderMobile = {
317
+ logoImageBase64Url: 'data:image/png;base64,AAAA',
318
+ };
319
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
320
+ (mockMakerStore.brandPageData as any).pageFooterDesktop = {
321
+ logoImageBase64Url: 'data:image/png;base64,AAAA',
322
+ };
323
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
+ (mockMakerStore.brandPageData as any).pageFooterMobile = {
325
+ logoImageBase64Url: 'data:image/png;base64,AAAA',
326
+ };
327
+ await store.captureBaseline();
328
+
329
+ // Logo text/font/color edit re-renders all four base64 fields.
330
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
331
+ const data = mockMakerStore.brandPageData as any;
332
+
333
+ data.pageHeaderDesktop.logoImageBase64Url = 'data:image/png;base64,BBBB';
334
+ data.pageHeaderMobile.logoImageBase64Url = 'data:image/png;base64,BBBB';
335
+ data.pageFooterDesktop.logoImageBase64Url = 'data:image/png;base64,BBBB';
336
+ data.pageFooterMobile.logoImageBase64Url = 'data:image/png;base64,BBBB';
337
+
338
+ const result = store.capture({ source: 'manual_edit', actor: 'user' });
339
+
340
+ expect(result).toBe('noop');
341
+ expect(store.entries.length).toBe(1);
342
+ });
343
+
344
+ // BYO-upload undo MUST still work — that diff is in
345
+ // `logoImage.logoDraftToken`, not `logoImageBase64Url`. Stripping the
346
+ // base64 must not collapse a draft-token change.
347
+ it('still commits when logoImage.logoDraftToken changes (BYO upload)', async () => {
348
+ const store = useHistoryStore();
349
+
350
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
351
+ (mockMakerStore.brandPageData as any).pageHeaderDesktop = {
352
+ logoImage: { logoDraftToken: 'token-original', logoVariation: 0 },
353
+ logoImageBase64Url: 'data:image/png;base64,AAAA',
354
+ };
355
+ await store.captureBaseline();
356
+
357
+ // BYO upload swaps both the draft token AND the base64.
358
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
359
+ const header = (mockMakerStore.brandPageData as any).pageHeaderDesktop;
360
+
361
+ header.logoImage.logoDraftToken = 'token-uploaded';
362
+ header.logoImageBase64Url = 'data:image/png;base64,BBBB';
363
+
364
+ const result = store.capture({ source: 'manual_edit', actor: 'user' });
365
+
366
+ expect(result).toBe('committed');
367
+ expect(store.entries.length).toBe(2);
368
+ });
369
+
300
370
  it('treats isModifiedAfterTemplateApplied flip as a no-op', async () => {
301
371
  const store = useHistoryStore();
302
372
 
@@ -419,7 +489,10 @@ describe('history-store (single stack + JSON-string snapshots)', () => {
419
489
  expect(store.entries.length).toBe(1);
420
490
  });
421
491
 
422
- it('still commits when RTE content has real inline formatting tags', async () => {
492
+ it('still commits when RTE content has partial-text inline formatting', async () => {
493
+ // Partial bold (`Hello <strong>world</strong>`) is a real edit — only
494
+ // *part* of the text is bold, which can't be expressed via the global
495
+ // textStyle. The dedup must NOT collapse this.
423
496
  const store = useHistoryStore();
424
497
 
425
498
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -435,8 +508,6 @@ describe('history-store (single stack + JSON-string snapshots)', () => {
435
508
  ];
436
509
  await store.captureBaseline();
437
510
 
438
- // Real formatting (the <strong> tag) must NOT be collapsed to plain
439
- // text — it represents a user-visible diff.
440
511
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
441
512
  (mockMakerStore.brandPageData as any).pages[0].contents[0].title =
442
513
  '<p>Hello <strong>world</strong></p>';
@@ -447,6 +518,104 @@ describe('history-store (single stack + JSON-string snapshots)', () => {
447
518
  expect(store.entries.length).toBe(2);
448
519
  });
449
520
 
521
+ // BP-5506 follow-up: TipTap re-emits a whole-content <strong>/<em>/<u>/<s>
522
+ // wrapper on first hydration to mirror the textStyle's fontWeight /
523
+ // fontStyle / textDecoration. The whole-content case is hydration noise
524
+ // (textStyle already encodes it), so it must dedup. The earlier RTE
525
+ // canonicalizer rejected anything containing `<` or `>` inside the inner
526
+ // text, so this case slipped through and produced phantom undo entries
527
+ // when the user clicked any content section that had bolded text.
528
+ it('treats whole-content <strong>/<em>/<u>/<s> hydration wrappers as a no-op', async () => {
529
+ const store = useHistoryStore();
530
+
531
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
532
+ (mockMakerStore.brandPageData as any).pages = [
533
+ {
534
+ contents: [
535
+ {
536
+ type: 'shapes-section',
537
+ textItems: [
538
+ { text: 'This test is for text' },
539
+ { text: 'Italic line' },
540
+ { text: 'Underline line' },
541
+ { text: 'Strikethrough line' },
542
+ { text: 'Bold italic combo' },
543
+ ],
544
+ },
545
+ ],
546
+ },
547
+ ];
548
+ await store.captureBaseline();
549
+
550
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
551
+ const items = (mockMakerStore.brandPageData as any).pages[0].contents[0]
552
+ .textItems;
553
+
554
+ // Each item gets hydrated to its TipTap default-shape with a
555
+ // whole-content format tag mirroring the textStyle.
556
+ items[0].text =
557
+ '<p style="text-align: left;"><span style="font-family: Lemonada; font-size: 1.875em;"><strong>This test is for text</strong></span></p>';
558
+ items[1].text =
559
+ '<p><span style="font-family: Lato;"><em>Italic line</em></span></p>';
560
+ items[2].text = '<p><u>Underline line</u></p>';
561
+ items[3].text = '<p><s>Strikethrough line</s></p>';
562
+ items[4].text = '<p><em><strong>Bold italic combo</strong></em></p>';
563
+
564
+ const result = store.capture({ source: 'manual_edit', actor: 'user' });
565
+
566
+ expect(result).toBe('noop');
567
+ expect(store.entries.length).toBe(1);
568
+ });
569
+
570
+ // Anything beyond the hydration whitelist must NOT be collapsed.
571
+ // <a> is the canonical case — a TipTap-rendered link is real authoring
572
+ // intent and must survive dedup.
573
+ it('still commits when RTE content adds a link (real authoring intent)', async () => {
574
+ const store = useHistoryStore();
575
+
576
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
577
+ (mockMakerStore.brandPageData as any).pages = [
578
+ {
579
+ contents: [{ type: 'banner', title: 'click here' }],
580
+ },
581
+ ];
582
+ await store.captureBaseline();
583
+
584
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
585
+ (mockMakerStore.brandPageData as any).pages[0].contents[0].title =
586
+ '<p>click <a href="https://example.com">here</a></p>';
587
+
588
+ const result = store.capture({ source: 'manual_edit', actor: 'user' });
589
+
590
+ expect(result).toBe('committed');
591
+ expect(store.entries.length).toBe(2);
592
+ });
593
+
594
+ // A <span> carrying a style property outside the hydration whitelist
595
+ // (e.g. `text-shadow`, `text-transform`) is a real edit. TipTap's
596
+ // hydration span only emits `font-family / font-size / letter-spacing /
597
+ // line-height / color / background-color`.
598
+ it('still commits when RTE content adds a non-hydration style property', async () => {
599
+ const store = useHistoryStore();
600
+
601
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
602
+ (mockMakerStore.brandPageData as any).pages = [
603
+ {
604
+ contents: [{ type: 'banner', title: 'shadowed' }],
605
+ },
606
+ ];
607
+ await store.captureBaseline();
608
+
609
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
610
+ (mockMakerStore.brandPageData as any).pages[0].contents[0].title =
611
+ '<p><span style="text-shadow: 1px 1px 2px black;">shadowed</span></p>';
612
+
613
+ const result = store.capture({ source: 'manual_edit', actor: 'user' });
614
+
615
+ expect(result).toBe('committed');
616
+ expect(store.entries.length).toBe(2);
617
+ });
618
+
450
619
  it('treats canvas section-click cascade as a no-op (BP-5506 repro)', async () => {
451
620
  // Captured live from UAT: clicking any section on the canvas mounts
452
621
  // every RTE editor on the page at once + resolves textStyleType for
@@ -139,6 +139,10 @@ export const useHistoryStore = defineStore('history', () => {
139
139
  * (e.g. `""` → `<p style="text-align: center;"></p>`).
140
140
  * - TipTap RTE writing back its default-style hydration on first mount
141
141
  * (e.g. plain `"Hello"` → `<p style="..."><span style="..."><mark ...>Hello</mark></span></p>`).
142
+ * - TipTap re-emitting whole-content `<strong>`/`<em>`/`<u>`/`<s>` tags
143
+ * to mirror the resolved `textStyle.fontWeight / fontStyle /
144
+ * textDecoration` (e.g. plain `"text"` →
145
+ * `<p>...<span>...<strong>text</strong></span></p>`).
142
146
  * - Lazy hydration of `*Style` objects when a content settings panel —
143
147
  * or section selection on the canvas — resolves `textStyleType` and
144
148
  * writes the full resolved style (fontFamily, fontSize, color,
@@ -146,43 +150,223 @@ export const useHistoryStore = defineStore('history', () => {
146
150
  * onto the model. The `textStyleType` itself is the authoring
147
151
  * intent; the resolved keys are just cached values.
148
152
  * - `isModifiedAfterTemplateApplied` flipping on first edit gesture.
153
+ * - `logoImageBase64Url` — the rendered logo PNG. Per-property logo
154
+ * edits author through `editableLogoStore.logoEdits` (not in
155
+ * `brandPageData`), so an undo would revert the canvas image but
156
+ * leave the sidebar fields showing the user's typed value. Logo
157
+ * property edits are excluded from V1 undo scope; BYO-upload undo
158
+ * still works because that diff lives in `logoImage.logoDraftToken`.
159
+ *
160
+ * RTE strings are canonicalized by parsing the HTML with DOMParser and
161
+ * walking the tree; only attributeless format wrappers (and a tightly
162
+ * scoped set of hydration styles on `<p>` / `<span>` / `<mark>`) are
163
+ * collapsed. Real authoring intent (links, custom colors, partial-text
164
+ * formatting like `Hello <strong>world</strong>`) survives the walk
165
+ * because at least one node falls outside the hydration whitelist,
166
+ * and the walker bails to "preserve original" the moment that happens.
149
167
  *
150
168
  * Used ONLY for dedup comparison. Stored snapshots remain unchanged so
151
169
  * `applyHistorySnapshot` always sees the real state.
152
170
  */
171
+ // Tags TipTap re-emits purely to mirror the resolved textStyle on first
172
+ // hydration. None carry user authoring intent on their own when they are
173
+ // the sole element-child wrapping the entire content:
174
+ // - <p>: only its `text-align` style is meaningful, and that's already
175
+ // captured in `textStyle.textAlign`.
176
+ // - <span>: TipTap wraps content in a span with `font-family`, `font-size`,
177
+ // `color`, `letter-spacing`, `line-height` resolved from the
178
+ // textStyle. The textStyle object on the same content is the
179
+ // authoritative source.
180
+ // - <mark>: TipTap's highlight extension default — collapses regardless
181
+ // of `data-color` / `style` attrs, mirroring the original
182
+ // regex's behavior on a hydrated highlight.
183
+ // - <strong>/<em>/<u>/<s>: attributeless tags that mirror
184
+ // `fontWeight: bold` / `fontStyle: italic` / `textDecoration:
185
+ // underline / line-through`. ONLY recognized as hydration when
186
+ // they are the sole child of their parent — partial-content
187
+ // usage like `Hello <strong>world</strong>` is a real edit.
188
+ // Anything else (`<a>`, `<br>`, `<span style="color: red">`, attributes on
189
+ // <strong>/<em>, etc.) is preserved as a real diff.
190
+ const STRUCTURAL_HYDRATION_TAGS = new Set(['P', 'SPAN', 'MARK']);
191
+ const FORMAT_HYDRATION_TAGS = new Set(['STRONG', 'EM', 'U', 'S']);
192
+
193
+ // Default style properties TipTap emits on a hydration <span> /
194
+ // <mark>. The textStyle resolver writes all of these onto the live
195
+ // model, so when TipTap re-emits them as inline style on its
196
+ // hydration span/mark there is no user-visible delta.
197
+ // Anything outside this whitelist (e.g. `text-decoration` mid-content,
198
+ // `text-shadow`, `text-transform`) is treated as a real edit.
199
+ const HYDRATION_INLINE_STYLE_PROPS = new Set([
200
+ 'font-family',
201
+ 'font-size',
202
+ 'letter-spacing',
203
+ 'line-height',
204
+ 'color',
205
+ 'background-color',
206
+ ]);
207
+
153
208
  // Empty paragraph forms TipTap emits when an editor mounts on an empty
154
209
  // string. Treated as equivalent to `""` for dedup.
155
- const EMPTY_RTE_HTML = /^<p(\s[^>]*)?>(<br\s*\/?>)?<\/p>$/;
156
-
157
- // TipTap's default-hydration shape: a single <p> (with optional align
158
- // attr) containing the original plain text, optionally wrapped in
159
- // default-styled <span> (font-family / font-size / color) and/or
160
- // <mark> (highlight) tags. The inner capture intentionally rejects any
161
- // string that itself contains `<` or `>` so that real formatting tags
162
- // — `<strong>`, `<em>`, `<a>`, nested spans with non-default styles —
163
- // are preserved as a real diff rather than collapsed.
164
- const RTE_DEFAULT_HYDRATION =
165
- /^<p(\s[^>]*)?>(<span(\s[^>]*)?>)?(<mark(\s[^>]*)?>)?([^<>]*)(<\/mark>)?(<\/span>)?<\/p>$/;
166
-
167
210
  function isEmptyRteHtml(value: unknown): boolean {
168
- return typeof value === 'string' && EMPTY_RTE_HTML.test(value);
211
+ if (typeof value !== 'string') return false;
212
+
213
+ return /^<p(\s[^>]*)?>(<br\s*\/?>)?<\/p>$/.test(value);
214
+ }
215
+
216
+ function isHydrationOnlyAttributes(el: Element): boolean {
217
+ // Format tags (<strong>/<em>/<u>/<s>) must be attributeless. Anything
218
+ // with class/style/data-*/etc. is a real edit.
219
+ if (FORMAT_HYDRATION_TAGS.has(el.tagName))
220
+ return el.attributes.length === 0;
221
+
222
+ for (const attr of Array.from(el.attributes)) {
223
+ // <mark> may carry a `data-color` attr (TipTap highlight extension's
224
+ // default-and-resolved value). The accompanying `style` is checked
225
+ // against the same hydration-prop whitelist as <span>.
226
+ if (
227
+ el.tagName === 'MARK' &&
228
+ (attr.name === 'data-color' || attr.name === 'style')
229
+ ) {
230
+ if (attr.name === 'data-color') continue;
231
+ } else if (attr.name !== 'style') {
232
+ return false;
233
+ }
234
+
235
+ const decls = attr.value
236
+ .split(';')
237
+ .map((d) => d.trim())
238
+ .filter(Boolean);
239
+
240
+ if (el.tagName === 'P') {
241
+ for (const decl of decls) {
242
+ const [prop] = decl.split(':').map((s) => s.trim());
243
+
244
+ if (prop !== 'text-align') return false;
245
+ }
246
+ } else if (el.tagName === 'SPAN' || el.tagName === 'MARK') {
247
+ for (const decl of decls) {
248
+ const [prop, value] = decl.split(':').map((s) => s.trim());
249
+
250
+ // Allow `color: inherit` on <mark> (TipTap stamps it on the
251
+ // default highlight to inherit the span's color). Otherwise
252
+ // require the prop to be in the hydration whitelist.
253
+ if (
254
+ el.tagName === 'MARK' &&
255
+ prop === 'color' &&
256
+ value === 'inherit'
257
+ ) {
258
+ continue;
259
+ }
260
+
261
+ if (!HYDRATION_INLINE_STYLE_PROPS.has(prop)) return false;
262
+ }
263
+ } else {
264
+ return false;
265
+ }
266
+ }
267
+
268
+ return true;
169
269
  }
170
270
 
171
271
  /**
172
- * If `value` is TipTap's default-hydration wrapper around a plain text
173
- * (no inline formatting tags), return the bare inner text. Otherwise
174
- * return the original string. This collapses the BP-5506 phantom where
175
- * opening a content sidebar mounts every RTE editor at once and writes
176
- * back wrapped HTML with no user-visible delta.
272
+ * Walk a parsed HTML node tree. Return the joined text content if every
273
+ * descendant is hydration noise; return null if any node carries real
274
+ * authoring intent (links, partial formatting, custom styles, etc.).
275
+ *
276
+ * `isFormatTag` differs in how it's allowed to appear: a structural tag
277
+ * (P/SPAN/MARK) can have siblings; a format tag (STRONG/EM/U/S) is only
278
+ * allowed when it's the SOLE child of its parent. The caller enforces
279
+ * that at the iteration site.
280
+ */
281
+ function extractCanonicalText(node: Node): string | null {
282
+ if (node.nodeType === 3 /* TEXT_NODE */) return node.textContent ?? '';
283
+
284
+ if (node.nodeType !== 1 /* ELEMENT_NODE */) {
285
+ // Comments, CDATA, etc. — treat as opaque (preserve original).
286
+ return null;
287
+ }
288
+
289
+ const el = node as Element;
290
+ const isStructural = STRUCTURAL_HYDRATION_TAGS.has(el.tagName);
291
+ const isFormat = FORMAT_HYDRATION_TAGS.has(el.tagName);
292
+
293
+ if (!isStructural && !isFormat) return null;
294
+
295
+ if (!isHydrationOnlyAttributes(el)) return null;
296
+
297
+ const children = Array.from(el.childNodes);
298
+ let out = '';
299
+
300
+ for (const child of children) {
301
+ // Format tags only count as hydration when they wrap the ENTIRE
302
+ // content of their parent. If a <strong>/<em>/<u>/<s> appears
303
+ // alongside any sibling (text or element), it's partial-content
304
+ // formatting — a real edit that can't be expressed via textStyle —
305
+ // so bail.
306
+ if (
307
+ child.nodeType === 1 &&
308
+ FORMAT_HYDRATION_TAGS.has((child as Element).tagName) &&
309
+ children.length > 1
310
+ ) {
311
+ return null;
312
+ }
313
+
314
+ const part = extractCanonicalText(child);
315
+
316
+ if (part === null) return null;
317
+
318
+ out += part;
319
+ }
320
+
321
+ return out;
322
+ }
323
+
324
+ /**
325
+ * If `value` is TipTap's default-hydration wrapper around plain text
326
+ * (only hydration-noise elements wrapping bare text), return that bare
327
+ * text. Otherwise return the original string unchanged.
328
+ *
329
+ * This collapses the BP-5506 phantom where opening a content sidebar —
330
+ * or selecting a section on the canvas — mounts every RTE editor at
331
+ * once and writes back wrapped HTML with no user-visible delta.
177
332
  */
178
333
  function canonicalizeRteString(value: string): string {
179
- if (EMPTY_RTE_HTML.test(value)) return '';
334
+ // Cheap exit: no tags at all.
335
+ if (!value.includes('<')) return value;
336
+
337
+ if (isEmptyRteHtml(value)) return '';
180
338
 
181
- const match = value.match(RTE_DEFAULT_HYDRATION);
339
+ // DOMParser is unavailable in some non-browser test environments;
340
+ // fall back to leaving the value as-is so the caller produces a
341
+ // real diff (i.e. fail open — never falsely collapse).
342
+ if (typeof DOMParser === 'undefined') return value;
182
343
 
183
- if (!match) return value;
344
+ let doc: Document;
184
345
 
185
- return match[6] ?? '';
346
+ try {
347
+ doc = new DOMParser().parseFromString(
348
+ `<div id="__rte_root__">${value}</div>`,
349
+ 'text/html',
350
+ );
351
+ } catch {
352
+ return value;
353
+ }
354
+
355
+ const root = doc.getElementById('__rte_root__');
356
+
357
+ if (!root) return value;
358
+
359
+ let out = '';
360
+
361
+ for (const child of Array.from(root.childNodes)) {
362
+ const part = extractCanonicalText(child);
363
+
364
+ if (part === null) return value;
365
+
366
+ out += part;
367
+ }
368
+
369
+ return out;
186
370
  }
187
371
 
188
372
  function normalizeNode(node: unknown): unknown {
@@ -200,6 +384,17 @@ export const useHistoryStore = defineStore('history', () => {
200
384
  // represent a user-visible change.
201
385
  if (key === 'isModifiedAfterTemplateApplied') continue;
202
386
 
387
+ // Strip the rendered logo PNG. Logo property edits (text, color,
388
+ // font, outline, shadow) authored via the Logo sidebar live in
389
+ // `editableLogoStore.logoEdits` — which is NOT in `brandPageData`,
390
+ // so an undo would revert this base64 image on the canvas without
391
+ // touching the sidebar fields. Per V1 scope, logo property edits
392
+ // are non-undoable (parallels the PRD's admin-logo exclusion);
393
+ // BYO-upload undo still works because that change shows up in
394
+ // `logoImage.logoDraftToken`, not here. Stripping the field keeps
395
+ // those isolated render-output diffs out of the stack entirely.
396
+ if (key === 'logoImageBase64Url') continue;
397
+
203
398
  // Collapse RTE default-hydration wrappers down to the plain text
204
399
  // they wrap. Plain "X" and `<p style="..."><span ...><mark ...>X
205
400
  // </mark></span></p>` share the same canonical "X" so a sidebar