@nordcraft/runtime 1.0.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.
Files changed (168) hide show
  1. package/README.md +5 -0
  2. package/dist/api/createAPI.d.ts +20 -0
  3. package/dist/api/createAPI.js +319 -0
  4. package/dist/api/createAPI.js.map +1 -0
  5. package/dist/api/createAPIv2.d.ts +7 -0
  6. package/dist/api/createAPIv2.js +686 -0
  7. package/dist/api/createAPIv2.js.map +1 -0
  8. package/dist/components/createComponent.d.ts +13 -0
  9. package/dist/components/createComponent.js +216 -0
  10. package/dist/components/createComponent.js.map +1 -0
  11. package/dist/components/createElement.d.ts +3 -0
  12. package/dist/components/createElement.js +208 -0
  13. package/dist/components/createElement.js.map +1 -0
  14. package/dist/components/createNode.d.ts +22 -0
  15. package/dist/components/createNode.js +272 -0
  16. package/dist/components/createNode.js.map +1 -0
  17. package/dist/components/createSlot.d.ts +3 -0
  18. package/dist/components/createSlot.js +49 -0
  19. package/dist/components/createSlot.js.map +1 -0
  20. package/dist/components/createText.d.ts +23 -0
  21. package/dist/components/createText.js +68 -0
  22. package/dist/components/createText.js.map +1 -0
  23. package/dist/components/createText.test.d.ts +1 -0
  24. package/dist/components/createText.test.js +113 -0
  25. package/dist/components/createText.test.js.map +1 -0
  26. package/dist/components/renderComponent.d.ts +34 -0
  27. package/dist/components/renderComponent.js +66 -0
  28. package/dist/components/renderComponent.js.map +1 -0
  29. package/dist/context/isContextProvider.d.ts +2 -0
  30. package/dist/context/isContextProvider.js +5 -0
  31. package/dist/context/isContextProvider.js.map +1 -0
  32. package/dist/context/subscribeToContext.d.ts +4 -0
  33. package/dist/context/subscribeToContext.js +93 -0
  34. package/dist/context/subscribeToContext.js.map +1 -0
  35. package/dist/custom-components/components.d.ts +1 -0
  36. package/dist/custom-components/components.js +2 -0
  37. package/dist/custom-components/components.js.map +1 -0
  38. package/dist/custom-components/toddle-portal.d.ts +6 -0
  39. package/dist/custom-components/toddle-portal.js +20 -0
  40. package/dist/custom-components/toddle-portal.js.map +1 -0
  41. package/dist/custom-element/ToddleComponent.d.ts +37 -0
  42. package/dist/custom-element/ToddleComponent.js +244 -0
  43. package/dist/custom-element/ToddleComponent.js.map +1 -0
  44. package/dist/custom-element/defineComponents.d.ts +26 -0
  45. package/dist/custom-element/defineComponents.js +42 -0
  46. package/dist/custom-element/defineComponents.js.map +1 -0
  47. package/dist/custom-element.main.d.ts +3 -0
  48. package/dist/custom-element.main.esm.js +266 -0
  49. package/dist/custom-element.main.esm.js.map +7 -0
  50. package/dist/custom-element.main.js +14 -0
  51. package/dist/custom-element.main.js.map +1 -0
  52. package/dist/debug/logState.d.ts +4 -0
  53. package/dist/debug/logState.js +19 -0
  54. package/dist/debug/logState.js.map +1 -0
  55. package/dist/editor/drag-drop/dragEnded.d.ts +2 -0
  56. package/dist/editor/drag-drop/dragEnded.js +56 -0
  57. package/dist/editor/drag-drop/dragEnded.js.map +1 -0
  58. package/dist/editor/drag-drop/dragMove.d.ts +3 -0
  59. package/dist/editor/drag-drop/dragMove.js +74 -0
  60. package/dist/editor/drag-drop/dragMove.js.map +1 -0
  61. package/dist/editor/drag-drop/dragReorder.d.ts +3 -0
  62. package/dist/editor/drag-drop/dragReorder.js +92 -0
  63. package/dist/editor/drag-drop/dragReorder.js.map +1 -0
  64. package/dist/editor/drag-drop/dragStarted.d.ts +9 -0
  65. package/dist/editor/drag-drop/dragStarted.js +100 -0
  66. package/dist/editor/drag-drop/dragStarted.js.map +1 -0
  67. package/dist/editor/drag-drop/dropHighlight.d.ts +16 -0
  68. package/dist/editor/drag-drop/dropHighlight.js +50 -0
  69. package/dist/editor/drag-drop/dropHighlight.js.map +1 -0
  70. package/dist/editor/drag-drop/getInsertAreas.d.ts +20 -0
  71. package/dist/editor/drag-drop/getInsertAreas.js +220 -0
  72. package/dist/editor/drag-drop/getInsertAreas.js.map +1 -0
  73. package/dist/editor-preview.main.d.ts +19 -0
  74. package/dist/editor-preview.main.js +1303 -0
  75. package/dist/editor-preview.main.js.map +1 -0
  76. package/dist/events/handleAction.d.ts +3 -0
  77. package/dist/events/handleAction.js +307 -0
  78. package/dist/events/handleAction.js.map +1 -0
  79. package/dist/page.main.d.ts +7 -0
  80. package/dist/page.main.esm.js +8 -0
  81. package/dist/page.main.esm.js.map +7 -0
  82. package/dist/page.main.js +395 -0
  83. package/dist/page.main.js.map +1 -0
  84. package/dist/signal/signal.d.ts +19 -0
  85. package/dist/signal/signal.js +65 -0
  86. package/dist/signal/signal.js.map +1 -0
  87. package/dist/styles/style.d.ts +4 -0
  88. package/dist/styles/style.js +196 -0
  89. package/dist/styles/style.js.map +1 -0
  90. package/dist/utils/BatchQueue.d.ts +10 -0
  91. package/dist/utils/BatchQueue.js +25 -0
  92. package/dist/utils/BatchQueue.js.map +1 -0
  93. package/dist/utils/createFormulaCache.d.ts +3 -0
  94. package/dist/utils/createFormulaCache.js +81 -0
  95. package/dist/utils/createFormulaCache.js.map +1 -0
  96. package/dist/utils/findNearestLine.d.ts +13 -0
  97. package/dist/utils/findNearestLine.js +74 -0
  98. package/dist/utils/findNearestLine.js.map +1 -0
  99. package/dist/utils/findNearestLine.test.d.ts +1 -0
  100. package/dist/utils/findNearestLine.test.js +59 -0
  101. package/dist/utils/findNearestLine.test.js.map +1 -0
  102. package/dist/utils/getDragData.d.ts +1 -0
  103. package/dist/utils/getDragData.js +10 -0
  104. package/dist/utils/getDragData.js.map +1 -0
  105. package/dist/utils/getElementTagName.d.ts +3 -0
  106. package/dist/utils/getElementTagName.js +7 -0
  107. package/dist/utils/getElementTagName.js.map +1 -0
  108. package/dist/utils/nodes.d.ts +21 -0
  109. package/dist/utils/nodes.js +89 -0
  110. package/dist/utils/nodes.js.map +1 -0
  111. package/dist/utils/omitStyle.d.ts +2 -0
  112. package/dist/utils/omitStyle.js +13 -0
  113. package/dist/utils/omitStyle.js.map +1 -0
  114. package/dist/utils/rectHasPoint.d.ts +2 -0
  115. package/dist/utils/rectHasPoint.js +4 -0
  116. package/dist/utils/rectHasPoint.js.map +1 -0
  117. package/dist/utils/setAttribute.d.ts +4 -0
  118. package/dist/utils/setAttribute.js +57 -0
  119. package/dist/utils/setAttribute.js.map +1 -0
  120. package/dist/utils/tryStartViewTransition.d.ts +5 -0
  121. package/dist/utils/tryStartViewTransition.js +14 -0
  122. package/dist/utils/tryStartViewTransition.js.map +1 -0
  123. package/dist/utils/url.d.ts +2 -0
  124. package/dist/utils/url.js +36 -0
  125. package/dist/utils/url.js.map +1 -0
  126. package/package.json +25 -0
  127. package/src/api/createAPI.ts +375 -0
  128. package/src/api/createAPIv2.ts +931 -0
  129. package/src/components/createComponent.ts +280 -0
  130. package/src/components/createElement.ts +240 -0
  131. package/src/components/createNode.ts +381 -0
  132. package/src/components/createSlot.ts +61 -0
  133. package/src/components/createText.test.ts +117 -0
  134. package/src/components/createText.ts +104 -0
  135. package/src/components/renderComponent.ts +145 -0
  136. package/src/context/isContextProvider.ts +12 -0
  137. package/src/context/subscribeToContext.ts +135 -0
  138. package/src/custom-components/components.ts +1 -0
  139. package/src/custom-components/toddle-portal.ts +19 -0
  140. package/src/custom-element/ToddleComponent.ts +315 -0
  141. package/src/custom-element/defineComponents.ts +65 -0
  142. package/src/custom-element.main.ts +24 -0
  143. package/src/debug/logState.ts +30 -0
  144. package/src/editor/drag-drop/dragEnded.ts +75 -0
  145. package/src/editor/drag-drop/dragMove.ts +95 -0
  146. package/src/editor/drag-drop/dragReorder.ts +137 -0
  147. package/src/editor/drag-drop/dragStarted.ts +145 -0
  148. package/src/editor/drag-drop/dropHighlight.ts +82 -0
  149. package/src/editor/drag-drop/getInsertAreas.ts +235 -0
  150. package/src/editor/types.d.ts +36 -0
  151. package/src/editor-preview.main.ts +1782 -0
  152. package/src/events/handleAction.ts +387 -0
  153. package/src/page.main.ts +489 -0
  154. package/src/signal/signal.ts +74 -0
  155. package/src/styles/style.ts +254 -0
  156. package/src/types.d.ts +93 -0
  157. package/src/utils/BatchQueue.ts +24 -0
  158. package/src/utils/createFormulaCache.ts +96 -0
  159. package/src/utils/findNearestLine.test.ts +65 -0
  160. package/src/utils/findNearestLine.ts +92 -0
  161. package/src/utils/getDragData.ts +11 -0
  162. package/src/utils/getElementTagName.ts +14 -0
  163. package/src/utils/nodes.ts +125 -0
  164. package/src/utils/omitStyle.ts +19 -0
  165. package/src/utils/rectHasPoint.ts +5 -0
  166. package/src/utils/setAttribute.ts +56 -0
  167. package/src/utils/tryStartViewTransition.ts +32 -0
  168. package/src/utils/url.ts +45 -0
@@ -0,0 +1,1782 @@
1
+ /* eslint-disable no-console */
2
+ /* eslint-disable @typescript-eslint/prefer-optional-chain */
3
+ /* eslint-disable no-case-declarations */
4
+ /* eslint-disable no-fallthrough */
5
+ import { isLegacyApi } from '@nordcraft/core/dist/api/api'
6
+ import type {
7
+ AnimationKeyframe,
8
+ Component,
9
+ ComponentData,
10
+ MetaEntry,
11
+ } from '@nordcraft/core/dist/component/component.types'
12
+ import { isPageComponent } from '@nordcraft/core/dist/component/isPageComponent'
13
+ import type {
14
+ FormulaContext,
15
+ ToddleEnv,
16
+ } from '@nordcraft/core/dist/formula/formula'
17
+ import { applyFormula } from '@nordcraft/core/dist/formula/formula'
18
+ import type { PluginFormula } from '@nordcraft/core/dist/formula/formulaTypes'
19
+ import { valueFormula } from '@nordcraft/core/dist/formula/formulaUtils'
20
+ import { getClassName } from '@nordcraft/core/dist/styling/className'
21
+ import type { OldTheme, Theme } from '@nordcraft/core/dist/styling/theme'
22
+ import { getThemeCss } from '@nordcraft/core/dist/styling/theme'
23
+ import { theme } from '@nordcraft/core/dist/styling/theme.const'
24
+ import type {
25
+ ActionHandler,
26
+ ArgumentInputDataFunction,
27
+ FormulaHandler,
28
+ FormulaHandlerV2,
29
+ PluginActionV2,
30
+ Toddle,
31
+ } from '@nordcraft/core/dist/types'
32
+ import { mapObject, omitKeys } from '@nordcraft/core/dist/utils/collections'
33
+ import * as libActions from '@nordcraft/std-lib/dist/actions'
34
+ import * as libFormulas from '@nordcraft/std-lib/dist/formulas'
35
+ import fastDeepEqual from 'fast-deep-equal'
36
+ import { createLegacyAPI } from './api/createAPI'
37
+ import { createAPI } from './api/createAPIv2'
38
+ import { createNode } from './components/createNode'
39
+ import { isContextProvider } from './context/isContextProvider'
40
+ import { dragEnded } from './editor/drag-drop/dragEnded'
41
+ import { dragMove } from './editor/drag-drop/dragMove'
42
+ import { dragReorder } from './editor/drag-drop/dragReorder'
43
+ import { dragStarted } from './editor/drag-drop/dragStarted'
44
+ import type { DragState } from './editor/types'
45
+ import { handleAction } from './events/handleAction'
46
+ import type { Signal } from './signal/signal'
47
+ import { signal } from './signal/signal'
48
+ import { insertStyles, styleToCss } from './styles/style'
49
+ import type {
50
+ ComponentContext,
51
+ LocationSignal,
52
+ PreviewShowSignal,
53
+ } from './types'
54
+ import { createFormulaCache } from './utils/createFormulaCache'
55
+ import { getNodeAndAncestors, isNodeOrAncestorConditional } from './utils/nodes'
56
+ import { omitSubnodeStyleForComponent } from './utils/omitStyle'
57
+ import { rectHasPoint } from './utils/rectHasPoint'
58
+
59
+ type ToddlePreviewEvent =
60
+ | {
61
+ type: 'style_variant_changed'
62
+ variantIndex: number | null
63
+ }
64
+ | {
65
+ type: 'update'
66
+ }
67
+ | {
68
+ type: 'component'
69
+ component: Component
70
+ }
71
+ | { type: 'components'; components: Component[] }
72
+ | {
73
+ type: 'packages'
74
+ packages: Record<
75
+ string,
76
+ {
77
+ components: Record<string, Component>
78
+ manifest: {
79
+ name: string
80
+ // commit represents the commit hash (version) of the package
81
+ commit: string
82
+ }
83
+ }
84
+ >
85
+ }
86
+ | { type: 'theme'; theme: Theme | OldTheme }
87
+ | { type: 'mode'; mode: 'design' | 'test' }
88
+ | { type: 'attrs'; attrs: Record<string, unknown> }
89
+ | { type: 'selection'; selectedNodeId: string | null }
90
+ | { type: 'highlight'; highlightedNodeId: string | null }
91
+ | {
92
+ type: 'click' | 'mousemove' | 'dblclick'
93
+ metaKey: boolean
94
+ x: number
95
+ y: number
96
+ }
97
+ | { type: 'report_document_scroll_size' }
98
+ | { type: 'update_inner_text'; innerText: string }
99
+ | { type: 'reload' }
100
+ | { type: 'fetch_api'; apiKey: string }
101
+ | { type: 'drag-started'; x: number; y: number }
102
+ | { type: 'drag-ended'; canceled?: true }
103
+ | { type: 'keydown'; key: string; altKey: boolean; metaKey: boolean }
104
+ | { type: 'keyup'; key: string; altKey: boolean; metaKey: boolean }
105
+ | {
106
+ type: 'get_computed_style'
107
+ styles: string[]
108
+ }
109
+ | {
110
+ type: 'set_timeline_keyframes'
111
+ keyframes: Record<string, AnimationKeyframe> | null
112
+ }
113
+ | {
114
+ type: 'set_timeline_time'
115
+ time: number | null
116
+ timingFunction:
117
+ | 'linear'
118
+ | 'ease'
119
+ | 'ease-in'
120
+ | 'ease-out'
121
+ | 'ease-in-out'
122
+ | 'step-start'
123
+ | 'step-end'
124
+ | string
125
+ | undefined
126
+ fillMode: 'none' | 'forwards' | 'backwards' | 'both' | undefined
127
+ }
128
+ | { type: 'preview_style'; styles: Record<string, string> | null }
129
+
130
+ /**
131
+ * Styles required for rendering the same exact text again somewhere else (on a overlay rect in the editor)
132
+ */
133
+ enum TextNodeComputedStyles {
134
+ // Caret color is important as it is the only visible part of the text node (when text is not highlighted)
135
+ CARET_COLOR = 'caret-color',
136
+ FONT_FAMILY = 'font-family',
137
+ FONT_SIZE = 'font-size',
138
+ FONT_WEIGHT = 'font-weight',
139
+ FONT_STYLE = 'font-style',
140
+ FONT_VARIANT = 'font-variant',
141
+ FONT_STRETCH = 'font-stretch',
142
+ LINE_HEIGHT = 'line-height',
143
+ TEXT_ALIGN = 'text-align',
144
+ TEXT_TRANSFORM = 'text-transform',
145
+ LETTER_SPACING = 'letter-spacing',
146
+ WHITE_SPACE = 'white-space',
147
+ WORD_SPACING = 'word-spacing',
148
+ TEXT_INDENT = 'text-indent',
149
+ TEXT_OVERFLOW = 'text-overflow',
150
+ TEXT_RENDERING = 'text-rendering',
151
+ WORD_BREAK = 'word-break',
152
+ WORD_WRAP = 'word-wrap',
153
+ DIRECTION = 'direction',
154
+ UNICODE_BIDI = 'unicode-bidi',
155
+ VERTICAL_ALIGN = 'vertical-align',
156
+ }
157
+
158
+ let env: ToddleEnv
159
+
160
+ export const initGlobalObject = ({
161
+ formulas,
162
+ actions,
163
+ }: {
164
+ formulas: Record<string, Record<string, PluginFormula<FormulaHandlerV2>>>
165
+ actions: Record<string, Record<string, PluginActionV2>>
166
+ }) => {
167
+ env = {
168
+ isServer: false,
169
+ branchName: window.__toddle.branch,
170
+ request: undefined,
171
+ runtime: 'preview',
172
+ logErrors: true,
173
+ }
174
+ window.toddle = (() => {
175
+ const legacyActions: Record<string, ActionHandler> = {}
176
+ const legacyFormulas: Record<string, FormulaHandler> = {}
177
+ const argumentInputDataList: Record<string, ArgumentInputDataFunction> = {}
178
+ const toddle: Toddle<LocationSignal, PreviewShowSignal> = {
179
+ isEqual: fastDeepEqual,
180
+ errors: [],
181
+ formulas,
182
+ actions,
183
+ registerAction: (name, handler) => {
184
+ if (legacyActions[name]) {
185
+ console.error('There already exists an action with the name ', name)
186
+ return
187
+ }
188
+ legacyActions[name] = handler
189
+ },
190
+ getAction: (name) => legacyActions[name],
191
+ registerFormula: (name, handler, getArgumentInputData) => {
192
+ if (legacyFormulas[name]) {
193
+ console.error('There already exists a formula with the name ', name)
194
+ return
195
+ }
196
+ legacyFormulas[name] = handler
197
+ if (getArgumentInputData) {
198
+ argumentInputDataList[name] = getArgumentInputData
199
+ }
200
+ },
201
+ getFormula: (name) => legacyFormulas[name],
202
+ getCustomAction: (name, packageName) => {
203
+ return (
204
+ toddle.actions[packageName ?? window.__toddle.project]?.[name] ??
205
+ toddle.actions[window.__toddle.project]?.[name]
206
+ )
207
+ },
208
+ getCustomFormula: (name, packageName) => {
209
+ return (
210
+ toddle.formulas[packageName ?? window.__toddle.project]?.[name] ??
211
+ toddle.formulas[window.__toddle.project]?.[name]
212
+ )
213
+ },
214
+ // eslint-disable-next-line max-params
215
+ getArgumentInputData: (formulaName, args, argIndex, data) =>
216
+ argumentInputDataList[formulaName]?.(args, argIndex, data) || data,
217
+ data: {},
218
+ eventLog: [],
219
+ project: window.__toddle.project,
220
+ branch: window.__toddle.branch,
221
+ commit: window.__toddle.commit,
222
+ components: window.__toddle.components,
223
+ pageState: window.__toddle.pageState,
224
+ locationSignal: signal<any>({
225
+ query: {},
226
+ params: {},
227
+ }),
228
+ env,
229
+ }
230
+ return toddle
231
+ })()
232
+
233
+ // load default formulas and actions
234
+ Object.entries(libFormulas).forEach(([name, module]) =>
235
+ window.toddle.registerFormula(
236
+ '@toddle/' + name,
237
+ module.default as FormulaHandler,
238
+ 'getArgumentInputData' in module
239
+ ? module.getArgumentInputData
240
+ : undefined,
241
+ ),
242
+ )
243
+ Object.entries(libActions).forEach(([name, module]) =>
244
+ window.toddle.registerAction('@toddle/' + name, module.default),
245
+ )
246
+ }
247
+
248
+ // imported by "/.toddle/preview" (see worker/src/preview.ts)
249
+ export const createRoot = (
250
+ domNode: HTMLElement | null = document.getElementById('App'),
251
+ ) => {
252
+ if (!domNode) {
253
+ throw new Error('Cant find root domNode')
254
+ }
255
+ const isInputTarget = (event: Event) => {
256
+ const target = event.target
257
+ if (target instanceof HTMLElement) {
258
+ if (
259
+ target.tagName === 'INPUT' ||
260
+ target.tagName === 'TEXTAREA' ||
261
+ target.tagName === 'SELECT' ||
262
+ target.tagName === 'STYLE-EDITOR'
263
+ ) {
264
+ return true
265
+ }
266
+ if (target.contentEditable?.toLocaleLowerCase() === 'true') {
267
+ return true
268
+ }
269
+ }
270
+ return false
271
+ }
272
+
273
+ insertTheme(document.head, theme)
274
+ const dataSignal = signal<ComponentData>({
275
+ Location: {
276
+ query: {},
277
+ params: {},
278
+ page: '/',
279
+ path: '/',
280
+ hash: '',
281
+ },
282
+ Attributes: {},
283
+ Variables: {},
284
+ })
285
+ let ctxDataSignal: Signal<ComponentData> | undefined
286
+
287
+ let ctx: ComponentContext | null = null
288
+ let mode: 'design' | 'test' = 'design'
289
+ // Signal for overriding conditional elements when they're
290
+ // selected in design mode and for reverting back to normal
291
+ // in test mode
292
+ const showSignal = signal<{ displayedNodes: string[]; testMode: boolean }>({
293
+ displayedNodes: [],
294
+ testMode: false,
295
+ })
296
+ window.toddle._preview = { showSignal }
297
+ document.body.setAttribute('data-mode', 'design')
298
+ let components: Component[] | null = null
299
+ let packageComponents: Component[] | null = null
300
+ const getAllComponents = () => [
301
+ ...(components ?? []),
302
+ ...(packageComponents ?? []),
303
+ ]
304
+ let component: Component | null = null
305
+ let selectedNodeId: string | null = null
306
+ let highlightedNodeId: string | null = null
307
+ let styleVariantSelection: {
308
+ nodeId: string
309
+ styleVariantIndex: number
310
+ } | null = null
311
+ let routeSignal: Signal<any> | null = null
312
+ let dragState: DragState | null = null
313
+ let animationState: {
314
+ animatedElementId: string | null
315
+ time: number | null
316
+ timingFunction?: string
317
+ fillMode?: string
318
+ } | null = null
319
+ let altKey = false
320
+ let metaKey = false
321
+ let previewStyleAnimationFrame = -1
322
+
323
+ /**
324
+ * Modifies all link nodes on a component
325
+ * NOTE: alters in place
326
+ */
327
+ const updateComponentLinks = (component: Component) => {
328
+ // Find all links and add target="_blank" to them
329
+ Object.entries(component.nodes ?? {}).forEach(([_, node]) => {
330
+ if (node.type === 'element' && node.tag === 'a') {
331
+ node.attrs['target'] = valueFormula('_blank')
332
+ }
333
+ })
334
+ return component
335
+ }
336
+
337
+ window.addEventListener(
338
+ 'message',
339
+ (message: MessageEvent<ToddlePreviewEvent>) => {
340
+ if (!message.isTrusted) {
341
+ console.error('UNTRUSTED MESSAGE')
342
+ }
343
+ switch (message.data?.type) {
344
+ case 'update':
345
+ {
346
+ if (highlightedNodeId) {
347
+ const highlightedNode = getDOMNodeFromNodeId(highlightedNodeId)
348
+ if (highlightedNode) {
349
+ window.parent?.postMessage(
350
+ {
351
+ type: 'highlightRect',
352
+ rect: getRectData(highlightedNode),
353
+ },
354
+ '*',
355
+ )
356
+ }
357
+ }
358
+ if (selectedNodeId) {
359
+ const selectedNode = getDOMNodeFromNodeId(selectedNodeId)
360
+ if (selectedNode) {
361
+ window.parent?.postMessage(
362
+ {
363
+ type: 'selectionRect',
364
+ rect: getRectData(selectedNode),
365
+ },
366
+ '*',
367
+ )
368
+ }
369
+ }
370
+ }
371
+ break
372
+ case 'component': {
373
+ if (!message.data.component) {
374
+ return
375
+ }
376
+ if (message.data.component.name != component?.name) {
377
+ showSignal.cleanSubscribers()
378
+ }
379
+
380
+ component = updateComponentLinks(message.data.component)
381
+
382
+ if (components && packageComponents && ctx) {
383
+ // Since we're not receiving the current component in
384
+ // "components" updates (see `SetupCanvas` action)
385
+ // we need to manually update the component in components
386
+ const componentIndex = components.findIndex(
387
+ (c) => c.name === component!.name,
388
+ )
389
+ if (componentIndex !== -1) {
390
+ components[componentIndex] = component
391
+ } else {
392
+ components.push(component)
393
+ }
394
+ ctx.components = getAllComponents()
395
+ }
396
+
397
+ dataSignal.update((data) => {
398
+ const newData: ComponentData = {
399
+ ...data,
400
+ Location: data.Location
401
+ ? {
402
+ ...data.Location,
403
+ path: component?.page ?? '',
404
+ }
405
+ : undefined,
406
+ // Ensure that URL parameters are only available for pages and not components
407
+ 'URL parameters': component?.route
408
+ ? data['URL parameters']
409
+ : undefined,
410
+ }
411
+ return newData
412
+ })
413
+
414
+ update()
415
+
416
+ if (highlightedNodeId) {
417
+ const highlightedNode = getDOMNodeFromNodeId(highlightedNodeId)
418
+ if (highlightedNode) {
419
+ window.parent?.postMessage(
420
+ {
421
+ type: 'highlightRect',
422
+ rect: getRectData(highlightedNode),
423
+ },
424
+ '*',
425
+ )
426
+ }
427
+ }
428
+ if (selectedNodeId) {
429
+ if (styleVariantSelection) {
430
+ updateSelectedStyleVariant(
431
+ styleVariantSelection.styleVariantIndex,
432
+ )
433
+ }
434
+ const selectedNode = getDOMNodeFromNodeId(selectedNodeId)
435
+ if (selectedNode) {
436
+ window.parent?.postMessage(
437
+ {
438
+ type: 'selectionRect',
439
+ rect: getRectData(selectedNode),
440
+ },
441
+ '*',
442
+ )
443
+ }
444
+ }
445
+
446
+ break
447
+ }
448
+ case 'components': {
449
+ if (Array.isArray(message.data.components)) {
450
+ components = (message.data.components as Component[]).map(
451
+ updateComponentLinks,
452
+ )
453
+ const allComponents = getAllComponents()
454
+ if (ctx) {
455
+ ctx.components = allComponents
456
+ }
457
+
458
+ updateStyle()
459
+ update()
460
+ }
461
+
462
+ break
463
+ }
464
+ case 'packages': {
465
+ if (message.data.packages) {
466
+ packageComponents = Object.values(message.data.packages ?? {})
467
+ .flatMap((p) =>
468
+ Object.values(p.components).map((c) => ({
469
+ ...c,
470
+ name: `${p.manifest.name}/${c.name}`,
471
+ })),
472
+ )
473
+ .map(updateComponentLinks)
474
+
475
+ const allComponents = getAllComponents()
476
+ if (ctx) {
477
+ ctx.components = allComponents
478
+ }
479
+
480
+ updateStyle()
481
+ update()
482
+ }
483
+
484
+ break
485
+ }
486
+ case 'theme': {
487
+ insertTheme(document.head, message.data.theme)
488
+ break
489
+ }
490
+ case 'mode': {
491
+ mode = message.data.mode
492
+ document.body.setAttribute('data-mode', message.data.mode)
493
+ updateConditionalElements()
494
+ break
495
+ }
496
+ case 'attrs': {
497
+ if (
498
+ message.data.attrs &&
499
+ fastDeepEqual(message.data.attrs, dataSignal.get().Attributes) ===
500
+ false
501
+ ) {
502
+ const attrs = message.data.attrs
503
+ dataSignal.update((data) => {
504
+ // TODO: We should figure out if "Props" is used anywhere and get rid of it if it's not
505
+ const newData: ComponentData & {
506
+ Props: Record<string, unknown>
507
+ } = {
508
+ ...data,
509
+ Location:
510
+ data.Location && component?.page
511
+ ? {
512
+ ...data.Location,
513
+ query: attrs as Record<string, string>,
514
+ }
515
+ : data.Location,
516
+ Props: attrs ?? {},
517
+ }
518
+ return newData
519
+ })
520
+ }
521
+ break
522
+ }
523
+ case 'selection': {
524
+ if (selectedNodeId !== message.data.selectedNodeId) {
525
+ selectedNodeId = message.data.selectedNodeId ?? null
526
+ clearSelectedStyleVariant()
527
+
528
+ updateConditionalElements()
529
+
530
+ const selectedNode = getDOMNodeFromNodeId(selectedNodeId)
531
+ window.parent?.postMessage(
532
+ {
533
+ type: 'selectionRect',
534
+ rect: getRectData(selectedNode),
535
+ },
536
+ '*',
537
+ )
538
+
539
+ const node = getDOMNodeFromNodeId(selectedNodeId)
540
+ const element =
541
+ component?.nodes[node?.getAttribute('data-node-id') ?? '']
542
+ if (
543
+ node &&
544
+ element &&
545
+ element.type === 'text' &&
546
+ element.value.type === 'value'
547
+ ) {
548
+ const computedStyle = window.getComputedStyle(node)
549
+ window.parent?.postMessage(
550
+ {
551
+ type: 'textComputedStyle',
552
+ computedStyle: Object.fromEntries(
553
+ Object.values(TextNodeComputedStyles).map((style) => [
554
+ style,
555
+ computedStyle.getPropertyValue(style),
556
+ ]),
557
+ ),
558
+ },
559
+ '*',
560
+ )
561
+ } else if (node && node.getAttribute('data-node-type') !== 'text') {
562
+ // Reset computed style on blur
563
+ window.parent?.postMessage(
564
+ {
565
+ type: 'textComputedStyle',
566
+ computedStyle: {},
567
+ },
568
+ '*',
569
+ )
570
+ }
571
+ }
572
+ return
573
+ }
574
+ case 'update_inner_text': {
575
+ const { innerText } = message.data
576
+ const selectedNode = getDOMNodeFromNodeId(selectedNodeId)
577
+ if (
578
+ selectedNode &&
579
+ selectedNode.getAttribute('data-node-type') === 'text'
580
+ ) {
581
+ ;(selectedNode as HTMLElement).innerText = innerText
582
+ window.parent?.postMessage(
583
+ {
584
+ type: 'selectionRect',
585
+ rect: getRectData(selectedNode),
586
+ },
587
+ '*',
588
+ )
589
+ }
590
+ return
591
+ }
592
+ case 'highlight': {
593
+ if (highlightedNodeId !== message.data.highlightedNodeId) {
594
+ highlightedNodeId = message.data.highlightedNodeId ?? null
595
+ const highlightedNode = getDOMNodeFromNodeId(highlightedNodeId)
596
+ window.parent?.postMessage(
597
+ {
598
+ type: 'highlightRect',
599
+ rect: getRectData(highlightedNode),
600
+ },
601
+ '*',
602
+ )
603
+ }
604
+ return
605
+ }
606
+ case 'mousemove':
607
+ if (dragState && !dragState.destroying) {
608
+ const { x, y } = message.data
609
+ dragState.lastCursorPosition = { x, y }
610
+ const draggingInsideContainer = rectHasPoint(
611
+ dragState.initialContainer.getBoundingClientRect(),
612
+ { x, y },
613
+ )
614
+ if (draggingInsideContainer && !metaKey) {
615
+ dragReorder(dragState)
616
+ } else {
617
+ dragMove(
618
+ dragState,
619
+ metaKey
620
+ ? [dragState.element]
621
+ : [dragState.element, dragState.initialContainer],
622
+ )
623
+ }
624
+ dragState.element.style.setProperty(
625
+ 'translate',
626
+ `${x - dragState.offset.x}px ${y - dragState.offset.y}px`,
627
+ )
628
+ return
629
+ }
630
+ case 'click':
631
+ case 'dblclick':
632
+ if (mode === 'test' || !component) {
633
+ return
634
+ }
635
+ const { x, y, type } = message.data
636
+ const elementsAtPoint = document.elementsFromPoint(x, y)
637
+ let element = elementsAtPoint.find((elem) => {
638
+ const id = elem.getAttribute('data-id')
639
+ if (
640
+ typeof id !== 'string' ||
641
+ component === null ||
642
+ elem.getAttribute('data-component')
643
+ ) {
644
+ return false
645
+ }
646
+ const nodeId = getNodeId(component, id.split('.').slice(1))
647
+ const node = nodeId ? component?.nodes[nodeId] : undefined
648
+ if (!node) {
649
+ return false
650
+ }
651
+ if (elem.getAttribute('data-node-type') === 'text') {
652
+ return (
653
+ // Select text nodes if the meta key is pressed or the text node is double-clicked
654
+ metaKey ||
655
+ type === 'dblclick' ||
656
+ // Select text nodes if the selected node is a text node. This is useful as the user is likely in a text editing mode
657
+ getDOMNodeFromNodeId(selectedNodeId)?.getAttribute(
658
+ 'data-node-type',
659
+ ) === 'text'
660
+ )
661
+ }
662
+ return true
663
+ })
664
+
665
+ // Bubble selection to the topmost parent that has the exact same size as the element.
666
+ // This is important for drag and drop as you are often left with childless parents after dragging.
667
+ while (
668
+ element?.parentElement &&
669
+ element.getAttribute('data-node-id') !== 'root' &&
670
+ fastDeepEqual(
671
+ element.getBoundingClientRect().toJSON(),
672
+ element.parentElement.getBoundingClientRect().toJSON(),
673
+ ) &&
674
+ element.getAttribute('data-node-type') !== 'text'
675
+ ) {
676
+ element = element.parentElement
677
+ }
678
+
679
+ const id = element?.getAttribute('data-id') ?? null
680
+ if (type === 'click' && id !== selectedNodeId) {
681
+ if (message.data.metaKey) {
682
+ // Figure out if the clicked element is a text element
683
+ // or if one of its descendants is a text element
684
+ const root = component.nodes.root
685
+ if (root && id) {
686
+ const nodeLookup = getNodeAndAncestors(component, root, id)
687
+ if (nodeLookup?.node.type === 'text') {
688
+ window.parent?.postMessage(
689
+ {
690
+ type: 'selection',
691
+ selectedNodeId: id,
692
+ },
693
+ '*',
694
+ )
695
+ } else {
696
+ const firstTextChild =
697
+ nodeLookup?.node.type === 'element'
698
+ ? nodeLookup.node.children.find(
699
+ (c) => component?.nodes[c]?.type === 'text',
700
+ )
701
+ : undefined
702
+ if (firstTextChild) {
703
+ window.parent?.postMessage(
704
+ {
705
+ type: 'selection',
706
+ selectedNodeId: `${id}.0`,
707
+ },
708
+ '*',
709
+ )
710
+ }
711
+ }
712
+ }
713
+ } else {
714
+ window.parent?.postMessage(
715
+ {
716
+ type: 'selection',
717
+ selectedNodeId: id,
718
+ },
719
+ '*',
720
+ )
721
+ }
722
+ } else if (type === 'mousemove' && id !== highlightedNodeId) {
723
+ window.parent?.postMessage(
724
+ {
725
+ type: 'highlight',
726
+ highlightedNodeId: id,
727
+ },
728
+ '*',
729
+ )
730
+ } else if (
731
+ type === 'dblclick' &&
732
+ id &&
733
+ // We only allow dblclick --> navigation if we're not in test mode
734
+ mode === 'design'
735
+ ) {
736
+ // Figure out if the clicked element is a component
737
+ const root = component.nodes.root
738
+ if (root) {
739
+ const nodeLookup = getNodeAndAncestors(component, root, id)
740
+ if (
741
+ nodeLookup?.node.type === 'component' &&
742
+ nodeLookup.node.name
743
+ ) {
744
+ window.parent?.postMessage(
745
+ {
746
+ type: 'navigate',
747
+ name: nodeLookup.node.name,
748
+ },
749
+ '*',
750
+ )
751
+ }
752
+ // Double click on text node should select the text node for editing
753
+ else if (nodeLookup?.node.type === 'text') {
754
+ window.parent?.postMessage(
755
+ {
756
+ type: 'selection',
757
+ selectedNodeId: id,
758
+ },
759
+ '*',
760
+ )
761
+ }
762
+ }
763
+ }
764
+ break
765
+ case 'style_variant_changed':
766
+ const { variantIndex } = message.data
767
+ updateSelectedStyleVariant(variantIndex)
768
+ break
769
+ // We request manually instead of automatic to avoid mutation observer spam.
770
+ // Also, reporting automatically proved unreliable when elements' height was in %
771
+ case 'report_document_scroll_size':
772
+ window.parent?.postMessage(
773
+ {
774
+ type: 'documentScrollSize',
775
+ scrollHeight: domNode.scrollHeight,
776
+ scrollWidth: domNode.scrollWidth,
777
+ },
778
+ '*',
779
+ )
780
+ break
781
+ case 'reload':
782
+ window.location.reload()
783
+ break
784
+ case 'fetch_api':
785
+ const { apiKey } = message.data
786
+ dataSignal.update((data) => ({
787
+ ...data,
788
+ Apis: {
789
+ ...data.Apis,
790
+ [apiKey]: {
791
+ isLoading: true,
792
+ data: null,
793
+ error: null,
794
+ },
795
+ },
796
+ }))
797
+ ctx?.apis[apiKey]?.fetch({})
798
+ break
799
+ case 'drag-started':
800
+ const draggedElement = getDOMNodeFromNodeId(selectedNodeId)
801
+ if (!draggedElement || !draggedElement.parentElement) {
802
+ return
803
+ }
804
+ const repeatedNodes = Array.from(
805
+ draggedElement.parentElement.children,
806
+ ).filter(
807
+ (node) =>
808
+ node instanceof HTMLElement &&
809
+ node.getAttribute('data-id')?.startsWith(selectedNodeId + '('),
810
+ ) as HTMLElement[]
811
+ dragState = dragStarted({
812
+ element: draggedElement as HTMLElement,
813
+ lastCursorPosition: { x: message.data.x, y: message.data.y },
814
+ repeatedNodes,
815
+ asCopy: altKey,
816
+ })
817
+ if (altKey) {
818
+ const nextRect = dragState.element.getBoundingClientRect()
819
+ dragState.offset.x += nextRect.left - dragState.initialRect.left
820
+ dragState.offset.y += nextRect.top - dragState.initialRect.top
821
+ }
822
+
823
+ break
824
+ case 'drag-ended':
825
+ switch (dragState?.mode) {
826
+ case 'reorder':
827
+ const parentDataId =
828
+ dragState?.initialContainer.getAttribute('data-id')
829
+ const parentNodeId =
830
+ dragState?.initialContainer.getAttribute('data-node-id')
831
+ if (!parentDataId || !parentNodeId) {
832
+ return
833
+ }
834
+
835
+ const nextSibling = dragState?.element.nextElementSibling
836
+ const nextSiblingId = parseInt(
837
+ nextSibling?.getAttribute('data-id')?.split('.').at(-1) ?? '',
838
+ )
839
+
840
+ const rect = dragState?.element?.getBoundingClientRect()
841
+ if (
842
+ rect &&
843
+ !message.data.canceled &&
844
+ (nextSibling !== dragState?.initialNextSibling ||
845
+ dragState?.copy)
846
+ ) {
847
+ void dragEnded(dragState, false).then(() => {
848
+ window.parent?.postMessage(
849
+ {
850
+ type: 'nodeMoved',
851
+ copy: Boolean(dragState?.copy),
852
+ parent: parentDataId,
853
+ index: !isNaN(nextSiblingId)
854
+ ? nextSiblingId
855
+ : component?.nodes[parentNodeId]?.children?.length,
856
+ },
857
+ '*',
858
+ )
859
+ dragState = null
860
+ })
861
+ } else {
862
+ void dragEnded(dragState, true).then(() => {
863
+ dragState = null
864
+ })
865
+ }
866
+ break
867
+ case 'insert':
868
+ const selectedPermutation =
869
+ dragState?.insertAreas?.[
870
+ dragState?.selectedInsertAreaIndex ?? -1
871
+ ]
872
+ if (selectedPermutation && !message.data.canceled) {
873
+ void dragEnded(dragState, false).then(() => {
874
+ window.parent?.postMessage(
875
+ {
876
+ type: 'nodeMoved',
877
+ copy: Boolean(dragState?.copy),
878
+ parent:
879
+ selectedPermutation?.parent.getAttribute('data-id'),
880
+ index: selectedPermutation?.index,
881
+ },
882
+ '*',
883
+ )
884
+ dragState = null
885
+ })
886
+ } else {
887
+ void dragEnded(dragState, true).then(() => {
888
+ dragState = null
889
+ })
890
+ }
891
+ break
892
+ }
893
+ break
894
+ case 'keydown':
895
+ case 'keyup':
896
+ // If the `altKey` is pressed/released and the user is currently dragging, then restart the drag with/without a copy.
897
+ if (
898
+ dragState &&
899
+ !dragState.destroying &&
900
+ message.data.altKey !== altKey
901
+ ) {
902
+ const asCopy = message.data.altKey
903
+ const prevRect = dragState.element.getBoundingClientRect()
904
+ void dragEnded(dragState, true).then(() => {
905
+ if (!dragState) return
906
+ dragState = dragStarted({
907
+ element: dragState.element,
908
+ lastCursorPosition: dragState.lastCursorPosition,
909
+ repeatedNodes: dragState.repeatedNodes,
910
+ asCopy,
911
+ initialContainer: dragState.initialContainer,
912
+ initialNextSibling: dragState.initialNextSibling,
913
+ })
914
+ const nextRect = dragState.element.getBoundingClientRect()
915
+ dragState.offset.x += nextRect.left - prevRect.left
916
+ dragState.offset.y += nextRect.top - prevRect.top
917
+ })
918
+ }
919
+ altKey = message.data.altKey
920
+ metaKey = message.data.metaKey
921
+ break
922
+
923
+ case 'get_computed_style':
924
+ const selectedNode = getDOMNodeFromNodeId(selectedNodeId)
925
+ if (!selectedNode) {
926
+ return
927
+ }
928
+
929
+ const { styles } = message.data
930
+ const computedStyle = window.getComputedStyle(selectedNode)
931
+ window.parent?.postMessage(
932
+ {
933
+ type: 'computedStyle',
934
+ computedStyle: Object.fromEntries(
935
+ styles.map((style) => [
936
+ style,
937
+ computedStyle.getPropertyValue(style),
938
+ ]),
939
+ ),
940
+ },
941
+ '*',
942
+ )
943
+ break
944
+
945
+ case 'set_timeline_keyframes':
946
+ const { keyframes } = message.data
947
+ document.head.querySelector('[data-timeline-keyframes]')?.remove()
948
+ if (!keyframes) {
949
+ return
950
+ }
951
+
952
+ const styleElem = document.createElement('style')
953
+ styleElem.appendChild(
954
+ document.createTextNode(`
955
+ @keyframes preview_timeline {
956
+ ${Object.values(keyframes)
957
+ .map(
958
+ ({ key, value, position, easing }) =>
959
+ `${position * 100}% {
960
+ ${key}: ${value};
961
+ ${easing ? `animation-timing-function: ${easing};` : ''}
962
+ }`,
963
+ )
964
+ .join('\n')}
965
+ }
966
+ `),
967
+ )
968
+ styleElem.setAttribute('data-timeline-keyframes', '')
969
+ document.head.appendChild(styleElem)
970
+ window.parent?.postMessage(
971
+ {
972
+ type: 'selectionRect',
973
+ rect: getRectData(
974
+ getDOMNodeFromNodeId(selectedNodeId) ?? document.body,
975
+ ),
976
+ },
977
+ '*',
978
+ )
979
+ break
980
+
981
+ case 'set_timeline_time':
982
+ const { time, timingFunction, fillMode } = message.data
983
+ const prevAnimatedElement = getDOMNodeFromNodeId(
984
+ animationState?.animatedElementId ?? '',
985
+ )
986
+ animationState = {
987
+ animatedElementId: time !== null ? selectedNodeId : null,
988
+ time,
989
+ timingFunction,
990
+ fillMode,
991
+ }
992
+
993
+ const animatedElement = getDOMNodeFromNodeId(
994
+ animationState.animatedElementId,
995
+ )
996
+ if (
997
+ prevAnimatedElement === null ||
998
+ prevAnimatedElement !== animatedElement
999
+ ) {
1000
+ prevAnimatedElement?.classList.remove('editor-preview-timeline')
1001
+ }
1002
+
1003
+ if (animatedElement && time !== null) {
1004
+ animatedElement.classList.add('editor-preview-timeline')
1005
+ document.body.style.setProperty(
1006
+ '--editor-timeline-position',
1007
+ `${time}s`,
1008
+ )
1009
+ document.body.style.setProperty(
1010
+ '--editor-timeline-timing-function',
1011
+ timingFunction ?? 'ease',
1012
+ )
1013
+ document.body.style.setProperty(
1014
+ '--editor-timeline-fill-mode',
1015
+ fillMode ?? 'none',
1016
+ )
1017
+ } else {
1018
+ document.body.style.removeProperty('--editor-timeline-position')
1019
+ document.body.style.removeProperty(
1020
+ '--editor-timeline-timing-function',
1021
+ )
1022
+ document.body.style.removeProperty('--editor-timeline-fill-mode')
1023
+ update()
1024
+ }
1025
+
1026
+ window.parent?.postMessage(
1027
+ {
1028
+ type: 'selectionRect',
1029
+ rect: getRectData(animatedElement),
1030
+ },
1031
+ '*',
1032
+ )
1033
+ break
1034
+ case 'preview_style':
1035
+ const { styles: previewStyleStyles } = message.data
1036
+ cancelAnimationFrame(previewStyleAnimationFrame)
1037
+ previewStyleAnimationFrame = requestAnimationFrame(() => {
1038
+ // Update or create a new style tag and set the given styles with important priority
1039
+ let styleTag = document.head.querySelector(
1040
+ '[data-id="selected-node-styles"]',
1041
+ )
1042
+
1043
+ // Cleanup when null styles are sent
1044
+ if (!previewStyleStyles) {
1045
+ styleTag?.remove()
1046
+ return
1047
+ }
1048
+
1049
+ if (!styleTag) {
1050
+ styleTag = document.createElement('style')
1051
+ styleTag.setAttribute('data-id', 'selected-node-styles')
1052
+ document.head.appendChild(styleTag)
1053
+ }
1054
+
1055
+ const previewStyles = Object.entries(previewStyleStyles)
1056
+ .map(([key, value]) => `${key}: ${value} !important;`)
1057
+ .join('\n')
1058
+ styleTag.innerHTML = `[data-id="${selectedNodeId}"], [data-id="${selectedNodeId}"] ~ [data-id^="${selectedNodeId}("] {
1059
+ ${previewStyles}
1060
+ transition: none !important;
1061
+ }`
1062
+
1063
+ window.parent?.postMessage(
1064
+ {
1065
+ type: 'selectionRect',
1066
+ rect: getRectData(getDOMNodeFromNodeId(selectedNodeId)),
1067
+ },
1068
+ '*',
1069
+ )
1070
+ })
1071
+ break
1072
+ }
1073
+ },
1074
+ )
1075
+
1076
+ const updateStyle = () => {
1077
+ if (component) {
1078
+ insertStyles(document.head, component, getAllComponents())
1079
+ }
1080
+ }
1081
+
1082
+ /**
1083
+ * Get the current representation of the component, but with
1084
+ * updated conditions based on selectedNodeId and updated
1085
+ * styling based on styleVariantSelection
1086
+ */
1087
+ const getCurrentComponent = (): Component | null => {
1088
+ const _component = structuredClone(component)
1089
+ if (!_component) {
1090
+ return null
1091
+ }
1092
+ if (mode === 'design') {
1093
+ if (selectedNodeId !== null) {
1094
+ const root = _component?.nodes.root
1095
+ if (root) {
1096
+ const nodeLookup = getNodeAndAncestors(
1097
+ _component,
1098
+ root,
1099
+ selectedNodeId,
1100
+ )
1101
+ if (nodeLookup) {
1102
+ if (isNodeOrAncestorConditional(nodeLookup)) {
1103
+ // Show the selected node and all its ancestors by
1104
+ // removing their "show" condition
1105
+ nodeLookup.node.condition = undefined
1106
+ nodeLookup.ancestors.forEach((a) => (a.condition = undefined))
1107
+ }
1108
+ }
1109
+ }
1110
+ }
1111
+ }
1112
+ return _component
1113
+ }
1114
+
1115
+ const updateSelectedStyleVariant = (variantIndex: number | null) => {
1116
+ clearSelectedStyleVariant()
1117
+ if (selectedNodeId !== null && typeof variantIndex === 'number') {
1118
+ styleVariantSelection = {
1119
+ nodeId: selectedNodeId,
1120
+ styleVariantIndex: variantIndex,
1121
+ }
1122
+ const root = component?.nodes.root
1123
+ if (root && component) {
1124
+ const nodeLookup = getNodeAndAncestors(component, root, selectedNodeId)
1125
+ if (nodeLookup) {
1126
+ if (
1127
+ styleVariantSelection?.nodeId === selectedNodeId &&
1128
+ (nodeLookup.node.type === 'element' ||
1129
+ nodeLookup.node.type === 'component')
1130
+ ) {
1131
+ const selectedStyleVariant = nodeLookup.node.variants?.[
1132
+ styleVariantSelection.styleVariantIndex
1133
+ ] ?? { style: {} }
1134
+ // Add a style element specific to the selected element which
1135
+ // is only applied when the preview is in design mode
1136
+ const styleElem = document.createElement('style')
1137
+ styleElem.setAttribute('data-hash', selectedNodeId)
1138
+ styleElem.appendChild(
1139
+ document.createTextNode(`
1140
+ body[data-mode="design"] [data-id="${selectedNodeId}"] {
1141
+ ${styleToCss({
1142
+ ...nodeLookup.node.style,
1143
+ ...selectedStyleVariant.style,
1144
+ })}
1145
+ }
1146
+ `),
1147
+ )
1148
+ const existingStyleElement = document.head.querySelector(
1149
+ `[data-hash="${selectedNodeId}"]`,
1150
+ )
1151
+ if (existingStyleElement) {
1152
+ document.head.removeChild(existingStyleElement)
1153
+ }
1154
+ document.head.appendChild(styleElem)
1155
+ }
1156
+ }
1157
+ }
1158
+ }
1159
+ const selectedNode = getDOMNodeFromNodeId(selectedNodeId)
1160
+ window.parent?.postMessage(
1161
+ {
1162
+ type: 'selectionRect',
1163
+ rect: getRectData(selectedNode),
1164
+ },
1165
+ '*',
1166
+ )
1167
+ }
1168
+
1169
+ const update = () => {
1170
+ const _component = getCurrentComponent()
1171
+ if (!_component || !components || !packageComponents) {
1172
+ return
1173
+ }
1174
+
1175
+ let { Attributes, Variables, Contexts } = dataSignal.get()
1176
+ if (
1177
+ fastDeepEqual(ctx?.component.attributes, _component.attributes) === false
1178
+ ) {
1179
+ Attributes = mapObject(_component.attributes, ([name, { testValue }]) => [
1180
+ name,
1181
+ testValue,
1182
+ ])
1183
+ }
1184
+ if (
1185
+ _component.route &&
1186
+ fastDeepEqual(ctx?.component.route, _component.route) === false
1187
+ ) {
1188
+ // Subscribe to the route signal so we can preview URL parameter changes in the editor
1189
+ routeSignal?.destroy()
1190
+ if (_component.route) {
1191
+ // Populate initial URL parameters with test data
1192
+ window.toddle.locationSignal.update((location) => {
1193
+ if (!_component.route) return location
1194
+
1195
+ return {
1196
+ ...location,
1197
+ route: _component.route,
1198
+ params: Object.fromEntries(
1199
+ _component.route.path
1200
+ .filter((p) => p.type === 'param')
1201
+ .map((p) => [p.name, p.testValue]),
1202
+ ),
1203
+ query: mapObject(
1204
+ _component.route.query,
1205
+ ([name, { testValue }]: [string, { testValue: string }]) => [
1206
+ name,
1207
+ testValue,
1208
+ ],
1209
+ ),
1210
+ }
1211
+ })
1212
+
1213
+ routeSignal = window.toddle.locationSignal.map(({ query, params }) => {
1214
+ return { ...query, ...params }
1215
+ })
1216
+
1217
+ routeSignal.subscribe((route) =>
1218
+ dataSignal.update((data) => ({
1219
+ ...data,
1220
+ 'URL parameters': route,
1221
+ Attributes: route,
1222
+ })),
1223
+ )
1224
+ }
1225
+
1226
+ Attributes = mapObject(_component.attributes, ([name, { testValue }]) => [
1227
+ name,
1228
+ testValue,
1229
+ ])
1230
+ }
1231
+ if (
1232
+ fastDeepEqual(
1233
+ ctx?.component.route?.info?.meta,
1234
+ _component.route?.info?.meta,
1235
+ ) === false
1236
+ ) {
1237
+ insertHeadTags(_component.route?.info?.meta ?? {}, {
1238
+ component: _component,
1239
+ data: { Attributes },
1240
+ root: document,
1241
+ package: ctx?.package,
1242
+ toddle: window.toddle,
1243
+ env,
1244
+ })
1245
+ }
1246
+ if (fastDeepEqual(_component.contexts, ctx?.component.contexts) === false) {
1247
+ Contexts = (function createStaticContextFromComponent(
1248
+ component: Component,
1249
+ contextProvidersCreated?: Set<string>,
1250
+ ) {
1251
+ contextProvidersCreated?.add(component.name)
1252
+ return mapObject(
1253
+ component.contexts ?? {},
1254
+ ([providerName, context]) => {
1255
+ if (contextProvidersCreated?.has(providerName)) {
1256
+ // Circular dependency detected in context-providers (ie. A -> B -> A -> ...), stop recursion
1257
+ return [providerName, {}]
1258
+ }
1259
+
1260
+ const providerComponent = getAllComponents().find(
1261
+ (c) => c.name === providerName,
1262
+ )
1263
+ if (!providerComponent) {
1264
+ console.warn(
1265
+ `Could not find a provider-component named "${providerName}" in files`,
1266
+ )
1267
+ return [providerName, {}]
1268
+ }
1269
+
1270
+ // TODO: Should we also run APIs for the provider?
1271
+ const formulaContext: FormulaContext = {
1272
+ data: {
1273
+ Attributes: mapObject(
1274
+ providerComponent.attributes,
1275
+ ([name, attr]) => [name, attr.testValue],
1276
+ ),
1277
+ // Recursively resolve contexts providers before their children to build up the fake context tree in preview mode
1278
+ Contexts: createStaticContextFromComponent(
1279
+ providerComponent,
1280
+ contextProvidersCreated ?? new Set(),
1281
+ ),
1282
+ },
1283
+ component: providerComponent,
1284
+ root: ctx?.root,
1285
+ formulaCache: {},
1286
+ package: ctx?.package,
1287
+ toddle: window.toddle,
1288
+ env,
1289
+ }
1290
+
1291
+ // Pages can also be context-providers!
1292
+ // Exposed formulas can derive their preview output from URL data,
1293
+ // so we must populate Url parameters with their test data
1294
+ if (providerComponent.route) {
1295
+ formulaContext.data['URL parameters'] = {
1296
+ ...Object.fromEntries(
1297
+ providerComponent.route.path
1298
+ .filter((p) => p.type === 'param')
1299
+ .map((p) => [p.name, p.testValue]),
1300
+ ),
1301
+ ...mapObject(
1302
+ providerComponent.route.query,
1303
+ ([name, { testValue }]) => [name, testValue],
1304
+ ),
1305
+ }
1306
+ }
1307
+ formulaContext.data.Variables = mapObject(
1308
+ providerComponent.variables,
1309
+ ([name, variable]) => [
1310
+ name,
1311
+ applyFormula(variable.initialValue, formulaContext),
1312
+ ],
1313
+ )
1314
+
1315
+ return [
1316
+ providerName,
1317
+ Object.fromEntries(
1318
+ context.formulas.map((formulaName) => {
1319
+ const formula = providerComponent.formulas?.[formulaName]
1320
+ if (!formula) {
1321
+ console.warn(
1322
+ `Could not find formula "${formulaName}" in component "${providerName}"`,
1323
+ )
1324
+ return [formulaName, null]
1325
+ }
1326
+
1327
+ return [
1328
+ formulaName,
1329
+ applyFormula(formula.formula, formulaContext),
1330
+ ]
1331
+ }),
1332
+ ),
1333
+ ]
1334
+ },
1335
+ )
1336
+ })(_component)
1337
+ }
1338
+ if (
1339
+ fastDeepEqual(_component.variables, ctx?.component.variables) === false
1340
+ ) {
1341
+ Variables = mapObject(
1342
+ _component.variables,
1343
+ ([name, { initialValue }]) => [
1344
+ name,
1345
+ applyFormula(initialValue, {
1346
+ data: { Attributes, Contexts },
1347
+ component: _component!,
1348
+ root: document,
1349
+ package: ctx?.package,
1350
+ toddle: window.toddle,
1351
+ env,
1352
+ }),
1353
+ ],
1354
+ )
1355
+ }
1356
+
1357
+ dataSignal.update((data) => {
1358
+ return {
1359
+ ...data,
1360
+ 'URL parameters':
1361
+ component && isPageComponent(component)
1362
+ ? ({
1363
+ ...window.toddle.locationSignal.get().query,
1364
+ ...window.toddle.locationSignal.get().params,
1365
+ } as Record<string, string>)
1366
+ : {},
1367
+ Attributes,
1368
+ Variables,
1369
+ Contexts,
1370
+ }
1371
+ })
1372
+ const newCtx: ComponentContext = {
1373
+ ...(ctx ?? createContext(_component, getAllComponents())),
1374
+ component: _component,
1375
+ }
1376
+
1377
+ for (const api in newCtx.component.apis) {
1378
+ // check if the api has changed (ignoring onCompleted and onFailed).
1379
+ const apiInstance = newCtx.component.apis[api]
1380
+ const previousApiInstance = ctx?.component.apis[api]
1381
+ if (isLegacyApi(apiInstance)) {
1382
+ if (
1383
+ fastDeepEqual(
1384
+ omitKeys(apiInstance, ['onCompleted', 'onFailed']),
1385
+ previousApiInstance && isLegacyApi(previousApiInstance)
1386
+ ? omitKeys(previousApiInstance, ['onCompleted', 'onFailed'])
1387
+ : (previousApiInstance ?? {}),
1388
+ ) === false
1389
+ ) {
1390
+ newCtx.apis[api]?.destroy()
1391
+ dataSignal.update((data) => {
1392
+ return {
1393
+ ...data,
1394
+ Apis: omitKeys(data.Apis ?? {}, [
1395
+ ...Object.keys(data.Apis ?? {}).filter(
1396
+ // remove any data from an api that is not part of the component
1397
+ (key) => !newCtx.component.apis[key],
1398
+ ),
1399
+ api,
1400
+ ]),
1401
+ }
1402
+ })
1403
+ newCtx.apis[api] = createLegacyAPI(apiInstance, newCtx)
1404
+ }
1405
+ } else {
1406
+ if (!newCtx.apis[api]) {
1407
+ newCtx.apis[api] = createAPI(apiInstance, newCtx)
1408
+ } else {
1409
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1410
+ newCtx.apis[api].update && newCtx.apis[api].update(apiInstance)
1411
+ }
1412
+ }
1413
+ }
1414
+
1415
+ if (
1416
+ fastDeepEqual(newCtx.component.nodes, ctx?.component?.nodes) === false
1417
+ ) {
1418
+ updateStyle()
1419
+
1420
+ // Remove preview styles automatically when the component changes
1421
+ document.head.querySelector('[data-id="selected-node-styles"]')?.remove()
1422
+ if (
1423
+ fastDeepEqual(
1424
+ omitSubnodeStyleForComponent(newCtx.component),
1425
+ omitSubnodeStyleForComponent(ctx?.component),
1426
+ )
1427
+ ) {
1428
+ // If we're in here, then the latest update was only a style change, so we should try some optimistic updates
1429
+ Object.keys(newCtx.component.nodes).forEach((nodeId) => {
1430
+ const newNode = newCtx.component.nodes[nodeId]
1431
+ const oldNode = ctx?.component.nodes[nodeId]
1432
+ if (
1433
+ (newNode.type === 'element' || newNode.type === 'component') &&
1434
+ (oldNode?.type === 'element' || oldNode?.type === 'component') &&
1435
+ (!fastDeepEqual(newNode.style, oldNode.style) ||
1436
+ !fastDeepEqual(newNode.variants, oldNode.variants))
1437
+ ) {
1438
+ document
1439
+ .querySelectorAll(`[data-node-id="${nodeId}"]`)
1440
+ .forEach((nodeInstance) => {
1441
+ nodeInstance.classList.remove(
1442
+ getClassName([oldNode.style, oldNode.variants]),
1443
+ )
1444
+ nodeInstance.classList.add(
1445
+ getClassName([newNode.style, newNode.variants]),
1446
+ )
1447
+ })
1448
+ }
1449
+ })
1450
+ } else {
1451
+ Array.from(domNode.children).forEach((child) => {
1452
+ if (child.tagName !== 'SCRIPT') {
1453
+ child.remove()
1454
+ }
1455
+ })
1456
+
1457
+ // Clear old root signal and create a new one to not keep old signals with previous root around
1458
+ ctxDataSignal?.destroy()
1459
+ ctxDataSignal = dataSignal.map((data) => data)
1460
+ const rootElem = createNode({
1461
+ id: 'root',
1462
+ path: '0',
1463
+ dataSignal: ctxDataSignal,
1464
+ ctx: newCtx,
1465
+ parentElement: domNode,
1466
+ instance: { [newCtx.component.name]: 'root' },
1467
+ })
1468
+ newCtx.component.onLoad?.actions.forEach((action) => {
1469
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1470
+ handleAction(action, dataSignal.get(), newCtx)
1471
+ })
1472
+ rootElem.forEach((elem) => domNode.appendChild(elem))
1473
+ window.parent?.postMessage(
1474
+ {
1475
+ type: 'style',
1476
+ time: new Intl.DateTimeFormat('en-GB', {
1477
+ timeStyle: 'long',
1478
+ }).format(new Date()),
1479
+ },
1480
+ '*',
1481
+ )
1482
+ }
1483
+ }
1484
+ // Rerendering may clear editor-preview-only styles, so we need to reapply them
1485
+ getDOMNodeFromNodeId(animationState?.animatedElementId)?.classList.add(
1486
+ 'editor-preview-timeline',
1487
+ )
1488
+
1489
+ ctx = newCtx
1490
+ }
1491
+
1492
+ const createContext = (
1493
+ component: Component,
1494
+ components: Component[],
1495
+ ): ComponentContext => {
1496
+ const ctx: ComponentContext = {
1497
+ component,
1498
+ components,
1499
+ triggerEvent: (event, data) => {
1500
+ window.parent?.postMessage(
1501
+ {
1502
+ type: 'component event',
1503
+ event,
1504
+ time: new Intl.DateTimeFormat('en-GB', {
1505
+ timeStyle: 'long',
1506
+ }).format(new Date()),
1507
+ data,
1508
+ },
1509
+ '*',
1510
+ )
1511
+ },
1512
+ dataSignal,
1513
+ root: document,
1514
+ isRootComponent: true,
1515
+ apis: {},
1516
+ children: {},
1517
+ abortSignal: new AbortController().signal,
1518
+ formulaCache: createFormulaCache(component),
1519
+ providers: {},
1520
+ package: undefined,
1521
+ toddle: window.toddle,
1522
+ env,
1523
+ }
1524
+
1525
+ if (isContextProvider(component)) {
1526
+ // Subscribe to exposed formulas and update the component's data signal
1527
+ const formulaDataSignals = Object.fromEntries(
1528
+ Object.entries(component.formulas ?? {})
1529
+ .filter(([, formula]) => formula.exposeInContext)
1530
+ .map(([name, formula]) => [
1531
+ name,
1532
+ dataSignal.map((data) =>
1533
+ applyFormula(formula.formula, {
1534
+ data,
1535
+ component,
1536
+ formulaCache: ctx.formulaCache,
1537
+ root: ctx.root,
1538
+ package: ctx.package,
1539
+ toddle: window.toddle,
1540
+ env,
1541
+ }),
1542
+ ),
1543
+ ]),
1544
+ )
1545
+
1546
+ ctx.providers = {
1547
+ ...ctx.providers,
1548
+ [component.name]: {
1549
+ component,
1550
+ formulaDataSignals,
1551
+ ctx,
1552
+ },
1553
+ }
1554
+ }
1555
+
1556
+ return ctx
1557
+ }
1558
+
1559
+ document.addEventListener('keydown', (event) => {
1560
+ if (isInputTarget(event)) {
1561
+ return
1562
+ }
1563
+ switch (event.key) {
1564
+ case 'k':
1565
+ if (event.metaKey) {
1566
+ event.preventDefault()
1567
+ }
1568
+ }
1569
+ window.parent?.postMessage(
1570
+ {
1571
+ type: 'keydown',
1572
+ event: {
1573
+ key: event.key,
1574
+ metaKey: event.metaKey,
1575
+ shiftKey: event.shiftKey,
1576
+ altKey: event.altKey,
1577
+ },
1578
+ },
1579
+ '*',
1580
+ )
1581
+ })
1582
+ document.addEventListener('keyup', (event) => {
1583
+ if (isInputTarget(event)) {
1584
+ return
1585
+ }
1586
+ window.parent?.postMessage(
1587
+ {
1588
+ type: 'keyup',
1589
+ event: {
1590
+ key: event.key,
1591
+ metaKey: event.metaKey,
1592
+ shiftKey: event.shiftKey,
1593
+ altKey: event.altKey,
1594
+ },
1595
+ },
1596
+ '*',
1597
+ )
1598
+ })
1599
+ document.addEventListener('keypress', (event) => {
1600
+ if (isInputTarget(event)) {
1601
+ return
1602
+ }
1603
+ window.parent?.postMessage(
1604
+ {
1605
+ type: 'keypress',
1606
+ event: {
1607
+ key: event.key,
1608
+ metaKey: event.metaKey,
1609
+ shiftKey: event.shiftKey,
1610
+ altKey: event.altKey,
1611
+ },
1612
+ },
1613
+ '*',
1614
+ )
1615
+ })
1616
+
1617
+ dataSignal.subscribe((data) => {
1618
+ if (component && components && packageComponents && data) {
1619
+ try {
1620
+ window.parent?.postMessage({ type: 'data', data }, '*')
1621
+ } catch {
1622
+ // If we're unable to send the data, let's try to JSON serialize it
1623
+ window.parent?.postMessage(
1624
+ { type: 'data', data: JSON.parse(JSON.stringify(data)) },
1625
+ '*',
1626
+ )
1627
+ }
1628
+ }
1629
+ })
1630
+
1631
+ const clearSelectedStyleVariant = () => {
1632
+ if (styleVariantSelection) {
1633
+ const styleElem = document.head.querySelector(
1634
+ `[data-hash="${styleVariantSelection.nodeId}"]`,
1635
+ )
1636
+ if (styleElem) {
1637
+ document.head.removeChild(styleElem)
1638
+ }
1639
+ styleVariantSelection = null
1640
+ }
1641
+ }
1642
+
1643
+ const updateConditionalElements = () => {
1644
+ const displayedNodes: string[] = []
1645
+ if (selectedNodeId && component) {
1646
+ const root = component.nodes.root
1647
+ if (root) {
1648
+ const nodeLookup = getNodeAndAncestors(component, root, selectedNodeId)
1649
+ if (isNodeOrAncestorConditional(nodeLookup)) {
1650
+ displayedNodes.push(selectedNodeId)
1651
+ displayedNodes.push(
1652
+ ...[...nodeLookup.ancestors, nodeLookup.node]
1653
+ .filter((a) => a.condition)
1654
+ .map((a) => a.nodeId),
1655
+ )
1656
+ }
1657
+ }
1658
+ }
1659
+ showSignal.set({
1660
+ displayedNodes,
1661
+ testMode: mode === 'test',
1662
+ })
1663
+ }
1664
+ }
1665
+
1666
+ const insertOrReplaceHeadNode = (id: string, node: Node) => {
1667
+ const existing = document.head.querySelector(`[data-meta-id="${id}"]`)
1668
+ if (existing) {
1669
+ existing.replaceWith(node)
1670
+ } else {
1671
+ document.head.appendChild(node)
1672
+ }
1673
+ }
1674
+
1675
+ const insertHeadTags = (
1676
+ entries: Record<string, MetaEntry>,
1677
+ context: FormulaContext,
1678
+ ) => {
1679
+ // Remove all tags that has a data-meta-id attribute that is not in the entries
1680
+ Array.from(document.head.querySelectorAll('[data-meta-id]'))
1681
+ .filter((elem) => !entries[elem.getAttribute('data-meta-id')!])
1682
+ .forEach((elem) => elem.remove())
1683
+
1684
+ // Skip anything that is not <link> or <script> tags, as they don't have any influence on the preview
1685
+ Object.entries(entries).forEach(([id, entry]) => {
1686
+ switch (entry.tag) {
1687
+ case 'link':
1688
+ return insertOrReplaceHeadNode(
1689
+ id,
1690
+ document.createRange().createContextualFragment(`
1691
+ <link
1692
+ data-meta-id="${id}"
1693
+ ${Object.entries(entry.attrs)
1694
+ .map(([key, value]) => `${key}="${applyFormula(value, context)}"`)
1695
+ .join(' ')}
1696
+ />
1697
+ `),
1698
+ )
1699
+ case 'script':
1700
+ return insertOrReplaceHeadNode(
1701
+ id,
1702
+ document.createRange().createContextualFragment(`
1703
+ <script
1704
+ data-meta-id="${id}"
1705
+ ${Object.entries(entry.attrs)
1706
+ .map(([key, value]) => `${key}="${applyFormula(value, context)}"`)
1707
+ .join(' ')}
1708
+ ></script>
1709
+ `),
1710
+ )
1711
+ }
1712
+ })
1713
+ }
1714
+
1715
+ export function getDOMNodeFromNodeId(
1716
+ selectedNodeId: string | null | undefined,
1717
+ ) {
1718
+ if (!selectedNodeId) {
1719
+ return null
1720
+ }
1721
+
1722
+ return document.querySelector(
1723
+ `[data-id="${selectedNodeId}"]:not([data-component])`,
1724
+ )
1725
+ }
1726
+
1727
+ export function getRectData(selectedNode: Element | null | undefined) {
1728
+ if (!selectedNode) {
1729
+ return null
1730
+ }
1731
+
1732
+ const rect = selectedNode.getBoundingClientRect()
1733
+ return {
1734
+ left: rect.left,
1735
+ right: rect.right,
1736
+ top: rect.top,
1737
+ bottom: rect.bottom,
1738
+ width: rect.width,
1739
+ height: rect.height,
1740
+ x: rect.x,
1741
+ y: rect.y,
1742
+ borderRadius: window
1743
+ .getComputedStyle(selectedNode)
1744
+ .borderRadius.split(' ')
1745
+ .map(parseFloat),
1746
+ }
1747
+ }
1748
+
1749
+ function getNodeId(component: Component, path: string[]) {
1750
+ function getId(
1751
+ [nextChild, ...path]: string[],
1752
+ currentId: string | undefined,
1753
+ ): string | null {
1754
+ if (nextChild === undefined || currentId === undefined) {
1755
+ return currentId ?? null
1756
+ }
1757
+ const currentNode = component.nodes[currentId]
1758
+ if (!currentNode?.children) {
1759
+ return null
1760
+ }
1761
+
1762
+ // We only allow selecting the first element in a repeat (which does not have a repeat-index "()")
1763
+ if (nextChild.endsWith(')')) {
1764
+ return null
1765
+ }
1766
+
1767
+ return getId(path, currentNode.children[parseInt(nextChild)])
1768
+ }
1769
+ return getId(path, 'root')
1770
+ }
1771
+
1772
+ const insertTheme = (parent: HTMLElement, theme: Theme | OldTheme) => {
1773
+ document.getElementById('theme-style')?.remove()
1774
+ const styleElem = document.createElement('style')
1775
+ styleElem.setAttribute('type', 'text/css')
1776
+ styleElem.setAttribute('id', 'theme-style')
1777
+ styleElem.innerHTML = getThemeCss(theme, {
1778
+ includeResetStyle: false,
1779
+ createFontFaces: true,
1780
+ })
1781
+ parent.appendChild(styleElem)
1782
+ }