@scaleflex/template-builder 0.1.1
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 +356 -0
- package/CHANGELOG.md +66 -0
- package/LICENSE +50 -0
- package/README.md +775 -0
- package/dist/define.cjs +2 -0
- package/dist/define.cjs.map +1 -0
- package/dist/define.d.ts +2 -0
- package/dist/define.js +6 -0
- package/dist/define.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +237 -0
- package/dist/react.cjs +2 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.ts +95 -0
- package/dist/react.js +59 -0
- package/dist/react.js.map +1 -0
- package/dist/template-builder-CSyPZni9.cjs +52 -0
- package/dist/template-builder-CSyPZni9.cjs.map +1 -0
- package/dist/template-builder-S33H_d5T.js +354 -0
- package/dist/template-builder-S33H_d5T.js.map +1 -0
- package/dist/template-builder.d.ts +206 -0
- package/package.json +75 -0
- package/src/define.ts +10 -0
- package/src/index.ts +9 -0
- package/src/protocol.ts +298 -0
- package/src/react.ts +223 -0
- package/src/template-builder.ts +599 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { LitElement, html, css, nothing, type PropertyValues } from 'lit'
|
|
2
|
+
import { property, state } from 'lit/decorators.js'
|
|
3
|
+
import {
|
|
4
|
+
BUILDER_CLOSE,
|
|
5
|
+
BUILDER_CONTENT,
|
|
6
|
+
BUILDER_CONTENT_REQUEST,
|
|
7
|
+
BUILDER_DIRTY,
|
|
8
|
+
BUILDER_ERROR,
|
|
9
|
+
BUILDER_OPEN,
|
|
10
|
+
BUILDER_READY,
|
|
11
|
+
BUILDER_SAVE,
|
|
12
|
+
EMBED_PARAMS,
|
|
13
|
+
EMBED_ROUTE,
|
|
14
|
+
HOST_LOAD,
|
|
15
|
+
HOST_SAVED,
|
|
16
|
+
builderRoute,
|
|
17
|
+
type BuilderContentData,
|
|
18
|
+
type BuilderContentMessage,
|
|
19
|
+
type BuilderDirtyData,
|
|
20
|
+
type BuilderDirtyMessage,
|
|
21
|
+
type BuilderErrorData,
|
|
22
|
+
type BuilderErrorMessage,
|
|
23
|
+
type BuilderSaveData,
|
|
24
|
+
type BuilderSaveMessage,
|
|
25
|
+
type BuilderTheme,
|
|
26
|
+
} from './protocol'
|
|
27
|
+
|
|
28
|
+
export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `save` payload. Which variant arrives follows the mode the element was
|
|
32
|
+
* configured in:
|
|
33
|
+
* - DAM-backed (default) — `BuilderSaveData`; the app uploaded the template and
|
|
34
|
+
* reports the resulting `uuid`.
|
|
35
|
+
* - `stateless` — `BuilderContentData`; nothing was stored, and `content` is
|
|
36
|
+
* the edited template for the host to persist.
|
|
37
|
+
*/
|
|
38
|
+
export type TemplateBuilderSaveDetail =
|
|
39
|
+
| BuilderSaveData
|
|
40
|
+
| BuilderContentData
|
|
41
|
+
| undefined
|
|
42
|
+
|
|
43
|
+
export interface TemplateBuilderEventMap {
|
|
44
|
+
ready: CustomEvent<void>
|
|
45
|
+
open: CustomEvent<void>
|
|
46
|
+
close: CustomEvent<void>
|
|
47
|
+
save: CustomEvent<TemplateBuilderSaveDetail>
|
|
48
|
+
error: CustomEvent<BuilderErrorData>
|
|
49
|
+
dirtychange: CustomEvent<BuilderDirtyData>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `<sfx-template-builder>` — embeds the Filerobot design-templates builder.
|
|
54
|
+
*
|
|
55
|
+
* The element owns an iframe pointed at a design-templates-app deployment,
|
|
56
|
+
* passes auth via URL params (converted to cookies by the app's proxy), and
|
|
57
|
+
* translates the app's postMessage protocol into DOM CustomEvents:
|
|
58
|
+
* `ready`, `open`, `close`, `save`, `error`.
|
|
59
|
+
*
|
|
60
|
+
* Required: `base-url`, `token`, and one of two credentials:
|
|
61
|
+
* - `sass-key` + `session-uuid` — a Hub session. Full features.
|
|
62
|
+
* - `sec-template` — a Filerobot security-template key. No Hub session needed,
|
|
63
|
+
* but it only works with `stateless`, and Hub-project features (metadata
|
|
64
|
+
* fields, regional variants, project branding) come back empty.
|
|
65
|
+
*
|
|
66
|
+
* In `inline` mode the editor loads as soon as config is complete and fills
|
|
67
|
+
* the host element (size it explicitly). In `modal` mode nothing renders
|
|
68
|
+
* until `open()` is called; the editor then covers the viewport.
|
|
69
|
+
*
|
|
70
|
+
* Two ways to supply the template:
|
|
71
|
+
* - **DAM-backed** (default) — set `template-id` to a Filerobot file uuid. The
|
|
72
|
+
* app loads and saves it itself, and `save` reports the new uuid.
|
|
73
|
+
* - **Stateless** — set `stateless` and assign `content`. The element sends the
|
|
74
|
+
* template into the editor over postMessage and `save` returns the edited
|
|
75
|
+
* document; nothing is stored on the Scaleflex side, and `template-id` is
|
|
76
|
+
* just an opaque string echoed back. Rendering, fonts and asset browsing
|
|
77
|
+
* still use the session's Filerobot tenant.
|
|
78
|
+
*
|
|
79
|
+
* `brand-color` and `theme` restyle the editor chrome to match the host page.
|
|
80
|
+
* They do not touch the rendered template — its colours live in the document.
|
|
81
|
+
*/
|
|
82
|
+
export class SfxTemplateBuilder extends LitElement {
|
|
83
|
+
static styles = css`
|
|
84
|
+
:host {
|
|
85
|
+
display: block;
|
|
86
|
+
position: relative;
|
|
87
|
+
}
|
|
88
|
+
:host([mode='modal']) {
|
|
89
|
+
display: contents;
|
|
90
|
+
}
|
|
91
|
+
.overlay {
|
|
92
|
+
position: fixed;
|
|
93
|
+
inset: 0;
|
|
94
|
+
z-index: 2147483000;
|
|
95
|
+
background: rgba(0, 0, 0, 0.55);
|
|
96
|
+
display: flex;
|
|
97
|
+
}
|
|
98
|
+
.stage {
|
|
99
|
+
position: relative;
|
|
100
|
+
flex: 1;
|
|
101
|
+
display: flex;
|
|
102
|
+
}
|
|
103
|
+
iframe {
|
|
104
|
+
border: 0;
|
|
105
|
+
flex: 1;
|
|
106
|
+
width: 100%;
|
|
107
|
+
height: 100%;
|
|
108
|
+
}
|
|
109
|
+
.spinner {
|
|
110
|
+
position: absolute;
|
|
111
|
+
inset: 0;
|
|
112
|
+
margin: auto;
|
|
113
|
+
width: 32px;
|
|
114
|
+
height: 32px;
|
|
115
|
+
border: 3px solid rgba(128, 128, 128, 0.3);
|
|
116
|
+
border-top-color: currentColor;
|
|
117
|
+
border-radius: 50%;
|
|
118
|
+
animation: sfx-tb-spin 0.8s linear infinite;
|
|
119
|
+
pointer-events: none;
|
|
120
|
+
}
|
|
121
|
+
@keyframes sfx-tb-spin {
|
|
122
|
+
to {
|
|
123
|
+
transform: rotate(360deg);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
`
|
|
127
|
+
|
|
128
|
+
/** Origin + optional path prefix of the design-templates-app deployment. */
|
|
129
|
+
@property({ attribute: 'base-url' }) baseUrl = ''
|
|
130
|
+
/** Filerobot token (`ftoken`). */
|
|
131
|
+
@property() token = ''
|
|
132
|
+
@property({ attribute: 'sass-key' }) sassKey = ''
|
|
133
|
+
@property({ attribute: 'session-uuid' }) sessionUuid = ''
|
|
134
|
+
/**
|
|
135
|
+
* Filerobot security-template key — the alternative to `sass-key` +
|
|
136
|
+
* `session-uuid` for hosts with no Hub session to hand over. Requires
|
|
137
|
+
* `stateless`, and degrades the features that come from the Hub project
|
|
138
|
+
* model (metadata fields, regional variants, project branding). When set it
|
|
139
|
+
* wins: neither `sass-key` nor `session-uuid` is passed to the app.
|
|
140
|
+
*/
|
|
141
|
+
@property({ attribute: 'sec-template' }) secTemplate = ''
|
|
142
|
+
@property({ attribute: 'company-uuid' }) companyUuid = ''
|
|
143
|
+
@property({ attribute: 'project-uuid' }) projectUuid = ''
|
|
144
|
+
/**
|
|
145
|
+
* DAM-backed mode: the Filerobot uuid to load; empty opens the new-template
|
|
146
|
+
* flow. Stateless mode: an opaque host id, echoed back on `save`.
|
|
147
|
+
*/
|
|
148
|
+
@property({ attribute: 'template-id' }) templateId = ''
|
|
149
|
+
@property({ reflect: true }) mode: 'inline' | 'modal' = 'inline'
|
|
150
|
+
/**
|
|
151
|
+
* Hand the template in and take it back out instead of letting the app read
|
|
152
|
+
* and write Filerobot. Requires `content`.
|
|
153
|
+
*/
|
|
154
|
+
@property({ type: Boolean, reflect: true }) stateless = false
|
|
155
|
+
/**
|
|
156
|
+
* Stateless mode: the template to edit, as `.fdt` XML. Property only — templates
|
|
157
|
+
* routinely exceed practical attribute/URL sizes, so it is never reflected.
|
|
158
|
+
* Assigning a different value while open loads it into the running editor.
|
|
159
|
+
*/
|
|
160
|
+
@property({ attribute: false }) content = ''
|
|
161
|
+
/** Stateless mode: display name for the editor header. */
|
|
162
|
+
@property({ attribute: 'template-name' }) templateName = ''
|
|
163
|
+
/**
|
|
164
|
+
* Stateless mode: the `template_query` to open on — the value handed back in
|
|
165
|
+
* the `save` payload. Pass back what you stored and the editor reopens on the
|
|
166
|
+
* same layout and variable values; leave it empty and the render falls back
|
|
167
|
+
* to the XML's own `default=` attributes.
|
|
168
|
+
*/
|
|
169
|
+
@property({ attribute: 'template-query' }) templateQuery = ''
|
|
170
|
+
/**
|
|
171
|
+
* Accent colour for the editor chrome, as `#rgb` / `#rrggbb`. The app derives
|
|
172
|
+
* buttons, focus rings and highlights from it. Empty keeps the Scaleflex
|
|
173
|
+
* default. Themes the editor UI only — never the rendered template, whose
|
|
174
|
+
* colours live in the document.
|
|
175
|
+
*/
|
|
176
|
+
@property({ attribute: 'brand-color' }) brandColor = ''
|
|
177
|
+
/** Colour scheme for the editor chrome. Empty leaves the app's own default. */
|
|
178
|
+
@property() theme: BuilderTheme | '' = ''
|
|
179
|
+
/** Ms to wait for the app's ready signal before emitting `error`. 0 disables. */
|
|
180
|
+
@property({ type: Number, attribute: 'ready-timeout' }) readyTimeout = 20000
|
|
181
|
+
|
|
182
|
+
@state() private _status: TemplateBuilderStatus = 'idle'
|
|
183
|
+
@state() private _open = false
|
|
184
|
+
@state() private _src = ''
|
|
185
|
+
|
|
186
|
+
private _handshakeTimer?: number
|
|
187
|
+
/**
|
|
188
|
+
* The app asked for content. Tracked because the request and the `content`
|
|
189
|
+
* assignment race: whichever lands second triggers the send.
|
|
190
|
+
*/
|
|
191
|
+
private _contentRequested = false
|
|
192
|
+
/**
|
|
193
|
+
* Identity of the template already delivered, so an unrelated re-render does
|
|
194
|
+
* not resend it and discard the user's edits. Covers the id and name too, not
|
|
195
|
+
* just the content: two host records can hold byte-identical templates, and
|
|
196
|
+
* resending only on content change would leave the app echoing a stale id
|
|
197
|
+
* back on save.
|
|
198
|
+
*/
|
|
199
|
+
private _sentKey?: string
|
|
200
|
+
/**
|
|
201
|
+
* The `baseUrl` value already reported as unparseable. `_computeSrc()` runs
|
|
202
|
+
* on every update cycle, so without this a bad URL re-emits `error` forever —
|
|
203
|
+
* once per render, since the error status it sets is already in place after
|
|
204
|
+
* the first.
|
|
205
|
+
*/
|
|
206
|
+
private _reportedBadBaseUrl?: string
|
|
207
|
+
/**
|
|
208
|
+
* Whether the sec-template-without-stateless mistake has been reported. Same
|
|
209
|
+
* reason as `_reportedBadBaseUrl`: `_computeSrc()` runs every update cycle
|
|
210
|
+
* and the error status it sets is already in place after the first pass.
|
|
211
|
+
*/
|
|
212
|
+
private _reportedStatelessRequired = false
|
|
213
|
+
|
|
214
|
+
@state() private _isDirty = false
|
|
215
|
+
|
|
216
|
+
get status(): TemplateBuilderStatus {
|
|
217
|
+
return this._status
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Stateless mode: whether the editor holds edits that have not been handed
|
|
222
|
+
* back yet. Check this before calling `load()` — a swap discards them.
|
|
223
|
+
* Always false in DAM-backed mode, where the app owns saving.
|
|
224
|
+
*/
|
|
225
|
+
get isDirty(): boolean {
|
|
226
|
+
return this._isDirty
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Open the editor (loads the iframe). Optionally switch template first. */
|
|
230
|
+
open(templateId?: string): void {
|
|
231
|
+
if (templateId !== undefined) this.templateId = templateId
|
|
232
|
+
this._open = true
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Close the editor and unload the iframe. Does not emit `close`. */
|
|
236
|
+
close(): void {
|
|
237
|
+
this._open = false
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Stateless mode: load a template, opening the editor if needed. Equivalent
|
|
242
|
+
* to assigning `templateId` / `content` / `templateName` and calling `open()`.
|
|
243
|
+
*/
|
|
244
|
+
load({
|
|
245
|
+
content,
|
|
246
|
+
templateId,
|
|
247
|
+
name,
|
|
248
|
+
templateQuery,
|
|
249
|
+
}: {
|
|
250
|
+
content: string
|
|
251
|
+
templateId?: string
|
|
252
|
+
name?: string
|
|
253
|
+
templateQuery?: string
|
|
254
|
+
}): void {
|
|
255
|
+
if (templateId !== undefined) this.templateId = templateId
|
|
256
|
+
if (name !== undefined) this.templateName = name
|
|
257
|
+
// Assigned before `content`: all four ship as one HOST_LOAD, and leaving a
|
|
258
|
+
// previous template's query in place while the new content goes out would
|
|
259
|
+
// open the new document on the old layout and values.
|
|
260
|
+
if (templateQuery !== undefined) this.templateQuery = templateQuery
|
|
261
|
+
this.content = content
|
|
262
|
+
this._open = true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Stateless mode: report back whether a `save` was persisted on your side.
|
|
267
|
+
*
|
|
268
|
+
* Optional. The editor clears its unsaved-changes flag as soon as it hands
|
|
269
|
+
* the content over, so not calling this leaves the previous behaviour intact.
|
|
270
|
+
* Calling it with `false` is what earns something: the editor restores the
|
|
271
|
+
* dirty flag and tells the user, rather than showing a failed write as saved.
|
|
272
|
+
*
|
|
273
|
+
* No-op outside stateless mode, where the app did the saving and has nothing
|
|
274
|
+
* to hear back about.
|
|
275
|
+
*/
|
|
276
|
+
confirmSave(ok: boolean, message?: string): void {
|
|
277
|
+
if (!this.stateless) return
|
|
278
|
+
this._postToApp({ type: HOST_SAVED, data: { ok, message } })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Whether the inline-implies-open decision has been made. It cannot be made
|
|
283
|
+
* in `connectedCallback`: frameworks insert the element first and assign
|
|
284
|
+
* properties afterwards in the same task (the React wrapper does), so at
|
|
285
|
+
* connect time `mode` may still hold its `'inline'` default — deciding there
|
|
286
|
+
* flashes a modal's full-viewport overlay open on mount. By the first update
|
|
287
|
+
* cycle the real value has settled.
|
|
288
|
+
*/
|
|
289
|
+
private _autoOpenDecided = false
|
|
290
|
+
|
|
291
|
+
connectedCallback(): void {
|
|
292
|
+
super.connectedCallback()
|
|
293
|
+
window.addEventListener('message', this._onMessage)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
disconnectedCallback(): void {
|
|
297
|
+
super.disconnectedCallback()
|
|
298
|
+
window.removeEventListener('message', this._onMessage)
|
|
299
|
+
this._clearHandshakeTimer()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
protected willUpdate(changed: PropertyValues): void {
|
|
303
|
+
super.willUpdate(changed)
|
|
304
|
+
if (!this._autoOpenDecided) {
|
|
305
|
+
this._autoOpenDecided = true
|
|
306
|
+
if (this.mode === 'inline') this._open = true
|
|
307
|
+
}
|
|
308
|
+
const src = this._computeSrc()
|
|
309
|
+
if (src !== this._src) {
|
|
310
|
+
this._src = src
|
|
311
|
+
this._status = src ? 'loading' : 'idle'
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
protected updated(changed: PropertyValues): void {
|
|
316
|
+
if (changed.has('_src')) {
|
|
317
|
+
this._clearHandshakeTimer()
|
|
318
|
+
// A new document means a new app instance: it has not asked for content
|
|
319
|
+
// yet, and nothing has been delivered to it.
|
|
320
|
+
this._contentRequested = false
|
|
321
|
+
this._sentKey = undefined
|
|
322
|
+
if (this._isDirty) {
|
|
323
|
+
this._isDirty = false
|
|
324
|
+
this._emit('dirtychange', { isDirty: false })
|
|
325
|
+
}
|
|
326
|
+
if (this._src) this._startHandshakeTimer()
|
|
327
|
+
}
|
|
328
|
+
// Swapping any part of the template on a running editor reloads it. The id
|
|
329
|
+
// matters as much as the content: a host moving between two identical
|
|
330
|
+
// templates must not leave the app saving under the previous id.
|
|
331
|
+
if (
|
|
332
|
+
changed.has('content') ||
|
|
333
|
+
changed.has('templateId') ||
|
|
334
|
+
changed.has('templateName') ||
|
|
335
|
+
changed.has('templateQuery')
|
|
336
|
+
) {
|
|
337
|
+
this._maybeSendContent()
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
render() {
|
|
342
|
+
const frame = this._src
|
|
343
|
+
? html`<iframe
|
|
344
|
+
part="iframe"
|
|
345
|
+
title="Template builder"
|
|
346
|
+
src=${this._src}
|
|
347
|
+
allow="clipboard-read; clipboard-write"
|
|
348
|
+
></iframe>`
|
|
349
|
+
: nothing
|
|
350
|
+
const spinner =
|
|
351
|
+
this._status === 'loading'
|
|
352
|
+
? html`<div class="spinner" part="spinner"></div>`
|
|
353
|
+
: nothing
|
|
354
|
+
|
|
355
|
+
if (this.mode === 'modal') {
|
|
356
|
+
return this._open
|
|
357
|
+
? html`<div class="overlay" part="overlay">
|
|
358
|
+
<div class="stage">${frame}${spinner}</div>
|
|
359
|
+
</div>`
|
|
360
|
+
: nothing
|
|
361
|
+
}
|
|
362
|
+
return html`${frame}${spinner}`
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private _computeSrc(): string {
|
|
366
|
+
if (!this._open) return ''
|
|
367
|
+
if (!this.baseUrl || !this.token) return ''
|
|
368
|
+
if (this.secTemplate) {
|
|
369
|
+
// A security template is a guest credential with no user identity behind
|
|
370
|
+
// it, so the app takes it on the stateless route only. Saying so here
|
|
371
|
+
// turns a config mistake into a message instead of a login redirect the
|
|
372
|
+
// host sees as `handshake-timeout`.
|
|
373
|
+
if (!this.stateless) {
|
|
374
|
+
if (!this._reportedStatelessRequired) {
|
|
375
|
+
this._reportedStatelessRequired = true
|
|
376
|
+
queueMicrotask(() =>
|
|
377
|
+
this._fail({
|
|
378
|
+
code: 'invalid-config',
|
|
379
|
+
message:
|
|
380
|
+
'sec-template requires stateless mode — the app accepts a ' +
|
|
381
|
+
'security template on the stateless embed route only.',
|
|
382
|
+
}),
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
return ''
|
|
386
|
+
}
|
|
387
|
+
// Cleared on a valid pass so a host that fixes the combination and later
|
|
388
|
+
// breaks it again is told again, matching `_reportedBadBaseUrl`.
|
|
389
|
+
this._reportedStatelessRequired = false
|
|
390
|
+
} else if (!this.sassKey || !this.sessionUuid) {
|
|
391
|
+
return ''
|
|
392
|
+
}
|
|
393
|
+
let url: URL
|
|
394
|
+
try {
|
|
395
|
+
// Stateless mode keeps the id out of the URL — it is a host-side value
|
|
396
|
+
// the app never resolves, and the content arrives by postMessage.
|
|
397
|
+
const route = this.stateless
|
|
398
|
+
? EMBED_ROUTE
|
|
399
|
+
: builderRoute(this.templateId || undefined)
|
|
400
|
+
// Resolve relative to the base, not the origin: routes are absolute
|
|
401
|
+
// paths, and `new URL('/x', 'https://host/app')` would silently drop
|
|
402
|
+
// the documented path prefix, 404 on subpath deployments, and surface
|
|
403
|
+
// only as a handshake-timeout.
|
|
404
|
+
const base = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`
|
|
405
|
+
url = new URL(route.replace(/^\//, ''), base)
|
|
406
|
+
} catch {
|
|
407
|
+
// Report each bad value once. This runs on every update cycle, and
|
|
408
|
+
// `_fail` sets a status that is already 'error' by the second pass, so
|
|
409
|
+
// there is no state change to fall out of the loop on.
|
|
410
|
+
if (this._reportedBadBaseUrl !== this.baseUrl) {
|
|
411
|
+
this._reportedBadBaseUrl = this.baseUrl
|
|
412
|
+
// Emitted from a state-compute path; defer so consumers attached after
|
|
413
|
+
// this update cycle still receive it.
|
|
414
|
+
queueMicrotask(() =>
|
|
415
|
+
this._fail({
|
|
416
|
+
code: 'invalid-base-url',
|
|
417
|
+
message: `base-url is not a valid URL: ${this.baseUrl}`,
|
|
418
|
+
}),
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
return ''
|
|
422
|
+
}
|
|
423
|
+
this._reportedBadBaseUrl = undefined
|
|
424
|
+
url.searchParams.set(EMBED_PARAMS.FILEROBOT_TOKEN, this.token)
|
|
425
|
+
if (this.secTemplate) {
|
|
426
|
+
// Exclusive with the session credentials: the app reads the mode off
|
|
427
|
+
// which of the two arrived, and the Hub uuids below name a project it
|
|
428
|
+
// cannot look up without a session anyway.
|
|
429
|
+
url.searchParams.set(EMBED_PARAMS.SEC_TEMPLATE, this.secTemplate)
|
|
430
|
+
} else {
|
|
431
|
+
url.searchParams.set(EMBED_PARAMS.SASS_KEY, this.sassKey)
|
|
432
|
+
url.searchParams.set(EMBED_PARAMS.SESSION_UUID, this.sessionUuid)
|
|
433
|
+
if (this.companyUuid) {
|
|
434
|
+
url.searchParams.set(EMBED_PARAMS.COMPANY_UUID, this.companyUuid)
|
|
435
|
+
}
|
|
436
|
+
if (this.projectUuid) {
|
|
437
|
+
url.searchParams.set(EMBED_PARAMS.PROJECT_UUID, this.projectUuid)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (this.brandColor) {
|
|
441
|
+
url.searchParams.set(EMBED_PARAMS.BRAND_COLOR, this.brandColor)
|
|
442
|
+
}
|
|
443
|
+
if (this.theme) {
|
|
444
|
+
url.searchParams.set(EMBED_PARAMS.THEME, this.theme)
|
|
445
|
+
}
|
|
446
|
+
url.searchParams.set(EMBED_PARAMS.IFRAME, '1')
|
|
447
|
+
url.searchParams.set(EMBED_PARAMS.EMBED_ORIGIN, window.location.origin)
|
|
448
|
+
return url.toString()
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private get _appOrigin(): string | null {
|
|
452
|
+
try {
|
|
453
|
+
return new URL(this.baseUrl).origin
|
|
454
|
+
} catch {
|
|
455
|
+
return null
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private _onMessage = (event: MessageEvent): void => {
|
|
460
|
+
if (!this._open) return
|
|
461
|
+
if (!event.origin || event.origin !== this._appOrigin) return
|
|
462
|
+
const iframe = this.shadowRoot?.querySelector('iframe')
|
|
463
|
+
if (!iframe) return
|
|
464
|
+
// Ignore messages from other frames of the same app origin. Strict: a
|
|
465
|
+
// missing source is not given the benefit of the doubt — the app side
|
|
466
|
+
// (`readHostLoadMessage`) applies the same rule.
|
|
467
|
+
if (event.source !== iframe.contentWindow) return
|
|
468
|
+
|
|
469
|
+
const msg = event.data as { type?: unknown } | null
|
|
470
|
+
if (!msg || typeof msg.type !== 'string') return
|
|
471
|
+
|
|
472
|
+
switch (msg.type) {
|
|
473
|
+
case BUILDER_READY:
|
|
474
|
+
case BUILDER_OPEN:
|
|
475
|
+
this._clearHandshakeTimer()
|
|
476
|
+
if (this._status !== 'ready') {
|
|
477
|
+
this._status = 'ready'
|
|
478
|
+
this._emit('ready')
|
|
479
|
+
}
|
|
480
|
+
if (msg.type === BUILDER_OPEN) this._emit('open')
|
|
481
|
+
break
|
|
482
|
+
case BUILDER_SAVE:
|
|
483
|
+
this._emit('save', (msg as BuilderSaveMessage).data)
|
|
484
|
+
break
|
|
485
|
+
case BUILDER_CONTENT_REQUEST:
|
|
486
|
+
this._contentRequested = true
|
|
487
|
+
// A fresh request is authoritative: the app is saying it holds no
|
|
488
|
+
// template. It may have remounted or reloaded inside an unchanged
|
|
489
|
+
// iframe, so resend even if this content went out already — otherwise
|
|
490
|
+
// the editor waits on a skeleton forever.
|
|
491
|
+
this._sentKey = undefined
|
|
492
|
+
this._maybeSendContent()
|
|
493
|
+
break
|
|
494
|
+
case BUILDER_CONTENT: {
|
|
495
|
+
// Stateless save. Surfaced as `save` so hosts have one event to bind
|
|
496
|
+
// regardless of mode; the detail shape follows the mode they chose.
|
|
497
|
+
const data = (msg as BuilderContentMessage).data
|
|
498
|
+
// The saved document is what the editor now holds. A host that stores
|
|
499
|
+
// it and echoes it back into `content` — the natural controlled
|
|
500
|
+
// pattern — must not trigger a HOST_LOAD reload that wipes the
|
|
501
|
+
// editor's undo history behind a skeleton flash.
|
|
502
|
+
this._sentKey = this._contentKey(data.content)
|
|
503
|
+
this._emit('save', data)
|
|
504
|
+
break
|
|
505
|
+
}
|
|
506
|
+
case BUILDER_DIRTY: {
|
|
507
|
+
const data = (msg as BuilderDirtyMessage).data
|
|
508
|
+
this._isDirty = !!data?.isDirty
|
|
509
|
+
this._emit('dirtychange', { isDirty: this._isDirty })
|
|
510
|
+
break
|
|
511
|
+
}
|
|
512
|
+
case BUILDER_CLOSE:
|
|
513
|
+
this._emit('close')
|
|
514
|
+
if (this.mode === 'modal') this._open = false
|
|
515
|
+
break
|
|
516
|
+
case BUILDER_ERROR:
|
|
517
|
+
this._fail((msg as BuilderErrorMessage).data ?? { code: 'unknown' })
|
|
518
|
+
break
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Deliver `content` to the app once both sides are ready: it has asked, and
|
|
524
|
+
* we have something new to give it. Skips a re-send of identical content so
|
|
525
|
+
* an unrelated re-render can't discard the user's in-progress edits.
|
|
526
|
+
*/
|
|
527
|
+
private _maybeSendContent(): void {
|
|
528
|
+
if (!this.stateless || !this._contentRequested || !this.content) return
|
|
529
|
+
|
|
530
|
+
const data = {
|
|
531
|
+
templateId: this.templateId || undefined,
|
|
532
|
+
content: this.content,
|
|
533
|
+
name: this.templateName || undefined,
|
|
534
|
+
templateQuery: this.templateQuery || undefined,
|
|
535
|
+
}
|
|
536
|
+
const key = this._contentKey(this.content)
|
|
537
|
+
if (key === this._sentKey) return
|
|
538
|
+
|
|
539
|
+
if (!this._postToApp({ type: HOST_LOAD, data })) return
|
|
540
|
+
this._sentKey = key
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Identity of a delivered template, as compared against `_sentKey`. */
|
|
544
|
+
private _contentKey(content: string): string {
|
|
545
|
+
return JSON.stringify({
|
|
546
|
+
templateId: this.templateId || undefined,
|
|
547
|
+
content,
|
|
548
|
+
name: this.templateName || undefined,
|
|
549
|
+
templateQuery: this.templateQuery || undefined,
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Post into the iframe, targeted at the app origin. False if not mounted. */
|
|
554
|
+
private _postToApp(message: unknown): boolean {
|
|
555
|
+
const target = this.shadowRoot?.querySelector('iframe')?.contentWindow
|
|
556
|
+
const appOrigin = this._appOrigin
|
|
557
|
+
if (!target || !appOrigin) return false
|
|
558
|
+
target.postMessage(message, appOrigin)
|
|
559
|
+
return true
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private _startHandshakeTimer(): void {
|
|
563
|
+
if (this.readyTimeout <= 0) return
|
|
564
|
+
this._handshakeTimer = window.setTimeout(() => {
|
|
565
|
+
this._fail({
|
|
566
|
+
code: 'handshake-timeout',
|
|
567
|
+
message:
|
|
568
|
+
`No ready signal from ${this.baseUrl} within ${this.readyTimeout}ms. ` +
|
|
569
|
+
'Check that this origin is in the app\'s frame-ancestors allowlist ' +
|
|
570
|
+
'and that third-party cookies are not blocked.',
|
|
571
|
+
})
|
|
572
|
+
}, this.readyTimeout)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private _clearHandshakeTimer(): void {
|
|
576
|
+
if (this._handshakeTimer !== undefined) {
|
|
577
|
+
window.clearTimeout(this._handshakeTimer)
|
|
578
|
+
this._handshakeTimer = undefined
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private _fail(data: BuilderErrorData): void {
|
|
583
|
+
this._clearHandshakeTimer()
|
|
584
|
+
this._status = 'error'
|
|
585
|
+
this._emit('error', data)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private _emit<T>(name: string, detail?: T): void {
|
|
589
|
+
this.dispatchEvent(
|
|
590
|
+
new CustomEvent(name, { detail, bubbles: true, composed: true }),
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
declare global {
|
|
596
|
+
interface HTMLElementTagNameMap {
|
|
597
|
+
'sfx-template-builder': SfxTemplateBuilder
|
|
598
|
+
}
|
|
599
|
+
}
|