@designcrowd/library.brand-page 5.17.25-undo-3 → 5.17.25-undo-4
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-xR_8611Z.js → CreatedOnBrandCrowd-de-de-Bfno64hd.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-CrKMPM86.js → CreatedOnBrandCrowd-en-us-D_vCn5Pl.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-C7lJoCwP.js → CreatedOnBrandCrowd-es-es-Dbsv2mXS.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-B2P1kNsi.js → CreatedOnBrandCrowd-fr-ca-C7cjAdAT.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-B2P1kNsi.js → CreatedOnBrandCrowd-fr-fr-C7cjAdAT.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-Cg9ulkXK.js → CreatedOnBrandCrowd-pt-br-qkmkw32p.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-Cg9ulkXK.js → CreatedOnBrandCrowd-pt-pt-qkmkw32p.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-RKjXZKTE.js → CreatedOnDcom-de-de-CQUNS-WQ.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-Bma4WSgm.js → CreatedOnDcom-en-us-DSTor88S.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-CAqF8RQd.js → CreatedOnDcom-es-es-B_gzXzCR.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-BZzsaA-O.js → CreatedOnDcom-fr-ca-DskYXGaJ.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-lB4hUGDb.js → CreatedOnDcom-fr-fr-B3A-09VT.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-dD3iqhZb.js → CreatedOnDcom-pt-br-EhBegIrb.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-80E4SvnQ.js → CreatedOnDcom-pt-pt-BkWfCkfb.js} +1 -1
- package/lib/src/brand-page-maker/stores/history/history-store.js +87 -2
- package/lib/src/brand-page-maker/stores/user/user-store.js +1 -1
- package/lib/src/index.mjs +1 -1
- package/lib/src/{lib-BBctNsms.js → lib-qbF5b3zi.js} +3682 -3650
- package/package.json +1 -1
- package/src/brand-page-maker/stores/history/history-store.test.ts +136 -0
- package/src/brand-page-maker/stores/history/history-store.ts +101 -1
- package/src/brand-page-maker/stores/user/user-store.ts +1 -1
package/package.json
CHANGED
|
@@ -156,6 +156,142 @@ describe('history-store (single stack + JSON-string snapshots)', () => {
|
|
|
156
156
|
expect(store.currentIndex).toBe(1);
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
// BP-5506 phantom-undo dedup: opening a content settings panel can
|
|
160
|
+
// mutate `brandPageData` in ways the user can't see (RTE empty-paragraph
|
|
161
|
+
// init, lazy textStyle hydration, isModifiedAfterTemplateApplied flip).
|
|
162
|
+
// The dedup gate normalizes these out so capture returns 'noop'.
|
|
163
|
+
describe('phantom-edit dedup (sidebar-open cascades)', () => {
|
|
164
|
+
it('treats RTE empty-paragraph init as a no-op', async () => {
|
|
165
|
+
const store = useHistoryStore();
|
|
166
|
+
|
|
167
|
+
// Baseline has description = "" (cold start).
|
|
168
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
169
|
+
(mockMakerStore.brandPageData as any).pages = [
|
|
170
|
+
{
|
|
171
|
+
contents: [{ type: 'contact-form', description: '' }],
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
await store.captureBaseline();
|
|
175
|
+
|
|
176
|
+
// Sidebar open writes back TipTap's empty-paragraph default.
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
178
|
+
(mockMakerStore.brandPageData as any).pages[0].contents[0].description =
|
|
179
|
+
'<p style="text-align: center;"></p>';
|
|
180
|
+
|
|
181
|
+
const result = store.capture({ source: 'manual_edit', actor: 'user' });
|
|
182
|
+
|
|
183
|
+
expect(result).toBe('noop');
|
|
184
|
+
expect(store.entries.length).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('treats lazy textStyle hydration (fontSize / letterSpacing) as a no-op', async () => {
|
|
188
|
+
const store = useHistoryStore();
|
|
189
|
+
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
191
|
+
(mockMakerStore.brandPageData as any).pages = [
|
|
192
|
+
{
|
|
193
|
+
contents: [
|
|
194
|
+
{
|
|
195
|
+
type: 'contact-form',
|
|
196
|
+
titleStyle: { textStyleType: 'titles' },
|
|
197
|
+
descriptionStyle: { textStyleType: 'paragraphs' },
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
await store.captureBaseline();
|
|
203
|
+
|
|
204
|
+
// Settings panel resolves textStyleType → fills in fontSize / letterSpacing.
|
|
205
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
206
|
+
const cf = (mockMakerStore.brandPageData as any).pages[0].contents[0];
|
|
207
|
+
|
|
208
|
+
cf.titleStyle.fontSize = '2.25em';
|
|
209
|
+
cf.titleStyle.letterSpacing = '0';
|
|
210
|
+
cf.descriptionStyle.fontSize = '1em';
|
|
211
|
+
cf.descriptionStyle.letterSpacing = '0';
|
|
212
|
+
|
|
213
|
+
const result = store.capture({ source: 'manual_edit', actor: 'user' });
|
|
214
|
+
|
|
215
|
+
expect(result).toBe('noop');
|
|
216
|
+
expect(store.entries.length).toBe(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('treats isModifiedAfterTemplateApplied flip as a no-op', async () => {
|
|
220
|
+
const store = useHistoryStore();
|
|
221
|
+
|
|
222
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
223
|
+
(mockMakerStore.brandPageData as any).isModifiedAfterTemplateApplied =
|
|
224
|
+
false;
|
|
225
|
+
await store.captureBaseline();
|
|
226
|
+
|
|
227
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
228
|
+
(mockMakerStore.brandPageData as any).isModifiedAfterTemplateApplied =
|
|
229
|
+
true;
|
|
230
|
+
|
|
231
|
+
const result = store.capture({ source: 'manual_edit', actor: 'user' });
|
|
232
|
+
|
|
233
|
+
expect(result).toBe('noop');
|
|
234
|
+
expect(store.entries.length).toBe(1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('treats RTE empty-paragraph init as a no-op when the prior key was absent', async () => {
|
|
238
|
+
const store = useHistoryStore();
|
|
239
|
+
|
|
240
|
+
// Baseline content has NO `description` key at all (the cold-start
|
|
241
|
+
// shape for sections like testimonial-section before any editor
|
|
242
|
+
// mounts). JSON.stringify drops `undefined`, so the captured snapshot
|
|
243
|
+
// simply lacks the key.
|
|
244
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
245
|
+
(mockMakerStore.brandPageData as any).pages = [
|
|
246
|
+
{
|
|
247
|
+
contents: [{ type: 'testimonial-section', title: 'A' }],
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
await store.captureBaseline();
|
|
251
|
+
|
|
252
|
+
// Sidebar open writes back TipTap's empty-paragraph default, creating
|
|
253
|
+
// the key that was previously absent.
|
|
254
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
255
|
+
(mockMakerStore.brandPageData as any).pages[0].contents[0].description =
|
|
256
|
+
'<p style="text-align: center;"></p>';
|
|
257
|
+
|
|
258
|
+
const result = store.capture({ source: 'manual_edit', actor: 'user' });
|
|
259
|
+
|
|
260
|
+
expect(result).toBe('noop');
|
|
261
|
+
expect(store.entries.length).toBe(1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('still commits when a real edit accompanies a phantom field', async () => {
|
|
265
|
+
const store = useHistoryStore();
|
|
266
|
+
|
|
267
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
268
|
+
(mockMakerStore.brandPageData as any).pages = [
|
|
269
|
+
{
|
|
270
|
+
contents: [
|
|
271
|
+
{
|
|
272
|
+
type: 'contact-form',
|
|
273
|
+
buttonText: 'Send',
|
|
274
|
+
description: '',
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
await store.captureBaseline();
|
|
280
|
+
|
|
281
|
+
// Real edit + phantom together — must commit.
|
|
282
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
283
|
+
const cf = (mockMakerStore.brandPageData as any).pages[0].contents[0];
|
|
284
|
+
|
|
285
|
+
cf.buttonText = 'Submit';
|
|
286
|
+
cf.description = '<p style="text-align: center;"></p>';
|
|
287
|
+
|
|
288
|
+
const result = store.capture({ source: 'manual_edit', actor: 'user' });
|
|
289
|
+
|
|
290
|
+
expect(result).toBe('committed');
|
|
291
|
+
expect(store.entries.length).toBe(2);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
159
295
|
it('reentrancy guard: capture no-ops when isApplyingSnapshot is true', async () => {
|
|
160
296
|
const store = useHistoryStore();
|
|
161
297
|
|
|
@@ -128,6 +128,92 @@ export const useHistoryStore = defineStore('history', () => {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Normalize a snapshot for the dedup byte-compare. Strips/canonicalizes
|
|
133
|
+
* fields that get mutated by sidebar-open reactive cascades but aren't
|
|
134
|
+
* user-visible edits — without this, opening a content panel produces an
|
|
135
|
+
* undo step that "does nothing" when replayed.
|
|
136
|
+
*
|
|
137
|
+
* Known phantom sources (BP-5506):
|
|
138
|
+
* - TipTap RTE writing back its empty-paragraph default on first mount
|
|
139
|
+
* (e.g. `""` → `<p style="text-align: center;"></p>`).
|
|
140
|
+
* - Lazy hydration of `textStyle` fields (`fontSize`, `letterSpacing`)
|
|
141
|
+
* when a content settings panel computes resolved values from
|
|
142
|
+
* `textStyleType` and writes them onto the model.
|
|
143
|
+
* - `isModifiedAfterTemplateApplied` flipping on first edit gesture.
|
|
144
|
+
*
|
|
145
|
+
* Used ONLY for dedup comparison. Stored snapshots remain unchanged so
|
|
146
|
+
* `applyHistorySnapshot` always sees the real state.
|
|
147
|
+
*/
|
|
148
|
+
// Empty paragraph forms TipTap emits when an editor mounts on an empty
|
|
149
|
+
// string. Treated as equivalent to `""` for dedup.
|
|
150
|
+
const EMPTY_RTE_HTML = /^<p(\s[^>]*)?>(<br\s*\/?>)?<\/p>$/;
|
|
151
|
+
|
|
152
|
+
function isEmptyRteHtml(value: unknown): boolean {
|
|
153
|
+
return typeof value === 'string' && EMPTY_RTE_HTML.test(value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function normalizeNode(node: unknown): unknown {
|
|
157
|
+
if (Array.isArray(node)) return node.map(normalizeNode);
|
|
158
|
+
|
|
159
|
+
if (node === null || typeof node !== 'object') return node;
|
|
160
|
+
|
|
161
|
+
const out: Record<string, unknown> = {};
|
|
162
|
+
|
|
163
|
+
for (const key of Object.keys(node as Record<string, unknown>)) {
|
|
164
|
+
const v = (node as Record<string, unknown>)[key];
|
|
165
|
+
|
|
166
|
+
// Strip the template-modified flag — it's meta-state that flips on
|
|
167
|
+
// first edit gesture (e.g. clicking into a panel) and does not
|
|
168
|
+
// represent a user-visible change.
|
|
169
|
+
if (key === 'isModifiedAfterTemplateApplied') continue;
|
|
170
|
+
|
|
171
|
+
// Empty TipTap paragraphs collapse to "" — and an empty string is
|
|
172
|
+
// equivalent to the key being absent (JSON.stringify drops `undefined`),
|
|
173
|
+
// so skip the key entirely. Otherwise the normalizer would emit
|
|
174
|
+
// `"description": ""` here while the baseline snapshot — captured when
|
|
175
|
+
// `description` was `undefined` — has no `description` key at all,
|
|
176
|
+
// and the two normalized strings would differ.
|
|
177
|
+
if (isEmptyRteHtml(v) || v === '') continue;
|
|
178
|
+
|
|
179
|
+
// textStyle / descriptionStyle / titleStyle / *Style objects:
|
|
180
|
+
// strip lazily-hydrated fields that get filled in when the settings
|
|
181
|
+
// panel computes resolved values from `textStyleType`.
|
|
182
|
+
if (
|
|
183
|
+
v !== null &&
|
|
184
|
+
typeof v === 'object' &&
|
|
185
|
+
!Array.isArray(v) &&
|
|
186
|
+
/Style$/.test(key)
|
|
187
|
+
) {
|
|
188
|
+
const styleObj = v as Record<string, unknown>;
|
|
189
|
+
const cleanedStyle: Record<string, unknown> = {};
|
|
190
|
+
|
|
191
|
+
for (const sk of Object.keys(styleObj)) {
|
|
192
|
+
if (sk === 'fontSize' || sk === 'letterSpacing') continue;
|
|
193
|
+
|
|
194
|
+
cleanedStyle[sk] = normalizeNode(styleObj[sk]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
out[key] = cleanedStyle;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
out[key] = normalizeNode(v);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeSnapshotForDedup(snapshotStr: string): string {
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(snapshotStr);
|
|
210
|
+
|
|
211
|
+
return JSON.stringify(normalizeNode(parsed));
|
|
212
|
+
} catch {
|
|
213
|
+
return snapshotStr;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
131
217
|
/**
|
|
132
218
|
* Commit a new entry into the stack at the current pointer position.
|
|
133
219
|
*
|
|
@@ -160,7 +246,21 @@ export const useHistoryStore = defineStore('history', () => {
|
|
|
160
246
|
}): 'committed' | 'oversized' | 'noop' {
|
|
161
247
|
const current = entries.value[currentIndex.value];
|
|
162
248
|
|
|
163
|
-
if (current
|
|
249
|
+
if (current) {
|
|
250
|
+
if (current.snapshot === args.snapshot) return 'noop';
|
|
251
|
+
|
|
252
|
+
// Phantom-edit dedup: normalize both snapshots to ignore
|
|
253
|
+
// sidebar-open reactive cascades (RTE empty-paragraph init, lazy
|
|
254
|
+
// textStyle hydration, isModifiedAfterTemplateApplied flips). If
|
|
255
|
+
// they're equal after normalization, this capture has no
|
|
256
|
+
// user-visible diff vs. the entry at the pointer.
|
|
257
|
+
if (
|
|
258
|
+
normalizeSnapshotForDedup(current.snapshot) ===
|
|
259
|
+
normalizeSnapshotForDedup(args.snapshot)
|
|
260
|
+
) {
|
|
261
|
+
return 'noop';
|
|
262
|
+
}
|
|
263
|
+
}
|
|
164
264
|
|
|
165
265
|
if (args.snapshot.length > MAX_ENTRY_BYTES) {
|
|
166
266
|
eventTracker.sendHistoryOversizedEvent({
|
|
@@ -34,7 +34,7 @@ export const useBrandPageMakerUserStore = defineStore('brandPageMakerUser', {
|
|
|
34
34
|
canUseAITextGenerator: false,
|
|
35
35
|
canUseGoogleAdsense: false,
|
|
36
36
|
googleAdsenseEnabled: false,
|
|
37
|
-
canUseUndoRedo:
|
|
37
|
+
canUseUndoRedo: false,
|
|
38
38
|
},
|
|
39
39
|
emailAddress: '',
|
|
40
40
|
hasPremiumSubscription: false,
|