@widgetic/editor 4.0.10 → 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 -49
- 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,508 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The composition editor: the settings of one Widgetic composition, beside a live preview of its widget.
|
|
4
|
+
*
|
|
5
|
+
* It loads the composition, the address of its widget's preview and both schemas through the Widgetic API
|
|
6
|
+
* with the token `getAccessToken` gives (an editor session token is enough: that is what a partner's page
|
|
7
|
+
* has for its managed users), draws the settings editor and the preview, and saves what is changed: by itself
|
|
8
|
+
* a moment after the last change, and when "Save" is pressed.
|
|
9
|
+
*
|
|
10
|
+
* The page that holds it hears about it through events:
|
|
11
|
+
* ready { name, widgetId } the editor is on the screen
|
|
12
|
+
* dirty { dirty } there is, or is no longer, work that is not saved
|
|
13
|
+
* saved { composition, savedAt }
|
|
14
|
+
* error { during, failure, fatal } during is 'load', 'schemas' or 'save'
|
|
15
|
+
* deviceView { deviceView } the preview is looked at as 'desktop' or 'mobile' now (a button of the
|
|
16
|
+
* preview, a size the user gave it, or `setDeviceView`)
|
|
17
|
+
*
|
|
18
|
+
* A page with controls of its own for what the editor shows can use `setDeviceView` (the same toggle as the
|
|
19
|
+
* preview's buttons) and, when it saved the composition's embed settings itself, `adoptEmbedSettings`.
|
|
20
|
+
*
|
|
21
|
+
* Closing it: `await flush()` first, then remove the component. Removing it cancels whatever is on its way,
|
|
22
|
+
* and edits that were not saved are lost.
|
|
23
|
+
*
|
|
24
|
+
* The component is made for ONE composition: to edit another one, make another component.
|
|
25
|
+
*/
|
|
26
|
+
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte';
|
|
27
|
+
import { createWidgeticMediaLibrary, type MediaLibraryProvider } from '@widgetic/file-browser';
|
|
28
|
+
import PropsEditor from './PropsEditor.svelte';
|
|
29
|
+
import WidgetPreview from '../WidgetPreview.svelte';
|
|
30
|
+
import { setGoogleFontsApiKey } from '../../config';
|
|
31
|
+
import FontManager from '../../utils/FontManager';
|
|
32
|
+
import { createCompositionApi, type TokenReason } from '../../composition/compositionApi.js';
|
|
33
|
+
import {
|
|
34
|
+
CompositionEditorController,
|
|
35
|
+
failureMessage,
|
|
36
|
+
type CompositionEditorEvent,
|
|
37
|
+
type CompositionEditorState,
|
|
38
|
+
type EditorFailure,
|
|
39
|
+
type PreviewMessage,
|
|
40
|
+
type PropertyChange
|
|
41
|
+
} from '../../composition/compositionEditorController.js';
|
|
42
|
+
|
|
43
|
+
const dispatch = createEventDispatcher<{
|
|
44
|
+
ready: { name: string; widgetId: string };
|
|
45
|
+
dirty: { dirty: boolean };
|
|
46
|
+
saved: { composition: Record<string, unknown>; savedAt: number };
|
|
47
|
+
error: { during: 'load' | 'schemas' | 'save'; failure: EditorFailure; fatal: boolean };
|
|
48
|
+
deviceView: { deviceView: 'desktop' | 'mobile' };
|
|
49
|
+
}>();
|
|
50
|
+
|
|
51
|
+
let className = '';
|
|
52
|
+
export { className as class };
|
|
53
|
+
export let style = '';
|
|
54
|
+
|
|
55
|
+
/** The composition to edit. */
|
|
56
|
+
export let compositionId: string;
|
|
57
|
+
/** The Widgetic API's address, with its version: `https://api.widgetic.com/v1`. */
|
|
58
|
+
export let apiBasePath: string;
|
|
59
|
+
/**
|
|
60
|
+
* Gives the token to call the API with. Asked before every request, so a host that keeps its token fresh is
|
|
61
|
+
* always used up to date; asked again with `'expired'` when the API refuses a token, after which the request
|
|
62
|
+
* is sent once more if the token that comes back is a different one. That second call also gets the token that
|
|
63
|
+
* was refused (`refusedToken`), so a page can tell the refusal of its current token from the late refusal of
|
|
64
|
+
* one it has already replaced, when several requests are refused together.
|
|
65
|
+
*/
|
|
66
|
+
export let getAccessToken: (reason: TokenReason, refusedToken?: string) => Promise<string> | string;
|
|
67
|
+
/**
|
|
68
|
+
* Where the file picker gets its files, when they are not the Widgetic library: the partner's own media
|
|
69
|
+
* library. Without it the files are the ones of the Widgetic library the token can see.
|
|
70
|
+
*/
|
|
71
|
+
export let mediaLibrary: MediaLibraryProvider | undefined = undefined;
|
|
72
|
+
/** The key the font pickers list Google Fonts with. Without one only the fonts that need no key are listed. */
|
|
73
|
+
export let googleFontsApiKey = '';
|
|
74
|
+
/** Wait this long after the last change before saving by itself; `0` turns saving by itself off. Read once, when the editor starts. */
|
|
75
|
+
export let autosaveDelayMs: number | undefined = undefined;
|
|
76
|
+
/** Writes what the layout does to the console. */
|
|
77
|
+
export let debug = false;
|
|
78
|
+
|
|
79
|
+
// --- the controller -------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
// A wrong address or token function throws here, when the editor is made: it is a mistake in the page, not a failure to handle.
|
|
82
|
+
// The token function is read when it is called, so a page that gives a new one later is heard.
|
|
83
|
+
const api = createCompositionApi({
|
|
84
|
+
apiBasePath,
|
|
85
|
+
getAccessToken: (reason, refusedToken) => getAccessToken(reason, refusedToken)
|
|
86
|
+
});
|
|
87
|
+
const controller = new CompositionEditorController({
|
|
88
|
+
compositionId,
|
|
89
|
+
api,
|
|
90
|
+
autosaveDelayMs,
|
|
91
|
+
// The preview page hears about every change as it happens (and everything, once its page has loaded).
|
|
92
|
+
onPreviewMessage: (message: PreviewMessage) =>
|
|
93
|
+
previewRef?.sendMessageToPreview?.({ ...message })
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
let state: CompositionEditorState = controller.getState();
|
|
97
|
+
// What the props editor is given is only replaced when it really is another object: the props editor takes a
|
|
98
|
+
// replaced prop for a change, whether the value differs or not.
|
|
99
|
+
let propsEditorContentSchema: any = controller.getPropsEditorContentSchema();
|
|
100
|
+
let propsEditorContentValues: Record<string, unknown> = controller.getPropsEditorContentValues();
|
|
101
|
+
|
|
102
|
+
// The view the page was last told about, so it hears of a change once, whoever made it.
|
|
103
|
+
let announcedDeviceView = state.deviceView;
|
|
104
|
+
|
|
105
|
+
const stopWatchingState = controller.subscribe((next) => {
|
|
106
|
+
state = next;
|
|
107
|
+
const contentSchema = controller.getPropsEditorContentSchema();
|
|
108
|
+
if (contentSchema !== propsEditorContentSchema) propsEditorContentSchema = contentSchema;
|
|
109
|
+
const contentValues = controller.getPropsEditorContentValues();
|
|
110
|
+
if (contentValues !== propsEditorContentValues) propsEditorContentValues = contentValues;
|
|
111
|
+
if (next.deviceView !== announcedDeviceView) {
|
|
112
|
+
announcedDeviceView = next.deviceView;
|
|
113
|
+
dispatch('deviceView', { deviceView: next.deviceView });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const stopWatchingEvents = controller.onEvent((event: CompositionEditorEvent) => {
|
|
118
|
+
switch (event.type) {
|
|
119
|
+
case 'ready':
|
|
120
|
+
dispatch('ready', {
|
|
121
|
+
name: controller.getState().name,
|
|
122
|
+
widgetId: controller.getState().widgetId
|
|
123
|
+
});
|
|
124
|
+
break;
|
|
125
|
+
case 'dirty':
|
|
126
|
+
dispatch('dirty', { dirty: event.dirty });
|
|
127
|
+
break;
|
|
128
|
+
case 'saved':
|
|
129
|
+
dispatch('saved', { composition: event.composition, savedAt: event.savedAt });
|
|
130
|
+
break;
|
|
131
|
+
case 'error':
|
|
132
|
+
dispatch('error', { during: event.during, failure: event.failure, fatal: event.fatal });
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// --- what the page can ask of it ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** Saves what is unsaved now. Resolves `true` when nothing is left unsaved afterwards. */
|
|
140
|
+
export function save(): Promise<boolean> {
|
|
141
|
+
return controller.save('manual');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Saves everything unsaved and waits until it is. Resolves `true` when nothing is left unsaved. Call it before removing the editor. */
|
|
145
|
+
export function flush(): Promise<boolean> {
|
|
146
|
+
return controller.flush();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether there is work that is not saved. */
|
|
150
|
+
export function isDirty(): boolean {
|
|
151
|
+
return controller.getState().dirty;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What the editor knows now (the composition's values, whether it is saving, the last error...). */
|
|
155
|
+
export function getState(): CompositionEditorState {
|
|
156
|
+
return controller.getState();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Loads the composition again. Edits that are not saved are lost. */
|
|
160
|
+
export function reload(): Promise<void> {
|
|
161
|
+
return controller.load();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Looks at the preview as a phone (`'mobile'`) or at the size that is saved (`'desktop'`): what the preview's own
|
|
166
|
+
* buttons do. Nothing becomes unsaved. Does nothing before the editor is ready.
|
|
167
|
+
*/
|
|
168
|
+
export function setDeviceView(deviceView: 'desktop' | 'mobile'): void {
|
|
169
|
+
setDevicePreview(deviceView === 'mobile');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Tells the editor that the page saved the composition's embed settings itself, so a later save does not put
|
|
174
|
+
* the ones the editor loaded back. Call it after `flush()`, with the settings the API now holds.
|
|
175
|
+
*/
|
|
176
|
+
export function adoptEmbedSettings(settings: Record<string, unknown>): void {
|
|
177
|
+
controller.adoptEmbedSettings(settings);
|
|
178
|
+
// (The preview may have a new size: the pair is placed again.)
|
|
179
|
+
void tick().then(() => dockFloatingEditorOnce());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- the preview ----------------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
let previewRef: {
|
|
185
|
+
sendMessageToPreview?: (message: Record<string, unknown>) => void;
|
|
186
|
+
setShellPosition?: (left: number, top: number) => void;
|
|
187
|
+
} | null = null;
|
|
188
|
+
let previewHydrating = false;
|
|
189
|
+
let isContentReady = false;
|
|
190
|
+
|
|
191
|
+
$: previewSrc = state.preview?.previewUrl ?? null;
|
|
192
|
+
$: previewLoading = state.phase === 'idle' || state.phase === 'loading';
|
|
193
|
+
$: isMobilePreview = state.deviceView === 'mobile';
|
|
194
|
+
// "Loading editor…" sits in the middle while the composition loads, and where the editor is about to appear after.
|
|
195
|
+
$: loadingCardPosition =
|
|
196
|
+
state.phase === 'ready' ? 'left-4 top-4' : 'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2';
|
|
197
|
+
// A preview address that has just arrived is a page still to load: the preview says so until its frame has loaded.
|
|
198
|
+
$: startHydrating(previewSrc);
|
|
199
|
+
|
|
200
|
+
function startHydrating(address: string | null) {
|
|
201
|
+
previewHydrating = Boolean(address);
|
|
202
|
+
isContentReady = false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function handlePreviewIframeLoad() {
|
|
206
|
+
log('Preview frame loaded');
|
|
207
|
+
controller.syncPreview();
|
|
208
|
+
await tick();
|
|
209
|
+
requestAnimationFrame(() => {
|
|
210
|
+
previewHydrating = false;
|
|
211
|
+
isContentReady = true;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function handlePreviewResize(
|
|
216
|
+
event: CustomEvent<{ width: number; height: number; source?: string }>
|
|
217
|
+
) {
|
|
218
|
+
// A box smaller than the preview shrinks the preview to fit; that is the box's doing, not a size the user chose,
|
|
219
|
+
// so it is not saved (the composition would change just by being opened in a small window).
|
|
220
|
+
if (event.detail.source === 'clamp') {
|
|
221
|
+
log('The preview was shrunk to fit its box; the saved size is unchanged', event.detail);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
controller.setPreviewSize(event.detail.width, event.detail.height);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function setDevicePreview(mobile: boolean) {
|
|
228
|
+
controller.setDeviceView(mobile ? 'mobile' : 'desktop');
|
|
229
|
+
void tick().then(() => dockFloatingEditorOnce());
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- the props editor -----------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
let propsEditorRef: {
|
|
235
|
+
setPosition?: (left: number, top: number) => void;
|
|
236
|
+
getPosition?: () => { left: number; top: number };
|
|
237
|
+
} | null = null;
|
|
238
|
+
|
|
239
|
+
function handleEditorPropertyChange(event: CustomEvent<PropertyChange>) {
|
|
240
|
+
const { schema, propertyId, value, itemId } = event.detail;
|
|
241
|
+
controller.changeProperty({ schema, propertyId, value, itemId });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function handleEditorContentItemsChanged(event: CustomEvent<{ items: unknown[] }>) {
|
|
245
|
+
controller.changeContentItems(event.detail.items);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The Widgetic library, as the file picker's source, when the page brings none of its own.
|
|
249
|
+
$: widgetId = state.widgetId;
|
|
250
|
+
$: defaultMediaLibrary =
|
|
251
|
+
!mediaLibrary && widgetId ? createDefaultMediaLibrary(widgetId) : undefined;
|
|
252
|
+
|
|
253
|
+
function createDefaultMediaLibrary(forWidgetId: string): MediaLibraryProvider {
|
|
254
|
+
return createWidgeticMediaLibrary({
|
|
255
|
+
apiBasePath,
|
|
256
|
+
getAccessToken: (reason, refusedToken) => getAccessToken(reason, refusedToken),
|
|
257
|
+
contextType: 'content-property',
|
|
258
|
+
source: 'file-browser-upload',
|
|
259
|
+
widgetId: forWidgetId,
|
|
260
|
+
// An editor session cannot use the resumable upload: files over its limit are refused in words instead of with a 403.
|
|
261
|
+
allowResumableUploads: false
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
$: if (googleFontsApiKey) setGoogleFontsApiKey(googleFontsApiKey);
|
|
266
|
+
|
|
267
|
+
// --- the layout: settings editor left of the middle, preview right of it ------------------------
|
|
268
|
+
|
|
269
|
+
const EDITOR_WIDTH_PX = 450;
|
|
270
|
+
const EDITOR_PADDING = 20;
|
|
271
|
+
const EDITOR_MIN_HEIGHT = 360;
|
|
272
|
+
const PREVIEW_CHROME_HEIGHT = 36;
|
|
273
|
+
|
|
274
|
+
let editorShellEl: HTMLElement | null = null;
|
|
275
|
+
let floatingEditorHeight = 640;
|
|
276
|
+
|
|
277
|
+
$: schemasAvailable = Boolean(state.designSchema || state.contentSchema);
|
|
278
|
+
$: editorVisible =
|
|
279
|
+
state.phase === 'ready' && !state.schemasLoading && !state.schemasError && schemasAvailable;
|
|
280
|
+
// Placed once the editor is on the screen, not on every change: it must not jump back while it is being dragged.
|
|
281
|
+
$: if (editorVisible) requestDock();
|
|
282
|
+
|
|
283
|
+
function log(message: string, details?: unknown) {
|
|
284
|
+
if (!debug) return;
|
|
285
|
+
if (details === undefined) console.log(`[CompositionEditorApp] ${message}`);
|
|
286
|
+
else console.log(`[CompositionEditorApp] ${message}`, details);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function clampEditorHeight(containerHeight: number): number {
|
|
290
|
+
return Math.max(EDITOR_MIN_HEIGHT, containerHeight - EDITOR_PADDING * 2);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function requestDock() {
|
|
294
|
+
void tick().then(() => requestAnimationFrame(() => dockFloatingEditorOnce()));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Editor left of the middle, preview right of it, each EDITOR_PADDING from the middle. */
|
|
298
|
+
function dockFloatingEditorOnce() {
|
|
299
|
+
if (!editorShellEl) return;
|
|
300
|
+
try {
|
|
301
|
+
const shellWidth = editorShellEl.clientWidth || window.innerWidth;
|
|
302
|
+
const shellHeight = editorShellEl.clientHeight || window.innerHeight;
|
|
303
|
+
floatingEditorHeight = clampEditorHeight(shellHeight);
|
|
304
|
+
|
|
305
|
+
const middleX = Math.round(shellWidth / 2);
|
|
306
|
+
const previewHeight = state.previewHeight + PREVIEW_CHROME_HEIGHT;
|
|
307
|
+
const leftEditor = Math.max(EDITOR_PADDING, middleX - EDITOR_PADDING - EDITOR_WIDTH_PX);
|
|
308
|
+
const topEditor = EDITOR_PADDING;
|
|
309
|
+
const leftPreview = middleX + EDITOR_PADDING;
|
|
310
|
+
const topPreview = Math.max(EDITOR_PADDING, Math.round((shellHeight - previewHeight) / 2));
|
|
311
|
+
|
|
312
|
+
void tick().then(() => {
|
|
313
|
+
propsEditorRef?.setPosition?.(leftEditor, topEditor);
|
|
314
|
+
previewRef?.setShellPosition?.(leftPreview, topPreview);
|
|
315
|
+
});
|
|
316
|
+
log('Docked the editor and the preview', {
|
|
317
|
+
height: floatingEditorHeight,
|
|
318
|
+
middleX,
|
|
319
|
+
leftEditor,
|
|
320
|
+
topEditor,
|
|
321
|
+
leftPreview,
|
|
322
|
+
topPreview
|
|
323
|
+
});
|
|
324
|
+
} catch (error) {
|
|
325
|
+
console.error('[CompositionEditorApp] Placing the editor and the preview failed:', error);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** When the box the editor is in changes size: the height follows, and the pair is placed again. */
|
|
330
|
+
function syncFloatingEditorHeight() {
|
|
331
|
+
if (!editorShellEl) return;
|
|
332
|
+
floatingEditorHeight = clampEditorHeight(editorShellEl.clientHeight || window.innerHeight);
|
|
333
|
+
dockFloatingEditorOnce();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- life -----------------------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
let shellObserver: ResizeObserver | null = null;
|
|
339
|
+
|
|
340
|
+
onMount(() => {
|
|
341
|
+
void controller.load();
|
|
342
|
+
|
|
343
|
+
window.addEventListener('resize', syncFloatingEditorHeight);
|
|
344
|
+
if (editorShellEl && typeof ResizeObserver !== 'undefined') {
|
|
345
|
+
shellObserver = new ResizeObserver(() => syncFloatingEditorHeight());
|
|
346
|
+
shellObserver.observe(editorShellEl);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
onDestroy(() => {
|
|
351
|
+
// (Also runs when the page is rendered on a server, where there is no window.)
|
|
352
|
+
if (typeof window !== 'undefined')
|
|
353
|
+
window.removeEventListener('resize', syncFloatingEditorHeight);
|
|
354
|
+
shellObserver?.disconnect();
|
|
355
|
+
stopWatchingState();
|
|
356
|
+
stopWatchingEvents();
|
|
357
|
+
controller.dispose();
|
|
358
|
+
// The font stylesheets the editor put in the page's <head> go with it (a page of somebody else's is left as found)
|
|
359
|
+
FontManager.releasePageResources();
|
|
360
|
+
});
|
|
361
|
+
</script>
|
|
362
|
+
|
|
363
|
+
<div
|
|
364
|
+
class="composition-editor-ct relative h-full min-h-0 w-full overflow-hidden {className}"
|
|
365
|
+
{style}
|
|
366
|
+
bind:this={editorShellEl}
|
|
367
|
+
>
|
|
368
|
+
<!-- Keep preview resize handles in their own layer below the floating editor. -->
|
|
369
|
+
<div
|
|
370
|
+
class="composition-editor-preview-area absolute inset-0 z-0 flex min-h-0 min-w-0 bg-[#f3f4f6]"
|
|
371
|
+
>
|
|
372
|
+
<!--
|
|
373
|
+
This editor can run inside a partner's page, and a widget is code written by somebody else. The preview may
|
|
374
|
+
run its scripts, keep its own storage (allow-same-origin), open links in a new tab and submit forms, and
|
|
375
|
+
nothing more: no top navigation (it cannot send the visitor's tab away), no alert/confirm dialogs, no
|
|
376
|
+
downloads; of the page's permissions it may only write to the clipboard.
|
|
377
|
+
-->
|
|
378
|
+
<WidgetPreview
|
|
379
|
+
bind:this={previewRef}
|
|
380
|
+
class="composition-widget-preview h-full w-full"
|
|
381
|
+
mode="controlled"
|
|
382
|
+
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
|
383
|
+
allow="clipboard-write"
|
|
384
|
+
enableResize={true}
|
|
385
|
+
enableDrag={true}
|
|
386
|
+
showDimensionInputs={true}
|
|
387
|
+
showChromeDeviceToggle={true}
|
|
388
|
+
deviceMobile={isMobilePreview}
|
|
389
|
+
on:deviceView={(event) => setDevicePreview(event.detail.mobile)}
|
|
390
|
+
resizeMode="corner"
|
|
391
|
+
defaultStageAlign="center"
|
|
392
|
+
compactChrome={true}
|
|
393
|
+
showUrlBar={false}
|
|
394
|
+
src={previewSrc}
|
|
395
|
+
displayUrl={previewSrc}
|
|
396
|
+
resizeWidth={state.previewWidth}
|
|
397
|
+
resizeHeight={state.previewHeight}
|
|
398
|
+
{previewLoading}
|
|
399
|
+
{previewHydrating}
|
|
400
|
+
previewError={state.previewError}
|
|
401
|
+
{isContentReady}
|
|
402
|
+
on:iframeLoad={handlePreviewIframeLoad}
|
|
403
|
+
on:resize={handlePreviewResize}
|
|
404
|
+
/>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
{#if state.phase === 'failed' && state.loadError}
|
|
408
|
+
<!-- Nothing can be shown behind this one (no composition, so no editor and no preview), so it sits in the middle. -->
|
|
409
|
+
<div
|
|
410
|
+
role="alert"
|
|
411
|
+
class="composition-editor-error absolute left-1/2 top-1/2 z-30 flex w-max max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col gap-2 rounded-lg border border-red-200 bg-white p-4 shadow-sm"
|
|
412
|
+
>
|
|
413
|
+
<p class="text-sm text-red-600">{failureMessage(state.loadError, 'load')}</p>
|
|
414
|
+
<button
|
|
415
|
+
type="button"
|
|
416
|
+
class="retry-load-btn w-fit cursor-pointer rounded-lg bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200 focus:outline-none focus-visible:outline-none"
|
|
417
|
+
on:click={() => {
|
|
418
|
+
void controller.load();
|
|
419
|
+
}}
|
|
420
|
+
>
|
|
421
|
+
Try again
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
424
|
+
{:else if state.phase !== 'ready' || state.schemasLoading}
|
|
425
|
+
<div
|
|
426
|
+
role="status"
|
|
427
|
+
class="composition-editor-loading absolute z-30 rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-500 shadow-sm {loadingCardPosition}"
|
|
428
|
+
>
|
|
429
|
+
Loading editor…
|
|
430
|
+
</div>
|
|
431
|
+
{:else if state.schemasError}
|
|
432
|
+
<div
|
|
433
|
+
role="alert"
|
|
434
|
+
class="composition-editor-error absolute left-4 top-4 z-30 flex max-w-sm flex-col gap-2 rounded-lg border border-red-200 bg-white p-4 shadow-sm"
|
|
435
|
+
>
|
|
436
|
+
<span class="text-xs text-red-600">{state.schemasError}</span>
|
|
437
|
+
<button
|
|
438
|
+
type="button"
|
|
439
|
+
class="retry-schemas-btn w-fit cursor-pointer rounded-lg bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200 focus:outline-none focus-visible:outline-none"
|
|
440
|
+
on:click={() => {
|
|
441
|
+
void controller.reloadSchemas();
|
|
442
|
+
}}
|
|
443
|
+
>
|
|
444
|
+
Retry
|
|
445
|
+
</button>
|
|
446
|
+
</div>
|
|
447
|
+
{:else if schemasAvailable}
|
|
448
|
+
<PropsEditor
|
|
449
|
+
bind:this={propsEditorRef}
|
|
450
|
+
class="composition-props-editor absolute z-20 shadow-lg"
|
|
451
|
+
style="left: {EDITOR_PADDING}px; top: {EDITOR_PADDING}px;"
|
|
452
|
+
designSchema={state.designSchema}
|
|
453
|
+
contentSchema={propsEditorContentSchema}
|
|
454
|
+
initialDesignValues={state.designValues}
|
|
455
|
+
initialContentValues={propsEditorContentValues}
|
|
456
|
+
mediaLibrary={mediaLibrary ?? defaultMediaLibrary}
|
|
457
|
+
width="{EDITOR_WIDTH_PX}px"
|
|
458
|
+
height="{floatingEditorHeight}px"
|
|
459
|
+
showDragHandle={true}
|
|
460
|
+
enableDrag={true}
|
|
461
|
+
showSaveButton={true}
|
|
462
|
+
saveDirty={state.dirty}
|
|
463
|
+
saveSaving={state.saving}
|
|
464
|
+
defaultTab="content"
|
|
465
|
+
on:propertyChange={handleEditorPropertyChange}
|
|
466
|
+
on:contentItemsChanged={handleEditorContentItemsChanged}
|
|
467
|
+
on:save={() => {
|
|
468
|
+
void controller.save('manual');
|
|
469
|
+
}}
|
|
470
|
+
/>
|
|
471
|
+
{/if}
|
|
472
|
+
|
|
473
|
+
{#if state.phase === 'ready' && state.saveError}
|
|
474
|
+
<div
|
|
475
|
+
role="alert"
|
|
476
|
+
class="composition-editor-save-error absolute bottom-4 left-4 z-30 flex max-w-sm flex-col gap-2 rounded-lg border border-red-200 bg-white p-4 shadow-sm"
|
|
477
|
+
>
|
|
478
|
+
<p class="text-sm text-red-600">{failureMessage(state.saveError, 'save')}</p>
|
|
479
|
+
<button
|
|
480
|
+
type="button"
|
|
481
|
+
class="retry-save-btn w-fit cursor-pointer rounded-lg bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200 focus:outline-none focus-visible:outline-none"
|
|
482
|
+
on:click={() => {
|
|
483
|
+
void controller.save('manual');
|
|
484
|
+
}}
|
|
485
|
+
>
|
|
486
|
+
Try again
|
|
487
|
+
</button>
|
|
488
|
+
</div>
|
|
489
|
+
{/if}
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
<style>
|
|
493
|
+
/* The props editor's own classes are out of reach of this component, so they are styled from the box it sits in. */
|
|
494
|
+
.composition-editor-ct :global(.composition-props-editor .ic-input-ct) {
|
|
495
|
+
width: 152px !important;
|
|
496
|
+
}
|
|
497
|
+
.composition-editor-ct :global(.composition-props-editor .input-controller > div:last-child) {
|
|
498
|
+
width: 152px !important;
|
|
499
|
+
}
|
|
500
|
+
.composition-editor-ct :global(.composition-props-editor .color-input-main-container) {
|
|
501
|
+
width: 152px !important;
|
|
502
|
+
}
|
|
503
|
+
.composition-editor-ct :global(.composition-props-editor .input-controller-header .subtext) {
|
|
504
|
+
overflow: visible;
|
|
505
|
+
text-overflow: unset;
|
|
506
|
+
white-space: nowrap;
|
|
507
|
+
}
|
|
508
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type MediaLibraryProvider } from '@widgetic/file-browser';
|
|
2
|
+
import { type TokenReason } from '../../composition/compositionApi.js';
|
|
3
|
+
import { type CompositionEditorState, type EditorFailure } from '../../composition/compositionEditorController.js';
|
|
4
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
5
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
6
|
+
$$bindings?: Bindings;
|
|
7
|
+
} & Exports;
|
|
8
|
+
(internal: unknown, props: Props & {
|
|
9
|
+
$$events?: Events;
|
|
10
|
+
$$slots?: Slots;
|
|
11
|
+
}): Exports & {
|
|
12
|
+
$set?: any;
|
|
13
|
+
$on?: any;
|
|
14
|
+
};
|
|
15
|
+
z_$$bindings?: Bindings;
|
|
16
|
+
}
|
|
17
|
+
declare const CompositionEditorApp: $$__sveltets_2_IsomorphicComponent<{
|
|
18
|
+
class?: string;
|
|
19
|
+
style?: string;
|
|
20
|
+
/** The composition to edit. */ compositionId: string;
|
|
21
|
+
/** The Widgetic API's address, with its version: `https://api.widgetic.com/v1`. */ apiBasePath: string;
|
|
22
|
+
/**
|
|
23
|
+
* Gives the token to call the API with. Asked before every request, so a host that keeps its token fresh is
|
|
24
|
+
* always used up to date; asked again with `'expired'` when the API refuses a token, after which the request
|
|
25
|
+
* is sent once more if the token that comes back is a different one. That second call also gets the token that
|
|
26
|
+
* was refused (`refusedToken`), so a page can tell the refusal of its current token from the late refusal of
|
|
27
|
+
* one it has already replaced, when several requests are refused together.
|
|
28
|
+
*/ getAccessToken: (reason: TokenReason, refusedToken?: string) => Promise<string> | string;
|
|
29
|
+
/**
|
|
30
|
+
* Where the file picker gets its files, when they are not the Widgetic library: the partner's own media
|
|
31
|
+
* library. Without it the files are the ones of the Widgetic library the token can see.
|
|
32
|
+
*/ mediaLibrary?: MediaLibraryProvider | undefined;
|
|
33
|
+
/** The key the font pickers list Google Fonts with. Without one only the fonts that need no key are listed. */ googleFontsApiKey?: string;
|
|
34
|
+
/** Wait this long after the last change before saving by itself; `0` turns saving by itself off. Read once, when the editor starts. */ autosaveDelayMs?: number | undefined;
|
|
35
|
+
/** Writes what the layout does to the console. */ debug?: boolean;
|
|
36
|
+
save?: () => Promise<boolean>;
|
|
37
|
+
flush?: () => Promise<boolean>;
|
|
38
|
+
isDirty?: () => boolean;
|
|
39
|
+
getState?: () => CompositionEditorState;
|
|
40
|
+
reload?: () => Promise<void>;
|
|
41
|
+
setDeviceView?: (deviceView: "desktop" | "mobile") => void;
|
|
42
|
+
adoptEmbedSettings?: (settings: Record<string, unknown>) => void;
|
|
43
|
+
}, {
|
|
44
|
+
ready: CustomEvent<{
|
|
45
|
+
name: string;
|
|
46
|
+
widgetId: string;
|
|
47
|
+
}>;
|
|
48
|
+
dirty: CustomEvent<{
|
|
49
|
+
dirty: boolean;
|
|
50
|
+
}>;
|
|
51
|
+
saved: CustomEvent<{
|
|
52
|
+
composition: Record<string, unknown>;
|
|
53
|
+
savedAt: number;
|
|
54
|
+
}>;
|
|
55
|
+
error: CustomEvent<{
|
|
56
|
+
during: "load" | "schemas" | "save";
|
|
57
|
+
failure: EditorFailure;
|
|
58
|
+
fatal: boolean;
|
|
59
|
+
}>;
|
|
60
|
+
deviceView: CustomEvent<{
|
|
61
|
+
deviceView: "desktop" | "mobile";
|
|
62
|
+
}>;
|
|
63
|
+
} & {
|
|
64
|
+
[evt: string]: CustomEvent<any>;
|
|
65
|
+
}, {}, {
|
|
66
|
+
save: () => Promise<boolean>;
|
|
67
|
+
flush: () => Promise<boolean>;
|
|
68
|
+
isDirty: () => boolean;
|
|
69
|
+
getState: () => CompositionEditorState;
|
|
70
|
+
reload: () => Promise<void>;
|
|
71
|
+
setDeviceView: (deviceView: "desktop" | "mobile") => void;
|
|
72
|
+
adoptEmbedSettings: (settings: Record<string, unknown>) => void;
|
|
73
|
+
}, string>;
|
|
74
|
+
type CompositionEditorApp = InstanceType<typeof CompositionEditorApp>;
|
|
75
|
+
export default CompositionEditorApp;
|