@widgetic/editor 4.0.10 → 4.1.0

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