@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.
- package/lib/src/{CreatedOnBrandCrowd-de-de-Bj3SnIcG.js → CreatedOnBrandCrowd-de-de-DmDSspcZ.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-Cctc4swb.js → CreatedOnBrandCrowd-en-us-B5Lh1tFl.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-BEH9gTNh.js → CreatedOnBrandCrowd-es-es-BOiOyh5Y.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-BgWoNZ-T.js → CreatedOnBrandCrowd-fr-ca-BbC8s_pY.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-BgWoNZ-T.js → CreatedOnBrandCrowd-fr-fr-BbC8s_pY.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-VI26X7xu.js → CreatedOnBrandCrowd-pt-br-B9PQ72m4.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-VI26X7xu.js → CreatedOnBrandCrowd-pt-pt-B9PQ72m4.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-CQKXLChO.js → CreatedOnDcom-de-de-C7NzbKIg.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-DTjoj2Iv.js → CreatedOnDcom-en-us-DbVi6Q9C.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-C7ZOaeD4.js → CreatedOnDcom-es-es-C4TPAk-c.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-CaODnIN8.js → CreatedOnDcom-fr-ca-xqzYFaKy.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-CrF0dPzz.js → CreatedOnDcom-fr-fr-Bh-7lY7W.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-51WgNOhj.js → CreatedOnDcom-pt-br-D3J8peaF.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-Bkn3fggv.js → CreatedOnDcom-pt-pt-101mCRJX.js} +1 -1
- package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +1 -0
- package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.js +6 -0
- package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +14 -0
- package/lib/src/brand-page-maker/stores/history/history-store.js +193 -19
- package/lib/src/css/library.brand-page-brandCrowd.min.css +1 -1
- package/lib/src/css/library.brand-page-brandPage.min.css +1 -1
- package/lib/src/css/library.brand-page-designCom.min.css +1 -1
- package/lib/src/css/library.brand-page-preview.min.css +1 -1
- package/lib/src/index.mjs +1 -1
- package/lib/src/{lib-rp5REB2a.js → lib-MGJszuw2.js} +5870 -5807
- package/package.json +1 -1
- package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.ts +7 -0
- package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.vue +1 -1
- package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.test.ts +121 -0
- package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +14 -0
- package/src/brand-page-maker/stores/history/history-store.test.ts +172 -3
- package/src/brand-page-maker/stores/history/history-store.ts +217 -22
- package/src/shared/content-settings/components/logo-selection-field/LogoSelection.vue +16 -23
package/package.json
CHANGED
|
@@ -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="
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
-
|
|
334
|
+
// Cheap exit: no tags at all.
|
|
335
|
+
if (!value.includes('<')) return value;
|
|
336
|
+
|
|
337
|
+
if (isEmptyRteHtml(value)) return '';
|
|
180
338
|
|
|
181
|
-
|
|
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
|
-
|
|
344
|
+
let doc: Document;
|
|
184
345
|
|
|
185
|
-
|
|
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
|