@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,289 @@
1
+ /**
2
+ * The calls an embedded composition editor makes to the Widgetic API, over `fetch`.
3
+ *
4
+ * Why not the generated SDK: the editor runs in a customer's browser and may only call a short list of routes
5
+ * (those an editor session allows), the list is fixed, and it must work with whatever token the host page gives it.
6
+ * A client of its own keeps the editor small and independent of the SDK's release cycle, and it keeps the few
7
+ * rules that matter in one place:
8
+ *
9
+ * - the token is asked from the host page before each request (`getAccessToken('initial')`); when the API
10
+ * answers 401 it is asked again (`'expired'`) and the request is sent once more with a token that is new;
11
+ * - a save carries an `Idempotency-Key` (the API requires one on every PUT from a session), kept the same when
12
+ * the request is sent again after a 401, so one save is never applied twice;
13
+ * - only the fields editing changes can be sent (`SaveCompositionBody`); a session is refused any other;
14
+ * - every failure is a `CompositionApiError` with the API's own code, so the host can tell an ended session
15
+ * (`EDITOR_SESSION_EXPIRED`...) from a missing composition (`NOT_FOUND`) from a widget that was never
16
+ * published (`WIDGET_NOT_PUBLISHED`);
17
+ * - a request that gets no answer does not hang the editor: it fails with `TIMEOUT` after a set time.
18
+ */
19
+ import { unwrapApiData } from './compositionHelpers.js';
20
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
21
+ /** What the API accepts as an idempotency key. */
22
+ const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_-]{8,50}$/;
23
+ /** A request to the API that did not give the answer wanted. */
24
+ export class CompositionApiError extends Error {
25
+ /** The HTTP status; `0` when no answer arrived (network, timeout, abort, or no token to send). */
26
+ status;
27
+ /** The API's error code (`NOT_FOUND`, `EDITOR_SESSION_EXPIRED`...), or one of {@link ClientErrorCode}. */
28
+ code;
29
+ /** The API's correlation id for the failed request, to quote in a support request. */
30
+ correlationId;
31
+ constructor(message, details) {
32
+ super(message);
33
+ this.name = 'CompositionApiError';
34
+ this.status = details.status;
35
+ this.code = details.code;
36
+ this.correlationId = details.correlationId;
37
+ }
38
+ /** True when the editor session cannot be used any more: the host page has to give a new one. */
39
+ get sessionEnded() {
40
+ return this.status === 401 || this.code === 'TOKEN_UNAVAILABLE';
41
+ }
42
+ /** True when sending the same request again later may work: nothing was refused, the network or the server failed. */
43
+ get retryable() {
44
+ return (this.code === 'NETWORK' ||
45
+ this.code === 'TIMEOUT' ||
46
+ this.status === 429 ||
47
+ this.status >= 500);
48
+ }
49
+ }
50
+ const STATUS_CODES = {
51
+ 400: 'VALIDATION_ERROR',
52
+ 401: 'UNAUTHORIZED',
53
+ 403: 'FORBIDDEN',
54
+ 404: 'NOT_FOUND',
55
+ 409: 'CONFLICT',
56
+ 429: 'RATE_LIMITED'
57
+ };
58
+ /** A code for an error status the API gave no code with. */
59
+ function codeForStatus(status) {
60
+ return STATUS_CODES[status] ?? (status >= 500 ? 'SERVER_ERROR' : 'HTTP_ERROR');
61
+ }
62
+ function isRecord(value) {
63
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
64
+ }
65
+ /** A key that is unique, not secret: the page's `crypto.randomUUID`, else random bytes, else the clock and `Math.random`. */
66
+ export function createRandomIdempotencyKey() {
67
+ const cryptoApi = globalThis.crypto;
68
+ if (cryptoApi && typeof cryptoApi.randomUUID === 'function')
69
+ return cryptoApi.randomUUID();
70
+ if (cryptoApi && typeof cryptoApi.getRandomValues === 'function') {
71
+ const bytes = cryptoApi.getRandomValues(new Uint8Array(16));
72
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
73
+ }
74
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}-${Math.random().toString(36).slice(2, 12)}`;
75
+ }
76
+ /** Only the part of the address that is safe to log: no query string, which could carry a token. */
77
+ function addressForLog(url) {
78
+ return url.split('?')[0];
79
+ }
80
+ /** A wait the way a person reads it: "30 ms", "1 second", "30 seconds". */
81
+ function describeWait(milliseconds) {
82
+ if (milliseconds < 1000)
83
+ return `${milliseconds} ms`;
84
+ const seconds = Math.round(milliseconds / 1000);
85
+ return seconds === 1 ? '1 second' : `${seconds} seconds`;
86
+ }
87
+ /** Stops a request that the caller has already cancelled, before the page is asked for a token or the network used. */
88
+ function throwIfAborted(signal) {
89
+ if (signal?.aborted) {
90
+ throw new CompositionApiError('The request was cancelled.', { status: 0, code: 'ABORTED' });
91
+ }
92
+ }
93
+ /** Builds the client. `apiBasePath` and `getAccessToken` are required. */
94
+ export function createCompositionApi(options) {
95
+ if (typeof options?.apiBasePath !== 'string' || options.apiBasePath.trim() === '') {
96
+ throw new TypeError('createCompositionApi needs the API address (apiBasePath)');
97
+ }
98
+ if (typeof options.getAccessToken !== 'function') {
99
+ throw new TypeError('createCompositionApi needs a getAccessToken function');
100
+ }
101
+ const apiBasePath = options.apiBasePath.trim().replace(/\/+$/, '');
102
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
103
+ const createIdempotencyKey = options.createIdempotencyKey ?? createRandomIdempotencyKey;
104
+ /** Asks the host page for a token (telling it which one the API refused, when that is why); a failure to give one is its own error. */
105
+ async function tokenFor(reason, refusedToken) {
106
+ let token;
107
+ try {
108
+ token =
109
+ refusedToken === undefined
110
+ ? await options.getAccessToken(reason)
111
+ : await options.getAccessToken(reason, refusedToken);
112
+ }
113
+ catch (error) {
114
+ console.error('[CompositionApi] The page could not give an access token:', error);
115
+ throw new CompositionApiError(`Could not get an access token from the page: ${error instanceof Error ? error.message : String(error)}`, { status: 0, code: 'TOKEN_UNAVAILABLE' });
116
+ }
117
+ if (typeof token !== 'string' || token.trim() === '') {
118
+ throw new CompositionApiError('The page gave no access token.', {
119
+ status: 0,
120
+ code: 'TOKEN_UNAVAILABLE'
121
+ });
122
+ }
123
+ return token;
124
+ }
125
+ /**
126
+ * One attempt: sends the request with `token` and reads the whole answer, all within the time limit and the
127
+ * caller's abort signal. The limit covers the answer's body as well as its start, so a server that stalls
128
+ * half way cannot hold the editor.
129
+ */
130
+ async function attempt(method, url, token, extras) {
131
+ throwIfAborted(extras.signal);
132
+ const fetchImplementation = options.fetch ?? globalThis.fetch.bind(globalThis);
133
+ const controller = new AbortController();
134
+ let timedOut = false;
135
+ const timer = setTimeout(() => {
136
+ timedOut = true;
137
+ controller.abort();
138
+ }, requestTimeoutMs);
139
+ const forwardAbort = () => controller.abort();
140
+ extras.signal?.addEventListener('abort', forwardAbort, { once: true });
141
+ const headers = {
142
+ Accept: 'application/json',
143
+ Authorization: `Bearer ${token}`
144
+ };
145
+ if (extras.body !== undefined)
146
+ headers['Content-Type'] = 'application/json';
147
+ if (extras.idempotencyKey)
148
+ headers['Idempotency-Key'] = extras.idempotencyKey;
149
+ try {
150
+ const response = await fetchImplementation(url, {
151
+ method,
152
+ headers,
153
+ body: extras.body !== undefined ? JSON.stringify(extras.body) : undefined,
154
+ // The token is the credential; the page's cookies for the API's domain play no part.
155
+ credentials: 'omit',
156
+ // What a composition holds changes with every save; never serve it from a cache.
157
+ cache: 'no-store',
158
+ signal: controller.signal
159
+ });
160
+ const text = await response.text();
161
+ return { status: response.status, ok: response.ok, text };
162
+ }
163
+ catch (error) {
164
+ if (timedOut) {
165
+ throw new CompositionApiError(`The API did not answer within ${describeWait(requestTimeoutMs)}.`, {
166
+ status: 0,
167
+ code: 'TIMEOUT'
168
+ });
169
+ }
170
+ if (extras.signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
171
+ throw new CompositionApiError('The request was cancelled.', { status: 0, code: 'ABORTED' });
172
+ }
173
+ console.warn(`[CompositionApi] ${method} ${addressForLog(url)} failed:`, error);
174
+ throw new CompositionApiError('Could not reach the Widgetic API. Check the connection and try again.', {
175
+ status: 0,
176
+ code: 'NETWORK'
177
+ });
178
+ }
179
+ finally {
180
+ clearTimeout(timer);
181
+ extras.signal?.removeEventListener('abort', forwardAbort);
182
+ }
183
+ }
184
+ /** The payload of a good answer, or the error the API described. */
185
+ function interpret(answer) {
186
+ // `undefined` stands for "no JSON in it": an empty body, or text that is not JSON (JSON never parses to `undefined`).
187
+ let parsed;
188
+ try {
189
+ parsed = JSON.parse(answer.text);
190
+ }
191
+ catch {
192
+ // Not JSON: `parsed` stays undefined.
193
+ }
194
+ if (!answer.ok) {
195
+ const failure = isRecord(parsed) && isRecord(parsed.error) ? parsed.error : null;
196
+ const code = typeof failure?.code === 'string' && failure.code !== ''
197
+ ? failure.code
198
+ : codeForStatus(answer.status);
199
+ const message = typeof failure?.message === 'string' && failure.message !== ''
200
+ ? failure.message
201
+ : `The API answered ${answer.status}.`;
202
+ const correlationId = typeof failure?.correlationId === 'string' ? failure.correlationId : undefined;
203
+ throw new CompositionApiError(message, { status: answer.status, code, correlationId });
204
+ }
205
+ if (parsed === undefined) {
206
+ throw new CompositionApiError('The API gave an answer that is not JSON.', {
207
+ status: answer.status,
208
+ code: 'INVALID_RESPONSE'
209
+ });
210
+ }
211
+ return unwrapApiData(parsed);
212
+ }
213
+ /**
214
+ * A request with the page's token. When the API refuses the token (401) the page is asked for another, and the
215
+ * request is sent again once with it, unless the page gives the same one back: that would only be refused again.
216
+ */
217
+ async function request(method, path, extras = {}) {
218
+ const url = `${apiBasePath}${path}`;
219
+ throwIfAborted(extras.signal);
220
+ const firstToken = await tokenFor('initial');
221
+ let answer = await attempt(method, url, firstToken, extras);
222
+ if (answer.status === 401) {
223
+ console.warn(`[CompositionApi] ${method} ${addressForLog(url)} was refused (401); asking the page for a new token`);
224
+ throwIfAborted(extras.signal);
225
+ const newToken = await tokenFor('expired', firstToken);
226
+ if (newToken !== firstToken)
227
+ answer = await attempt(method, url, newToken, extras);
228
+ }
229
+ return interpret(answer);
230
+ }
231
+ function compositionPath(compositionId, suffix = '') {
232
+ if (typeof compositionId !== 'string' || compositionId.trim() === '') {
233
+ throw new TypeError('A composition id is needed');
234
+ }
235
+ return `/compositions/${encodeURIComponent(compositionId)}${suffix}`;
236
+ }
237
+ function invalidResponse(message) {
238
+ return new CompositionApiError(message, { status: 200, code: 'INVALID_RESPONSE' });
239
+ }
240
+ return {
241
+ async getComposition(compositionId, requestOptions = {}) {
242
+ const record = await request('GET', compositionPath(compositionId), requestOptions);
243
+ if (!isRecord(record) || typeof record.id !== 'string')
244
+ throw invalidResponse('The API gave no composition.');
245
+ return record;
246
+ },
247
+ async getPreview(compositionId, requestOptions = {}) {
248
+ const preview = await request('GET', compositionPath(compositionId, '/preview'), requestOptions);
249
+ if (!isRecord(preview) || typeof preview.previewUrl !== 'string') {
250
+ throw invalidResponse('The API gave no preview address.');
251
+ }
252
+ // The address goes into a frame: only a web address may.
253
+ let address;
254
+ try {
255
+ address = new URL(preview.previewUrl);
256
+ }
257
+ catch {
258
+ throw invalidResponse('The API gave a preview address that is not valid.');
259
+ }
260
+ if (address.protocol !== 'https:' && address.protocol !== 'http:') {
261
+ throw invalidResponse('The API gave a preview address that is not a web address.');
262
+ }
263
+ return {
264
+ compositionId: typeof preview.compositionId === 'string' ? preview.compositionId : compositionId,
265
+ widgetId: typeof preview.widgetId === 'string' ? preview.widgetId : '',
266
+ version: typeof preview.version === 'number' ? preview.version : 0,
267
+ previewUrl: address.href
268
+ };
269
+ },
270
+ async getContentSchema(compositionId, requestOptions = {}) {
271
+ return request('GET', compositionPath(compositionId, '/schema/content'), requestOptions);
272
+ },
273
+ async getDesignSchema(compositionId, requestOptions = {}) {
274
+ return request('GET', compositionPath(compositionId, '/schema/design'), requestOptions);
275
+ },
276
+ async saveComposition(compositionId, body, saveOptions = {}) {
277
+ const idempotencyKey = saveOptions.idempotencyKey ?? createIdempotencyKey();
278
+ if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
279
+ throw new TypeError('An idempotency key is 8 to 50 letters, digits, dashes or underscores');
280
+ }
281
+ const record = await request('PUT', compositionPath(compositionId), {
282
+ body,
283
+ signal: saveOptions.signal,
284
+ idempotencyKey
285
+ });
286
+ return isRecord(record) ? record : {};
287
+ }
288
+ };
289
+ }
@@ -0,0 +1,254 @@
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 type { CompositionApi } from './compositionApi.js';
29
+ import { type JsonRecord } from './compositionHelpers.js';
30
+ /** Time to wait after the last edit before saving by itself. */
31
+ export declare 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 declare const DEFAULT_AUTOSAVE_RETRY_DELAYS_MS: readonly number[];
34
+ export declare const DEFAULT_PREVIEW_WIDTH = 800;
35
+ export declare const DEFAULT_PREVIEW_HEIGHT = 600;
36
+ /** The size the preview is drawn at in its mobile view. */
37
+ export declare const MOBILE_PREVIEW_WIDTH = 480;
38
+ export declare const MOBILE_PREVIEW_HEIGHT = 600;
39
+ /** The smallest size, in pixels, the preview can be given. */
40
+ export declare const MIN_PREVIEW_SIZE = 50;
41
+ export type EditorPhase = 'idle' | 'loading' | 'ready' | 'failed';
42
+ /** `'desktop'` draws the preview at the size that is saved; `'mobile'` at a phone's. */
43
+ export type DeviceView = 'desktop' | 'mobile';
44
+ /** Why a request failed, in terms an interface can act on. */
45
+ export interface EditorFailure {
46
+ message: string;
47
+ /** The API's error code, or one of the client's (`NETWORK`, `TIMEOUT`, `TOKEN_UNAVAILABLE`...). */
48
+ code: string;
49
+ /** The HTTP status; `0` when there was no answer. */
50
+ status: number;
51
+ /** Trying the same thing again later may work. */
52
+ retryable: boolean;
53
+ /** The session cannot be used any more: the host page has to give a new one. */
54
+ sessionEnded: boolean;
55
+ correlationId?: string;
56
+ }
57
+ export interface CompositionEditorState {
58
+ phase: EditorPhase;
59
+ compositionId: string;
60
+ name: string;
61
+ widgetId: string;
62
+ /** The widget build to show, or `null` when there is none (see `previewError`). */
63
+ preview: {
64
+ previewUrl: string;
65
+ widgetId: string;
66
+ version: number;
67
+ } | null;
68
+ /** Why there is no preview, in words for a person; the settings can still be edited. */
69
+ previewError: string | null;
70
+ designSchema: any;
71
+ contentSchema: any;
72
+ schemasLoading: boolean;
73
+ /** Why there is nothing to edit, in words for a person. */
74
+ schemasError: string | null;
75
+ designValues: JsonRecord;
76
+ contentValues: JsonRecord;
77
+ contentItems: any[];
78
+ /** The size the preview is drawn at, in pixels. In the mobile view that is a phone's, not the size that is saved. */
79
+ previewWidth: number;
80
+ previewHeight: number;
81
+ /** The size saved with the composition (`embedSettings.width` and `height`): the one the widget gets where it is embedded. */
82
+ embedWidth: number;
83
+ embedHeight: number;
84
+ deviceView: DeviceView;
85
+ /** There are edits no save has taken yet. */
86
+ dirty: boolean;
87
+ saving: boolean;
88
+ /** The last save failed (cleared when the next one starts). */
89
+ saveError: EditorFailure | null;
90
+ /** The composition could not be loaded (`phase` is then `failed`). */
91
+ loadError: EditorFailure | null;
92
+ lastSavedAt: number | null;
93
+ }
94
+ export type CompositionEditorEvent = {
95
+ type: 'ready';
96
+ } | {
97
+ type: 'dirty';
98
+ dirty: boolean;
99
+ } | {
100
+ type: 'saved';
101
+ composition: JsonRecord;
102
+ savedAt: number;
103
+ } | {
104
+ type: 'error';
105
+ during: 'load' | 'schemas' | 'save';
106
+ failure: EditorFailure;
107
+ fatal: boolean;
108
+ };
109
+ /** What the preview page is told when something changed. */
110
+ export interface PreviewMessage {
111
+ type: 'widgetic:update';
112
+ design?: Record<string, string>;
113
+ content?: JsonRecord;
114
+ contentItems?: unknown[];
115
+ }
116
+ /** A change of one property, as the props editor reports it. */
117
+ export interface PropertyChange {
118
+ schema: string;
119
+ propertyId: string;
120
+ value: unknown;
121
+ itemId?: string;
122
+ }
123
+ export type SaveReason = 'manual' | 'autosave' | 'flush';
124
+ export interface TimerFunctions {
125
+ setTimeout: (callback: () => void, milliseconds: number) => unknown;
126
+ clearTimeout: (handle: unknown) => void;
127
+ }
128
+ export interface CompositionEditorOptions {
129
+ compositionId: string;
130
+ api: CompositionApi;
131
+ /** Wait this long after the last edit before saving by itself; `0` turns autosave off. Default: 2.5 seconds. */
132
+ autosaveDelayMs?: number;
133
+ /** Waits before trying a failed autosave again; none means never. Default: 5, 15 and 45 seconds. */
134
+ autosaveRetryDelaysMs?: readonly number[];
135
+ /** Timer functions, so tests can run time by hand. Default: the page's. */
136
+ timers?: TimerFunctions;
137
+ /** The clock, in milliseconds. Default: `Date.now`. */
138
+ now?: () => number;
139
+ /** Called with every message the preview page should be sent. */
140
+ onPreviewMessage?: (message: PreviewMessage) => void;
141
+ /** Writes the editor's own notes (a failure worth knowing about). Default: the console. */
142
+ logger?: Pick<Console, 'warn' | 'error'>;
143
+ }
144
+ /** What went wrong, from whatever was thrown. */
145
+ export declare function describeFailure(error: unknown): EditorFailure;
146
+ /** A failure in words for the person using the editor. */
147
+ export declare function failureMessage(failure: EditorFailure, doing: 'load' | 'save'): string;
148
+ export declare class CompositionEditorController {
149
+ private readonly options;
150
+ private readonly timers;
151
+ private readonly now;
152
+ private readonly logger;
153
+ private readonly autosaveDelayMs;
154
+ private readonly autosaveRetryDelaysMs;
155
+ private readonly abortController;
156
+ private readonly stateListeners;
157
+ private readonly eventListeners;
158
+ private state;
159
+ /** Counts the edits made. */
160
+ private revision;
161
+ /** The revision the last successful save took. */
162
+ private savedRevision;
163
+ /** How many autosaves in a row have failed. */
164
+ private failedAutosaves;
165
+ /** The embed settings the composition has, sent back with the size on every save. */
166
+ private storedEmbedSettings;
167
+ private autosaveTimer;
168
+ /** What `getPropsEditorContentSchema` and `getPropsEditorContentValues` last made, and from what. */
169
+ private propsEditorSchemaCache;
170
+ private propsEditorValuesCache;
171
+ private loadPromise;
172
+ private savePromise;
173
+ private disposed;
174
+ constructor(options: CompositionEditorOptions);
175
+ /** The state now. It is never changed in place: a change makes a new object. */
176
+ getState(): CompositionEditorState;
177
+ /** Svelte store contract: calls `listener` with the state now, and again after every change. */
178
+ subscribe(listener: (state: CompositionEditorState) => void): () => void;
179
+ /** Calls `listener` with every event: `ready`, `dirty`, `saved`, `error`. */
180
+ onEvent(listener: (event: CompositionEditorEvent) => void): () => void;
181
+ /** Loads the composition, its preview address and its schemas. Asking again while it loads waits for the same load. */
182
+ load(): Promise<void>;
183
+ private runLoad;
184
+ private failLoad;
185
+ /**
186
+ * Turns the answers for the two schemas into what the editor draws, and into words when there is nothing to draw.
187
+ * A schema whose request failed is the `previous` one when there is one (asking again must not lose what is there).
188
+ */
189
+ private readSchemas;
190
+ /** Tells the watchers that there is nothing to edit, when that is so. */
191
+ private reportMissingSchemas;
192
+ /** Asks for the two schemas again (after they failed to load). Values already edited are kept. */
193
+ reloadSchemas(): Promise<void>;
194
+ /**
195
+ * The content schema the props editor draws: the widget's, with the composition's items merged into it.
196
+ * The same object comes back until the schema or the items change, so a component can hand it down without
197
+ * the props editor being told of a change that is none.
198
+ */
199
+ getPropsEditorContentSchema(): any;
200
+ /** The values the props editor starts from: the content values, and the items by id. The same object comes back until they change. */
201
+ getPropsEditorContentValues(): JsonRecord;
202
+ /** Tells the preview page everything it needs to look like the composition (once its page has loaded). */
203
+ syncPreview(): void;
204
+ private buildPreviewSnapshot;
205
+ /** One property was changed in the props editor. */
206
+ changeProperty(change: PropertyChange): void;
207
+ /** The content items (slides, rows, cards...) were changed in the props editor. */
208
+ changeContentItems(items: unknown[]): void;
209
+ /**
210
+ * The user gave the preview a size, by dragging its corner or typing it in. That is the size saved with the
211
+ * composition, and the preview is drawn at it (in the desktop view). A size that is not the user's choice
212
+ * (the one a small box forces on the preview) must not be passed here.
213
+ */
214
+ setPreviewSize(width: number, height: number): void;
215
+ /**
216
+ * The mobile and desktop buttons: the preview is drawn at a phone's size, or back at the saved size. That is a
217
+ * way of looking, not an edit: nothing becomes unsaved, and a save still sends the size the user gave the preview.
218
+ */
219
+ setDeviceView(deviceView: DeviceView): void;
220
+ /**
221
+ * The page saved the composition's embed settings itself (a page can have its own screen for them), so the ones
222
+ * this editor loaded, and sends back with every save, are out of date. From now on a save carries these, with the
223
+ * size the preview has, and the desktop view is drawn at the size they name. Nothing becomes unsaved: the page
224
+ * saved them already. Call it when no save of the editor is on its way (after `flush()`): the answer to a save
225
+ * that is replaces them.
226
+ */
227
+ adoptEmbedSettings(settings: unknown): void;
228
+ /** 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. */
229
+ private markEdited;
230
+ private hasUnsavedChanges;
231
+ /**
232
+ * Saves what has been edited. Resolves `true` when there is nothing unsaved afterwards, `false` when the save
233
+ * failed (see `saveError`) or the editor is not ready.
234
+ */
235
+ save(reason?: SaveReason): Promise<boolean>;
236
+ /**
237
+ * Saves everything that is unsaved, now, and waits until it is. Resolves `true` when nothing is left unsaved,
238
+ * `false` when a save failed. For closing the editor.
239
+ */
240
+ flush(): Promise<boolean>;
241
+ private runSave;
242
+ /** What a save sends: a copy of the values as they are now, so later edits cannot change it. */
243
+ private buildSaveBody;
244
+ /** Starts the wait for an autosave of what is unsaved (for callers that have something unsaved). */
245
+ private scheduleAutosave;
246
+ /** After an autosave failed for a reason that may pass: try again later, but only so many times. */
247
+ private scheduleAutosaveRetry;
248
+ private clearAutosaveTimer;
249
+ /** Stops everything: timers, requests on their way, and any answer that arrives later. Unsaved edits are lost; `flush()` first to keep them. */
250
+ dispose(): void;
251
+ /** Makes the next state out of the changes, tells the watchers, and says when the unsaved flag turned over. */
252
+ private patch;
253
+ private emit;
254
+ }