@poveste/app 0.5.3 → 0.6.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/dist/app/style/font.css +78 -0
- package/dist/app/style/main.pcss +4 -1
- package/dist/app/style/sandbox.css +3 -0
- package/dist/bundle-main-dev.js +1 -0
- package/dist/bundle-main.js +3 -0
- package/dist/bundled/app.css +28 -20
- package/dist/bundled/components/app/AppActions.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/base/BaseSelect.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/layout/LayoutModal.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/layout/LayoutPane.vue_vue_type_script_setup_true_lang.js +4 -4
- package/dist/bundled/components/panel/StatePresets.vue_vue_type_script_setup_true_lang.js +5 -5
- package/dist/bundled/components/panel/StoryControls.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/StoryDocs.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/StoryEvent.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/StorySidePanel.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/panel/StorySourceCode.js +1 -1
- package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/search/SearchItem.js +1 -1
- package/dist/bundled/components/search/SearchItem.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/search/SearchModal.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/search/SearchPane.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
- package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +69 -64
- package/dist/bundled/components/story/StoryVariantGrid.js +3 -1
- package/dist/bundled/components/story/StoryVariantGrid.vue_vue_type_script_setup_true_lang.js +82 -35
- package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
- package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +20 -12
- package/dist/bundled/components/story/StoryVariantListItem.js +1 -1
- package/dist/bundled/components/story/StoryVariantListItem.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/story/StoryVariantSinglePreviewNative.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +97 -32
- package/dist/bundled/components/story/StoryVariantSingleView.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/toolbar/ToolbarBackground.js +1 -1
- package/dist/bundled/components/toolbar/ToolbarBackground.vue_vue_type_script_setup_true_lang.js +7 -7
- package/dist/bundled/components/tree/StoryGroup.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/bundled/components/tree/StoryListFolder.js +1 -1
- package/dist/bundled/components/tree/StoryListFolder.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/bundled/components/tree/StoryListItem.js +1 -1
- package/dist/bundled/components/tree/StoryListItem.vue_vue_type_script_setup_true_lang.js +3 -3
- package/dist/bundled/index.js +3 -0
- package/dist/bundled/sandbox.js +53 -24
- package/dist/bundled/util/const.js +2 -1
- package/dist/bundled/util/grid-cell-height.js +18 -0
- package/dist/bundled/util/grid-slots.js +64 -0
- package/dist/bundled/util/icons.generated.js +104 -0
- package/dist/bundled/util/state-bridge.js +75 -0
- package/dist/bundled/util/state.js +19 -1
- package/dist/style.css +7 -2
- package/package.json +12 -5
- package/src/__tests__/font.spec.ts +30 -0
- package/src/__tests__/grid-slots.spec.ts +84 -0
- package/src/__tests__/icons.spec.ts +26 -0
- package/src/__tests__/state-bridge.spec.ts +155 -0
- package/src/__tests__/state-clone.spec.ts +66 -0
- package/src/app/components/app/AppActions.vue +2 -2
- package/src/app/components/layout/LayoutModal.vue +2 -2
- package/src/app/components/layout/LayoutPane.vue +4 -4
- package/src/app/components/panel/StatePresets.vue +5 -5
- package/src/app/components/panel/StoryControls.vue +1 -1
- package/src/app/components/panel/StoryDocs.vue +1 -1
- package/src/app/components/panel/StoryEvent.vue +1 -1
- package/src/app/components/panel/StorySidePanel.vue +1 -1
- package/src/app/components/panel/StorySourceCode.vue +2 -2
- package/src/app/components/search/SearchItem.vue +1 -1
- package/src/app/components/search/SearchModal.vue +1 -1
- package/src/app/components/search/SearchPane.vue +8 -0
- package/src/app/components/story/StoryResponsivePreview.vue +85 -50
- package/src/app/components/story/StoryVariantGrid.vue +126 -49
- package/src/app/components/story/StoryVariantGridItem.vue +35 -10
- package/src/app/components/story/StoryVariantListItem.vue +1 -1
- package/src/app/components/story/StoryVariantSinglePreviewNative.vue +1 -1
- package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +165 -35
- package/src/app/components/story/StoryVariantSingleView.vue +1 -1
- package/src/app/components/toolbar/ToolbarBackground.vue +4 -4
- package/src/app/components/tree/StoryGroup.vue +1 -1
- package/src/app/components/tree/StoryListFolder.vue +1 -1
- package/src/app/components/tree/StoryListItem.vue +1 -1
- package/src/app/index.ts +10 -0
- package/src/app/sandbox.ts +95 -31
- package/src/app/style/font.css +78 -0
- package/src/app/style/main.pcss +4 -1
- package/src/app/style/sandbox.css +3 -0
- package/src/app/util/const.ts +1 -0
- package/src/app/util/grid-cell-height.ts +16 -0
- package/src/app/util/grid-slots.ts +72 -0
- package/src/app/util/icons.generated.ts +9 -0
- package/src/app/util/state-bridge.ts +95 -0
- package/src/app/util/state.ts +19 -0
- package/src/bundle-main-dev.js +1 -0
- package/src/bundle-main.js +3 -0
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
2
|
import type { HstEvent } from '../../stores/events'
|
|
3
3
|
import type { Story, Variant } from '../../types'
|
|
4
|
-
import { applyState } from '@poveste/shared'
|
|
5
4
|
import { useEventListener } from '@vueuse/core'
|
|
6
5
|
import { computed, onBeforeUnmount, ref, toRaw, watch } from 'vue'
|
|
7
6
|
import { useEventsStore } from '../../stores/events'
|
|
8
7
|
import { usePreviewSettingsStore } from '../../stores/preview-settings'
|
|
9
8
|
import { useStoryStore } from '../../stores/story'
|
|
10
|
-
import { EVENT_SEND, PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, STATE_SYNC } from '../../util/const'
|
|
9
|
+
import { EVENT_SEND, PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, SANDBOX_RETARGET, STATE_SYNC } from '../../util/const'
|
|
10
|
+
import { firstReportedHeight } from '../../util/grid-cell-height'
|
|
11
11
|
import { trackWindow } from '../../util/keyboard'
|
|
12
12
|
import { getSandboxUrl } from '../../util/sandbox'
|
|
13
13
|
import { toRawDeep } from '../../util/state'
|
|
14
|
+
import { createStateBridge } from '../../util/state-bridge'
|
|
14
15
|
import StoryResponsivePreview from './StoryResponsivePreview.vue'
|
|
15
16
|
|
|
16
|
-
const props = defineProps<{
|
|
17
|
+
const props = withDefaults(defineProps<{
|
|
17
18
|
story: Story
|
|
18
19
|
variant: Variant
|
|
19
20
|
autoHeight?: boolean
|
|
20
|
-
|
|
21
|
+
responsive?: boolean
|
|
22
|
+
}>(), {
|
|
23
|
+
responsive: true,
|
|
24
|
+
})
|
|
21
25
|
|
|
22
26
|
const settings = usePreviewSettingsStore().currentSettings
|
|
23
27
|
|
|
@@ -25,67 +29,105 @@ const settings = usePreviewSettingsStore().currentSettings
|
|
|
25
29
|
|
|
26
30
|
const iframe = ref<HTMLIFrameElement>()
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// The host end of the bridge. It sends what this side changed rather than the
|
|
33
|
+
// whole state, so a write here and a write in the story cannot overwrite each
|
|
34
|
+
// other — see `createStateBridge` for what that used to cost.
|
|
35
|
+
function postState(changes: Record<string, any>) {
|
|
36
|
+
iframe.value?.contentWindow?.postMessage({
|
|
37
|
+
type: STATE_SYNC,
|
|
38
|
+
state: changes,
|
|
39
|
+
})
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
// with the same #95 caveat: `synced` may only be set when the write below will
|
|
39
|
-
// actually provoke the watcher. `applyState` reports that now. Setting it
|
|
40
|
-
// unconditionally means an apply that changes nothing leaves it set, and the
|
|
41
|
-
// next real edit is skipped instead — a control that does nothing.
|
|
42
|
-
let synced = false
|
|
42
|
+
let bridge = createStateBridge(postState)
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
function syncState() {
|
|
45
|
+
// Nothing can be sent yet, so nothing may be recorded as agreed either — the
|
|
46
|
+
// baseline would then hold changes the sandbox has never seen, and send it
|
|
47
|
+
// only the ones that came after. Returning early leaves the first message
|
|
48
|
+
// that does go out carrying the whole state, which is what it carried before.
|
|
49
|
+
if (!iframe.value?.contentWindow || !props.variant.previewReady) return
|
|
50
|
+
|
|
51
|
+
bridge.send(toRawDeep(props.variant.state, true))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
watch(() => props.variant.state, (state, previous) => {
|
|
55
|
+
// Another variant's state, not a change to this one: this cell was handed a
|
|
56
|
+
// new occupant (#240). The handover below syncs once the realm reports it in;
|
|
57
|
+
// sending now would put the new variant's state into the old occupant.
|
|
58
|
+
if (state !== previous) return
|
|
49
59
|
syncState()
|
|
50
60
|
}, {
|
|
51
61
|
deep: true,
|
|
52
|
-
immediate: true,
|
|
53
62
|
})
|
|
54
63
|
|
|
55
64
|
const storyStore = useStoryStore()
|
|
56
65
|
|
|
57
66
|
storyStore.setPreviewReady(props.variant, false)
|
|
58
67
|
|
|
68
|
+
// Floor for a reported height, and what a cell shows before its sandbox reports.
|
|
69
|
+
const MIN_HEIGHT = 40
|
|
70
|
+
|
|
71
|
+
// A cell may grow to its story but not past this, because a grid row is as tall
|
|
72
|
+
// as its tallest item — one long variant would otherwise stretch every sibling
|
|
73
|
+
// in its row to match.
|
|
74
|
+
const MAX_AUTO_HEIGHT = 400
|
|
75
|
+
|
|
59
76
|
const reportedHeight = ref<number | null>(null)
|
|
60
77
|
|
|
78
|
+
const placeholderHeight = computed(() => (
|
|
79
|
+
firstReportedHeight.value?.storyId === props.story.id ? firstReportedHeight.value.height : MIN_HEIGHT
|
|
80
|
+
))
|
|
81
|
+
|
|
82
|
+
// Whether a message is about the variant this cell shows now. A realm that has
|
|
83
|
+
// just been retargeted can still speak for its old occupant — a height from an
|
|
84
|
+
// observer, a state change from a timer — until it processes the handover, and
|
|
85
|
+
// those must not land on the new one. Unnamed messages come from sandboxes that
|
|
86
|
+
// predate retargeting; take those.
|
|
87
|
+
function fromCurrentOccupant(data: { storyId?: string, variantId?: string }) {
|
|
88
|
+
return !data.variantId || (data.variantId === props.variant.id && data.storyId === props.story.id)
|
|
89
|
+
}
|
|
90
|
+
|
|
61
91
|
useEventListener(window, 'message', (event) => {
|
|
62
92
|
// With many grid iframes mounted, every sandbox postMessage hits every
|
|
63
93
|
// parent listener. Skip events that didn't originate from this iframe.
|
|
64
94
|
if (event.source !== iframe.value?.contentWindow) return
|
|
65
95
|
switch (event.data?.type) {
|
|
66
96
|
case STATE_SYNC:
|
|
97
|
+
if (!fromCurrentOccupant(event.data)) break
|
|
67
98
|
updateVariantState(event.data.state)
|
|
68
99
|
break
|
|
69
100
|
case EVENT_SEND:
|
|
70
101
|
logEvent(event.data.event)
|
|
71
102
|
break
|
|
72
103
|
case SANDBOX_READY:
|
|
104
|
+
if (!fromCurrentOccupant(event.data)) break
|
|
73
105
|
setPreviewReady()
|
|
74
106
|
break
|
|
75
107
|
case PREVIEW_SETTINGS_REQUEST:
|
|
76
108
|
syncSettings()
|
|
77
109
|
break
|
|
78
110
|
case SANDBOX_HEIGHT:
|
|
79
|
-
if (
|
|
80
|
-
|
|
111
|
+
if (!fromCurrentOccupant(event.data)) break
|
|
112
|
+
// The sandbox reports on its first frames, before the story mounts, and
|
|
113
|
+
// those carry the empty body — 0, or a few px of margin. Clamping one to
|
|
114
|
+
// the floor would count as a real measurement and lock the cell out of
|
|
115
|
+
// the placeholder below, which is what left rows stuck at 40px. Anything
|
|
116
|
+
// under the floor renders as the floor anyway, so nothing is lost by
|
|
117
|
+
// waiting for a bigger number.
|
|
118
|
+
if (typeof event.data.h === 'number' && event.data.h >= MIN_HEIGHT) {
|
|
119
|
+
const next = event.data.h
|
|
81
120
|
if (next !== reportedHeight.value) reportedHeight.value = next
|
|
121
|
+
if (props.autoHeight && firstReportedHeight.value?.storyId !== props.story.id) {
|
|
122
|
+
firstReportedHeight.value = { storyId: props.story.id, height: next }
|
|
123
|
+
}
|
|
82
124
|
}
|
|
83
125
|
break
|
|
84
126
|
}
|
|
85
127
|
})
|
|
86
128
|
|
|
87
129
|
function updateVariantState(state: any) {
|
|
88
|
-
|
|
130
|
+
bridge.receive(props.variant.state, state)
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
function logEvent(event: HstEvent) {
|
|
@@ -93,28 +135,97 @@ function logEvent(event: HstEvent) {
|
|
|
93
135
|
eventsStore.addEvent(event)
|
|
94
136
|
}
|
|
95
137
|
|
|
138
|
+
// Realm reuse state (#240) — see the block below `sandboxUrl` for the design.
|
|
139
|
+
const REUSE_LIMIT = 30
|
|
140
|
+
const RETARGET_TIMEOUT = 8_000
|
|
141
|
+
const retargeting = ref(false)
|
|
142
|
+
let retargetTimer: ReturnType<typeof setTimeout> | undefined
|
|
143
|
+
|
|
96
144
|
function setPreviewReady() {
|
|
145
|
+
retargeting.value = false
|
|
146
|
+
clearTimeout(retargetTimer)
|
|
97
147
|
storyStore.setPreviewReady(props.variant, true)
|
|
148
|
+
// A fresh mount starts from the story's own state; what this side holds for
|
|
149
|
+
// the variant — earlier control edits, or the state it had the last time this
|
|
150
|
+
// realm showed it — is the truth the story should render.
|
|
151
|
+
syncState()
|
|
98
152
|
}
|
|
99
153
|
|
|
100
154
|
const sandboxUrl = computed(() => {
|
|
101
155
|
return getSandboxUrl(props.story, props.variant)
|
|
102
156
|
})
|
|
103
157
|
|
|
158
|
+
/*
|
|
159
|
+
* Realm reuse (#240). A sandbox document is a full realm boot — bundle
|
|
160
|
+
* evaluation, story mount — and same-origin iframes boot serially on one main
|
|
161
|
+
* thread, so a grid that scrolls pays that for every cell that enters the
|
|
162
|
+
* window. Instead of changing `src`, a warm realm is told which variant to
|
|
163
|
+
* show next and remounts it in place: the order of the realm boot cost becomes
|
|
164
|
+
* the number of cells on screen, not the number scrolled past.
|
|
165
|
+
*
|
|
166
|
+
* What survives between occupants is the realm's JS state — globals a story
|
|
167
|
+
* patched, timers it leaked. Style isolation is untouched. Two valves for
|
|
168
|
+
* stories that cannot share: `layout.isolate` opts a story back into a cold
|
|
169
|
+
* document per render — on the way in and on the way out, since what it
|
|
170
|
+
* leaves behind is the point — and a realm is cold-reloaded after REUSE_LIMIT
|
|
171
|
+
* retargets so what does accumulate stays bounded. A retarget that never
|
|
172
|
+
* reports ready falls back to a reload.
|
|
173
|
+
*/
|
|
174
|
+
// What the iframe actually loads. Only a cold (re)load assigns it.
|
|
175
|
+
const iframeSrc = ref(sandboxUrl.value)
|
|
104
176
|
const isIframeLoaded = ref(false)
|
|
177
|
+
let retargets = 0
|
|
178
|
+
// The story whose document the realm holds — booted or retargeted into it.
|
|
179
|
+
let servedStory = props.story
|
|
105
180
|
|
|
106
181
|
let stopTrackKeyboard: (() => void) | undefined
|
|
107
182
|
let unmounted = false
|
|
108
183
|
|
|
109
|
-
|
|
184
|
+
function reload(url: string) {
|
|
185
|
+
retargets = 0
|
|
186
|
+
retargeting.value = false
|
|
187
|
+
clearTimeout(retargetTimer)
|
|
110
188
|
isIframeLoaded.value = false
|
|
111
|
-
|
|
189
|
+
reportedHeight.value = null
|
|
190
|
+
servedStory = props.story
|
|
112
191
|
stopTrackKeyboard?.()
|
|
113
192
|
stopTrackKeyboard = undefined
|
|
193
|
+
// The document the realm booted with is the one wanted again — after a
|
|
194
|
+
// retarget that timed out, or one past the reuse limit. The binding would
|
|
195
|
+
// not change, so nothing would load; set the attribute by hand.
|
|
196
|
+
if (iframeSrc.value === url && iframe.value) iframe.value.src = url
|
|
197
|
+
iframeSrc.value = url
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
watch(sandboxUrl, (url) => {
|
|
201
|
+
// A new occupant has agreed to nothing, so neither has this end. Kept as a
|
|
202
|
+
// new bridge rather than a reset so there is one way to be in that state.
|
|
203
|
+
bridge = createStateBridge(postState)
|
|
204
|
+
storyStore.setPreviewReady(props.variant, false)
|
|
205
|
+
|
|
206
|
+
const reuse = props.story.layout?.isolate !== true && servedStory.layout?.isolate !== true
|
|
207
|
+
const target = iframe.value?.contentWindow
|
|
208
|
+
if (reuse && isIframeLoaded.value && target && retargets < REUSE_LIMIT) {
|
|
209
|
+
// Within a story the last height is the best guess until the new occupant
|
|
210
|
+
// reports; snapping to the placeholder in between made rows jump twice.
|
|
211
|
+
if (servedStory.id !== props.story.id) reportedHeight.value = null
|
|
212
|
+
servedStory = props.story
|
|
213
|
+
retargets++
|
|
214
|
+
retargeting.value = true
|
|
215
|
+
target.postMessage({ type: SANDBOX_RETARGET, storyId: props.story.id, variantId: props.variant.id }, window.location.origin)
|
|
216
|
+
clearTimeout(retargetTimer)
|
|
217
|
+
retargetTimer = setTimeout(() => {
|
|
218
|
+
if (retargeting.value && !unmounted) reload(url)
|
|
219
|
+
}, RETARGET_TIMEOUT)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
reload(url)
|
|
114
224
|
})
|
|
115
225
|
|
|
116
226
|
onBeforeUnmount(() => {
|
|
117
227
|
unmounted = true
|
|
228
|
+
clearTimeout(retargetTimer)
|
|
118
229
|
stopTrackKeyboard?.()
|
|
119
230
|
})
|
|
120
231
|
|
|
@@ -136,12 +247,33 @@ watch(() => settings, () => {
|
|
|
136
247
|
immediate: true,
|
|
137
248
|
})
|
|
138
249
|
|
|
250
|
+
// `auto-height` — the grid — sizes a cell to the story it renders, and that
|
|
251
|
+
// wins over the responsive height. The responsive size is for examining one
|
|
252
|
+
// component at a viewport; applied to every cell it just makes the grid
|
|
253
|
+
// unreadable. A stored `responsiveHeight` of 600 gives 729px cells holding one
|
|
254
|
+
// small button, which is the shape #198 was filed about.
|
|
255
|
+
function previewStyle(isResponsiveEnabled: boolean, finalWidth: number | null, finalHeight: number | null) {
|
|
256
|
+
const style: Record<string, string> = {}
|
|
257
|
+
|
|
258
|
+
if (isResponsiveEnabled) {
|
|
259
|
+
if (finalWidth) style.width = `${finalWidth}px`
|
|
260
|
+
if (finalHeight) style.height = `${finalHeight}px`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (props.autoHeight) {
|
|
264
|
+
style.height = `${Math.min(reportedHeight.value ?? placeholderHeight.value, MAX_AUTO_HEIGHT)}px`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return style
|
|
268
|
+
}
|
|
269
|
+
|
|
139
270
|
// Iframe load
|
|
140
271
|
|
|
141
272
|
function onIframeLoad() {
|
|
142
273
|
if (unmounted) {
|
|
143
274
|
return
|
|
144
275
|
}
|
|
276
|
+
retargets = 0
|
|
145
277
|
isIframeLoaded.value = true
|
|
146
278
|
syncState()
|
|
147
279
|
syncSettings()
|
|
@@ -157,20 +289,18 @@ function onIframeLoad() {
|
|
|
157
289
|
v-slot="{ isResponsiveEnabled, finalWidth, finalHeight, resizing }"
|
|
158
290
|
class="poveste-story-variant-single-preview-remote"
|
|
159
291
|
:variant="variant"
|
|
292
|
+
:responsive="responsive"
|
|
160
293
|
>
|
|
161
294
|
<iframe
|
|
162
295
|
ref="iframe"
|
|
163
|
-
:src="
|
|
296
|
+
:src="iframeSrc"
|
|
164
297
|
class="w-full h-full relative"
|
|
165
298
|
:class="{
|
|
166
|
-
'invisible': !isIframeLoaded,
|
|
299
|
+
'invisible': !isIframeLoaded || retargeting,
|
|
167
300
|
'pointer-events-none': resizing,
|
|
168
301
|
}"
|
|
169
|
-
:style="isResponsiveEnabled
|
|
170
|
-
|
|
171
|
-
height: finalHeight ? `${finalHeight}px` : null,
|
|
172
|
-
} : (props.autoHeight && reportedHeight ? { height: `${reportedHeight}px` } : undefined)"
|
|
173
|
-
data-test-id="preview-iframe"
|
|
302
|
+
:style="previewStyle(isResponsiveEnabled, finalWidth, finalHeight)"
|
|
303
|
+
data-testid="preview-iframe"
|
|
174
304
|
@load="onIframeLoad()"
|
|
175
305
|
/>
|
|
176
306
|
</StoryResponsivePreview>
|
|
@@ -12,7 +12,7 @@ defineProps<{
|
|
|
12
12
|
<template>
|
|
13
13
|
<div
|
|
14
14
|
class="poveste-story-variant-single-view h-full flex flex-col"
|
|
15
|
-
data-
|
|
15
|
+
data-testid="story-variant-single-view"
|
|
16
16
|
>
|
|
17
17
|
<StoryVariantSinglePreviewNative
|
|
18
18
|
v-if="story.layout?.iframe === false"
|
|
@@ -26,7 +26,7 @@ const showDropdown = showColorScheme || !!povesteConfig.backgroundPresets.length
|
|
|
26
26
|
placement="bottom-end"
|
|
27
27
|
:skidding="6"
|
|
28
28
|
class="poveste-toolbar-background flex-none"
|
|
29
|
-
data-
|
|
29
|
+
data-testid="toolbar-background"
|
|
30
30
|
>
|
|
31
31
|
<!-- The tooltip would sit on top of the first row of the dropdown, so drop
|
|
32
32
|
it while the dropdown is open. -->
|
|
@@ -49,17 +49,17 @@ const showDropdown = showColorScheme || !!povesteConfig.backgroundPresets.length
|
|
|
49
49
|
<template #popper="{ hide }">
|
|
50
50
|
<div
|
|
51
51
|
class="flex flex-col items-stretch"
|
|
52
|
-
data-
|
|
52
|
+
data-testid="background-popper"
|
|
53
53
|
>
|
|
54
54
|
<div
|
|
55
55
|
v-if="showColorScheme"
|
|
56
56
|
class="flex items-stretch gap-1 px-2 pt-2"
|
|
57
|
-
data-
|
|
57
|
+
data-testid="sandbox-color-scheme"
|
|
58
58
|
>
|
|
59
59
|
<button
|
|
60
60
|
v-for="option in colorSchemeOptions"
|
|
61
61
|
:key="option.value"
|
|
62
|
-
:data-
|
|
62
|
+
:data-testid="`sandbox-color-scheme-${option.value}`"
|
|
63
63
|
class="flex-1 px-3 py-1.5 rounded flex items-center justify-center gap-1.5 cursor-pointer text-sm"
|
|
64
64
|
:class="[
|
|
65
65
|
settings.colorScheme === option.value
|
package/src/app/index.ts
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
|
+
import { addCollection } from '@iconify/vue'
|
|
1
2
|
import FloatingVue from 'floating-vue'
|
|
2
3
|
import { createPinia } from 'pinia'
|
|
3
4
|
import { createApp } from 'vue'
|
|
4
5
|
import App from './App.vue'
|
|
5
6
|
import { setupPluginApi } from './plugin.js'
|
|
6
7
|
import { router } from './router'
|
|
8
|
+
import { iconCollections } from './util/icons.generated.js'
|
|
7
9
|
import 'virtual:$poveste-theme'
|
|
8
10
|
|
|
9
11
|
export async function mountMainApp() {
|
|
12
|
+
// The chrome's icon data ships with the app. Without this every Icon fetched
|
|
13
|
+
// its data from api.iconify.design at runtime — a handful of CDN requests per
|
|
14
|
+
// boot, and no toolbar offline (#219). Story icons a user sets still resolve
|
|
15
|
+
// through the runtime lookup.
|
|
16
|
+
for (const collection of iconCollections) {
|
|
17
|
+
addCollection(collection)
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
const app = createApp(App)
|
|
11
21
|
app.use(createPinia())
|
|
12
22
|
app.use(FloatingVue, {
|
package/src/app/sandbox.ts
CHANGED
|
@@ -1,22 +1,42 @@
|
|
|
1
1
|
import type { StoryFile } from './types'
|
|
2
|
-
import { applyState } from '@poveste/shared'
|
|
3
2
|
import { usePreferredDark } from '@vueuse/core'
|
|
4
3
|
import { createPinia } from 'pinia'
|
|
5
|
-
import { files } from 'virtual:$poveste-stories'
|
|
4
|
+
import { files, onUpdate } from 'virtual:$poveste-stories'
|
|
6
5
|
import { computed, createApp, h, onMounted, ref, watch } from 'vue'
|
|
7
6
|
import { parseQuery } from 'vue-router'
|
|
8
7
|
import GenericMountStory from './components/story/GenericMountStory.vue'
|
|
9
8
|
import GenericRenderStory from './components/story/GenericRenderStory.vue'
|
|
10
|
-
import { setupPluginApi } from './plugin.js'
|
|
11
9
|
import { previewDarkClasses, resolvePreviewDark } from './util/color-scheme.js'
|
|
12
|
-
import { PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, STATE_SYNC } from './util/const.js'
|
|
10
|
+
import { PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, SANDBOX_RETARGET, STATE_SYNC } from './util/const.js'
|
|
13
11
|
import { isDark } from './util/dark.js'
|
|
14
12
|
import { mapFile } from './util/mapping'
|
|
15
13
|
import { applyPreviewSettings, loadStoredPreviewSettings, receivedSettings } from './util/preview-settings.js'
|
|
14
|
+
import { createStateBridge } from './util/state-bridge.js'
|
|
16
15
|
import { toRawDeep } from './util/state.js'
|
|
17
16
|
|
|
18
17
|
const query = parseQuery(window.location.search)
|
|
19
|
-
|
|
18
|
+
|
|
19
|
+
// What this realm serves. Set from the URL on boot, and again by a
|
|
20
|
+
// SANDBOX_RETARGET from the host: a warm realm is handed the next story or
|
|
21
|
+
// variant instead of being torn down for a new document (#240).
|
|
22
|
+
const storyId = ref(typeof query.storyId === 'string' ? query.storyId : null)
|
|
23
|
+
const variantId = ref(typeof query.variantId === 'string' ? query.variantId : null)
|
|
24
|
+
|
|
25
|
+
// `files` is `[]` until the dev server's first collect lands. The app's iframe
|
|
26
|
+
// never sees that — it mounts after — but a sandbox opened on its own does, and
|
|
27
|
+
// the list only arrives through `onUpdate`.
|
|
28
|
+
const file = ref<StoryFile | null>(null)
|
|
29
|
+
function resolveFile(list: StoryFile[] = files) {
|
|
30
|
+
const found = list.find(f => f.id === storyId.value)
|
|
31
|
+
// Same story, different variant: keep the mapped file so the story component
|
|
32
|
+
// is not remounted for a variant switch.
|
|
33
|
+
if (found && file.value?.story.id === found.id) return
|
|
34
|
+
file.value = found ? mapFile(found) : null
|
|
35
|
+
}
|
|
36
|
+
resolveFile()
|
|
37
|
+
onUpdate((newFiles: StoryFile[]) => {
|
|
38
|
+
if (!file.value) resolveFile(newFiles)
|
|
39
|
+
})
|
|
20
40
|
|
|
21
41
|
// Sandboxes opened on their own (the "open in a new tab" toolbar button) never
|
|
22
42
|
// receive a PREVIEW_SETTINGS_SYNC, and inside the app the first one only lands
|
|
@@ -72,41 +92,64 @@ watch(previewDark, (value) => {
|
|
|
72
92
|
immediate: true,
|
|
73
93
|
})
|
|
74
94
|
|
|
95
|
+
// Height reporting state, declared here because the retarget handler inside the
|
|
96
|
+
// app resets it.
|
|
97
|
+
let pendingFrame: number | null = null
|
|
98
|
+
let lastReportedHeight = -1
|
|
99
|
+
|
|
75
100
|
const app = createApp({
|
|
76
101
|
name: 'SandboxApp',
|
|
77
102
|
|
|
78
103
|
setup() {
|
|
79
|
-
const story = computed(() => file.value
|
|
80
|
-
const variant = computed(() => story.value?.variants.find(v => v.id ===
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
104
|
+
const story = computed(() => file.value?.story)
|
|
105
|
+
const variant = computed(() => story.value?.variants.find(v => v.id === variantId.value))
|
|
106
|
+
|
|
107
|
+
// The sandbox end of the bridge. It sends what the story changed rather than
|
|
108
|
+
// the whole state, so a write here and a control edit on the host cannot
|
|
109
|
+
// overwrite each other — see `createStateBridge` for what that used to cost.
|
|
110
|
+
// Named, like SANDBOX_READY and SANDBOX_HEIGHT: after a retarget the host
|
|
111
|
+
// is waiting on the new occupant, and anything the old one still says
|
|
112
|
+
// must be told apart from that.
|
|
113
|
+
const postState = (changes: Record<string, any>) => {
|
|
114
|
+
window.parent?.postMessage({
|
|
115
|
+
type: STATE_SYNC,
|
|
116
|
+
state: changes,
|
|
117
|
+
storyId: storyId.value,
|
|
118
|
+
variantId: variantId.value,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
let bridge = createStateBridge(postState)
|
|
88
122
|
let mounted = false
|
|
89
123
|
|
|
90
124
|
window.addEventListener('message', (event) => {
|
|
91
125
|
// console.log('[sandbox] received message', event.data)
|
|
92
126
|
if (event.data?.type === STATE_SYNC) {
|
|
93
|
-
if (!mounted) return
|
|
94
|
-
|
|
127
|
+
if (!mounted || !variant.value) return
|
|
128
|
+
bridge.receive(variant.value.state, event.data.state)
|
|
95
129
|
}
|
|
96
130
|
else if (event.data?.type === PREVIEW_SETTINGS_SYNC) {
|
|
97
131
|
applyPreviewSettings(event.data.settings)
|
|
98
132
|
}
|
|
133
|
+
else if (event.data?.type === SANDBOX_RETARGET) {
|
|
134
|
+
// A new occupant: nothing has been agreed with the host about it, and
|
|
135
|
+
// the last height belonged to the old one. The keyed render below
|
|
136
|
+
// remounts and reports both afresh.
|
|
137
|
+
bridge = createStateBridge(postState)
|
|
138
|
+
lastReportedHeight = -1
|
|
139
|
+
variantId.value = typeof event.data.variantId === 'string' ? event.data.variantId : null
|
|
140
|
+
storyId.value = typeof event.data.storyId === 'string' ? event.data.storyId : null
|
|
141
|
+
resolveFile()
|
|
142
|
+
}
|
|
99
143
|
})
|
|
100
144
|
|
|
101
|
-
watch(() => variant.value
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
})
|
|
145
|
+
watch(() => variant.value?.state, (value, previous) => {
|
|
146
|
+
// A retarget swapped the occupant, not a story writing its state. The
|
|
147
|
+
// new one has agreed to nothing yet, and a send here would carry its
|
|
148
|
+
// whole state — defaults, or a copy left from the last time this realm
|
|
149
|
+
// served it — over what the host holds, and the host's does come down
|
|
150
|
+
// once the new mount reports ready.
|
|
151
|
+
if (!value || value !== previous) return
|
|
152
|
+
bridge.send(toRawDeep(value, true))
|
|
110
153
|
}, {
|
|
111
154
|
deep: true,
|
|
112
155
|
})
|
|
@@ -122,18 +165,36 @@ const app = createApp({
|
|
|
122
165
|
},
|
|
123
166
|
|
|
124
167
|
render() {
|
|
168
|
+
if (!file.value) {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
125
172
|
return [
|
|
126
173
|
h('div', { class: '__poveste-sandbox-hidden' }, [
|
|
127
174
|
h(GenericMountStory, {
|
|
128
175
|
key: file.value.story.id,
|
|
129
176
|
story: file.value.story,
|
|
177
|
+
// This realm serves exactly one variant at a time; a plugin that
|
|
178
|
+
// mounts variants can skip all the others (#197). Falls through
|
|
179
|
+
// GenericMountStory as an attr, so plugins that don't know the prop
|
|
180
|
+
// ignore it.
|
|
181
|
+
targetVariantId: variantId.value,
|
|
130
182
|
}),
|
|
131
183
|
]),
|
|
132
184
|
this.story && this.variant
|
|
133
|
-
|
|
185
|
+
// Keyed so a retarget is a clean unmount and mount, not a prop update
|
|
186
|
+
// into a host that is halfway through rendering the previous occupant.
|
|
187
|
+
? h(GenericRenderStory, { key: `${this.story.id}/${this.variant.id}`, story: this.story, variant: this.variant, onReady: () => {
|
|
188
|
+
// Named, so a host that has since retargeted this realm can tell a
|
|
189
|
+
// late ready for the old occupant from the one it is waiting for.
|
|
134
190
|
window.parent?.postMessage({
|
|
135
191
|
type: SANDBOX_READY,
|
|
192
|
+
storyId: this.story.id,
|
|
193
|
+
variantId: this.variant.id,
|
|
136
194
|
})
|
|
195
|
+
// A retarget to a same-height story would otherwise never report:
|
|
196
|
+
// the observer only fires on a change.
|
|
197
|
+
scheduleReport()
|
|
137
198
|
} })
|
|
138
199
|
: null,
|
|
139
200
|
]
|
|
@@ -148,8 +209,6 @@ app.mount('#app')
|
|
|
148
209
|
// source-level rules that add overflow/min-height to the render root.
|
|
149
210
|
document.body.classList.add('__poveste-render-story', '__poveste-render-custom-controls')
|
|
150
211
|
|
|
151
|
-
let pendingFrame: number | null = null
|
|
152
|
-
let lastReportedHeight = -1
|
|
153
212
|
function reportHeight() {
|
|
154
213
|
pendingFrame = null
|
|
155
214
|
const renderRoot = document.querySelector('.__poveste-render-story')
|
|
@@ -160,7 +219,7 @@ function reportHeight() {
|
|
|
160
219
|
lastReportedHeight = h
|
|
161
220
|
// Same-origin: parent and iframe are both served by the poveste dev /
|
|
162
221
|
// build server, so scope the target rather than broadcasting.
|
|
163
|
-
window.parent?.postMessage({ type: SANDBOX_HEIGHT, h }, window.location.origin)
|
|
222
|
+
window.parent?.postMessage({ type: SANDBOX_HEIGHT, h, storyId: storyId.value, variantId: variantId.value }, window.location.origin)
|
|
164
223
|
}
|
|
165
224
|
function scheduleReport() {
|
|
166
225
|
if (pendingFrame !== null) return
|
|
@@ -170,6 +229,11 @@ const ro = new ResizeObserver(scheduleReport)
|
|
|
170
229
|
ro.observe(document.body)
|
|
171
230
|
requestAnimationFrame(scheduleReport)
|
|
172
231
|
|
|
173
|
-
|
|
174
|
-
|
|
232
|
+
// Dev only, and loaded lazily on purpose: a static import of `plugin.js`
|
|
233
|
+
// carried the app router — and through its lazy routes every app view and
|
|
234
|
+
// their CSS — into the sandbox graph, so each sandbox realm parsed ~100 KB of
|
|
235
|
+
// chrome styles it can never match (#219). `import.meta.env.DEV` is a build-time
|
|
236
|
+
// literal, so the whole branch, dynamic import included, drops out of a build.
|
|
237
|
+
if (import.meta.env.DEV && import.meta.hot) {
|
|
238
|
+
import('./plugin.js').then(({ setupPluginApi }) => setupPluginApi())
|
|
175
239
|
}
|