@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.
- package/README.md +118 -0
- package/dist/components/WidgetPreview.svelte +65 -27
- package/dist/components/WidgetPreview.svelte.d.ts +8 -0
- package/dist/components/editors/CompositionEditorApp.svelte +508 -0
- package/dist/components/editors/CompositionEditorApp.svelte.d.ts +75 -0
- package/dist/components/editors/PropsEditor.svelte +193 -792
- package/dist/components/editors/PropsEditor.svelte.d.ts +7 -0
- package/dist/components/ic/BrowserInputController.svelte +21 -2
- package/dist/components/ic/ColorPickerInputController.svelte +24 -6
- package/dist/components/ic/DateFieldInputController.svelte +7 -3
- package/dist/components/ic/DatePickerInputController.svelte +10 -4
- package/dist/components/ic/DropdownInputController.svelte +11 -4
- package/dist/components/ic/FontInputController.svelte +68 -44
- package/dist/components/ic/InputControllerHeader.svelte +11 -8
- package/dist/components/ic/NumberInputController.svelte +4 -2
- package/dist/components/ic/PositionInputController.svelte +14 -11
- package/dist/components/ic/RangeCalendarInputController.svelte +10 -4
- package/dist/components/ic/RoundedCornersInputController.svelte +17 -9
- package/dist/components/ic/TextAreaInputController.svelte +20 -7
- package/dist/components/ic/TextAreaPreviewInputController.svelte +19 -69
- package/dist/components/ic/TimeInputController.svelte +9 -3
- package/dist/components/ic/VectorInputController.svelte +80 -54
- package/dist/components/ic/shared/ItemGrid.svelte +5 -1
- package/dist/composition/compositionApi.d.ts +95 -0
- package/dist/composition/compositionApi.js +289 -0
- package/dist/composition/compositionEditorController.d.ts +254 -0
- package/dist/composition/compositionEditorController.js +734 -0
- package/dist/composition/compositionHelpers.d.ts +72 -0
- package/dist/composition/compositionHelpers.js +403 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/mediaLibraryContext.d.ts +10 -0
- package/dist/mediaLibraryContext.js +30 -0
- package/dist/utils/FontManager.d.ts +21 -5
- package/dist/utils/FontManager.js +62 -67
- package/dist/utils/VectorManager.d.ts +0 -1
- package/dist/utils/VectorManager.js +10 -12
- package/dist/utils/editorStorage.d.ts +23 -0
- package/dist/utils/editorStorage.js +95 -0
- package/dist/utils/fontUtils.js +5 -3
- package/dist/utils/htmlSafety.d.ts +21 -0
- package/dist/utils/htmlSafety.js +38 -0
- package/dist/utils/itemReorder.d.ts +90 -0
- package/dist/utils/itemReorder.js +354 -0
- package/dist/utils/recentColors.js +4 -2
- package/dist/utils/richTextPreview.d.ts +38 -0
- package/dist/utils/richTextPreview.js +207 -0
- package/dist/utils/scrollActivity.d.ts +14 -0
- package/dist/utils/scrollActivity.js +63 -0
- package/dist/utils/shadowDom.d.ts +67 -0
- package/dist/utils/shadowDom.js +211 -0
- package/dist/utils/vectorUtils.js +5 -3
- package/dist/utils/visibilityCondition.d.ts +40 -0
- package/dist/utils/visibilityCondition.js +535 -0
- 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
|
+
}
|