@scaleflex/template-builder 0.1.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/integrate-template-builder/SKILL.md +79 -24
- package/CHANGELOG.md +190 -4
- package/README.md +452 -66
- package/dist/dam-store.d.ts +92 -0
- package/dist/define.cjs +1 -1
- package/dist/define.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +16 -14
- package/dist/protocol.d.ts +84 -2
- package/dist/react.cjs +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.ts +41 -1
- package/dist/react.js +45 -29
- package/dist/react.js.map +1 -1
- package/dist/template-builder-B9Cwo_Q-.js +651 -0
- package/dist/template-builder-B9Cwo_Q-.js.map +1 -0
- package/dist/template-builder-Byqg1q93.cjs +53 -0
- package/dist/template-builder-Byqg1q93.cjs.map +1 -0
- package/dist/template-builder.d.ts +201 -6
- package/package.json +3 -1
- package/src/dam-store.ts +388 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +92 -2
- package/src/react.ts +120 -27
- package/src/template-builder.ts +467 -11
- package/dist/template-builder-CSyPZni9.cjs +0 -52
- package/dist/template-builder-CSyPZni9.cjs.map +0 -1
- package/dist/template-builder-S33H_d5T.js +0 -354
- package/dist/template-builder-S33H_d5T.js.map +0 -1
package/src/template-builder.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LitElement, html, css, nothing, type PropertyValues } from 'lit'
|
|
2
2
|
import { property, state } from 'lit/decorators.js'
|
|
3
3
|
import {
|
|
4
|
+
BLANK_TEMPLATE_XML,
|
|
4
5
|
BUILDER_CLOSE,
|
|
5
6
|
BUILDER_CONTENT,
|
|
6
7
|
BUILDER_CONTENT_REQUEST,
|
|
@@ -11,9 +12,11 @@ import {
|
|
|
11
12
|
BUILDER_SAVE,
|
|
12
13
|
EMBED_PARAMS,
|
|
13
14
|
EMBED_ROUTE,
|
|
15
|
+
HOST_CONFIG,
|
|
14
16
|
HOST_LOAD,
|
|
15
17
|
HOST_SAVED,
|
|
16
18
|
builderRoute,
|
|
19
|
+
type CustomMetadataField,
|
|
17
20
|
type BuilderContentData,
|
|
18
21
|
type BuilderContentMessage,
|
|
19
22
|
type BuilderDirtyData,
|
|
@@ -24,6 +27,13 @@ import {
|
|
|
24
27
|
type BuilderSaveMessage,
|
|
25
28
|
type BuilderTheme,
|
|
26
29
|
} from './protocol'
|
|
30
|
+
import {
|
|
31
|
+
storeTemplateInDam,
|
|
32
|
+
type DamStoreAuth,
|
|
33
|
+
type StoredTemplate,
|
|
34
|
+
} from './dam-store'
|
|
35
|
+
|
|
36
|
+
export type { StoredTemplate, DamStoreAuth } from './dam-store'
|
|
27
37
|
|
|
28
38
|
export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
|
|
29
39
|
|
|
@@ -33,11 +43,14 @@ export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
|
|
|
33
43
|
* - DAM-backed (default) — `BuilderSaveData`; the app uploaded the template and
|
|
34
44
|
* reports the resulting `uuid`.
|
|
35
45
|
* - `stateless` — `BuilderContentData`; nothing was stored, and `content` is
|
|
36
|
-
* the edited template for the host to persist.
|
|
46
|
+
* the edited template for the host to persist. With `dam-store` the element
|
|
47
|
+
* uploads a rendering copy to Filerobot first, and the detail additionally
|
|
48
|
+
* carries `stored` (the copy's uuid + CDN URL) — or `storeError` when that
|
|
49
|
+
* copy could not be made; the raw `content` arrives either way.
|
|
37
50
|
*/
|
|
38
51
|
export type TemplateBuilderSaveDetail =
|
|
39
52
|
| BuilderSaveData
|
|
40
|
-
| BuilderContentData
|
|
53
|
+
| (BuilderContentData & { stored?: StoredTemplate; storeError?: string })
|
|
41
54
|
| undefined
|
|
42
55
|
|
|
43
56
|
export interface TemplateBuilderEventMap {
|
|
@@ -74,7 +87,9 @@ export interface TemplateBuilderEventMap {
|
|
|
74
87
|
* template into the editor over postMessage and `save` returns the edited
|
|
75
88
|
* document; nothing is stored on the Scaleflex side, and `template-id` is
|
|
76
89
|
* just an opaque string echoed back. Rendering, fonts and asset browsing
|
|
77
|
-
* still use the session's Filerobot tenant.
|
|
90
|
+
* still use the session's Filerobot tenant. To start a template from
|
|
91
|
+
* scratch, set `new-template` (or call `createNew()`) instead of `content`
|
|
92
|
+
* — the widget supplies the blank document.
|
|
78
93
|
*
|
|
79
94
|
* `brand-color` and `theme` restyle the editor chrome to match the host page.
|
|
80
95
|
* They do not touch the rendered template — its colours live in the document.
|
|
@@ -149,7 +164,8 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
149
164
|
@property({ reflect: true }) mode: 'inline' | 'modal' = 'inline'
|
|
150
165
|
/**
|
|
151
166
|
* Hand the template in and take it back out instead of letting the app read
|
|
152
|
-
* and write Filerobot. Requires `content
|
|
167
|
+
* and write Filerobot. Requires `content`, or `new-template` to start from
|
|
168
|
+
* scratch.
|
|
153
169
|
*/
|
|
154
170
|
@property({ type: Boolean, reflect: true }) stateless = false
|
|
155
171
|
/**
|
|
@@ -158,6 +174,21 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
158
174
|
* Assigning a different value while open loads it into the running editor.
|
|
159
175
|
*/
|
|
160
176
|
@property({ attribute: false }) content = ''
|
|
177
|
+
/**
|
|
178
|
+
* Stateless mode: open on a new, empty template rather than one of yours.
|
|
179
|
+
* The widget supplies the blank document ({@link BLANK_TEMPLATE_XML}), so
|
|
180
|
+
* nothing here needs to know the template format — the user picks the canvas
|
|
181
|
+
* size in the editor, and `save` hands back a complete document to store.
|
|
182
|
+
*
|
|
183
|
+
* `content` wins when both are set: a host with a real template to edit is
|
|
184
|
+
* not asking for a blank one.
|
|
185
|
+
*
|
|
186
|
+
* A flag rather than an inferred meaning for empty `content`, because empty
|
|
187
|
+
* already means "the host is still fetching" — the editor waits on a spinner
|
|
188
|
+
* for it, and blanking that case would flash an empty document in front of
|
|
189
|
+
* every host that mounts the builder before its request resolves.
|
|
190
|
+
*/
|
|
191
|
+
@property({ type: Boolean, attribute: 'new-template' }) newTemplate = false
|
|
161
192
|
/** Stateless mode: display name for the editor header. */
|
|
162
193
|
@property({ attribute: 'template-name' }) templateName = ''
|
|
163
194
|
/**
|
|
@@ -176,6 +207,81 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
176
207
|
@property({ attribute: 'brand-color' }) brandColor = ''
|
|
177
208
|
/** Colour scheme for the editor chrome. Empty leaves the app's own default. */
|
|
178
209
|
@property() theme: BuilderTheme | '' = ''
|
|
210
|
+
/**
|
|
211
|
+
* Metadata model to offer in the editor as the **Custom metadata** value
|
|
212
|
+
* source: `[{ key, title?, group? }]`. Authors then bind a text variable to
|
|
213
|
+
* one of your field names instead of to a bare slug.
|
|
214
|
+
*
|
|
215
|
+
* Names only — no values travel with the model, and the app resolves nothing
|
|
216
|
+
* against it. The bound key is stored in the template as `custom_ckey`, and
|
|
217
|
+
* the variable renders like any free-text one: your pipeline puts
|
|
218
|
+
* `$slug=value` in the render query. Read the key back from the saved `.fdt`
|
|
219
|
+
* to know which of your fields each variable expects.
|
|
220
|
+
*
|
|
221
|
+
* Leave it empty and the source is not offered at all, so hosts that send
|
|
222
|
+
* nothing see the editor they always had. Its main use is `sec-template` /
|
|
223
|
+
* stateless embeds, where the Hub project model — and with it the "File
|
|
224
|
+
* metadata" source — is unavailable.
|
|
225
|
+
*
|
|
226
|
+
* Settable as a property (an array) or as a `custom-metadata` attribute
|
|
227
|
+
* holding that array as JSON. Neither is trusted to be well-formed:
|
|
228
|
+
* unparseable JSON is warned about, a value that is not an array is treated as
|
|
229
|
+
* no model, and individual fields the editor cannot use are dropped there —
|
|
230
|
+
* a config typo costs the field, not the editor.
|
|
231
|
+
*
|
|
232
|
+
* Lit compares by identity: assign a new array to change the model, don't
|
|
233
|
+
* mutate the one you passed.
|
|
234
|
+
*/
|
|
235
|
+
@property({
|
|
236
|
+
attribute: 'custom-metadata',
|
|
237
|
+
converter: {
|
|
238
|
+
fromAttribute: (value: string | null): CustomMetadataField[] => {
|
|
239
|
+
if (!value) return []
|
|
240
|
+
try {
|
|
241
|
+
const parsed: unknown = JSON.parse(value)
|
|
242
|
+
return Array.isArray(parsed) ? (parsed as CustomMetadataField[]) : []
|
|
243
|
+
} catch {
|
|
244
|
+
console.warn('[sfx-template-builder] custom-metadata is not valid JSON — ignoring.')
|
|
245
|
+
return []
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
toAttribute: (value: CustomMetadataField[]): string => JSON.stringify(value ?? []),
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
customMetadata: CustomMetadataField[] = []
|
|
252
|
+
/**
|
|
253
|
+
* Display name for the custom-metadata value source in the editor's UI.
|
|
254
|
+
* Empty means the editor's default ("Custom metadata"); a host can rename
|
|
255
|
+
* it after its own domain — e.g. "External metadata". Pure wording: the
|
|
256
|
+
* stored template is unaffected.
|
|
257
|
+
*/
|
|
258
|
+
@property({ attribute: 'custom-metadata-label' }) customMetadataLabel = ''
|
|
259
|
+
/**
|
|
260
|
+
* Stateless only: store each save in Filerobot too, so the CDN can render
|
|
261
|
+
* it. The `save` event then carries `stored: { uuid, url }` next to the raw
|
|
262
|
+
* `content` — or `storeError` when the copy failed (the raw data arrives
|
|
263
|
+
* either way; whether that fails the save is the host's call via the ack).
|
|
264
|
+
* Uses the element's own credentials; a security template needs a scope
|
|
265
|
+
* that allows uploads.
|
|
266
|
+
*/
|
|
267
|
+
@property({ type: Boolean, attribute: 'dam-store' }) damStore = false
|
|
268
|
+
/**
|
|
269
|
+
* Folder new templates are stored into when `dam-store` is on and the
|
|
270
|
+
* template id names no existing DAM file (an existing file's own folder
|
|
271
|
+
* always wins, so same name + folder versions it in place).
|
|
272
|
+
*/
|
|
273
|
+
@property({ attribute: 'store-folder' }) storeFolder = '/'
|
|
274
|
+
/**
|
|
275
|
+
* `dam-store`: the uuid of the rendering copy this document already has —
|
|
276
|
+
* the `stored.uuid` a previous session's save reported, passed back in by
|
|
277
|
+
* the host alongside the content. Without it the element only remembers
|
|
278
|
+
* copies it made itself, so after a reload an unchanged re-save cannot find
|
|
279
|
+
* its own file and reports a spurious `storeError`, and a changed one starts
|
|
280
|
+
* a fresh file instead of versioning the existing one. Set it when reopening
|
|
281
|
+
* a stored template; it belongs to the document, so hosts that swap
|
|
282
|
+
* documents must swap (or clear) it too — `load()` does this for you.
|
|
283
|
+
*/
|
|
284
|
+
@property({ attribute: 'stored-uuid' }) storedUuid = ''
|
|
179
285
|
/** Ms to wait for the app's ready signal before emitting `error`. 0 disables. */
|
|
180
286
|
@property({ type: Number, attribute: 'ready-timeout' }) readyTimeout = 20000
|
|
181
287
|
|
|
@@ -197,6 +303,21 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
197
303
|
* back on save.
|
|
198
304
|
*/
|
|
199
305
|
private _sentKey?: string
|
|
306
|
+
/**
|
|
307
|
+
* Identity of the last SAVE the app handed back, in the same shape as
|
|
308
|
+
* `_sentKey`. A host echoing the full save detail into the props (content,
|
|
309
|
+
* name, templateQuery) matches this key rather than `_sentKey`, whose name
|
|
310
|
+
* and query still describe what the template was loaded with — without it
|
|
311
|
+
* the echo would post a HOST_LOAD that reloads the editor and wipes undo
|
|
312
|
+
* history on nearly every save (a save almost always changes the query).
|
|
313
|
+
*/
|
|
314
|
+
private _sentSaveKey?: string
|
|
315
|
+
/**
|
|
316
|
+
* Identity of the config already delivered, so re-renders don't re-post it.
|
|
317
|
+
* Undefined means the app has not been told anything yet — set back to that
|
|
318
|
+
* whenever a new app instance loads.
|
|
319
|
+
*/
|
|
320
|
+
private _sentConfigKey?: string
|
|
200
321
|
/**
|
|
201
322
|
* The `baseUrl` value already reported as unparseable. `_computeSrc()` runs
|
|
202
323
|
* on every update cycle, so without this a bad URL re-emits `error` forever —
|
|
@@ -246,11 +367,13 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
246
367
|
templateId,
|
|
247
368
|
name,
|
|
248
369
|
templateQuery,
|
|
370
|
+
storedUuid,
|
|
249
371
|
}: {
|
|
250
372
|
content: string
|
|
251
373
|
templateId?: string
|
|
252
374
|
name?: string
|
|
253
375
|
templateQuery?: string
|
|
376
|
+
storedUuid?: string
|
|
254
377
|
}): void {
|
|
255
378
|
if (templateId !== undefined) this.templateId = templateId
|
|
256
379
|
if (name !== undefined) this.templateName = name
|
|
@@ -258,10 +381,48 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
258
381
|
// previous template's query in place while the new content goes out would
|
|
259
382
|
// open the new document on the old layout and values.
|
|
260
383
|
if (templateQuery !== undefined) this.templateQuery = templateQuery
|
|
384
|
+
// Omission clears rather than keeps: the seed names the DOCUMENT's stored
|
|
385
|
+
// copy, and carrying one document's uuid into the next would anchor its
|
|
386
|
+
// saves to the wrong file. "Unknown" is the safe reading of not saying.
|
|
387
|
+
this.storedUuid = storedUuid ?? ''
|
|
388
|
+
// A template of your own is not the blank one: clearing the flag lets a
|
|
389
|
+
// host alternate between `createNew()` and `load()` on one element.
|
|
390
|
+
this.newTemplate = false
|
|
261
391
|
this.content = content
|
|
262
392
|
this._open = true
|
|
263
393
|
}
|
|
264
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Stateless mode: open the editor on a new, empty template, opening it if
|
|
397
|
+
* needed. Equivalent to setting `new-template` and calling `open()`.
|
|
398
|
+
*
|
|
399
|
+
* The user chooses the canvas size from the editor's empty state; `save`
|
|
400
|
+
* then hands back a complete `.fdt` document — the first one you store for
|
|
401
|
+
* this record. Pass `templateId` if you have already allocated one; leave it
|
|
402
|
+
* out and the `save` payload simply comes back without an id.
|
|
403
|
+
*
|
|
404
|
+
* Calling it again on an editor that is already showing the blank document
|
|
405
|
+
* does nothing — resending would discard whatever the user has built since.
|
|
406
|
+
* To genuinely start over, `close()` and reopen.
|
|
407
|
+
*/
|
|
408
|
+
createNew({
|
|
409
|
+
templateId,
|
|
410
|
+
name,
|
|
411
|
+
}: { templateId?: string; name?: string } = {}): void {
|
|
412
|
+
if (templateId !== undefined) this.templateId = templateId
|
|
413
|
+
if (name !== undefined) this.templateName = name
|
|
414
|
+
// A new document has no layouts and no variables, so there is no render
|
|
415
|
+
// for a query to select — and a leftover one would name layouts and
|
|
416
|
+
// variables of the previous template. Same for the stored-copy seed: a
|
|
417
|
+
// blank document has no copy, and a stale one would anchor the first save
|
|
418
|
+
// to the previous document's file.
|
|
419
|
+
this.templateQuery = ''
|
|
420
|
+
this.storedUuid = ''
|
|
421
|
+
this.newTemplate = true
|
|
422
|
+
this.content = ''
|
|
423
|
+
this._open = true
|
|
424
|
+
}
|
|
425
|
+
|
|
265
426
|
/**
|
|
266
427
|
* Stateless mode: report back whether a `save` was persisted on your side.
|
|
267
428
|
*
|
|
@@ -297,6 +458,21 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
297
458
|
super.disconnectedCallback()
|
|
298
459
|
window.removeEventListener('message', this._onMessage)
|
|
299
460
|
this._clearHandshakeTimer()
|
|
461
|
+
// Element-level listeners still fire on a detached element, so a vanilla
|
|
462
|
+
// host that removes the widget mid-upload still gets its raw save. (A
|
|
463
|
+
// framework wrapper unsubscribes its listeners before the node detaches,
|
|
464
|
+
// which is why the public `flushPendingSaves()` exists — the React wrapper
|
|
465
|
+
// calls it from its cleanup, while its listeners still hear the event.)
|
|
466
|
+
//
|
|
467
|
+
// Deferred a task: REPARENTING (appendChild into another container)
|
|
468
|
+
// detaches and reattaches synchronously, and flushing on the detach half
|
|
469
|
+
// would report a false storeError for an upload that lands fine. Only a
|
|
470
|
+
// detach that is still detached a tick later is a real removal.
|
|
471
|
+
setTimeout(() => {
|
|
472
|
+
if (!this.isConnected) {
|
|
473
|
+
this._flushPendingSaves('widget removed before the rendering copy completed')
|
|
474
|
+
}
|
|
475
|
+
}, 0)
|
|
300
476
|
}
|
|
301
477
|
|
|
302
478
|
protected willUpdate(changed: PropertyValues): void {
|
|
@@ -317,8 +493,14 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
317
493
|
this._clearHandshakeTimer()
|
|
318
494
|
// A new document means a new app instance: it has not asked for content
|
|
319
495
|
// yet, and nothing has been delivered to it.
|
|
496
|
+
// Delivery state only — this is a new APP instance, not a new document.
|
|
497
|
+
// `_lastDocKey` / `_docEpoch` / `_storeMemory` describe the document and
|
|
498
|
+
// survive: resetting them here made every modal close or theme swap
|
|
499
|
+
// forget the stored copy and fork the file on the next save.
|
|
320
500
|
this._contentRequested = false
|
|
321
501
|
this._sentKey = undefined
|
|
502
|
+
this._sentSaveKey = undefined
|
|
503
|
+
this._sentConfigKey = undefined
|
|
322
504
|
if (this._isDirty) {
|
|
323
505
|
this._isDirty = false
|
|
324
506
|
this._emit('dirtychange', { isDirty: false })
|
|
@@ -330,12 +512,23 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
330
512
|
// templates must not leave the app saving under the previous id.
|
|
331
513
|
if (
|
|
332
514
|
changed.has('content') ||
|
|
515
|
+
changed.has('newTemplate') ||
|
|
333
516
|
changed.has('templateId') ||
|
|
334
517
|
changed.has('templateName') ||
|
|
335
518
|
changed.has('templateQuery')
|
|
336
519
|
) {
|
|
337
520
|
this._maybeSendContent()
|
|
338
521
|
}
|
|
522
|
+
// Config is independent of the template: it also has to reach DAM-backed
|
|
523
|
+
// embeds, which never send a content request. Only once the app is ready,
|
|
524
|
+
// though — before that there is nothing listening, and the ready signal
|
|
525
|
+
// sends whatever the latest value is anyway.
|
|
526
|
+
if (
|
|
527
|
+
(changed.has('customMetadata') || changed.has('customMetadataLabel')) &&
|
|
528
|
+
this._status === 'ready'
|
|
529
|
+
) {
|
|
530
|
+
this._maybeSendConfig()
|
|
531
|
+
}
|
|
339
532
|
}
|
|
340
533
|
|
|
341
534
|
render() {
|
|
@@ -477,6 +670,17 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
477
670
|
this._status = 'ready'
|
|
478
671
|
this._emit('ready')
|
|
479
672
|
}
|
|
673
|
+
// Config rides the ready signal: the app has its listener attached by
|
|
674
|
+
// the time it announces, and a remount inside an unchanged iframe
|
|
675
|
+
// re-announces — so this is also the resend point after the editor
|
|
676
|
+
// reloads and forgets what it was told.
|
|
677
|
+
//
|
|
678
|
+
// Keyed on READY alone, because a single mount announces READY *and*
|
|
679
|
+
// OPEN and resetting on both would post the same config twice. OPEN
|
|
680
|
+
// still calls in, without the reset: after a READY that is a no-op, and
|
|
681
|
+
// it is the only signal an app deployment older than protocol v1 sends.
|
|
682
|
+
if (msg.type === BUILDER_READY) this._sentConfigKey = undefined
|
|
683
|
+
this._maybeSendConfig()
|
|
480
684
|
if (msg.type === BUILDER_OPEN) this._emit('open')
|
|
481
685
|
break
|
|
482
686
|
case BUILDER_SAVE:
|
|
@@ -489,6 +693,7 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
489
693
|
// iframe, so resend even if this content went out already — otherwise
|
|
490
694
|
// the editor waits on a skeleton forever.
|
|
491
695
|
this._sentKey = undefined
|
|
696
|
+
this._sentSaveKey = undefined
|
|
492
697
|
this._maybeSendContent()
|
|
493
698
|
break
|
|
494
699
|
case BUILDER_CONTENT: {
|
|
@@ -496,11 +701,47 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
496
701
|
// regardless of mode; the detail shape follows the mode they chose.
|
|
497
702
|
const data = (msg as BuilderContentMessage).data
|
|
498
703
|
// The saved document is what the editor now holds. A host that stores
|
|
499
|
-
// it and echoes it back into
|
|
704
|
+
// it and echoes it back into the props — the natural controlled
|
|
500
705
|
// pattern — must not trigger a HOST_LOAD reload that wipes the
|
|
501
|
-
// editor's undo history behind a skeleton flash.
|
|
706
|
+
// editor's undo history behind a skeleton flash. TWO keys are
|
|
707
|
+
// recorded because hosts echo different subsets: the prop-based key
|
|
708
|
+
// covers "content only, other props untouched", and the detail-based
|
|
709
|
+
// key covers "the whole save detail" — whose name and templateQuery
|
|
710
|
+
// (a save nearly always changes the query) differ from the props the
|
|
711
|
+
// template was loaded with.
|
|
502
712
|
this._sentKey = this._contentKey(data.content)
|
|
503
|
-
this.
|
|
713
|
+
this._sentSaveKey = this._detailKey(data)
|
|
714
|
+
if (this.damStore) {
|
|
715
|
+
// Serialized, not fired in parallel: the app discards a failure ack
|
|
716
|
+
// that has newer saves still outstanding as superseded, which is
|
|
717
|
+
// only sound while acks come back in post order — and ack order
|
|
718
|
+
// follows save-event order. Two racing uploads could swap it.
|
|
719
|
+
this._pendingSaves.add(data)
|
|
720
|
+
// Epoch, folder and credentials are captured now, not when the
|
|
721
|
+
// queued upload runs: the save belongs to the document — and the
|
|
722
|
+
// configuration — the app posted it under, and the host may have
|
|
723
|
+
// swapped both by the time the queue reaches it. Only the
|
|
724
|
+
// known-uuid seed stays live (validated against the epoch), because
|
|
725
|
+
// an earlier queued save of the SAME document must be able to hand
|
|
726
|
+
// its uuid to the next one.
|
|
727
|
+
const session = this._docEpoch
|
|
728
|
+
const job = {
|
|
729
|
+
auth: {
|
|
730
|
+
token: this.token,
|
|
731
|
+
sassKey: this.sassKey,
|
|
732
|
+
secTemplate: this.secTemplate,
|
|
733
|
+
sessionUuid: this.sessionUuid,
|
|
734
|
+
companyUuid: this.companyUuid,
|
|
735
|
+
projectUuid: this.projectUuid,
|
|
736
|
+
},
|
|
737
|
+
storeFolder: this.storeFolder || '/',
|
|
738
|
+
}
|
|
739
|
+
this._storeQueue = this._storeQueue.then(() =>
|
|
740
|
+
this._storeAndEmitSave(data, session, job),
|
|
741
|
+
)
|
|
742
|
+
} else {
|
|
743
|
+
this._emit('save', data)
|
|
744
|
+
}
|
|
504
745
|
break
|
|
505
746
|
}
|
|
506
747
|
case BUILDER_DIRTY: {
|
|
@@ -510,6 +751,13 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
510
751
|
break
|
|
511
752
|
}
|
|
512
753
|
case BUILDER_CLOSE:
|
|
754
|
+
// Pending `dam-store` uploads are deliberately NOT flushed here: the
|
|
755
|
+
// element outlives a close (inline keeps rendering, modal just drops
|
|
756
|
+
// its overlay), so a save still inside its upload window emits with
|
|
757
|
+
// its real outcome moments later — flushing would hand the host a
|
|
758
|
+
// `storeError` for a copy that lands fine. The paths where waiting
|
|
759
|
+
// genuinely loses the event — element removal — are covered by
|
|
760
|
+
// `disconnectedCallback` and the React wrapper's cleanup flush.
|
|
513
761
|
this._emit('close')
|
|
514
762
|
if (this.mode === 'modal') this._open = false
|
|
515
763
|
break
|
|
@@ -519,25 +767,219 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
519
767
|
}
|
|
520
768
|
}
|
|
521
769
|
|
|
770
|
+
/**
|
|
771
|
+
* Pending `dam-store` uploads, chained so saves emit in the order the app
|
|
772
|
+
* posted them. `_storeAndEmitSave` never rejects (its catch emits
|
|
773
|
+
* `storeError`), so the chain cannot wedge.
|
|
774
|
+
*/
|
|
775
|
+
private _storeQueue: Promise<void> = Promise.resolve()
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Saves handed over by the app whose `save` event has not fired yet — the
|
|
779
|
+
* upload window. Insertion-ordered; membership is what makes a flush and a
|
|
780
|
+
* completing upload not double-emit the same save.
|
|
781
|
+
*/
|
|
782
|
+
private _pendingSaves = new Set<BuilderContentData>()
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Identity (id, content, name — not query) of the last document posted to
|
|
786
|
+
* the app — updated on every ship, and on an accepted save echo (the echoed
|
|
787
|
+
* document IS the current one; leaving the pre-save key here would make a
|
|
788
|
+
* later content-request redelivery look like a new document and wipe the
|
|
789
|
+
* store memory below).
|
|
790
|
+
*/
|
|
791
|
+
private _lastDocKey?: string
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Monotonic id of the current DOCUMENT. Bumped in exactly one place: when
|
|
795
|
+
* `_maybeSendContent` ships a genuinely different document (docKey change).
|
|
796
|
+
* Deliberately NOT bumped on iframe/src changes — a modal close, a theme
|
|
797
|
+
* swap or an in-place remount is the same document, and treating it as new
|
|
798
|
+
* forked the file on every such boundary.
|
|
799
|
+
*/
|
|
800
|
+
private _docEpoch = 0
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* The copy this element last stored, tagged with the epoch of the document
|
|
804
|
+
* it belongs to. Written when an upload lands (with the SAVE's epoch, so a
|
|
805
|
+
* late-landing upload can never masquerade as another document's copy) and
|
|
806
|
+
* validated at read time — there is no eager reset to get wrong.
|
|
807
|
+
*/
|
|
808
|
+
private _storeMemory?: { epoch: number; uuid: string }
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Emit every not-yet-emitted `dam-store` save immediately, raw content with
|
|
812
|
+
* `storeError` in place of the links. Called when waiting any longer risks
|
|
813
|
+
* the event finding no listener; a still-running upload for a flushed save
|
|
814
|
+
* is left to finish (the copy usually lands) but will not emit again.
|
|
815
|
+
*
|
|
816
|
+
* Public for framework wrappers: one that unsubscribes its listeners before
|
|
817
|
+
* unmounting must call this first, while they are still attached — the
|
|
818
|
+
* element's own disconnect-time flush fires only after the wrapper has
|
|
819
|
+
* stopped listening, and the raw save would be lost. The React wrapper does
|
|
820
|
+
* this; a vanilla host never needs to call it.
|
|
821
|
+
*/
|
|
822
|
+
flushPendingSaves(
|
|
823
|
+
reason = 'widget removed before the rendering copy completed',
|
|
824
|
+
): void {
|
|
825
|
+
this._flushPendingSaves(reason)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private _flushPendingSaves(reason: string): void {
|
|
829
|
+
for (const data of this._pendingSaves) {
|
|
830
|
+
this._pendingSaves.delete(data)
|
|
831
|
+
this._emit('save', { ...data, storeError: reason })
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* The `dam-store` save path: upload the edited template to Filerobot, then
|
|
837
|
+
* emit `save` with the stored copy's links on the detail. The upload is the
|
|
838
|
+
* render side of the save — the CDN renders only stored files — while the
|
|
839
|
+
* raw `content` stays the host's copy exactly as without the flag.
|
|
840
|
+
*
|
|
841
|
+
* A failed upload still emits `save` (the raw data must reach the host
|
|
842
|
+
* either way), with `storeError` in place of `stored`; whether a save
|
|
843
|
+
* without a rendering copy counts as saved is the host's decision, made
|
|
844
|
+
* where it always is — the save ack.
|
|
845
|
+
*/
|
|
846
|
+
private async _storeAndEmitSave(
|
|
847
|
+
data: BuilderContentData,
|
|
848
|
+
session: number,
|
|
849
|
+
job: { auth: DamStoreAuth; storeFolder: string },
|
|
850
|
+
): Promise<void> {
|
|
851
|
+
if (!this._pendingSaves.has(data)) return
|
|
852
|
+
// The seeds are validated against the save's own document epoch. The
|
|
853
|
+
// element's memory carries the epoch it was recorded under; the host's
|
|
854
|
+
// `storedUuid` property is live and describes the CURRENT document, so it
|
|
855
|
+
// only applies while the save's epoch is still the current one — anchoring
|
|
856
|
+
// a stale save's upload to it would put the old document into the new
|
|
857
|
+
// one's file. Folder and credentials come from the job captured at
|
|
858
|
+
// enqueue time, for the same reason.
|
|
859
|
+
const knownUuid =
|
|
860
|
+
(this._storeMemory?.epoch === session
|
|
861
|
+
? this._storeMemory.uuid
|
|
862
|
+
: undefined) ||
|
|
863
|
+
(session === this._docEpoch ? this.storedUuid || undefined : undefined)
|
|
864
|
+
let stored: StoredTemplate | undefined
|
|
865
|
+
let storeError: string | undefined
|
|
866
|
+
try {
|
|
867
|
+
stored = await storeTemplateInDam(data, job.auth, job.storeFolder, knownUuid)
|
|
868
|
+
} catch (err) {
|
|
869
|
+
storeError = err instanceof Error ? err.message : String(err)
|
|
870
|
+
}
|
|
871
|
+
// A flush mid-upload already delivered this save; the copy (if it landed)
|
|
872
|
+
// is simply not reported. Emitting again would double the host's write —
|
|
873
|
+
// and recording the uuid would poison the session memory of whatever
|
|
874
|
+
// document has loaded since (the flush usually precedes a swap), pointing
|
|
875
|
+
// its next save at this document's file.
|
|
876
|
+
if (!this._pendingSaves.delete(data)) return
|
|
877
|
+
// Recorded under the SAVE's epoch, unconditionally: a copy always belongs
|
|
878
|
+
// to the document it was saved from. A later document's saves carry a
|
|
879
|
+
// higher epoch and simply never match this record — no current-vs-then
|
|
880
|
+
// comparison to get wrong. (Uploads complete in queue order, so the last
|
|
881
|
+
// write is always the newest save.)
|
|
882
|
+
if (stored) this._storeMemory = { epoch: session, uuid: stored.uuid }
|
|
883
|
+
this._emit('save', stored ? { ...data, stored } : { ...data, storeError })
|
|
884
|
+
}
|
|
885
|
+
|
|
522
886
|
/**
|
|
523
887
|
* Deliver `content` to the app once both sides are ready: it has asked, and
|
|
524
888
|
* we have something new to give it. Skips a re-send of identical content so
|
|
525
889
|
* an unrelated re-render can't discard the user's in-progress edits.
|
|
526
890
|
*/
|
|
527
891
|
private _maybeSendContent(): void {
|
|
528
|
-
if (!this.stateless || !this._contentRequested
|
|
892
|
+
if (!this.stateless || !this._contentRequested) return
|
|
893
|
+
|
|
894
|
+
// Empty `content` means the host has nothing to give yet — usually a fetch
|
|
895
|
+
// in flight — so the editor keeps waiting. Only `new-template` turns that
|
|
896
|
+
// into a document, and only until the host does supply one.
|
|
897
|
+
const content =
|
|
898
|
+
this.content || (this.newTemplate ? BLANK_TEMPLATE_XML : '')
|
|
899
|
+
if (!content) return
|
|
529
900
|
|
|
530
901
|
const data = {
|
|
531
902
|
templateId: this.templateId || undefined,
|
|
532
|
-
content
|
|
903
|
+
content,
|
|
533
904
|
name: this.templateName || undefined,
|
|
534
905
|
templateQuery: this.templateQuery || undefined,
|
|
535
906
|
}
|
|
536
|
-
const key = this._contentKey(
|
|
537
|
-
|
|
907
|
+
const key = this._contentKey(content)
|
|
908
|
+
// The document's identity — id, content, name; NOT the query. A
|
|
909
|
+
// query-only resend ("same XML, different render") and a content-request
|
|
910
|
+
// redelivery after an in-place remount are the same document, and
|
|
911
|
+
// treating them as new would orphan the stored copy.
|
|
912
|
+
const docKey = JSON.stringify({
|
|
913
|
+
templateId: this.templateId || undefined,
|
|
914
|
+
content,
|
|
915
|
+
name: this.templateName || undefined,
|
|
916
|
+
})
|
|
917
|
+
// `_sentSaveKey` counts as delivered too: matching it means the host has
|
|
918
|
+
// echoed the last save back into the props, and the editor already holds
|
|
919
|
+
// exactly that document — reloading would wipe its undo history. The doc
|
|
920
|
+
// key is still refreshed: the echoed document IS the current one, and
|
|
921
|
+
// leaving the pre-save key in place would make the next redelivery of
|
|
922
|
+
// this same content look like a document change and orphan the copy.
|
|
923
|
+
if (key === this._sentKey || key === this._sentSaveKey) {
|
|
924
|
+
this._lastDocKey = docKey
|
|
925
|
+
return
|
|
926
|
+
}
|
|
538
927
|
|
|
539
928
|
if (!this._postToApp({ type: HOST_LOAD, data })) return
|
|
929
|
+
// A genuinely different document starts a new epoch — the file the
|
|
930
|
+
// previous one made must not resolve this one's folder or unchanged
|
|
931
|
+
// re-saves.
|
|
932
|
+
if (docKey !== this._lastDocKey) this._docEpoch++
|
|
933
|
+
this._lastDocKey = docKey
|
|
540
934
|
this._sentKey = key
|
|
935
|
+
// A new document shipped: the previous save's echo key no longer names
|
|
936
|
+
// what the editor holds, and matching it later would wrongly skip a load.
|
|
937
|
+
this._sentSaveKey = undefined
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Deliver host config to the app. Unlike content this is not requested — the
|
|
942
|
+
* app has no way to know a host means to send any — so it goes out on the
|
|
943
|
+
* ready signal and on every later change.
|
|
944
|
+
*
|
|
945
|
+
* Sending an empty model is meaningful: it is how a host clears one it set
|
|
946
|
+
* before. What is skipped is only a *repeat* of what the app already holds,
|
|
947
|
+
* and the very first send when there was never anything to say.
|
|
948
|
+
*/
|
|
949
|
+
private _maybeSendConfig(): void {
|
|
950
|
+
// Hosts are plain JS as often as not, and the property has no converter to
|
|
951
|
+
// vet what lands on it the way the attribute does. Anything that is not a
|
|
952
|
+
// list of fields is treated as no model rather than posted onward as
|
|
953
|
+
// something the app would have to make sense of.
|
|
954
|
+
const customMetadata = Array.isArray(this.customMetadata)
|
|
955
|
+
? this.customMetadata
|
|
956
|
+
: []
|
|
957
|
+
const customMetadataLabel =
|
|
958
|
+
typeof this.customMetadataLabel === 'string'
|
|
959
|
+
? this.customMetadataLabel.trim()
|
|
960
|
+
: ''
|
|
961
|
+
const key = JSON.stringify({ customMetadata, customMetadataLabel })
|
|
962
|
+
if (key === this._sentConfigKey) return
|
|
963
|
+
if (
|
|
964
|
+
this._sentConfigKey === undefined &&
|
|
965
|
+
customMetadata.length === 0 &&
|
|
966
|
+
customMetadataLabel === ''
|
|
967
|
+
) {
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (
|
|
972
|
+
!this._postToApp({
|
|
973
|
+
type: HOST_CONFIG,
|
|
974
|
+
data: {
|
|
975
|
+
customMetadata,
|
|
976
|
+
...(customMetadataLabel ? { customMetadataLabel } : {}),
|
|
977
|
+
},
|
|
978
|
+
})
|
|
979
|
+
) {
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
this._sentConfigKey = key
|
|
541
983
|
}
|
|
542
984
|
|
|
543
985
|
/** Identity of a delivered template, as compared against `_sentKey`. */
|
|
@@ -550,6 +992,20 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
550
992
|
})
|
|
551
993
|
}
|
|
552
994
|
|
|
995
|
+
/**
|
|
996
|
+
* Identity of a save the app handed back — the same shape as
|
|
997
|
+
* `_contentKey`, but built from the save detail rather than the props, so
|
|
998
|
+
* a host echoing the detail (whose name/query the save changed) matches.
|
|
999
|
+
*/
|
|
1000
|
+
private _detailKey(data: BuilderContentData): string {
|
|
1001
|
+
return JSON.stringify({
|
|
1002
|
+
templateId: data.templateId || undefined,
|
|
1003
|
+
content: data.content,
|
|
1004
|
+
name: data.name || undefined,
|
|
1005
|
+
templateQuery: data.templateQuery || undefined,
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
1008
|
+
|
|
553
1009
|
/** Post into the iframe, targeted at the app origin. False if not mounted. */
|
|
554
1010
|
private _postToApp(message: unknown): boolean {
|
|
555
1011
|
const target = this.shadowRoot?.querySelector('iframe')?.contentWindow
|