@designcrowd/library.brand-page 5.17.25-undo-21 → 5.17.25
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-Bze5WUip.js → CreatedOnBrandCrowd-de-de-9YvB5ocm.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-Dw5uSnX8.js → CreatedOnBrandCrowd-en-us-B8OpZpMP.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-Cz-oPdjt.js → CreatedOnBrandCrowd-es-es-D_d6SwvE.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-zldLXw3G.js → CreatedOnBrandCrowd-fr-ca-CPqNeevd.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-zldLXw3G.js → CreatedOnBrandCrowd-fr-fr-CPqNeevd.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-64QJpEaD.js → CreatedOnBrandCrowd-pt-br-D0DpxtjY.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-64QJpEaD.js → CreatedOnBrandCrowd-pt-pt-D0DpxtjY.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-8SXOvjEH.js → CreatedOnDcom-de-de-BLKiD1pl.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-ClZBYY_t.js → CreatedOnDcom-en-us-DGNjFV5B.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-xg4ZwvEb.js → CreatedOnDcom-es-es-C2IDMZZe.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-4zo2gxc1.js → CreatedOnDcom-fr-ca-ZRxz3QKj.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-Dw9Fuy7U.js → CreatedOnDcom-fr-fr-5bE7oSdF.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-Dbl0EWcU.js → CreatedOnDcom-pt-br-WCumEx63.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-Bps73oQE.js → CreatedOnDcom-pt-pt-C8Hinnmg.js} +1 -1
- package/lib/src/brand-page-maker/components/action-bar/ActionBar.mixin.d.ts +2 -7
- package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.d.ts +2 -7
- package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.js +0 -10
- package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMakerTemplateSettings.mixin.d.ts +2 -7
- package/lib/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.js +0 -1
- package/lib/src/brand-page-maker/components/brandpage-maker-page/BrandPageMakerPage.mixin.d.ts +2 -23
- package/lib/src/brand-page-maker/components/brandpage-settings/ReplaceImageSettingsForBrandPageSettings.mixin.d.ts +2 -7
- package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.d.ts +0 -3
- package/lib/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.js +0 -11
- package/lib/src/brand-page-maker/components/seo-settings/SeoPageSettings.mixin.d.ts +2 -7
- package/lib/src/brand-page-maker/components/seo-settings/UrlSettings.mixin.d.ts +2 -7
- package/lib/src/brand-page-maker/components/social-share/SocialShare.mixin.d.ts +2 -7
- package/lib/src/brand-page-maker/components/template-preview/TemplatePreview.mixin.d.ts +2 -7
- package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +0 -9
- package/lib/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.js +1 -17
- package/lib/src/brand-page-maker/composables/useEventTracker.d.ts +0 -20
- package/lib/src/brand-page-maker/composables/useEventTracker.js +0 -17
- package/lib/src/brand-page-maker/stores/ai-thread/ai-website-builder-thread-store.d.ts +1 -1
- package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.d.ts +2 -41
- package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +5 -247
- package/lib/src/brand-page-maker/stores/logo-store/logo-store.d.ts +0 -1
- package/lib/src/brand-page-maker/stores/user/user-store.js +0 -1
- package/lib/src/bundled-translations-Bu_5FO26.js +173 -0
- package/lib/src/bundles/bundled-translations.json +1 -3
- package/lib/src/constants/ui.d.ts +0 -1
- package/lib/src/css/library.brand-page-brandCrowd.min.css +17 -22
- package/lib/src/css/library.brand-page-brandPage.min.css +18 -22
- package/lib/src/css/library.brand-page-designCom.min.css +17 -22
- package/lib/src/css/library.brand-page-preview.min.css +17 -22
- package/lib/src/index.mjs +1 -1
- package/lib/src/{lib-BgvZxeSl.js → lib-BF9ONIgx.js} +23292 -23913
- package/lib/src/shared/content-settings/components/GenericTextInput.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/content-settings/ContentSettings.mixin.d.ts +2 -23
- package/lib/src/shared/content-settings/components/content-settings/ContentSettings.stories.js +0 -1
- package/lib/src/shared/content-settings/components/content-settings/ReplaceImageSettingsForContent.mixin.d.ts +2 -23
- package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItem.mixin.d.ts +0 -16
- package/lib/src/shared/content-settings/components/content-settings-item/ContentSettingsItemField.mixin.d.ts +2 -7
- package/lib/src/shared/content-settings/components/draggable-item-list/DraggableItemList.mixin.d.ts +0 -3
- package/lib/src/shared/content-settings/components/draggable-item-list/DraggableItemList.mixin.js +2 -19
- package/lib/src/shared/content-settings/components/email-field/EmailField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/form-fields-field/FormFieldsField.mixin.js +8 -18
- package/lib/src/shared/content-settings/components/radio-field/RadioField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/tel-field/TelField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/text-field/TextField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/textarea-field/TextareaField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/url-field/UrlField.mixin.d.ts +1 -1
- package/lib/src/shared/content-settings/components/youtube-video-field/YoutubeVideoField.mixin.d.ts +1 -1
- package/lib/src/shared/logo-settings/components/EditLogoSettings.mixin.d.ts +0 -18
- package/lib/src/shared/logo-settings/components/EditLogoSettings.mixin.js +0 -11
- package/lib/src/shared/logo-settings/components/LogoSettings.mixin.d.ts +2 -9
- package/lib/src/shared/logo-settings/store/editable-logo-store.d.ts +1 -9
- package/lib/src/shared/logo-settings/store/editable-logo-store.js +0 -33
- package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +2 -24
- package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +2 -9
- package/lib/src/shared/publish-brand-page-with-state/PublishBrandPageModalWithState.mixin.d.ts +2 -23
- package/lib/src/shared/side-bar/components/SideBarTabButton.mixin.d.ts +0 -16
- package/lib/src/shared/style-settings/components/StyleSettings.mixin.d.ts +2 -23
- package/lib/src/shared/template-settings/components/TemplatesSettings.mixin.d.ts +0 -16
- package/package.json +1 -1
- package/src/brand-page/components/video/Video.vue +0 -2
- package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.mixin.ts +0 -11
- package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.vue +3 -2
- package/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.ts +0 -1
- package/src/brand-page-maker/components/cookie-collection/CookieSettings.vue +17 -18
- package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.mixin.ts +0 -13
- package/src/brand-page-maker/components/quick-link-bar/QuickLinkBar.vue +4 -14
- package/src/brand-page-maker/components/quick-link-bar/i18n/quick-link-bar.json +1 -3
- package/src/brand-page-maker/components/save-contact/SaveContact.vue +19 -12
- package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +0 -10
- package/src/brand-page-maker/composables/rich-text-editor/useRichTextEditor.ts +1 -20
- package/src/brand-page-maker/composables/useEventTracker.ts +0 -29
- package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.test.ts +0 -166
- package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +4 -287
- package/src/brand-page-maker/stores/logo-store/logo-store.ts +0 -5
- package/src/brand-page-maker/stores/user/user-store.ts +0 -1
- package/src/bundles/bundled-translations.json +1 -3
- package/src/constants/ui.ts +0 -1
- package/src/shared/content-settings/components/content-settings/ContentSettings.stories.ts +0 -1
- package/src/shared/content-settings/components/draggable-item-list/DraggableItemList.mixin.ts +2 -19
- package/src/shared/content-settings/components/draggable-item-list/DraggableItemList.vue +1 -1
- package/src/shared/content-settings/components/form-fields-field/FormFieldsField.mixin.ts +10 -26
- package/src/shared/content-settings/components/logo-selection-field/LogoSelection.vue +48 -63
- package/src/shared/content-settings/components/text-field/TextField.vue +0 -1
- package/src/shared/content-settings/components/text-image-field/TextImageField.vue +4 -33
- package/src/shared/logo-settings/components/EditLogoSettings.mixin.ts +0 -11
- package/src/shared/logo-settings/store/editable-logo-store.ts +1 -45
- package/lib/src/brand-page-maker/composables/useUndoRedoFlag.d.ts +0 -16
- package/lib/src/brand-page-maker/composables/useUndoRedoFlag.js +0 -22
- package/lib/src/brand-page-maker/stores/history/history-store.d.ts +0 -104
- package/lib/src/brand-page-maker/stores/history/history-store.js +0 -836
- package/lib/src/bundled-translations-CWHKs3in.js +0 -173
- package/lib/src/metadata-builder/builders/reorder-pages-builder.d.ts +0 -13
- package/lib/src/metadata-builder/builders/reorder-pages-builder.js +0 -49
- package/src/brand-page-maker/components/quick-link-bar/RedoButton.test.ts +0 -166
- package/src/brand-page-maker/components/quick-link-bar/RedoButton.vue +0 -66
- package/src/brand-page-maker/components/quick-link-bar/UndoButton.test.ts +0 -166
- package/src/brand-page-maker/components/quick-link-bar/UndoButton.vue +0 -66
- package/src/brand-page-maker/composables/useUndoRedoFlag.ts +0 -26
- package/src/brand-page-maker/stores/history/history-store.test.ts +0 -1246
- package/src/brand-page-maker/stores/history/history-store.ts +0 -1041
- package/src/metadata-builder/builders/reorder-pages-builder.ts +0 -80
|
@@ -1,1041 +0,0 @@
|
|
|
1
|
-
import { defineStore } from 'pinia';
|
|
2
|
-
import { computed, nextTick, ref, shallowRef, toRaw, watch } from 'vue';
|
|
3
|
-
import type { BrandPageModel } from '@brand-page';
|
|
4
|
-
import { useBrandPageMakerStore } from '../brandpage-maker/brandpage-maker-store';
|
|
5
|
-
import { useAiWebsiteBuilderThreadStore } from '../ai-thread/ai-website-builder-thread-store';
|
|
6
|
-
import { useEventTracker } from '../../composables/useEventTracker';
|
|
7
|
-
import { usePageNavigationStore } from '@shared/page-navigation/store/page-navigation-store';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* A single history entry.
|
|
11
|
-
*
|
|
12
|
-
* `snapshot` is a **JSON-stringified** `BrandPageModel`, not a plain object.
|
|
13
|
-
* Stringifying up-front has two benefits:
|
|
14
|
-
* 1. Pinia's reactive proxy treats the string as a primitive — no traversal,
|
|
15
|
-
* no per-field getters/setters on our entries, no accidental shared refs
|
|
16
|
-
* with the live store.
|
|
17
|
-
* 2. `sizeBytes === snapshot.length` is automatic and accurate for the V2
|
|
18
|
-
* memory-budget instrumentation.
|
|
19
|
-
*
|
|
20
|
-
* Sidecar state that doesn't live on `brandPageData` (the variations grid in
|
|
21
|
-
* the Logo sidebar, the active `logoTemplateAsset`, the active
|
|
22
|
-
* `logoDraftToken`) is restored on undo by `_syncLogoSettingsFromSnapshot`
|
|
23
|
-
* via per-draft-token caches on the logo / editable-logo stores — keyed off
|
|
24
|
-
* `logoImage.logoDraftToken` which IS in the snapshot.
|
|
25
|
-
*/
|
|
26
|
-
export interface HistoryEntry {
|
|
27
|
-
/** JSON.stringify(BrandPageModel) at the moment of capture. */
|
|
28
|
-
snapshot: string;
|
|
29
|
-
|
|
30
|
-
/** ISO-8601 timestamp of when the entry was captured. */
|
|
31
|
-
timestamp: string;
|
|
32
|
-
|
|
33
|
-
/** What kind of change produced this entry. */
|
|
34
|
-
source: 'manual_edit' | 'template_switch' | 'ai_changeset' | 'rich_text';
|
|
35
|
-
|
|
36
|
-
/** Who authored the change. */
|
|
37
|
-
actor: 'user' | 'ai_builder';
|
|
38
|
-
|
|
39
|
-
/** Optional human-readable short description. Reserved for V2 UI / debugging. */
|
|
40
|
-
label?: string;
|
|
41
|
-
|
|
42
|
-
/** Serialized size in bytes (= snapshot.length in JS string semantics). */
|
|
43
|
-
sizeBytes: number;
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The page the user was viewing when this entry was captured. Sidecar to the
|
|
47
|
-
* `BrandPageModel` snapshot — page-tab "selected" styling and the sidebar's
|
|
48
|
-
* `currentPage` getter both key off this id (via the route URL → routeStore →
|
|
49
|
-
* `pageNavigation.activePageId`). Snapshots only contain the document model,
|
|
50
|
-
* so without this we can't restore the URL/active-page on undo and the
|
|
51
|
-
* carousel highlight bleeds through to the page that no longer exists.
|
|
52
|
-
*
|
|
53
|
-
* Empty string when no page is active yet (e.g. earliest baseline before
|
|
54
|
-
* first router push). Always a real id once the editor has settled.
|
|
55
|
-
*/
|
|
56
|
-
activePageId: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Single-stack history model with a pointer instead of two stacks.
|
|
61
|
-
*
|
|
62
|
-
* entries: [e0, e1, e2, e3, e4]
|
|
63
|
-
* ^
|
|
64
|
-
* currentIndex
|
|
65
|
-
*
|
|
66
|
-
* canUndo = currentIndex > 0
|
|
67
|
-
* canRedo = currentIndex < entries.length - 1
|
|
68
|
-
*
|
|
69
|
-
* capture → entries = entries.slice(0, currentIndex + 1); push; currentIndex = end
|
|
70
|
-
* undo → currentIndex--; applySnapshot(entries[currentIndex])
|
|
71
|
-
* redo → currentIndex++; applySnapshot(entries[currentIndex])
|
|
72
|
-
*
|
|
73
|
-
* This model:
|
|
74
|
-
* - eliminates the two-stack-desync risk class (mutating two refs in sequence)
|
|
75
|
-
* - makes FR-11 "clear redo on new change" a single array truncation
|
|
76
|
-
* - makes it trivial to build a V2 timeline UI (iterate entries, highlight
|
|
77
|
-
* currentIndex)
|
|
78
|
-
*/
|
|
79
|
-
// Collapses multi-step mutations into a single capture. Monotonic: first
|
|
80
|
-
// mutation starts the timer, further mutations inside the window do not
|
|
81
|
-
// reset it. A max-wait ceiling forces a commit during continuous activity
|
|
82
|
-
// so a user holding down a key for 15 s still gets periodic snapshots.
|
|
83
|
-
const CAPTURE_DEBOUNCE_MS = 1700;
|
|
84
|
-
const CAPTURE_MAX_WAIT_MS = 10000;
|
|
85
|
-
|
|
86
|
-
// FR-12: 40-entry cap. FR-13: 100 MB memory budget. FR-13c: per-entry
|
|
87
|
-
// oversize threshold — entries above this are skipped + emit telemetry.
|
|
88
|
-
// 25 MB accommodates a high-res BYO logo upload, which renders ~2.5 MB
|
|
89
|
-
// of base64 PNG into each of four viewport sections (~10 MB) plus per-page
|
|
90
|
-
// logo content blocks. The variations grid (~10 MB) does NOT live in the
|
|
91
|
-
// snapshot — it sits on the editable-logo store keyed by draft token, and
|
|
92
|
-
// is restored on undo from there. The 100 MB total budget still evicts
|
|
93
|
-
// oldest non-baseline entries to keep memory bounded.
|
|
94
|
-
const MAX_ENTRIES = 40;
|
|
95
|
-
const MAX_MEMORY_BYTES = 100 * 1024 * 1024;
|
|
96
|
-
const MAX_ENTRY_BYTES = 25 * 1024 * 1024;
|
|
97
|
-
|
|
98
|
-
// Window after baseline-capture during which any auto-captured snapshot
|
|
99
|
-
// REPLACES the baseline rather than pushing a new entry. Covers post-init
|
|
100
|
-
// reactive cascades (template loadPageNo, router afterEach, layout settle)
|
|
101
|
-
// that would otherwise make undo light up before the user has done anything.
|
|
102
|
-
const BASELINE_SETTLE_MS = 2000;
|
|
103
|
-
|
|
104
|
-
export const useHistoryStore = defineStore('history', () => {
|
|
105
|
-
const eventTracker = useEventTracker();
|
|
106
|
-
|
|
107
|
-
// --- State ---
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* `shallowRef` keeps the array ref reactive (so dependent computeds recompute
|
|
111
|
-
* when we swap the array) but prevents Pinia / Vue from proxying every entry
|
|
112
|
-
* object inside. Combined with string snapshots, entries are fully opaque to
|
|
113
|
-
* reactivity traversal — which is what we want for a history log.
|
|
114
|
-
*/
|
|
115
|
-
const entries = shallowRef<HistoryEntry[]>([]);
|
|
116
|
-
const currentIndex = ref(-1); // -1 means "no baseline yet"
|
|
117
|
-
const isApplyingSnapshot = ref(false);
|
|
118
|
-
const baselineCaptured = ref(false);
|
|
119
|
-
const watcherInstalled = ref(false);
|
|
120
|
-
let baselineCapturedAt = 0;
|
|
121
|
-
|
|
122
|
-
// --- Getters ---
|
|
123
|
-
|
|
124
|
-
const canUndo = computed(() => currentIndex.value > 0);
|
|
125
|
-
const canRedo = computed(
|
|
126
|
-
() =>
|
|
127
|
-
currentIndex.value >= 0 && currentIndex.value < entries.value.length - 1,
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
// --- Internals ---
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Cached reference to the maker store. Set once when the watcher installs so
|
|
134
|
-
* the capture hot path is synchronous.
|
|
135
|
-
*/
|
|
136
|
-
let cachedMakerStore: ReturnType<typeof useBrandPageMakerStore> | null = null;
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Serialize the current `brandPageData` into a detached JSON string.
|
|
140
|
-
* Synchronous — safe to call inside a microtask without racing a follow-up
|
|
141
|
-
* mutation.
|
|
142
|
-
*/
|
|
143
|
-
function serializeCurrentSnapshot(): string | null {
|
|
144
|
-
if (!cachedMakerStore) return null;
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
return JSON.stringify(toRaw(cachedMakerStore.brandPageData));
|
|
148
|
-
} catch (error) {
|
|
149
|
-
console.error('history-store: failed to serialize snapshot', error);
|
|
150
|
-
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Read the currently active pageId from `pageNavigationStore`. Sidecar to
|
|
157
|
-
* the snapshot string — see `HistoryEntry.activePageId`. Empty string when
|
|
158
|
-
* no page is active yet.
|
|
159
|
-
*/
|
|
160
|
-
function getCurrentActivePageId(): string {
|
|
161
|
-
return usePageNavigationStore().activePageId ?? '';
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Normalize a snapshot for the dedup byte-compare. Strips/canonicalizes
|
|
166
|
-
* fields that get mutated by sidebar-open reactive cascades but aren't
|
|
167
|
-
* user-visible edits — without this, opening a content panel produces an
|
|
168
|
-
* undo step that "does nothing" when replayed.
|
|
169
|
-
*
|
|
170
|
-
* Known phantom sources (BP-5506):
|
|
171
|
-
* - TipTap RTE writing back its empty-paragraph default on first mount
|
|
172
|
-
* (e.g. `""` → `<p style="text-align: center;"></p>`).
|
|
173
|
-
* - TipTap RTE writing back its default-style hydration on first mount
|
|
174
|
-
* (e.g. plain `"Hello"` → `<p style="..."><span style="..."><mark ...>Hello</mark></span></p>`).
|
|
175
|
-
* - TipTap re-emitting whole-content `<strong>`/`<em>`/`<u>`/`<s>` tags
|
|
176
|
-
* to mirror the resolved `textStyle.fontWeight / fontStyle /
|
|
177
|
-
* textDecoration` (e.g. plain `"text"` →
|
|
178
|
-
* `<p>...<span>...<strong>text</strong></span></p>`).
|
|
179
|
-
* - Lazy hydration of `*Style` objects when a content settings panel —
|
|
180
|
-
* or section selection on the canvas — resolves `textStyleType` and
|
|
181
|
-
* writes the full resolved style (fontFamily, fontSize, color,
|
|
182
|
-
* fontWeight, fontStyle, letterSpacing, lineHeight, textDecoration)
|
|
183
|
-
* onto the model. The `textStyleType` itself is the authoring
|
|
184
|
-
* intent; the resolved keys are just cached values.
|
|
185
|
-
* - `isModifiedAfterTemplateApplied` flipping on first edit gesture.
|
|
186
|
-
* - `logoImageBase64Url` — the rendered logo PNG. Per-property logo
|
|
187
|
-
* edits author through `editableLogoStore.logoEdits` (not in
|
|
188
|
-
* `brandPageData`), so an undo would revert the canvas image but
|
|
189
|
-
* leave the sidebar fields showing the user's typed value. Logo
|
|
190
|
-
* property edits are excluded from V1 undo scope; BYO-upload undo
|
|
191
|
-
* still works because that diff lives in `logoImage.logoDraftToken`.
|
|
192
|
-
*
|
|
193
|
-
* RTE strings are canonicalized by parsing the HTML with DOMParser and
|
|
194
|
-
* walking the tree; only attributeless format wrappers (and a tightly
|
|
195
|
-
* scoped set of hydration styles on `<p>` / `<span>` / `<mark>`) are
|
|
196
|
-
* collapsed. Real authoring intent (links, custom colors, partial-text
|
|
197
|
-
* formatting like `Hello <strong>world</strong>`) survives the walk
|
|
198
|
-
* because at least one node falls outside the hydration whitelist,
|
|
199
|
-
* and the walker bails to "preserve original" the moment that happens.
|
|
200
|
-
*
|
|
201
|
-
* Used ONLY for dedup comparison. Stored snapshots remain unchanged so
|
|
202
|
-
* `applyHistorySnapshot` always sees the real state.
|
|
203
|
-
*/
|
|
204
|
-
// Tags TipTap re-emits purely to mirror the resolved textStyle on first
|
|
205
|
-
// hydration. None carry user authoring intent on their own when they are
|
|
206
|
-
// the sole element-child wrapping the entire content:
|
|
207
|
-
// - <p>: only its `text-align` style is meaningful, and that's already
|
|
208
|
-
// captured in `textStyle.textAlign`.
|
|
209
|
-
// - <span>: TipTap wraps content in a span with `font-family`, `font-size`,
|
|
210
|
-
// `color`, `letter-spacing`, `line-height` resolved from the
|
|
211
|
-
// textStyle. The textStyle object on the same content is the
|
|
212
|
-
// authoritative source.
|
|
213
|
-
// - <mark>: TipTap's highlight extension default — collapses regardless
|
|
214
|
-
// of `data-color` / `style` attrs, mirroring the original
|
|
215
|
-
// regex's behavior on a hydrated highlight.
|
|
216
|
-
// - <strong>/<em>/<u>/<s>: attributeless tags that mirror
|
|
217
|
-
// `fontWeight: bold` / `fontStyle: italic` / `textDecoration:
|
|
218
|
-
// underline / line-through`. ONLY recognized as hydration when
|
|
219
|
-
// they are the sole child of their parent — partial-content
|
|
220
|
-
// usage like `Hello <strong>world</strong>` is a real edit.
|
|
221
|
-
// Anything else (`<a>`, `<br>`, `<span style="color: red">`, attributes on
|
|
222
|
-
// <strong>/<em>, etc.) is preserved as a real diff.
|
|
223
|
-
const STRUCTURAL_HYDRATION_TAGS = new Set(['P', 'SPAN', 'MARK']);
|
|
224
|
-
const FORMAT_HYDRATION_TAGS = new Set(['STRONG', 'EM', 'U', 'S']);
|
|
225
|
-
|
|
226
|
-
// Default style properties TipTap emits on a hydration <span> /
|
|
227
|
-
// <mark>. The textStyle resolver writes all of these onto the live
|
|
228
|
-
// model, so when TipTap re-emits them as inline style on its
|
|
229
|
-
// hydration span/mark there is no user-visible delta.
|
|
230
|
-
// Anything outside this whitelist (e.g. `text-decoration` mid-content,
|
|
231
|
-
// `text-shadow`, `text-transform`) is treated as a real edit.
|
|
232
|
-
const HYDRATION_INLINE_STYLE_PROPS = new Set([
|
|
233
|
-
'font-family',
|
|
234
|
-
'font-size',
|
|
235
|
-
'letter-spacing',
|
|
236
|
-
'line-height',
|
|
237
|
-
'color',
|
|
238
|
-
'background-color',
|
|
239
|
-
]);
|
|
240
|
-
|
|
241
|
-
// Empty paragraph forms TipTap emits when an editor mounts on an empty
|
|
242
|
-
// string. Treated as equivalent to `""` for dedup.
|
|
243
|
-
function isEmptyRteHtml(value: unknown): boolean {
|
|
244
|
-
if (typeof value !== 'string') return false;
|
|
245
|
-
|
|
246
|
-
return /^<p(\s[^>]*)?>(<br\s*\/?>)?<\/p>$/.test(value);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function isHydrationOnlyAttributes(el: Element): boolean {
|
|
250
|
-
// Format tags (<strong>/<em>/<u>/<s>) must be attributeless. Anything
|
|
251
|
-
// with class/style/data-*/etc. is a real edit.
|
|
252
|
-
if (FORMAT_HYDRATION_TAGS.has(el.tagName))
|
|
253
|
-
return el.attributes.length === 0;
|
|
254
|
-
|
|
255
|
-
for (const attr of Array.from(el.attributes)) {
|
|
256
|
-
// <mark> may carry a `data-color` attr (TipTap highlight extension's
|
|
257
|
-
// default-and-resolved value). The accompanying `style` is checked
|
|
258
|
-
// against the same hydration-prop whitelist as <span>.
|
|
259
|
-
if (
|
|
260
|
-
el.tagName === 'MARK' &&
|
|
261
|
-
(attr.name === 'data-color' || attr.name === 'style')
|
|
262
|
-
) {
|
|
263
|
-
if (attr.name === 'data-color') continue;
|
|
264
|
-
} else if (attr.name !== 'style') {
|
|
265
|
-
return false;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const decls = attr.value
|
|
269
|
-
.split(';')
|
|
270
|
-
.map((d) => d.trim())
|
|
271
|
-
.filter(Boolean);
|
|
272
|
-
|
|
273
|
-
if (el.tagName === 'P') {
|
|
274
|
-
for (const decl of decls) {
|
|
275
|
-
const [prop] = decl.split(':').map((s) => s.trim());
|
|
276
|
-
|
|
277
|
-
if (prop !== 'text-align') return false;
|
|
278
|
-
}
|
|
279
|
-
} else if (el.tagName === 'SPAN' || el.tagName === 'MARK') {
|
|
280
|
-
for (const decl of decls) {
|
|
281
|
-
const [prop, value] = decl.split(':').map((s) => s.trim());
|
|
282
|
-
|
|
283
|
-
// Allow `color: inherit` on <mark> (TipTap stamps it on the
|
|
284
|
-
// default highlight to inherit the span's color). Otherwise
|
|
285
|
-
// require the prop to be in the hydration whitelist.
|
|
286
|
-
if (
|
|
287
|
-
el.tagName === 'MARK' &&
|
|
288
|
-
prop === 'color' &&
|
|
289
|
-
value === 'inherit'
|
|
290
|
-
) {
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (!HYDRATION_INLINE_STYLE_PROPS.has(prop)) return false;
|
|
295
|
-
}
|
|
296
|
-
} else {
|
|
297
|
-
return false;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return true;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Walk a parsed HTML node tree. Return the joined text content if every
|
|
306
|
-
* descendant is hydration noise; return null if any node carries real
|
|
307
|
-
* authoring intent (links, partial formatting, custom styles, etc.).
|
|
308
|
-
*
|
|
309
|
-
* `isFormatTag` differs in how it's allowed to appear: a structural tag
|
|
310
|
-
* (P/SPAN/MARK) can have siblings; a format tag (STRONG/EM/U/S) is only
|
|
311
|
-
* allowed when it's the SOLE child of its parent. The caller enforces
|
|
312
|
-
* that at the iteration site.
|
|
313
|
-
*/
|
|
314
|
-
function extractCanonicalText(node: Node): string | null {
|
|
315
|
-
if (node.nodeType === 3 /* TEXT_NODE */) return node.textContent ?? '';
|
|
316
|
-
|
|
317
|
-
if (node.nodeType !== 1 /* ELEMENT_NODE */) {
|
|
318
|
-
// Comments, CDATA, etc. — treat as opaque (preserve original).
|
|
319
|
-
return null;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const el = node as Element;
|
|
323
|
-
const isStructural = STRUCTURAL_HYDRATION_TAGS.has(el.tagName);
|
|
324
|
-
const isFormat = FORMAT_HYDRATION_TAGS.has(el.tagName);
|
|
325
|
-
|
|
326
|
-
if (!isStructural && !isFormat) return null;
|
|
327
|
-
|
|
328
|
-
if (!isHydrationOnlyAttributes(el)) return null;
|
|
329
|
-
|
|
330
|
-
const children = Array.from(el.childNodes);
|
|
331
|
-
let out = '';
|
|
332
|
-
|
|
333
|
-
for (const child of children) {
|
|
334
|
-
// Format tags only count as hydration when they wrap the ENTIRE
|
|
335
|
-
// content of their parent. If a <strong>/<em>/<u>/<s> appears
|
|
336
|
-
// alongside any sibling (text or element), it's partial-content
|
|
337
|
-
// formatting — a real edit that can't be expressed via textStyle —
|
|
338
|
-
// so bail.
|
|
339
|
-
if (
|
|
340
|
-
child.nodeType === 1 &&
|
|
341
|
-
FORMAT_HYDRATION_TAGS.has((child as Element).tagName) &&
|
|
342
|
-
children.length > 1
|
|
343
|
-
) {
|
|
344
|
-
return null;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const part = extractCanonicalText(child);
|
|
348
|
-
|
|
349
|
-
if (part === null) return null;
|
|
350
|
-
|
|
351
|
-
out += part;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
return out;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* If `value` is TipTap's default-hydration wrapper around plain text
|
|
359
|
-
* (only hydration-noise elements wrapping bare text), return that bare
|
|
360
|
-
* text. Otherwise return the original string unchanged.
|
|
361
|
-
*
|
|
362
|
-
* This collapses the BP-5506 phantom where opening a content sidebar —
|
|
363
|
-
* or selecting a section on the canvas — mounts every RTE editor at
|
|
364
|
-
* once and writes back wrapped HTML with no user-visible delta.
|
|
365
|
-
*/
|
|
366
|
-
function canonicalizeRteString(value: string): string {
|
|
367
|
-
// Cheap exit: no tags at all.
|
|
368
|
-
if (!value.includes('<')) return value;
|
|
369
|
-
|
|
370
|
-
if (isEmptyRteHtml(value)) return '';
|
|
371
|
-
|
|
372
|
-
// DOMParser is unavailable in some non-browser test environments;
|
|
373
|
-
// fall back to leaving the value as-is so the caller produces a
|
|
374
|
-
// real diff (i.e. fail open — never falsely collapse).
|
|
375
|
-
if (typeof DOMParser === 'undefined') return value;
|
|
376
|
-
|
|
377
|
-
let doc: Document;
|
|
378
|
-
|
|
379
|
-
try {
|
|
380
|
-
doc = new DOMParser().parseFromString(
|
|
381
|
-
`<div id="__rte_root__">${value}</div>`,
|
|
382
|
-
'text/html',
|
|
383
|
-
);
|
|
384
|
-
} catch {
|
|
385
|
-
return value;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const root = doc.getElementById('__rte_root__');
|
|
389
|
-
|
|
390
|
-
if (!root) return value;
|
|
391
|
-
|
|
392
|
-
let out = '';
|
|
393
|
-
|
|
394
|
-
for (const child of Array.from(root.childNodes)) {
|
|
395
|
-
const part = extractCanonicalText(child);
|
|
396
|
-
|
|
397
|
-
if (part === null) return value;
|
|
398
|
-
|
|
399
|
-
out += part;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
return out;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function normalizeNode(node: unknown): unknown {
|
|
406
|
-
if (Array.isArray(node)) return node.map(normalizeNode);
|
|
407
|
-
|
|
408
|
-
if (node === null || typeof node !== 'object') return node;
|
|
409
|
-
|
|
410
|
-
const out: Record<string, unknown> = {};
|
|
411
|
-
|
|
412
|
-
for (const key of Object.keys(node as Record<string, unknown>)) {
|
|
413
|
-
let v: unknown = (node as Record<string, unknown>)[key];
|
|
414
|
-
|
|
415
|
-
// Strip the template-modified flag — it's meta-state that flips on
|
|
416
|
-
// first edit gesture (e.g. clicking into a panel) and does not
|
|
417
|
-
// represent a user-visible change.
|
|
418
|
-
if (key === 'isModifiedAfterTemplateApplied') continue;
|
|
419
|
-
|
|
420
|
-
// Strip the rendered logo PNG. Logo property edits (text, color,
|
|
421
|
-
// font, outline, shadow) authored via the Logo sidebar live in
|
|
422
|
-
// `editableLogoStore.logoEdits` — which is NOT in `brandPageData`,
|
|
423
|
-
// so an undo would revert this base64 image on the canvas without
|
|
424
|
-
// touching the sidebar fields. Per V1 scope, logo property edits
|
|
425
|
-
// are non-undoable (parallels the PRD's admin-logo exclusion);
|
|
426
|
-
// BYO-upload undo still works because that change shows up in
|
|
427
|
-
// `logoImage.logoDraftToken`, not here. Stripping the field keeps
|
|
428
|
-
// those isolated render-output diffs out of the stack entirely.
|
|
429
|
-
if (key === 'logoImageBase64Url') continue;
|
|
430
|
-
|
|
431
|
-
// Collapse RTE default-hydration wrappers down to the plain text
|
|
432
|
-
// they wrap. Plain "X" and `<p style="..."><span ...><mark ...>X
|
|
433
|
-
// </mark></span></p>` share the same canonical "X" so a sidebar
|
|
434
|
-
// open that mounts every editor at once doesn't generate phantom
|
|
435
|
-
// undo steps.
|
|
436
|
-
if (typeof v === 'string') {
|
|
437
|
-
v = canonicalizeRteString(v);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Empty TipTap paragraphs collapse to "" — and an empty string is
|
|
441
|
-
// equivalent to the key being absent (JSON.stringify drops `undefined`),
|
|
442
|
-
// so skip the key entirely. Otherwise the normalizer would emit
|
|
443
|
-
// `"description": ""` here while the baseline snapshot — captured when
|
|
444
|
-
// `description` was `undefined` — has no `description` key at all,
|
|
445
|
-
// and the two normalized strings would differ.
|
|
446
|
-
if (isEmptyRteHtml(v) || v === '') continue;
|
|
447
|
-
|
|
448
|
-
// textStyle / descriptionStyle / titleStyle / *Style objects.
|
|
449
|
-
//
|
|
450
|
-
// When `textStyleType` is set, the live store lazily fills in the
|
|
451
|
-
// resolved values (fontFamily, fontSize, color, fontWeight,
|
|
452
|
-
// fontStyle, letterSpacing, lineHeight, textDecoration, etc.) the
|
|
453
|
-
// first time a settings panel — or section-selection on the canvas
|
|
454
|
-
// — resolves `textStyleType` against `styles.textStyles.textStyleOptions`.
|
|
455
|
-
// None of those lazy fields represent a real user edit; the
|
|
456
|
-
// `textStyleType` itself is the authoring intent.
|
|
457
|
-
//
|
|
458
|
-
// Real user edits via the inline RTE toolbar set `textStyleType: undefined`
|
|
459
|
-
// (see useRichTextEditor.ts `fontStyleChanges = { ..., textStyleType: undefined }`),
|
|
460
|
-
// so we leave every key intact in that case — those values ARE the
|
|
461
|
-
// real authoring intent.
|
|
462
|
-
//
|
|
463
|
-
// Per-content style edits via the global Style panel land in
|
|
464
|
-
// `snapshot.styles.textStyles` (a separate sub-tree), so they still
|
|
465
|
-
// produce a real diff outside this branch.
|
|
466
|
-
if (
|
|
467
|
-
v !== null &&
|
|
468
|
-
typeof v === 'object' &&
|
|
469
|
-
!Array.isArray(v) &&
|
|
470
|
-
/Style$/.test(key)
|
|
471
|
-
) {
|
|
472
|
-
const styleObj = v as Record<string, unknown>;
|
|
473
|
-
const hasTextStyleType =
|
|
474
|
-
styleObj.textStyleType !== undefined &&
|
|
475
|
-
styleObj.textStyleType !== null;
|
|
476
|
-
|
|
477
|
-
if (hasTextStyleType) {
|
|
478
|
-
// Drop everything except `textStyleType` itself + `textAlign`,
|
|
479
|
-
// which is the user's per-instance alignment override (preserved
|
|
480
|
-
// on hydration via `globalContentStyle = { ...resolved, textAlign: style?.textAlign }`).
|
|
481
|
-
out[key] = {
|
|
482
|
-
textStyleType: styleObj.textStyleType,
|
|
483
|
-
...(styleObj.textAlign !== undefined
|
|
484
|
-
? { textAlign: styleObj.textAlign }
|
|
485
|
-
: {}),
|
|
486
|
-
};
|
|
487
|
-
continue;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// No textStyleType — these are real per-content overrides. Recurse
|
|
491
|
-
// normally so nested objects still get the standard treatment.
|
|
492
|
-
out[key] = normalizeNode(styleObj);
|
|
493
|
-
continue;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
out[key] = normalizeNode(v);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
return out;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function normalizeSnapshotForDedup(snapshotStr: string): string {
|
|
503
|
-
try {
|
|
504
|
-
const parsed = JSON.parse(snapshotStr);
|
|
505
|
-
|
|
506
|
-
return JSON.stringify(normalizeNode(parsed));
|
|
507
|
-
} catch {
|
|
508
|
-
return snapshotStr;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Commit a new entry into the stack at the current pointer position.
|
|
514
|
-
*
|
|
515
|
-
* Returns 'committed' when the entry lands in the stack, 'oversized' when
|
|
516
|
-
* the entry exceeds `MAX_ENTRY_BYTES` and is skipped (FR-13c), or 'noop'
|
|
517
|
-
* when the snapshot is byte-identical to the entry at the current pointer.
|
|
518
|
-
* Callers can use the return to emit telemetry — analytics wiring lives
|
|
519
|
-
* in U5.
|
|
520
|
-
*
|
|
521
|
-
* Enforces these invariants in order:
|
|
522
|
-
* 1. Drop no-op duplicates — if the new snapshot is identical to the
|
|
523
|
-
* entry at the current pointer, skip it. Color/text settle paths,
|
|
524
|
-
* template-switch confirms with no real diff, and AI applies that
|
|
525
|
-
* land identical state would otherwise create undo/redo steps that
|
|
526
|
-
* do nothing visible.
|
|
527
|
-
* 2. FR-13c — if the new entry exceeds 5 MB, skip it entirely.
|
|
528
|
-
* 3. FR-11 — truncate any redo tail.
|
|
529
|
-
* 4. FR-12 — cap at 40 entries; drop oldest non-baseline on overflow.
|
|
530
|
-
* 5. FR-13 — keep total memory under 100 MB; drop oldest non-baseline
|
|
531
|
-
* repeatedly until we're under budget.
|
|
532
|
-
*
|
|
533
|
-
* Baseline (entry 0) is never evicted so "undo everything" always returns
|
|
534
|
-
* to session start.
|
|
535
|
-
*/
|
|
536
|
-
function commitEntry(args: {
|
|
537
|
-
snapshot: string;
|
|
538
|
-
source: HistoryEntry['source'];
|
|
539
|
-
actor: HistoryEntry['actor'];
|
|
540
|
-
label?: string;
|
|
541
|
-
activePageId: string;
|
|
542
|
-
}): 'committed' | 'oversized' | 'noop' {
|
|
543
|
-
const current = entries.value[currentIndex.value];
|
|
544
|
-
|
|
545
|
-
if (current) {
|
|
546
|
-
if (current.snapshot === args.snapshot) return 'noop';
|
|
547
|
-
|
|
548
|
-
// Phantom-edit dedup: normalize both snapshots to ignore
|
|
549
|
-
// sidebar-open reactive cascades (RTE empty-paragraph init, lazy
|
|
550
|
-
// textStyle hydration, isModifiedAfterTemplateApplied flips). If
|
|
551
|
-
// they're equal after normalization, this capture has no
|
|
552
|
-
// user-visible diff vs. the entry at the pointer.
|
|
553
|
-
if (
|
|
554
|
-
normalizeSnapshotForDedup(current.snapshot) ===
|
|
555
|
-
normalizeSnapshotForDedup(args.snapshot)
|
|
556
|
-
) {
|
|
557
|
-
return 'noop';
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
if (args.snapshot.length > MAX_ENTRY_BYTES) {
|
|
562
|
-
eventTracker.sendHistoryOversizedEvent({
|
|
563
|
-
attempted_bytes: args.snapshot.length,
|
|
564
|
-
source: args.source,
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
return 'oversized';
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
const entry: HistoryEntry = {
|
|
571
|
-
snapshot: args.snapshot,
|
|
572
|
-
timestamp: new Date().toISOString(),
|
|
573
|
-
source: args.source,
|
|
574
|
-
actor: args.actor,
|
|
575
|
-
label: args.label,
|
|
576
|
-
sizeBytes: args.snapshot.length,
|
|
577
|
-
activePageId: args.activePageId,
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
// FR-11: truncate redo tail, then append.
|
|
581
|
-
const next = entries.value.slice(0, currentIndex.value + 1);
|
|
582
|
-
|
|
583
|
-
next.push(entry);
|
|
584
|
-
|
|
585
|
-
// FR-12: 40-entry cap. Drop oldest non-baseline (index 1) if we'd exceed.
|
|
586
|
-
let didEvict = false;
|
|
587
|
-
|
|
588
|
-
while (next.length > MAX_ENTRIES && next.length > 1) {
|
|
589
|
-
next.splice(1, 1);
|
|
590
|
-
didEvict = true;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// FR-13: keep total serialized size under 100 MB. Drop oldest
|
|
594
|
-
// non-baseline repeatedly until under budget. Baseline alone can exceed
|
|
595
|
-
// the budget on a very large page — in that pathological case we stop
|
|
596
|
-
// evicting at length 2 (baseline + newest) rather than dropping the
|
|
597
|
-
// baseline.
|
|
598
|
-
let totalBytes = next.reduce((sum, e) => sum + e.sizeBytes, 0);
|
|
599
|
-
|
|
600
|
-
while (totalBytes > MAX_MEMORY_BYTES && next.length > 2) {
|
|
601
|
-
totalBytes -= next[1].sizeBytes;
|
|
602
|
-
next.splice(1, 1);
|
|
603
|
-
didEvict = true;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
entries.value = next;
|
|
607
|
-
currentIndex.value = next.length - 1;
|
|
608
|
-
|
|
609
|
-
if (didEvict) {
|
|
610
|
-
eventTracker.sendHistoryCappedEvent({
|
|
611
|
-
entries_count: next.length,
|
|
612
|
-
size_bytes: totalBytes,
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
return 'committed';
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Install a deep watcher on `brandPageData` that auto-captures mutations.
|
|
621
|
-
*
|
|
622
|
-
* Debounce strategy (FR-16 / ADR-005):
|
|
623
|
-
* - Monotonic — the first mutation starts the idle timer; subsequent
|
|
624
|
-
* mutations inside the window DO NOT reset it. This is what collapses
|
|
625
|
-
* e.g. an RTE italic click's sync style write + ~100 ms-later HTML
|
|
626
|
-
* update into a single capture.
|
|
627
|
-
* - Idle flush — the idle timer fires CAPTURE_DEBOUNCE_MS after the
|
|
628
|
-
* first mutation in a burst (2 s by default).
|
|
629
|
-
* - Max-wait ceiling — if mutations keep arriving and the idle timer
|
|
630
|
-
* keeps being "one more edit away", a second timer forces a commit at
|
|
631
|
-
* CAPTURE_MAX_WAIT_MS after the burst started (10 s by default). This
|
|
632
|
-
* is what keeps a user holding down a key from losing undo resolution.
|
|
633
|
-
*
|
|
634
|
-
* Both timers clear after a commit fires, so the next mutation starts a
|
|
635
|
-
* fresh burst.
|
|
636
|
-
*/
|
|
637
|
-
// Hoisted so force-commit paths (template switch, AI apply) can cancel any
|
|
638
|
-
// pending auto-capture that would otherwise duplicate their entry.
|
|
639
|
-
let idleTimerId: ReturnType<typeof setTimeout> | null = null;
|
|
640
|
-
let maxWaitTimerId: ReturnType<typeof setTimeout> | null = null;
|
|
641
|
-
|
|
642
|
-
function cancelPendingAutoCapture(): void {
|
|
643
|
-
if (idleTimerId !== null) {
|
|
644
|
-
clearTimeout(idleTimerId);
|
|
645
|
-
idleTimerId = null;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
if (maxWaitTimerId !== null) {
|
|
649
|
-
clearTimeout(maxWaitTimerId);
|
|
650
|
-
maxWaitTimerId = null;
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function installWatcher(): void {
|
|
655
|
-
if (watcherInstalled.value) return;
|
|
656
|
-
|
|
657
|
-
cachedMakerStore = useBrandPageMakerStore();
|
|
658
|
-
|
|
659
|
-
const flush = () => {
|
|
660
|
-
cancelPendingAutoCapture();
|
|
661
|
-
|
|
662
|
-
if (isApplyingSnapshot.value) return;
|
|
663
|
-
|
|
664
|
-
if (!baselineCaptured.value) return;
|
|
665
|
-
|
|
666
|
-
const snapshot = serializeCurrentSnapshot();
|
|
667
|
-
|
|
668
|
-
if (!snapshot) return;
|
|
669
|
-
|
|
670
|
-
const activePageId = getCurrentActivePageId();
|
|
671
|
-
|
|
672
|
-
// During the post-init settle window, fold any captured mutation back
|
|
673
|
-
// into the baseline (entry 0). Async cascades like template
|
|
674
|
-
// `loadPageNo`, the router's `afterEach`, and layout settling all
|
|
675
|
-
// mutate `brandPageData` after baseline is taken — without this they
|
|
676
|
-
// would each look like an undoable user edit on first paint. The
|
|
677
|
-
// baseline's `activePageId` is also overwritten so it tracks where
|
|
678
|
-
// the user actually settled (the router's `afterEach` runs inside
|
|
679
|
-
// this window, so the post-init `activePageId` is more accurate
|
|
680
|
-
// than the pre-router value captured in `captureBaseline`).
|
|
681
|
-
const inSettleWindow =
|
|
682
|
-
currentIndex.value === 0 &&
|
|
683
|
-
Date.now() - baselineCapturedAt < BASELINE_SETTLE_MS;
|
|
684
|
-
|
|
685
|
-
if (inSettleWindow) {
|
|
686
|
-
entries.value = [
|
|
687
|
-
{
|
|
688
|
-
...entries.value[0],
|
|
689
|
-
snapshot,
|
|
690
|
-
timestamp: new Date().toISOString(),
|
|
691
|
-
sizeBytes: snapshot.length,
|
|
692
|
-
activePageId,
|
|
693
|
-
},
|
|
694
|
-
];
|
|
695
|
-
|
|
696
|
-
return;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
commitEntry({
|
|
700
|
-
snapshot,
|
|
701
|
-
source: 'manual_edit',
|
|
702
|
-
actor: 'user',
|
|
703
|
-
activePageId,
|
|
704
|
-
});
|
|
705
|
-
};
|
|
706
|
-
|
|
707
|
-
// Monotonic burst arming. `isApplyingSnapshot` is gated here so an
|
|
708
|
-
// undo/redo doesn't leave a stale timer ticking that later flushes
|
|
709
|
-
// mid-keystroke when the user starts editing inside the debounce
|
|
710
|
-
// window. `baselineCaptured` is not gated here — `installWatcher` is
|
|
711
|
-
// only called from `captureBaseline`, so by the time this fires it is
|
|
712
|
-
// always true, and `flush` re-checks it.
|
|
713
|
-
const scheduleAutoCapture = (): void => {
|
|
714
|
-
if (isApplyingSnapshot.value) return;
|
|
715
|
-
|
|
716
|
-
if (idleTimerId !== null) return;
|
|
717
|
-
|
|
718
|
-
idleTimerId = setTimeout(flush, CAPTURE_DEBOUNCE_MS);
|
|
719
|
-
maxWaitTimerId = setTimeout(flush, CAPTURE_MAX_WAIT_MS);
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
watch(() => cachedMakerStore!.brandPageData, scheduleAutoCapture, {
|
|
723
|
-
deep: true,
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
watcherInstalled.value = true;
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
// --- Actions ---
|
|
730
|
-
|
|
731
|
-
/**
|
|
732
|
-
* Captures the baseline entry. Called once per session, typically by
|
|
733
|
-
* `BrandPageMaker.mixin.ts` immediately after `setIsBrandPageLoaded(true)`.
|
|
734
|
-
* Installs the reactive watcher so subsequent mutations auto-capture.
|
|
735
|
-
*/
|
|
736
|
-
function captureBaseline(): void {
|
|
737
|
-
if (baselineCaptured.value) return;
|
|
738
|
-
|
|
739
|
-
installWatcher();
|
|
740
|
-
|
|
741
|
-
const snapshot = serializeCurrentSnapshot();
|
|
742
|
-
|
|
743
|
-
if (!snapshot) return;
|
|
744
|
-
|
|
745
|
-
// Baseline starts a fresh stack of exactly 1 entry with pointer at 0.
|
|
746
|
-
entries.value = [
|
|
747
|
-
{
|
|
748
|
-
snapshot,
|
|
749
|
-
timestamp: new Date().toISOString(),
|
|
750
|
-
source: 'manual_edit',
|
|
751
|
-
actor: 'user',
|
|
752
|
-
label: 'baseline',
|
|
753
|
-
sizeBytes: snapshot.length,
|
|
754
|
-
activePageId: getCurrentActivePageId(),
|
|
755
|
-
},
|
|
756
|
-
];
|
|
757
|
-
currentIndex.value = 0;
|
|
758
|
-
baselineCaptured.value = true;
|
|
759
|
-
baselineCapturedAt = Date.now();
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/**
|
|
763
|
-
* Force-commit capture used by non-watcher call sites (template switch,
|
|
764
|
-
* AI apply). The watcher path commits atomically on its own.
|
|
765
|
-
*
|
|
766
|
-
* Returns the `commitEntry` result so force-commit callers can log
|
|
767
|
-
* oversized telemetry — analytics wiring lives in U5.
|
|
768
|
-
*/
|
|
769
|
-
function capture(args: {
|
|
770
|
-
source: HistoryEntry['source'];
|
|
771
|
-
actor: HistoryEntry['actor'];
|
|
772
|
-
label?: string;
|
|
773
|
-
}): 'committed' | 'oversized' | 'noop' | 'skipped' {
|
|
774
|
-
if (isApplyingSnapshot.value) return 'skipped';
|
|
775
|
-
|
|
776
|
-
if (!baselineCaptured.value) return 'skipped';
|
|
777
|
-
|
|
778
|
-
const snapshot = serializeCurrentSnapshot();
|
|
779
|
-
|
|
780
|
-
if (!snapshot) return 'skipped';
|
|
781
|
-
|
|
782
|
-
return commitEntry({
|
|
783
|
-
snapshot,
|
|
784
|
-
activePageId: getCurrentActivePageId(),
|
|
785
|
-
...args,
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
/**
|
|
790
|
-
* Force-commit a `template_switch` entry. Cancels any pending auto-capture
|
|
791
|
-
* so the template-switch change lands as exactly one entry (not a duplicate
|
|
792
|
-
* with the watcher's eventual flush).
|
|
793
|
-
*/
|
|
794
|
-
function captureTemplateSwitch(
|
|
795
|
-
label?: string,
|
|
796
|
-
): 'committed' | 'oversized' | 'noop' | 'skipped' {
|
|
797
|
-
cancelPendingAutoCapture();
|
|
798
|
-
|
|
799
|
-
return capture({ source: 'template_switch', actor: 'user', label });
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/**
|
|
803
|
-
* Force-commit an `ai_changeset` entry. Cancels any pending auto-capture
|
|
804
|
-
* so the AI apply lands as exactly one entry per prompt (US-E1).
|
|
805
|
-
*/
|
|
806
|
-
function captureAiChangeset(
|
|
807
|
-
label?: string,
|
|
808
|
-
): 'committed' | 'oversized' | 'noop' | 'skipped' {
|
|
809
|
-
cancelPendingAutoCapture();
|
|
810
|
-
|
|
811
|
-
return capture({ source: 'ai_changeset', actor: 'ai_builder', label });
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* Parse a stringified snapshot and push it through the maker store's
|
|
816
|
-
* dedicated `applyHistorySnapshot` action, which rehydrates every Pinia
|
|
817
|
-
* source store + own-store scalar fields that `brandPageData` reads from.
|
|
818
|
-
*
|
|
819
|
-
* Holds `isApplyingSnapshot` through `nextTick` so Vue's reactive queue
|
|
820
|
-
* drains while the watcher guard is still set, preventing the restore from
|
|
821
|
-
* being re-captured as a spurious duplicate entry.
|
|
822
|
-
*
|
|
823
|
-
* Active-page resolution priority:
|
|
824
|
-
* 1. Diff against `cameFromIndex` — any page present in the target
|
|
825
|
-
* snapshot but absent from the source snapshot is being "restored"
|
|
826
|
-
* by this transition. Navigate there. Covers undo-of-delete (lands
|
|
827
|
-
* on the deleted page) and redo-of-add (lands on the newly-added
|
|
828
|
-
* page) — both of which the user expects to "follow" the change.
|
|
829
|
-
* Without this, undo of a page-delete would resolve via walk-back
|
|
830
|
-
* to whatever page the user was on at the previous baseline, not
|
|
831
|
-
* the page they just un-deleted.
|
|
832
|
-
* 2. Walk back from `targetIndex` to find the closest entry whose
|
|
833
|
-
* captured `activePageId` still exists in the restored snapshot.
|
|
834
|
-
* Covers undo-of-add (falls back to the page the user was on
|
|
835
|
-
* before they added the new one) and any non-page-structural
|
|
836
|
-
* edits.
|
|
837
|
-
*/
|
|
838
|
-
async function applySnapshotString(
|
|
839
|
-
snapshotStr: string,
|
|
840
|
-
targetIndex: number,
|
|
841
|
-
cameFromIndex: number,
|
|
842
|
-
): Promise<void> {
|
|
843
|
-
const brandPageMakerStore = useBrandPageMakerStore();
|
|
844
|
-
|
|
845
|
-
let snapshot: BrandPageModel;
|
|
846
|
-
|
|
847
|
-
try {
|
|
848
|
-
snapshot = JSON.parse(snapshotStr) as BrandPageModel;
|
|
849
|
-
} catch (error) {
|
|
850
|
-
console.error('history-store: failed to parse snapshot', error);
|
|
851
|
-
|
|
852
|
-
return;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
const restoredPageIds = new Set<string>();
|
|
856
|
-
|
|
857
|
-
snapshot.pages?.forEach((p) => {
|
|
858
|
-
if (p?.id) restoredPageIds.add(p.id);
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
let resolvedActivePageId: string | undefined;
|
|
862
|
-
|
|
863
|
-
// Step 1: diff against the source-of-transition. The first page in
|
|
864
|
-
// the target snapshot whose id is absent from the source is the page
|
|
865
|
-
// being restored/added by this apply.
|
|
866
|
-
const sourceEntry = entries.value[cameFromIndex];
|
|
867
|
-
|
|
868
|
-
if (sourceEntry) {
|
|
869
|
-
const sourcePageIds = new Set<string>();
|
|
870
|
-
|
|
871
|
-
try {
|
|
872
|
-
const sourceSnap = JSON.parse(sourceEntry.snapshot) as BrandPageModel;
|
|
873
|
-
|
|
874
|
-
sourceSnap.pages?.forEach((p) => {
|
|
875
|
-
if (p?.id) sourcePageIds.add(p.id);
|
|
876
|
-
});
|
|
877
|
-
|
|
878
|
-
for (const p of snapshot.pages ?? []) {
|
|
879
|
-
if (p?.id && !sourcePageIds.has(p.id)) {
|
|
880
|
-
resolvedActivePageId = p.id;
|
|
881
|
-
break;
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
} catch {
|
|
885
|
-
// Source snapshot unparseable — fall through to walk-back.
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
// Step 2: walk-back fallback. Used when no page-structural diff
|
|
890
|
-
// applies (style/content edits) or when the diff produced nothing
|
|
891
|
-
// viable.
|
|
892
|
-
if (!resolvedActivePageId) {
|
|
893
|
-
for (let i = targetIndex; i >= 0; i--) {
|
|
894
|
-
const candidate = entries.value[i]?.activePageId;
|
|
895
|
-
|
|
896
|
-
if (candidate && restoredPageIds.has(candidate)) {
|
|
897
|
-
resolvedActivePageId = candidate;
|
|
898
|
-
break;
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
isApplyingSnapshot.value = true;
|
|
904
|
-
|
|
905
|
-
try {
|
|
906
|
-
await brandPageMakerStore.applyHistorySnapshot(
|
|
907
|
-
snapshot,
|
|
908
|
-
resolvedActivePageId,
|
|
909
|
-
);
|
|
910
|
-
await nextTick();
|
|
911
|
-
} finally {
|
|
912
|
-
isApplyingSnapshot.value = false;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
/** Move the pointer back one step and apply the entry at the new position. */
|
|
917
|
-
async function undo(): Promise<void> {
|
|
918
|
-
if (!canUndo.value) return;
|
|
919
|
-
|
|
920
|
-
const aiThreadStore = useAiWebsiteBuilderThreadStore();
|
|
921
|
-
|
|
922
|
-
if (aiThreadStore.isProcessing) return;
|
|
923
|
-
|
|
924
|
-
// Capture the actor of the entry we're leaving — that's the change being undone.
|
|
925
|
-
const undoneActor = entries.value[currentIndex.value]?.actor;
|
|
926
|
-
const cameFromIndex = currentIndex.value;
|
|
927
|
-
|
|
928
|
-
currentIndex.value -= 1;
|
|
929
|
-
const target = entries.value[currentIndex.value];
|
|
930
|
-
|
|
931
|
-
if (target) {
|
|
932
|
-
await applySnapshotString(
|
|
933
|
-
target.snapshot,
|
|
934
|
-
currentIndex.value,
|
|
935
|
-
cameFromIndex,
|
|
936
|
-
);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
eventTracker.sendUndoEvent({
|
|
940
|
-
entries_count: entries.value.length,
|
|
941
|
-
actor: undoneActor,
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
/** Move the pointer forward one step and apply the entry at the new position. */
|
|
946
|
-
async function redo(): Promise<void> {
|
|
947
|
-
if (!canRedo.value) return;
|
|
948
|
-
|
|
949
|
-
const aiThreadStore = useAiWebsiteBuilderThreadStore();
|
|
950
|
-
|
|
951
|
-
if (aiThreadStore.isProcessing) return;
|
|
952
|
-
|
|
953
|
-
const cameFromIndex = currentIndex.value;
|
|
954
|
-
|
|
955
|
-
currentIndex.value += 1;
|
|
956
|
-
const target = entries.value[currentIndex.value];
|
|
957
|
-
|
|
958
|
-
if (target) {
|
|
959
|
-
await applySnapshotString(
|
|
960
|
-
target.snapshot,
|
|
961
|
-
currentIndex.value,
|
|
962
|
-
cameFromIndex,
|
|
963
|
-
);
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
// Actor of the entry we're moving onto — that's the change being re-applied.
|
|
967
|
-
eventTracker.sendRedoEvent({
|
|
968
|
-
entries_count: entries.value.length,
|
|
969
|
-
actor: target?.actor,
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
/**
|
|
974
|
-
* Clear the stack and re-baseline against the just-saved state.
|
|
975
|
-
*
|
|
976
|
-
* PRD §3 Non-Goals: undo history resets on save/publish — the saved state
|
|
977
|
-
* becomes the new clean baseline so a user can't accidentally undo back
|
|
978
|
-
* past a save. Called by `brand-page-container-store.saveBrandPage` on a
|
|
979
|
-
* successful save (covers both Save Progress and Publish, since publish
|
|
980
|
-
* routes through `saveBrandPage`).
|
|
981
|
-
*
|
|
982
|
-
* No-ops when no baseline has ever been captured — the undo/redo feature
|
|
983
|
-
* is gated by `userStore.features.canUseUndoRedo` (see
|
|
984
|
-
* BrandPageMaker.mixin.ts), and only flagged sessions install the watcher
|
|
985
|
-
* + capture a baseline. Without this guard, calling reset() in an
|
|
986
|
-
* unflagged session would install the watcher and start eating memory
|
|
987
|
-
* for snapshots the user can never see.
|
|
988
|
-
*
|
|
989
|
-
* Re-runs `captureBaseline` synchronously so the next mutation lands as
|
|
990
|
-
* an actual undoable edit rather than disappearing into a fresh settle
|
|
991
|
-
* window. The watcher stays installed; `installWatcher` early-returns
|
|
992
|
-
* if `watcherInstalled` is already true.
|
|
993
|
-
*/
|
|
994
|
-
function reset(): void {
|
|
995
|
-
if (!baselineCaptured.value) return;
|
|
996
|
-
|
|
997
|
-
cancelPendingAutoCapture();
|
|
998
|
-
|
|
999
|
-
entries.value = [];
|
|
1000
|
-
currentIndex.value = -1;
|
|
1001
|
-
baselineCaptured.value = false;
|
|
1002
|
-
baselineCapturedAt = 0;
|
|
1003
|
-
|
|
1004
|
-
captureBaseline();
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// --- Instrumentation (NFR-04a) ---
|
|
1008
|
-
|
|
1009
|
-
/** Sum of all entries' serialized sizes in bytes. */
|
|
1010
|
-
function getCurrentSizeBytes(): number {
|
|
1011
|
-
return entries.value.reduce((sum, e) => sum + e.sizeBytes, 0);
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
/** Total number of entries currently in the stack. */
|
|
1015
|
-
function getCurrentEntryCount(): number {
|
|
1016
|
-
return entries.value.length;
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
return {
|
|
1020
|
-
// State
|
|
1021
|
-
entries,
|
|
1022
|
-
currentIndex,
|
|
1023
|
-
isApplyingSnapshot,
|
|
1024
|
-
baselineCaptured,
|
|
1025
|
-
|
|
1026
|
-
// Getters
|
|
1027
|
-
canUndo,
|
|
1028
|
-
canRedo,
|
|
1029
|
-
|
|
1030
|
-
// Actions
|
|
1031
|
-
captureBaseline,
|
|
1032
|
-
capture,
|
|
1033
|
-
captureTemplateSwitch,
|
|
1034
|
-
captureAiChangeset,
|
|
1035
|
-
undo,
|
|
1036
|
-
redo,
|
|
1037
|
-
reset,
|
|
1038
|
-
getCurrentSizeBytes,
|
|
1039
|
-
getCurrentEntryCount,
|
|
1040
|
-
};
|
|
1041
|
-
});
|