@scaleflex/template-builder 0.2.0 → 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 +27 -21
- package/CHANGELOG.md +168 -4
- package/README.md +207 -52
- 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 +9 -8
- package/dist/protocol.d.ts +60 -2
- package/dist/react.cjs +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.ts +34 -1
- package/dist/react.js +43 -28
- 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 +164 -4
- package/package.json +1 -1
- package/src/dam-store.ts +388 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +64 -2
- package/src/react.ts +111 -27
- package/src/template-builder.ts +405 -7
- package/dist/template-builder-CK2Zlo7E.cjs +0 -53
- package/dist/template-builder-CK2Zlo7E.cjs.map +0 -1
- package/dist/template-builder-De0hRO4s.js +0 -380
- package/dist/template-builder-De0hRO4s.js.map +0 -1
package/src/template-builder.ts
CHANGED
|
@@ -12,9 +12,11 @@ import {
|
|
|
12
12
|
BUILDER_SAVE,
|
|
13
13
|
EMBED_PARAMS,
|
|
14
14
|
EMBED_ROUTE,
|
|
15
|
+
HOST_CONFIG,
|
|
15
16
|
HOST_LOAD,
|
|
16
17
|
HOST_SAVED,
|
|
17
18
|
builderRoute,
|
|
19
|
+
type CustomMetadataField,
|
|
18
20
|
type BuilderContentData,
|
|
19
21
|
type BuilderContentMessage,
|
|
20
22
|
type BuilderDirtyData,
|
|
@@ -25,6 +27,13 @@ import {
|
|
|
25
27
|
type BuilderSaveMessage,
|
|
26
28
|
type BuilderTheme,
|
|
27
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'
|
|
28
37
|
|
|
29
38
|
export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
|
|
30
39
|
|
|
@@ -34,11 +43,14 @@ export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
|
|
|
34
43
|
* - DAM-backed (default) — `BuilderSaveData`; the app uploaded the template and
|
|
35
44
|
* reports the resulting `uuid`.
|
|
36
45
|
* - `stateless` — `BuilderContentData`; nothing was stored, and `content` is
|
|
37
|
-
* 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.
|
|
38
50
|
*/
|
|
39
51
|
export type TemplateBuilderSaveDetail =
|
|
40
52
|
| BuilderSaveData
|
|
41
|
-
| BuilderContentData
|
|
53
|
+
| (BuilderContentData & { stored?: StoredTemplate; storeError?: string })
|
|
42
54
|
| undefined
|
|
43
55
|
|
|
44
56
|
export interface TemplateBuilderEventMap {
|
|
@@ -195,6 +207,81 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
195
207
|
@property({ attribute: 'brand-color' }) brandColor = ''
|
|
196
208
|
/** Colour scheme for the editor chrome. Empty leaves the app's own default. */
|
|
197
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 = ''
|
|
198
285
|
/** Ms to wait for the app's ready signal before emitting `error`. 0 disables. */
|
|
199
286
|
@property({ type: Number, attribute: 'ready-timeout' }) readyTimeout = 20000
|
|
200
287
|
|
|
@@ -216,6 +303,21 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
216
303
|
* back on save.
|
|
217
304
|
*/
|
|
218
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
|
|
219
321
|
/**
|
|
220
322
|
* The `baseUrl` value already reported as unparseable. `_computeSrc()` runs
|
|
221
323
|
* on every update cycle, so without this a bad URL re-emits `error` forever —
|
|
@@ -265,11 +367,13 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
265
367
|
templateId,
|
|
266
368
|
name,
|
|
267
369
|
templateQuery,
|
|
370
|
+
storedUuid,
|
|
268
371
|
}: {
|
|
269
372
|
content: string
|
|
270
373
|
templateId?: string
|
|
271
374
|
name?: string
|
|
272
375
|
templateQuery?: string
|
|
376
|
+
storedUuid?: string
|
|
273
377
|
}): void {
|
|
274
378
|
if (templateId !== undefined) this.templateId = templateId
|
|
275
379
|
if (name !== undefined) this.templateName = name
|
|
@@ -277,6 +381,10 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
277
381
|
// previous template's query in place while the new content goes out would
|
|
278
382
|
// open the new document on the old layout and values.
|
|
279
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 ?? ''
|
|
280
388
|
// A template of your own is not the blank one: clearing the flag lets a
|
|
281
389
|
// host alternate between `createNew()` and `load()` on one element.
|
|
282
390
|
this.newTemplate = false
|
|
@@ -305,8 +413,11 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
305
413
|
if (name !== undefined) this.templateName = name
|
|
306
414
|
// A new document has no layouts and no variables, so there is no render
|
|
307
415
|
// for a query to select — and a leftover one would name layouts and
|
|
308
|
-
// variables of the previous template.
|
|
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.
|
|
309
419
|
this.templateQuery = ''
|
|
420
|
+
this.storedUuid = ''
|
|
310
421
|
this.newTemplate = true
|
|
311
422
|
this.content = ''
|
|
312
423
|
this._open = true
|
|
@@ -347,6 +458,21 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
347
458
|
super.disconnectedCallback()
|
|
348
459
|
window.removeEventListener('message', this._onMessage)
|
|
349
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)
|
|
350
476
|
}
|
|
351
477
|
|
|
352
478
|
protected willUpdate(changed: PropertyValues): void {
|
|
@@ -367,8 +493,14 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
367
493
|
this._clearHandshakeTimer()
|
|
368
494
|
// A new document means a new app instance: it has not asked for content
|
|
369
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.
|
|
370
500
|
this._contentRequested = false
|
|
371
501
|
this._sentKey = undefined
|
|
502
|
+
this._sentSaveKey = undefined
|
|
503
|
+
this._sentConfigKey = undefined
|
|
372
504
|
if (this._isDirty) {
|
|
373
505
|
this._isDirty = false
|
|
374
506
|
this._emit('dirtychange', { isDirty: false })
|
|
@@ -387,6 +519,16 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
387
519
|
) {
|
|
388
520
|
this._maybeSendContent()
|
|
389
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
|
+
}
|
|
390
532
|
}
|
|
391
533
|
|
|
392
534
|
render() {
|
|
@@ -528,6 +670,17 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
528
670
|
this._status = 'ready'
|
|
529
671
|
this._emit('ready')
|
|
530
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()
|
|
531
684
|
if (msg.type === BUILDER_OPEN) this._emit('open')
|
|
532
685
|
break
|
|
533
686
|
case BUILDER_SAVE:
|
|
@@ -540,6 +693,7 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
540
693
|
// iframe, so resend even if this content went out already — otherwise
|
|
541
694
|
// the editor waits on a skeleton forever.
|
|
542
695
|
this._sentKey = undefined
|
|
696
|
+
this._sentSaveKey = undefined
|
|
543
697
|
this._maybeSendContent()
|
|
544
698
|
break
|
|
545
699
|
case BUILDER_CONTENT: {
|
|
@@ -547,11 +701,47 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
547
701
|
// regardless of mode; the detail shape follows the mode they chose.
|
|
548
702
|
const data = (msg as BuilderContentMessage).data
|
|
549
703
|
// The saved document is what the editor now holds. A host that stores
|
|
550
|
-
// it and echoes it back into
|
|
704
|
+
// it and echoes it back into the props — the natural controlled
|
|
551
705
|
// pattern — must not trigger a HOST_LOAD reload that wipes the
|
|
552
|
-
// 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.
|
|
553
712
|
this._sentKey = this._contentKey(data.content)
|
|
554
|
-
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
|
+
}
|
|
555
745
|
break
|
|
556
746
|
}
|
|
557
747
|
case BUILDER_DIRTY: {
|
|
@@ -561,6 +751,13 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
561
751
|
break
|
|
562
752
|
}
|
|
563
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.
|
|
564
761
|
this._emit('close')
|
|
565
762
|
if (this.mode === 'modal') this._open = false
|
|
566
763
|
break
|
|
@@ -570,6 +767,122 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
570
767
|
}
|
|
571
768
|
}
|
|
572
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
|
+
|
|
573
886
|
/**
|
|
574
887
|
* Deliver `content` to the app once both sides are ready: it has asked, and
|
|
575
888
|
* we have something new to give it. Skips a re-send of identical content so
|
|
@@ -592,10 +905,81 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
592
905
|
templateQuery: this.templateQuery || undefined,
|
|
593
906
|
}
|
|
594
907
|
const key = this._contentKey(content)
|
|
595
|
-
|
|
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
|
+
}
|
|
596
927
|
|
|
597
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
|
|
598
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
|
|
599
983
|
}
|
|
600
984
|
|
|
601
985
|
/** Identity of a delivered template, as compared against `_sentKey`. */
|
|
@@ -608,6 +992,20 @@ export class SfxTemplateBuilder extends LitElement {
|
|
|
608
992
|
})
|
|
609
993
|
}
|
|
610
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
|
+
|
|
611
1009
|
/** Post into the iframe, targeted at the app origin. False if not mounted. */
|
|
612
1010
|
private _postToApp(message: unknown): boolean {
|
|
613
1011
|
const target = this.shadowRoot?.querySelector('iframe')?.contentWindow
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
"use strict";const p=require("lit"),i=require("lit/decorators.js"),S=2,u="design-templates:builder:ready",d="design-templates:builder:open",_="design-templates:builder:close",y="design-templates:builder:save",f="design-templates:builder:error",E="design-templates:builder:content-request",b="design-templates:builder:content",T="design-templates:builder:dirty",R="design-templates:host:load",U=`<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<template><templateInfo><version>0.3</version></templateInfo><rootStack/><layouts/><variables/></template>`,O="design-templates:host:saved",n={SESSION_UUID:"suuid",COMPANY_UUID:"cuuid",PROJECT_UUID:"puuid",SASS_KEY:"sassKey",FILEROBOT_TOKEN:"ftoken",SEC_TEMPLATE:"secTemplate",IFRAME:"iframe",EMBED_ORIGIN:"embedOrigin",BRAND_COLOR:"brandColor",THEME:"theme"},v={SESSION:"session",SEC_TEMPLATE:"secTemplate"},I=/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;function D(h){return h?`/templates/${encodeURIComponent(h)}/edit`:"/templates/new"}const g="/templates/embed";var L=Object.defineProperty,r=(h,e,t,a)=>{for(var o=void 0,l=h.length-1,m;l>=0;l--)(m=h[l])&&(o=m(e,t,o)||o);return o&&L(e,t,o),o};const c=class c extends p.LitElement{constructor(){super(...arguments),this.baseUrl="",this.token="",this.sassKey="",this.sessionUuid="",this.secTemplate="",this.companyUuid="",this.projectUuid="",this.templateId="",this.mode="inline",this.stateless=!1,this.content="",this.newTemplate=!1,this.templateName="",this.templateQuery="",this.brandColor="",this.theme="",this.readyTimeout=2e4,this._status="idle",this._open=!1,this._src="",this._contentRequested=!1,this._reportedStatelessRequired=!1,this._isDirty=!1,this._autoOpenDecided=!1,this._onMessage=e=>{if(!this._open||!e.origin||e.origin!==this._appOrigin)return;const t=this.shadowRoot?.querySelector("iframe");if(!t||e.source!==t.contentWindow)return;const a=e.data;if(!(!a||typeof a.type!="string"))switch(a.type){case u:case d:this._clearHandshakeTimer(),this._status!=="ready"&&(this._status="ready",this._emit("ready")),a.type===d&&this._emit("open");break;case y:this._emit("save",a.data);break;case E:this._contentRequested=!0,this._sentKey=void 0,this._maybeSendContent();break;case b:{const o=a.data;this._sentKey=this._contentKey(o.content),this._emit("save",o);break}case T:{const o=a.data;this._isDirty=!!o?.isDirty,this._emit("dirtychange",{isDirty:this._isDirty});break}case _:this._emit("close"),this.mode==="modal"&&(this._open=!1);break;case f:this._fail(a.data??{code:"unknown"});break}}}get status(){return this._status}get isDirty(){return this._isDirty}open(e){e!==void 0&&(this.templateId=e),this._open=!0}close(){this._open=!1}load({content:e,templateId:t,name:a,templateQuery:o}){t!==void 0&&(this.templateId=t),a!==void 0&&(this.templateName=a),o!==void 0&&(this.templateQuery=o),this.newTemplate=!1,this.content=e,this._open=!0}createNew({templateId:e,name:t}={}){e!==void 0&&(this.templateId=e),t!==void 0&&(this.templateName=t),this.templateQuery="",this.newTemplate=!0,this.content="",this._open=!0}confirmSave(e,t){this.stateless&&this._postToApp({type:O,data:{ok:e,message:t}})}connectedCallback(){super.connectedCallback(),window.addEventListener("message",this._onMessage)}disconnectedCallback(){super.disconnectedCallback(),window.removeEventListener("message",this._onMessage),this._clearHandshakeTimer()}willUpdate(e){super.willUpdate(e),this._autoOpenDecided||(this._autoOpenDecided=!0,this.mode==="inline"&&(this._open=!0));const t=this._computeSrc();t!==this._src&&(this._src=t,this._status=t?"loading":"idle")}updated(e){e.has("_src")&&(this._clearHandshakeTimer(),this._contentRequested=!1,this._sentKey=void 0,this._isDirty&&(this._isDirty=!1,this._emit("dirtychange",{isDirty:!1})),this._src&&this._startHandshakeTimer()),(e.has("content")||e.has("newTemplate")||e.has("templateId")||e.has("templateName")||e.has("templateQuery"))&&this._maybeSendContent()}render(){const e=this._src?p.html`<iframe
|
|
3
|
-
part="iframe"
|
|
4
|
-
title="Template builder"
|
|
5
|
-
src=${this._src}
|
|
6
|
-
allow="clipboard-read; clipboard-write"
|
|
7
|
-
></iframe>`:p.nothing,t=this._status==="loading"?p.html`<div class="spinner" part="spinner"></div>`:p.nothing;return this.mode==="modal"?this._open?p.html`<div class="overlay" part="overlay">
|
|
8
|
-
<div class="stage">${e}${t}</div>
|
|
9
|
-
</div>`:p.nothing:p.html`${e}${t}`}_computeSrc(){if(!this._open||!this.baseUrl||!this.token)return"";if(this.secTemplate){if(!this.stateless)return this._reportedStatelessRequired||(this._reportedStatelessRequired=!0,queueMicrotask(()=>this._fail({code:"invalid-config",message:"sec-template requires stateless mode — the app accepts a security template on the stateless embed route only."}))),"";this._reportedStatelessRequired=!1}else if(!this.sassKey||!this.sessionUuid)return"";let e;try{const t=this.stateless?g:D(this.templateId||void 0),a=this.baseUrl.endsWith("/")?this.baseUrl:`${this.baseUrl}/`;e=new URL(t.replace(/^\//,""),a)}catch{return this._reportedBadBaseUrl!==this.baseUrl&&(this._reportedBadBaseUrl=this.baseUrl,queueMicrotask(()=>this._fail({code:"invalid-base-url",message:`base-url is not a valid URL: ${this.baseUrl}`}))),""}return this._reportedBadBaseUrl=void 0,e.searchParams.set(n.FILEROBOT_TOKEN,this.token),this.secTemplate?e.searchParams.set(n.SEC_TEMPLATE,this.secTemplate):(e.searchParams.set(n.SASS_KEY,this.sassKey),e.searchParams.set(n.SESSION_UUID,this.sessionUuid),this.companyUuid&&e.searchParams.set(n.COMPANY_UUID,this.companyUuid),this.projectUuid&&e.searchParams.set(n.PROJECT_UUID,this.projectUuid)),this.brandColor&&e.searchParams.set(n.BRAND_COLOR,this.brandColor),this.theme&&e.searchParams.set(n.THEME,this.theme),e.searchParams.set(n.IFRAME,"1"),e.searchParams.set(n.EMBED_ORIGIN,window.location.origin),e.toString()}get _appOrigin(){try{return new URL(this.baseUrl).origin}catch{return null}}_maybeSendContent(){if(!this.stateless||!this._contentRequested)return;const e=this.content||(this.newTemplate?U:"");if(!e)return;const t={templateId:this.templateId||void 0,content:e,name:this.templateName||void 0,templateQuery:this.templateQuery||void 0},a=this._contentKey(e);a!==this._sentKey&&this._postToApp({type:R,data:t})&&(this._sentKey=a)}_contentKey(e){return JSON.stringify({templateId:this.templateId||void 0,content:e,name:this.templateName||void 0,templateQuery:this.templateQuery||void 0})}_postToApp(e){const t=this.shadowRoot?.querySelector("iframe")?.contentWindow,a=this._appOrigin;return!t||!a?!1:(t.postMessage(e,a),!0)}_startHandshakeTimer(){this.readyTimeout<=0||(this._handshakeTimer=window.setTimeout(()=>{this._fail({code:"handshake-timeout",message:`No ready signal from ${this.baseUrl} within ${this.readyTimeout}ms. Check that this origin is in the app's frame-ancestors allowlist and that third-party cookies are not blocked.`})},this.readyTimeout))}_clearHandshakeTimer(){this._handshakeTimer!==void 0&&(window.clearTimeout(this._handshakeTimer),this._handshakeTimer=void 0)}_fail(e){this._clearHandshakeTimer(),this._status="error",this._emit("error",e)}_emit(e,t){this.dispatchEvent(new CustomEvent(e,{detail:t,bubbles:!0,composed:!0}))}};c.styles=p.css`
|
|
10
|
-
:host {
|
|
11
|
-
display: block;
|
|
12
|
-
position: relative;
|
|
13
|
-
}
|
|
14
|
-
:host([mode='modal']) {
|
|
15
|
-
display: contents;
|
|
16
|
-
}
|
|
17
|
-
.overlay {
|
|
18
|
-
position: fixed;
|
|
19
|
-
inset: 0;
|
|
20
|
-
z-index: 2147483000;
|
|
21
|
-
background: rgba(0, 0, 0, 0.55);
|
|
22
|
-
display: flex;
|
|
23
|
-
}
|
|
24
|
-
.stage {
|
|
25
|
-
position: relative;
|
|
26
|
-
flex: 1;
|
|
27
|
-
display: flex;
|
|
28
|
-
}
|
|
29
|
-
iframe {
|
|
30
|
-
border: 0;
|
|
31
|
-
flex: 1;
|
|
32
|
-
width: 100%;
|
|
33
|
-
height: 100%;
|
|
34
|
-
}
|
|
35
|
-
.spinner {
|
|
36
|
-
position: absolute;
|
|
37
|
-
inset: 0;
|
|
38
|
-
margin: auto;
|
|
39
|
-
width: 32px;
|
|
40
|
-
height: 32px;
|
|
41
|
-
border: 3px solid rgba(128, 128, 128, 0.3);
|
|
42
|
-
border-top-color: currentColor;
|
|
43
|
-
border-radius: 50%;
|
|
44
|
-
animation: sfx-tb-spin 0.8s linear infinite;
|
|
45
|
-
pointer-events: none;
|
|
46
|
-
}
|
|
47
|
-
@keyframes sfx-tb-spin {
|
|
48
|
-
to {
|
|
49
|
-
transform: rotate(360deg);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
`;let s=c;r([i.property({attribute:"base-url"})],s.prototype,"baseUrl");r([i.property()],s.prototype,"token");r([i.property({attribute:"sass-key"})],s.prototype,"sassKey");r([i.property({attribute:"session-uuid"})],s.prototype,"sessionUuid");r([i.property({attribute:"sec-template"})],s.prototype,"secTemplate");r([i.property({attribute:"company-uuid"})],s.prototype,"companyUuid");r([i.property({attribute:"project-uuid"})],s.prototype,"projectUuid");r([i.property({attribute:"template-id"})],s.prototype,"templateId");r([i.property({reflect:!0})],s.prototype,"mode");r([i.property({type:Boolean,reflect:!0})],s.prototype,"stateless");r([i.property({attribute:!1})],s.prototype,"content");r([i.property({type:Boolean,attribute:"new-template"})],s.prototype,"newTemplate");r([i.property({attribute:"template-name"})],s.prototype,"templateName");r([i.property({attribute:"template-query"})],s.prototype,"templateQuery");r([i.property({attribute:"brand-color"})],s.prototype,"brandColor");r([i.property()],s.prototype,"theme");r([i.property({type:Number,attribute:"ready-timeout"})],s.prototype,"readyTimeout");r([i.state()],s.prototype,"_status");r([i.state()],s.prototype,"_open");r([i.state()],s.prototype,"_src");r([i.state()],s.prototype,"_isDirty");exports.AUTH_MODES=v;exports.BLANK_TEMPLATE_XML=U;exports.BRAND_COLOR_PATTERN=I;exports.BUILDER_CLOSE=_;exports.BUILDER_CONTENT=b;exports.BUILDER_CONTENT_REQUEST=E;exports.BUILDER_DIRTY=T;exports.BUILDER_ERROR=f;exports.BUILDER_OPEN=d;exports.BUILDER_READY=u;exports.BUILDER_SAVE=y;exports.EMBED_PARAMS=n;exports.EMBED_ROUTE=g;exports.HOST_LOAD=R;exports.HOST_SAVED=O;exports.PROTOCOL_VERSION=S;exports.SfxTemplateBuilder=s;exports.builderRoute=D;
|
|
53
|
-
//# sourceMappingURL=template-builder-CK2Zlo7E.cjs.map
|