@widgetic/editor 4.0.9 → 4.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +118 -0
  2. package/dist/components/WidgetPreview.svelte +65 -27
  3. package/dist/components/WidgetPreview.svelte.d.ts +8 -0
  4. package/dist/components/editors/CompositionEditorApp.svelte +508 -0
  5. package/dist/components/editors/CompositionEditorApp.svelte.d.ts +75 -0
  6. package/dist/components/editors/PropsEditor.svelte +193 -792
  7. package/dist/components/editors/PropsEditor.svelte.d.ts +7 -0
  8. package/dist/components/ic/BrowserInputController.svelte +21 -2
  9. package/dist/components/ic/ColorPickerInputController.svelte +24 -6
  10. package/dist/components/ic/DateFieldInputController.svelte +7 -3
  11. package/dist/components/ic/DatePickerInputController.svelte +10 -4
  12. package/dist/components/ic/DropdownInputController.svelte +11 -4
  13. package/dist/components/ic/FontInputController.svelte +68 -44
  14. package/dist/components/ic/InputControllerHeader.svelte +11 -8
  15. package/dist/components/ic/NumberInputController.svelte +4 -2
  16. package/dist/components/ic/PositionInputController.svelte +14 -11
  17. package/dist/components/ic/RangeCalendarInputController.svelte +10 -4
  18. package/dist/components/ic/RoundedCornersInputController.svelte +17 -9
  19. package/dist/components/ic/TextAreaInputController.svelte +20 -7
  20. package/dist/components/ic/TextAreaPreviewInputController.svelte +19 -69
  21. package/dist/components/ic/TimeInputController.svelte +9 -3
  22. package/dist/components/ic/VectorInputController.svelte +80 -54
  23. package/dist/components/ic/shared/ItemGrid.svelte +5 -1
  24. package/dist/composition/compositionApi.d.ts +95 -0
  25. package/dist/composition/compositionApi.js +289 -0
  26. package/dist/composition/compositionEditorController.d.ts +254 -0
  27. package/dist/composition/compositionEditorController.js +734 -0
  28. package/dist/composition/compositionHelpers.d.ts +72 -0
  29. package/dist/composition/compositionHelpers.js +403 -0
  30. package/dist/index.d.ts +3 -0
  31. package/dist/index.js +4 -0
  32. package/dist/mediaLibraryContext.d.ts +10 -0
  33. package/dist/mediaLibraryContext.js +30 -0
  34. package/dist/utils/FontManager.d.ts +21 -5
  35. package/dist/utils/FontManager.js +62 -67
  36. package/dist/utils/VectorManager.d.ts +0 -1
  37. package/dist/utils/VectorManager.js +10 -12
  38. package/dist/utils/editorStorage.d.ts +23 -0
  39. package/dist/utils/editorStorage.js +95 -0
  40. package/dist/utils/fontUtils.js +5 -3
  41. package/dist/utils/htmlSafety.d.ts +21 -0
  42. package/dist/utils/htmlSafety.js +38 -0
  43. package/dist/utils/itemReorder.d.ts +90 -0
  44. package/dist/utils/itemReorder.js +354 -0
  45. package/dist/utils/recentColors.js +4 -2
  46. package/dist/utils/richTextPreview.d.ts +38 -0
  47. package/dist/utils/richTextPreview.js +207 -0
  48. package/dist/utils/scrollActivity.d.ts +14 -0
  49. package/dist/utils/scrollActivity.js +63 -0
  50. package/dist/utils/shadowDom.d.ts +67 -0
  51. package/dist/utils/shadowDom.js +211 -0
  52. package/dist/utils/vectorUtils.js +5 -3
  53. package/dist/utils/visibilityCondition.d.ts +40 -0
  54. package/dist/utils/visibilityCondition.js +535 -0
  55. package/package.json +6 -5
@@ -0,0 +1,734 @@
1
+ /**
2
+ * The state and the behaviour of an embedded composition editor, with no user interface in it.
3
+ *
4
+ * It loads a composition and what is needed to edit it (the widget's preview address and its two schemas),
5
+ * keeps the values being edited, tells the preview page what changed, and saves: when asked, and by itself a
6
+ * moment after the last edit. A Svelte component draws it; a test can drive it with a fake API and fake timers.
7
+ *
8
+ * The rules that keep a customer's work safe live here:
9
+ *
10
+ * - "Unsaved changes" is counted, not flagged. Every edit raises a revision; a save remembers the revision it
11
+ * saved. An edit made while a save is on its way therefore stays unsaved when the save returns (the page
12
+ * this was taken from cleared the flag and lost track of it).
13
+ * - There is one save on the way at a time. A save asked for meanwhile waits for it, then saves what is new.
14
+ * - What is sent is a copy taken when the save starts, so edits made while it travels cannot change it, and
15
+ * sending it again after a 401 sends exactly the same thing.
16
+ * - A save that fails leaves everything unsaved and says so. An autosave that fails because of the network or
17
+ * the server is tried again a few times, further and further apart; a failure that retrying cannot fix
18
+ * (the session ended, the field is refused) is not.
19
+ * - The embed settings the composition already has (anything set by the site or by the partner's own backend)
20
+ * are sent back with the new size, never replaced by it.
21
+ * - The size that is saved is the one the user gave the preview (by dragging its corner or typing it in), and
22
+ * nothing else. Looking at the preview as on a phone changes how it is drawn, not what is saved, and the
23
+ * size a small box forces on the preview is not the user's choice, so it is never saved either (the page this
24
+ * was taken from saved both, so opening a composition in a small window, or trying the mobile view, changed
25
+ * the size of the widget embedded on a site).
26
+ * - After `dispose()` nothing more happens: no request goes out, and answers still on their way are ignored.
27
+ */
28
+ import { buildPropsEditorContentSchema, compositionWidgetId, contentItemsToEditorValueMap, ensureSubtitleFontInDesignSchema, extractContentItemsFromCompositionRaw, extractContentValuesFromCompositionRaw, flattenContentItemsForPreview, flattenDesignValuesForPreview, getCompositionEmbedSettings, mergeContentItemsWithSchema, mergeMissingContentValues, normalizeContentSchemaFromApiResponse, normalizeDesignSchemaFromApiResponse, parseEmbedDimension, seedMissingFontFaces } from './compositionHelpers.js';
29
+ /** Time to wait after the last edit before saving by itself. */
30
+ export const DEFAULT_AUTOSAVE_DELAY_MS = 2500;
31
+ /** How long to wait before trying an autosave again after it failed for a reason that may pass: first, second, third time. */
32
+ export const DEFAULT_AUTOSAVE_RETRY_DELAYS_MS = [5_000, 15_000, 45_000];
33
+ export const DEFAULT_PREVIEW_WIDTH = 800;
34
+ export const DEFAULT_PREVIEW_HEIGHT = 600;
35
+ /** The size the preview is drawn at in its mobile view. */
36
+ export const MOBILE_PREVIEW_WIDTH = 480;
37
+ export const MOBILE_PREVIEW_HEIGHT = 600;
38
+ /** The smallest size, in pixels, the preview can be given. */
39
+ export const MIN_PREVIEW_SIZE = 50;
40
+ /** Saving again and again while edits keep coming in must end: this many rounds at most when flushing. */
41
+ const MAX_FLUSH_ROUNDS = 10;
42
+ /**
43
+ * Whether `error` came from the API client. It is recognized by what it is called and carries, not by its class:
44
+ * a page that loads a second copy of the client makes errors a class check would not know.
45
+ */
46
+ function isCompositionApiError(error) {
47
+ const candidate = error;
48
+ return (error instanceof Error &&
49
+ error.name === 'CompositionApiError' &&
50
+ typeof candidate?.code === 'string' &&
51
+ typeof candidate?.status === 'number');
52
+ }
53
+ /** What went wrong, from whatever was thrown. */
54
+ export function describeFailure(error) {
55
+ if (isCompositionApiError(error)) {
56
+ return {
57
+ message: error.message,
58
+ code: error.code,
59
+ status: error.status,
60
+ retryable: error.retryable === true,
61
+ sessionEnded: error.sessionEnded === true,
62
+ correlationId: error.correlationId
63
+ };
64
+ }
65
+ return {
66
+ message: error instanceof Error && error.message ? error.message : 'Something went wrong.',
67
+ code: 'UNKNOWN',
68
+ status: 0,
69
+ retryable: false,
70
+ sessionEnded: false
71
+ };
72
+ }
73
+ /** A failure in words for the person using the editor. */
74
+ export function failureMessage(failure, doing) {
75
+ if (failure.code === 'WIDGET_NOT_PUBLISHED')
76
+ return 'Publish the widget before editing a composition.';
77
+ if (failure.sessionEnded) {
78
+ return doing === 'save'
79
+ ? 'Your session has ended, so your changes could not be saved. Reload the page to continue.'
80
+ : 'Your session has ended. Reload the page to continue.';
81
+ }
82
+ if (failure.status === 404)
83
+ return 'This composition could not be found.';
84
+ if (failure.status === 403)
85
+ return 'You do not have access to this composition.';
86
+ if (failure.retryable) {
87
+ return doing === 'save'
88
+ ? 'Your changes could not be saved. Check the connection and try again.'
89
+ : 'The editor could not be loaded. Check the connection and try again.';
90
+ }
91
+ return (failure.message ||
92
+ (doing === 'save' ? 'Your changes could not be saved.' : 'The editor could not be loaded.'));
93
+ }
94
+ /** A refusal that makes every other answer worthless: the session is over, or it may not see this composition. */
95
+ function refusesAccess(failure) {
96
+ return failure.sessionEnded || failure.status === 403;
97
+ }
98
+ /** Why there is no preview, in words for a person. */
99
+ function previewFailureMessage(failure) {
100
+ if (failure.status === 404)
101
+ return 'The widget this composition is made with could not be found.';
102
+ return failureMessage(failure, 'load');
103
+ }
104
+ function isRecord(value) {
105
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
106
+ }
107
+ /** What a request gave, or `null` when it failed. */
108
+ function valueOf(result) {
109
+ return result.status === 'fulfilled' ? result.value : null;
110
+ }
111
+ /** Why a request failed, or `null` when it did not. */
112
+ function failureOf(result) {
113
+ return result.status === 'rejected' ? describeFailure(result.reason) : null;
114
+ }
115
+ /** The content items the schema lists, wherever it keeps them. */
116
+ function schemaContentItems(contentSchema) {
117
+ const items = contentSchema?.contentItems ?? contentSchema?.data?.contentItems;
118
+ return Array.isArray(items) ? items : [];
119
+ }
120
+ export class CompositionEditorController {
121
+ options;
122
+ timers;
123
+ now;
124
+ logger;
125
+ autosaveDelayMs;
126
+ autosaveRetryDelaysMs;
127
+ abortController = new AbortController();
128
+ stateListeners = new Set();
129
+ eventListeners = new Set();
130
+ state;
131
+ /** Counts the edits made. */
132
+ revision = 0;
133
+ /** The revision the last successful save took. */
134
+ savedRevision = 0;
135
+ /** How many autosaves in a row have failed. */
136
+ failedAutosaves = 0;
137
+ /** The embed settings the composition has, sent back with the size on every save. */
138
+ storedEmbedSettings = {};
139
+ autosaveTimer = null;
140
+ /** What `getPropsEditorContentSchema` and `getPropsEditorContentValues` last made, and from what. */
141
+ propsEditorSchemaCache = null;
142
+ propsEditorValuesCache = null;
143
+ loadPromise = null;
144
+ savePromise = null;
145
+ disposed = false;
146
+ constructor(options) {
147
+ if (typeof options?.compositionId !== 'string' || options.compositionId.trim() === '') {
148
+ throw new TypeError('CompositionEditorController needs a composition id');
149
+ }
150
+ if (!options.api)
151
+ throw new TypeError('CompositionEditorController needs an api');
152
+ this.options = options;
153
+ this.timers = options.timers ?? {
154
+ setTimeout: (callback, milliseconds) => setTimeout(callback, milliseconds),
155
+ clearTimeout: (handle) => clearTimeout(handle)
156
+ };
157
+ this.now = options.now ?? Date.now;
158
+ this.logger = options.logger ?? console;
159
+ this.autosaveDelayMs = options.autosaveDelayMs ?? DEFAULT_AUTOSAVE_DELAY_MS;
160
+ this.autosaveRetryDelaysMs = options.autosaveRetryDelaysMs ?? DEFAULT_AUTOSAVE_RETRY_DELAYS_MS;
161
+ this.state = {
162
+ phase: 'idle',
163
+ compositionId: options.compositionId,
164
+ name: 'Untitled Composition',
165
+ widgetId: '',
166
+ preview: null,
167
+ previewError: null,
168
+ designSchema: null,
169
+ contentSchema: null,
170
+ schemasLoading: false,
171
+ schemasError: null,
172
+ designValues: {},
173
+ contentValues: {},
174
+ contentItems: [],
175
+ previewWidth: DEFAULT_PREVIEW_WIDTH,
176
+ previewHeight: DEFAULT_PREVIEW_HEIGHT,
177
+ embedWidth: DEFAULT_PREVIEW_WIDTH,
178
+ embedHeight: DEFAULT_PREVIEW_HEIGHT,
179
+ deviceView: 'desktop',
180
+ dirty: false,
181
+ saving: false,
182
+ saveError: null,
183
+ loadError: null,
184
+ lastSavedAt: null
185
+ };
186
+ }
187
+ // ---------------------------------------------------------------------------------------------
188
+ // Watching
189
+ // ---------------------------------------------------------------------------------------------
190
+ /** The state now. It is never changed in place: a change makes a new object. */
191
+ getState() {
192
+ return this.state;
193
+ }
194
+ /** Svelte store contract: calls `listener` with the state now, and again after every change. */
195
+ subscribe(listener) {
196
+ this.stateListeners.add(listener);
197
+ listener(this.state);
198
+ return () => {
199
+ this.stateListeners.delete(listener);
200
+ };
201
+ }
202
+ /** Calls `listener` with every event: `ready`, `dirty`, `saved`, `error`. */
203
+ onEvent(listener) {
204
+ this.eventListeners.add(listener);
205
+ return () => {
206
+ this.eventListeners.delete(listener);
207
+ };
208
+ }
209
+ // ---------------------------------------------------------------------------------------------
210
+ // Loading
211
+ // ---------------------------------------------------------------------------------------------
212
+ /** Loads the composition, its preview address and its schemas. Asking again while it loads waits for the same load. */
213
+ load() {
214
+ if (this.disposed)
215
+ return Promise.resolve();
216
+ if (this.loadPromise)
217
+ return this.loadPromise;
218
+ // (No other load can have taken its place: while this one is set, `load()` hands it out instead of starting one.)
219
+ const running = this.runLoad().finally(() => {
220
+ this.loadPromise = null;
221
+ });
222
+ this.loadPromise = running;
223
+ return running;
224
+ }
225
+ async runLoad() {
226
+ const { api, compositionId } = this.options;
227
+ const { signal } = this.abortController;
228
+ this.patch({
229
+ phase: 'loading',
230
+ loadError: null,
231
+ previewError: null,
232
+ schemasLoading: true,
233
+ schemasError: null
234
+ });
235
+ // None of the four needs another's answer, so they travel together.
236
+ const [compositionResult, previewResult, designResult, contentResult] = await Promise.allSettled([
237
+ api.getComposition(compositionId, { signal }),
238
+ api.getPreview(compositionId, { signal }),
239
+ api.getDesignSchema(compositionId, { signal }),
240
+ api.getContentSchema(compositionId, { signal })
241
+ ]);
242
+ if (this.disposed)
243
+ return;
244
+ // What stops the load: no composition, or a refusal that makes every other answer worthless.
245
+ if (compositionResult.status === 'rejected') {
246
+ this.failLoad(describeFailure(compositionResult.reason));
247
+ return;
248
+ }
249
+ const refusal = [previewResult, designResult, contentResult]
250
+ .map(failureOf)
251
+ .find((failure) => failure !== null && refusesAccess(failure));
252
+ if (refusal) {
253
+ this.failLoad(refusal);
254
+ return;
255
+ }
256
+ const composition = compositionResult.value;
257
+ const widgetId = compositionWidgetId(composition);
258
+ if (!widgetId) {
259
+ this.failLoad({
260
+ message: 'This composition has no widget.',
261
+ code: 'COMPOSITION_HAS_NO_WIDGET',
262
+ status: 0,
263
+ retryable: false,
264
+ sessionEnded: false
265
+ });
266
+ return;
267
+ }
268
+ // The preview: its absence is told in the preview's place; the settings can still be edited.
269
+ const previewInfo = valueOf(previewResult);
270
+ const previewFailure = failureOf(previewResult);
271
+ if (previewFailure) {
272
+ this.logger.warn('[CompositionEditor] The preview address could not be loaded:', previewFailure.code, previewFailure.message);
273
+ }
274
+ const preview = previewInfo
275
+ ? {
276
+ previewUrl: previewInfo.previewUrl,
277
+ widgetId: previewInfo.widgetId,
278
+ version: previewInfo.version
279
+ }
280
+ : null;
281
+ const schemas = this.readSchemas(designResult, contentResult);
282
+ // The values being edited: what the composition saved, with the schema's defaults for what it never saved.
283
+ const rawContent = (isRecord(composition.content) ? composition.content : {});
284
+ const designValues = seedMissingFontFaces((rawContent.design ?? composition.design ?? {}));
285
+ const contentValues = mergeMissingContentValues(extractContentValuesFromCompositionRaw(rawContent), schemas.contentSchema);
286
+ // (With no items in the schema, the items are the saved ones as they are.)
287
+ const contentItems = mergeContentItemsWithSchema(extractContentItemsFromCompositionRaw(rawContent), schemaContentItems(schemas.contentSchema));
288
+ this.storedEmbedSettings = getCompositionEmbedSettings(composition);
289
+ const embedWidth = parseEmbedDimension(this.storedEmbedSettings.width, DEFAULT_PREVIEW_WIDTH);
290
+ const embedHeight = parseEmbedDimension(this.storedEmbedSettings.height, DEFAULT_PREVIEW_HEIGHT);
291
+ this.revision = 0;
292
+ this.savedRevision = 0;
293
+ this.patch({
294
+ phase: 'ready',
295
+ name: String(composition.name || 'Untitled Composition'),
296
+ widgetId,
297
+ preview,
298
+ previewError: previewFailure ? previewFailureMessage(previewFailure) : null,
299
+ designSchema: schemas.designSchema,
300
+ contentSchema: schemas.contentSchema,
301
+ schemasLoading: false,
302
+ schemasError: schemas.schemasError,
303
+ designValues,
304
+ contentValues,
305
+ contentItems,
306
+ previewWidth: embedWidth,
307
+ previewHeight: embedHeight,
308
+ embedWidth,
309
+ embedHeight,
310
+ deviceView: 'desktop',
311
+ dirty: false,
312
+ saveError: null,
313
+ loadError: null
314
+ });
315
+ this.emit({ type: 'ready' });
316
+ this.reportMissingSchemas(schemas);
317
+ }
318
+ failLoad(failure) {
319
+ this.logger.error('[CompositionEditor] The composition could not be loaded:', failure.code, failure.message);
320
+ this.patch({ phase: 'failed', schemasLoading: false, loadError: failure });
321
+ this.emit({ type: 'error', during: 'load', failure, fatal: true });
322
+ }
323
+ /**
324
+ * Turns the answers for the two schemas into what the editor draws, and into words when there is nothing to draw.
325
+ * A schema whose request failed is the `previous` one when there is one (asking again must not lose what is there).
326
+ */
327
+ readSchemas(designResult, contentResult, previous) {
328
+ const designFailure = failureOf(designResult);
329
+ const contentFailure = failureOf(contentResult);
330
+ if (designFailure)
331
+ this.logger.warn('[CompositionEditor] The design schema could not be loaded:', designFailure.code, designFailure.message);
332
+ if (contentFailure)
333
+ this.logger.warn('[CompositionEditor] The content schema could not be loaded:', contentFailure.code, contentFailure.message);
334
+ // Either schema may be missing, but not both.
335
+ const designSchema = designFailure
336
+ ? (previous?.designSchema ?? null)
337
+ : ensureSubtitleFontInDesignSchema(normalizeDesignSchemaFromApiResponse(valueOf(designResult)));
338
+ const contentSchema = contentFailure
339
+ ? (previous?.contentSchema ?? null)
340
+ : normalizeContentSchemaFromApiResponse(valueOf(contentResult));
341
+ const failure = designFailure ?? contentFailure;
342
+ let schemasError = null;
343
+ if (!designSchema && !contentSchema) {
344
+ schemasError = failure
345
+ ? 'The editor settings could not be loaded. Try again.'
346
+ : 'No editable settings found. Generate and publish the widget first.';
347
+ }
348
+ return { designSchema, contentSchema, schemasError, failure };
349
+ }
350
+ /** Tells the watchers that there is nothing to edit, when that is so. */
351
+ reportMissingSchemas(schemas) {
352
+ if (!schemas.schemasError)
353
+ return;
354
+ this.emit({
355
+ type: 'error',
356
+ during: 'schemas',
357
+ failure: schemas.failure ?? {
358
+ message: schemas.schemasError,
359
+ code: 'NO_EDITABLE_SETTINGS',
360
+ status: 0,
361
+ retryable: false,
362
+ sessionEnded: false
363
+ },
364
+ fatal: false
365
+ });
366
+ }
367
+ /** Asks for the two schemas again (after they failed to load). Values already edited are kept. */
368
+ async reloadSchemas() {
369
+ if (this.disposed || this.state.phase !== 'ready')
370
+ return;
371
+ const { api, compositionId } = this.options;
372
+ const { signal } = this.abortController;
373
+ this.patch({ schemasLoading: true, schemasError: null });
374
+ const [designResult, contentResult] = await Promise.allSettled([
375
+ api.getDesignSchema(compositionId, { signal }),
376
+ api.getContentSchema(compositionId, { signal })
377
+ ]);
378
+ if (this.disposed)
379
+ return;
380
+ const schemas = this.readSchemas(designResult, contentResult, this.state);
381
+ // The defaults of the schema fill in what is missing; what is there (edited or saved) stays.
382
+ const itemsOfSchema = schemaContentItems(schemas.contentSchema);
383
+ this.patch({
384
+ designSchema: schemas.designSchema,
385
+ contentSchema: schemas.contentSchema,
386
+ schemasLoading: false,
387
+ schemasError: schemas.schemasError,
388
+ contentValues: mergeMissingContentValues(this.state.contentValues, schemas.contentSchema),
389
+ contentItems: itemsOfSchema.length > 0
390
+ ? mergeContentItemsWithSchema(this.state.contentItems, itemsOfSchema)
391
+ : this.state.contentItems
392
+ });
393
+ this.reportMissingSchemas(schemas);
394
+ }
395
+ // ---------------------------------------------------------------------------------------------
396
+ // What the props editor gets, and what the preview page is told
397
+ // ---------------------------------------------------------------------------------------------
398
+ /**
399
+ * The content schema the props editor draws: the widget's, with the composition's items merged into it.
400
+ * The same object comes back until the schema or the items change, so a component can hand it down without
401
+ * the props editor being told of a change that is none.
402
+ */
403
+ getPropsEditorContentSchema() {
404
+ const { contentSchema, contentItems } = this.state;
405
+ const cached = this.propsEditorSchemaCache;
406
+ if (cached && cached.contentSchema === contentSchema && cached.contentItems === contentItems)
407
+ return cached.result;
408
+ const result = buildPropsEditorContentSchema(contentSchema, contentItems);
409
+ this.propsEditorSchemaCache = { contentSchema, contentItems, result };
410
+ return result;
411
+ }
412
+ /** The values the props editor starts from: the content values, and the items by id. The same object comes back until they change. */
413
+ getPropsEditorContentValues() {
414
+ const { contentValues, contentItems } = this.state;
415
+ const cached = this.propsEditorValuesCache;
416
+ if (cached && cached.contentValues === contentValues && cached.contentItems === contentItems)
417
+ return cached.result;
418
+ const result = {
419
+ ...contentValues,
420
+ items: contentItemsToEditorValueMap(contentItems)
421
+ };
422
+ this.propsEditorValuesCache = { contentValues, contentItems, result };
423
+ return result;
424
+ }
425
+ /** Tells the preview page everything it needs to look like the composition (once its page has loaded). */
426
+ syncPreview() {
427
+ for (const message of this.buildPreviewSnapshot())
428
+ this.options.onPreviewMessage?.(message);
429
+ }
430
+ buildPreviewSnapshot() {
431
+ const { designValues, contentValues, contentItems } = this.state;
432
+ const messages = [];
433
+ if (Object.keys(designValues).length > 0) {
434
+ messages.push({
435
+ type: 'widgetic:update',
436
+ design: flattenDesignValuesForPreview(designValues)
437
+ });
438
+ }
439
+ if (Object.keys(contentValues).length > 0) {
440
+ messages.push({ type: 'widgetic:update', content: contentValues });
441
+ }
442
+ if (contentItems.length > 0) {
443
+ messages.push({
444
+ type: 'widgetic:update',
445
+ contentItems: flattenContentItemsForPreview(contentItems)
446
+ });
447
+ }
448
+ return messages;
449
+ }
450
+ // ---------------------------------------------------------------------------------------------
451
+ // Editing
452
+ // ---------------------------------------------------------------------------------------------
453
+ /** One property was changed in the props editor. */
454
+ changeProperty(change) {
455
+ if (this.disposed || this.state.phase !== 'ready')
456
+ return;
457
+ const { schema, propertyId, value, itemId } = change;
458
+ if (schema === 'design') {
459
+ const designValues = { ...this.state.designValues, [propertyId]: value };
460
+ this.patch({ designValues });
461
+ this.options.onPreviewMessage?.({
462
+ type: 'widgetic:update',
463
+ design: flattenDesignValuesForPreview(designValues)
464
+ });
465
+ }
466
+ else if (schema === 'content') {
467
+ const key = itemId ? `${itemId}__${propertyId}` : propertyId;
468
+ const contentValues = { ...this.state.contentValues, [key]: value };
469
+ this.patch({ contentValues });
470
+ this.options.onPreviewMessage?.({ type: 'widgetic:update', content: contentValues });
471
+ }
472
+ else {
473
+ this.logger.warn('[CompositionEditor] A change to an unknown kind of property was ignored:', schema);
474
+ return;
475
+ }
476
+ this.markEdited();
477
+ }
478
+ /** The content items (slides, rows, cards...) were changed in the props editor. */
479
+ changeContentItems(items) {
480
+ if (this.disposed || this.state.phase !== 'ready')
481
+ return;
482
+ const incoming = Array.isArray(items) ? items : [];
483
+ const contentItems = mergeContentItemsWithSchema(incoming, schemaContentItems(this.state.contentSchema));
484
+ this.patch({ contentItems });
485
+ this.options.onPreviewMessage?.({
486
+ type: 'widgetic:update',
487
+ contentItems: flattenContentItemsForPreview(contentItems)
488
+ });
489
+ this.markEdited();
490
+ }
491
+ /**
492
+ * The user gave the preview a size, by dragging its corner or typing it in. That is the size saved with the
493
+ * composition, and the preview is drawn at it (in the desktop view). A size that is not the user's choice
494
+ * (the one a small box forces on the preview) must not be passed here.
495
+ */
496
+ setPreviewSize(width, height) {
497
+ if (this.disposed || this.state.phase !== 'ready')
498
+ return;
499
+ const previewWidth = Math.max(MIN_PREVIEW_SIZE, Math.round(width));
500
+ const previewHeight = Math.max(MIN_PREVIEW_SIZE, Math.round(height));
501
+ if (!Number.isFinite(previewWidth) || !Number.isFinite(previewHeight))
502
+ return;
503
+ // (The preview reports the size it was just given, too: that is not a new size.)
504
+ if (previewWidth === this.state.previewWidth && previewHeight === this.state.previewHeight)
505
+ return;
506
+ const embedChanged = previewWidth !== this.state.embedWidth || previewHeight !== this.state.embedHeight;
507
+ this.patch({
508
+ previewWidth,
509
+ previewHeight,
510
+ embedWidth: previewWidth,
511
+ embedHeight: previewHeight,
512
+ deviceView: 'desktop'
513
+ });
514
+ if (embedChanged)
515
+ this.markEdited();
516
+ }
517
+ /**
518
+ * The mobile and desktop buttons: the preview is drawn at a phone's size, or back at the saved size. That is a
519
+ * way of looking, not an edit: nothing becomes unsaved, and a save still sends the size the user gave the preview.
520
+ */
521
+ setDeviceView(deviceView) {
522
+ if (this.disposed || this.state.phase !== 'ready' || deviceView === this.state.deviceView)
523
+ return;
524
+ const mobile = deviceView === 'mobile';
525
+ this.patch({
526
+ deviceView,
527
+ previewWidth: mobile ? MOBILE_PREVIEW_WIDTH : this.state.embedWidth,
528
+ previewHeight: mobile ? MOBILE_PREVIEW_HEIGHT : this.state.embedHeight
529
+ });
530
+ }
531
+ /**
532
+ * The page saved the composition's embed settings itself (a page can have its own screen for them), so the ones
533
+ * this editor loaded, and sends back with every save, are out of date. From now on a save carries these, with the
534
+ * size the preview has, and the desktop view is drawn at the size they name. Nothing becomes unsaved: the page
535
+ * saved them already. Call it when no save of the editor is on its way (after `flush()`): the answer to a save
536
+ * that is replaces them.
537
+ */
538
+ adoptEmbedSettings(settings) {
539
+ if (this.disposed || this.state.phase !== 'ready' || !isRecord(settings))
540
+ return;
541
+ // A copy: the page may go on using, and changing, the object it gave.
542
+ let copiedSettings;
543
+ try {
544
+ copiedSettings = JSON.parse(JSON.stringify(settings));
545
+ }
546
+ catch (error) {
547
+ this.logger.warn('[CompositionEditor] The embed settings could not be copied, so they were ignored:', error);
548
+ return;
549
+ }
550
+ this.storedEmbedSettings = copiedSettings;
551
+ const embedWidth = parseEmbedDimension(settings.width, this.state.embedWidth);
552
+ const embedHeight = parseEmbedDimension(settings.height, this.state.embedHeight);
553
+ if (embedWidth === this.state.embedWidth && embedHeight === this.state.embedHeight)
554
+ return;
555
+ const desktop = this.state.deviceView === 'desktop';
556
+ this.patch({
557
+ embedWidth,
558
+ embedHeight,
559
+ ...(desktop ? { previewWidth: embedWidth, previewHeight: embedHeight } : {})
560
+ });
561
+ }
562
+ /** An edit was made: there is something new to save, the wait for the next autosave starts again, and so does the count of failures. */
563
+ markEdited() {
564
+ this.revision += 1;
565
+ this.failedAutosaves = 0;
566
+ this.patch({ dirty: true });
567
+ this.scheduleAutosave(this.autosaveDelayMs);
568
+ }
569
+ hasUnsavedChanges() {
570
+ return this.revision !== this.savedRevision;
571
+ }
572
+ // ---------------------------------------------------------------------------------------------
573
+ // Saving
574
+ // ---------------------------------------------------------------------------------------------
575
+ /**
576
+ * Saves what has been edited. Resolves `true` when there is nothing unsaved afterwards, `false` when the save
577
+ * failed (see `saveError`) or the editor is not ready.
578
+ */
579
+ save(reason = 'manual') {
580
+ if (this.disposed || this.state.phase !== 'ready')
581
+ return Promise.resolve(false);
582
+ if (this.savePromise) {
583
+ // One save at a time: wait for the one on its way, then save what is new since, if it worked.
584
+ return this.savePromise.then((worked) => worked && this.hasUnsavedChanges() ? this.save(reason) : worked);
585
+ }
586
+ if (!this.hasUnsavedChanges())
587
+ return Promise.resolve(true);
588
+ // (No other save can have taken its place: while this one is set, `save()` waits for it instead of starting one.)
589
+ const running = this.runSave(reason).finally(() => {
590
+ this.savePromise = null;
591
+ });
592
+ this.savePromise = running;
593
+ return running;
594
+ }
595
+ /**
596
+ * Saves everything that is unsaved, now, and waits until it is. Resolves `true` when nothing is left unsaved,
597
+ * `false` when a save failed. For closing the editor.
598
+ */
599
+ async flush() {
600
+ // A round saves what is unsaved (waiting for a save on its way first, and saving that save's newer edits after it).
601
+ // Edits that keep coming in while it saves are chased for a few rounds, not for ever.
602
+ for (let round = 0; round < MAX_FLUSH_ROUNDS; round++) {
603
+ if (!this.hasUnsavedChanges())
604
+ return true;
605
+ if (!(await this.save('flush')))
606
+ return false;
607
+ }
608
+ return !this.hasUnsavedChanges();
609
+ }
610
+ async runSave(reason) {
611
+ this.clearAutosaveTimer();
612
+ const savingRevision = this.revision;
613
+ this.patch({ saving: true, saveError: null });
614
+ try {
615
+ const body = this.buildSaveBody();
616
+ const updated = await this.options.api.saveComposition(this.options.compositionId, body, {
617
+ signal: this.abortController.signal
618
+ });
619
+ if (this.disposed)
620
+ return false;
621
+ const savedAt = this.now();
622
+ this.savedRevision = savingRevision;
623
+ // What the API now holds for the embed settings, else what was just sent.
624
+ const settingsNow = getCompositionEmbedSettings(updated);
625
+ this.storedEmbedSettings =
626
+ Object.keys(settingsNow).length > 0 ? settingsNow : (body.embedSettings ?? {});
627
+ this.patch({
628
+ saving: false,
629
+ saveError: null,
630
+ lastSavedAt: savedAt,
631
+ dirty: this.hasUnsavedChanges()
632
+ });
633
+ this.emit({ type: 'saved', composition: updated, savedAt });
634
+ // (Edits made while this save travelled are still unsaved: the autosave they started waits for its turn.)
635
+ return true;
636
+ }
637
+ catch (error) {
638
+ if (this.disposed)
639
+ return false;
640
+ const failure = describeFailure(error);
641
+ this.logger.warn('[CompositionEditor] Saving failed:', failure.code, failure.message);
642
+ this.patch({ saving: false, saveError: failure });
643
+ this.emit({ type: 'error', during: 'save', failure, fatal: false });
644
+ if (reason === 'autosave' && failure.retryable)
645
+ this.scheduleAutosaveRetry();
646
+ return false;
647
+ }
648
+ }
649
+ /** What a save sends: a copy of the values as they are now, so later edits cannot change it. */
650
+ buildSaveBody() {
651
+ const { name, designValues, contentValues, contentItems, embedWidth, embedHeight } = this.state;
652
+ const content = { design: designValues, content: contentValues };
653
+ if (contentItems.length > 0)
654
+ content.contentItems = contentItems;
655
+ const body = {
656
+ name,
657
+ content,
658
+ embedSettings: {
659
+ ...this.storedEmbedSettings,
660
+ width: String(embedWidth),
661
+ height: String(embedHeight)
662
+ }
663
+ };
664
+ return JSON.parse(JSON.stringify(body));
665
+ }
666
+ // ---------------------------------------------------------------------------------------------
667
+ // Autosave timing
668
+ // ---------------------------------------------------------------------------------------------
669
+ /** Starts the wait for an autosave of what is unsaved (for callers that have something unsaved). */
670
+ scheduleAutosave(delayMs) {
671
+ if (this.disposed || this.autosaveDelayMs <= 0 || this.state.phase !== 'ready')
672
+ return;
673
+ this.clearAutosaveTimer();
674
+ this.autosaveTimer = this.timers.setTimeout(() => {
675
+ this.autosaveTimer = null;
676
+ void this.save('autosave');
677
+ }, delayMs);
678
+ }
679
+ /** After an autosave failed for a reason that may pass: try again later, but only so many times. */
680
+ scheduleAutosaveRetry() {
681
+ const delayMs = this.autosaveRetryDelaysMs[this.failedAutosaves];
682
+ this.failedAutosaves += 1;
683
+ if (delayMs === undefined) {
684
+ this.logger.warn('[CompositionEditor] Autosave gave up after repeated failures; the next edit or a manual save tries again');
685
+ return;
686
+ }
687
+ this.scheduleAutosave(delayMs);
688
+ }
689
+ clearAutosaveTimer() {
690
+ if (this.autosaveTimer === null)
691
+ return;
692
+ this.timers.clearTimeout(this.autosaveTimer);
693
+ this.autosaveTimer = null;
694
+ }
695
+ // ---------------------------------------------------------------------------------------------
696
+ // Ending
697
+ // ---------------------------------------------------------------------------------------------
698
+ /** Stops everything: timers, requests on their way, and any answer that arrives later. Unsaved edits are lost; `flush()` first to keep them. */
699
+ dispose() {
700
+ this.disposed = true;
701
+ this.clearAutosaveTimer();
702
+ this.abortController.abort();
703
+ this.stateListeners.clear();
704
+ this.eventListeners.clear();
705
+ }
706
+ // ---------------------------------------------------------------------------------------------
707
+ // Internals
708
+ // ---------------------------------------------------------------------------------------------
709
+ /** Makes the next state out of the changes, tells the watchers, and says when the unsaved flag turned over. */
710
+ patch(changes) {
711
+ const wasDirty = this.state.dirty;
712
+ this.state = { ...this.state, ...changes };
713
+ for (const listener of [...this.stateListeners]) {
714
+ try {
715
+ listener(this.state);
716
+ }
717
+ catch (error) {
718
+ this.logger.error('[CompositionEditor] A state listener failed:', error);
719
+ }
720
+ }
721
+ if (this.state.dirty !== wasDirty)
722
+ this.emit({ type: 'dirty', dirty: this.state.dirty });
723
+ }
724
+ emit(event) {
725
+ for (const listener of [...this.eventListeners]) {
726
+ try {
727
+ listener(event);
728
+ }
729
+ catch (error) {
730
+ this.logger.error('[CompositionEditor] An event listener failed:', error);
731
+ }
732
+ }
733
+ }
734
+ }