@poveste/app 0.5.2 → 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 +22 -14
- 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 +3 -6
- package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +101 -33
- 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 +57 -29
- package/dist/bundled/util/color-scheme.js +11 -1
- package/dist/bundled/util/const.js +3 -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__/color-scheme.spec.ts +55 -0
- 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 +37 -14
- package/src/app/components/story/StoryVariantListItem.vue +1 -1
- package/src/app/components/story/StoryVariantSinglePreviewNative.vue +3 -6
- package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +169 -36
- 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 +108 -40
- 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/color-scheme.ts +13 -0
- package/src/app/util/const.ts +2 -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_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,64 +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
|
|
107
|
+
case PREVIEW_SETTINGS_REQUEST:
|
|
108
|
+
syncSettings()
|
|
109
|
+
break
|
|
75
110
|
case SANDBOX_HEIGHT:
|
|
76
|
-
if (
|
|
77
|
-
|
|
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
|
|
78
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
|
+
}
|
|
79
124
|
}
|
|
80
125
|
break
|
|
81
126
|
}
|
|
82
127
|
})
|
|
83
128
|
|
|
84
129
|
function updateVariantState(state: any) {
|
|
85
|
-
|
|
130
|
+
bridge.receive(props.variant.state, state)
|
|
86
131
|
}
|
|
87
132
|
|
|
88
133
|
function logEvent(event: HstEvent) {
|
|
@@ -90,35 +135,104 @@ function logEvent(event: HstEvent) {
|
|
|
90
135
|
eventsStore.addEvent(event)
|
|
91
136
|
}
|
|
92
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
|
+
|
|
93
144
|
function setPreviewReady() {
|
|
145
|
+
retargeting.value = false
|
|
146
|
+
clearTimeout(retargetTimer)
|
|
94
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()
|
|
95
152
|
}
|
|
96
153
|
|
|
97
154
|
const sandboxUrl = computed(() => {
|
|
98
155
|
return getSandboxUrl(props.story, props.variant)
|
|
99
156
|
})
|
|
100
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)
|
|
101
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
|
|
102
180
|
|
|
103
181
|
let stopTrackKeyboard: (() => void) | undefined
|
|
104
182
|
let unmounted = false
|
|
105
183
|
|
|
106
|
-
|
|
184
|
+
function reload(url: string) {
|
|
185
|
+
retargets = 0
|
|
186
|
+
retargeting.value = false
|
|
187
|
+
clearTimeout(retargetTimer)
|
|
107
188
|
isIframeLoaded.value = false
|
|
108
|
-
|
|
189
|
+
reportedHeight.value = null
|
|
190
|
+
servedStory = props.story
|
|
109
191
|
stopTrackKeyboard?.()
|
|
110
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)
|
|
111
224
|
})
|
|
112
225
|
|
|
113
226
|
onBeforeUnmount(() => {
|
|
114
227
|
unmounted = true
|
|
228
|
+
clearTimeout(retargetTimer)
|
|
115
229
|
stopTrackKeyboard?.()
|
|
116
230
|
})
|
|
117
231
|
|
|
118
232
|
// Settings
|
|
119
233
|
|
|
120
234
|
function syncSettings() {
|
|
121
|
-
if (iframe.value) {
|
|
235
|
+
if (iframe.value?.contentWindow) {
|
|
122
236
|
iframe.value.contentWindow.postMessage({
|
|
123
237
|
type: PREVIEW_SETTINGS_SYNC,
|
|
124
238
|
settings: toRaw(settings),
|
|
@@ -133,12 +247,33 @@ watch(() => settings, () => {
|
|
|
133
247
|
immediate: true,
|
|
134
248
|
})
|
|
135
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
|
+
|
|
136
270
|
// Iframe load
|
|
137
271
|
|
|
138
272
|
function onIframeLoad() {
|
|
139
273
|
if (unmounted) {
|
|
140
274
|
return
|
|
141
275
|
}
|
|
276
|
+
retargets = 0
|
|
142
277
|
isIframeLoaded.value = true
|
|
143
278
|
syncState()
|
|
144
279
|
syncSettings()
|
|
@@ -154,20 +289,18 @@ function onIframeLoad() {
|
|
|
154
289
|
v-slot="{ isResponsiveEnabled, finalWidth, finalHeight, resizing }"
|
|
155
290
|
class="poveste-story-variant-single-preview-remote"
|
|
156
291
|
:variant="variant"
|
|
292
|
+
:responsive="responsive"
|
|
157
293
|
>
|
|
158
294
|
<iframe
|
|
159
295
|
ref="iframe"
|
|
160
|
-
:src="
|
|
296
|
+
:src="iframeSrc"
|
|
161
297
|
class="w-full h-full relative"
|
|
162
298
|
:class="{
|
|
163
|
-
'invisible': !isIframeLoaded,
|
|
299
|
+
'invisible': !isIframeLoaded || retargeting,
|
|
164
300
|
'pointer-events-none': resizing,
|
|
165
301
|
}"
|
|
166
|
-
:style="isResponsiveEnabled
|
|
167
|
-
|
|
168
|
-
height: finalHeight ? `${finalHeight}px` : null,
|
|
169
|
-
} : (props.autoHeight && reportedHeight ? { height: `${reportedHeight}px` } : undefined)"
|
|
170
|
-
data-test-id="preview-iframe"
|
|
302
|
+
:style="previewStyle(isResponsiveEnabled, finalWidth, finalHeight)"
|
|
303
|
+
data-testid="preview-iframe"
|
|
171
304
|
@load="onIframeLoad()"
|
|
172
305
|
/>
|
|
173
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,23 +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 {
|
|
11
|
-
import {
|
|
12
|
-
import { povesteConfig } from './util/config.js'
|
|
13
|
-
import { PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, STATE_SYNC } from './util/const.js'
|
|
9
|
+
import { previewDarkClasses, resolvePreviewDark } from './util/color-scheme.js'
|
|
10
|
+
import { PREVIEW_SETTINGS_REQUEST, PREVIEW_SETTINGS_SYNC, SANDBOX_HEIGHT, SANDBOX_READY, SANDBOX_RETARGET, STATE_SYNC } from './util/const.js'
|
|
14
11
|
import { isDark } from './util/dark.js'
|
|
15
12
|
import { mapFile } from './util/mapping'
|
|
16
13
|
import { applyPreviewSettings, loadStoredPreviewSettings, receivedSettings } from './util/preview-settings.js'
|
|
14
|
+
import { createStateBridge } from './util/state-bridge.js'
|
|
17
15
|
import { toRawDeep } from './util/state.js'
|
|
18
16
|
|
|
19
17
|
const query = parseQuery(window.location.search)
|
|
20
|
-
|
|
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
|
+
})
|
|
21
40
|
|
|
22
41
|
// Sandboxes opened on their own (the "open in a new tab" toolbar button) never
|
|
23
42
|
// receive a PREVIEW_SETTINGS_SYNC, and inside the app the first one only lands
|
|
@@ -28,15 +47,20 @@ if (storedSettings) {
|
|
|
28
47
|
applyPreviewSettings(storedSettings)
|
|
29
48
|
}
|
|
30
49
|
|
|
50
|
+
// The app pushes settings when it sees the iframe load, which is one document
|
|
51
|
+
// swap away from being wrong: a push aimed at a document that is going away is
|
|
52
|
+
// dropped with no error and nothing re-sends it, leaving the sandbox on the
|
|
53
|
+
// stored value or on none at all (#75). Ask instead — a request can only come
|
|
54
|
+
// from a document that is running and listening.
|
|
55
|
+
if (window.parent && window.parent !== window) {
|
|
56
|
+
window.parent.postMessage({ type: PREVIEW_SETTINGS_REQUEST }, window.location.origin)
|
|
57
|
+
}
|
|
58
|
+
|
|
31
59
|
// The story preview has its own color scheme, independent from the app chrome.
|
|
32
60
|
// The fallback covers settings stored before the option existed.
|
|
33
61
|
const prefersDark = usePreferredDark()
|
|
34
62
|
const previewDark = computed(() => resolvePreviewDark(receivedSettings.colorScheme, prefersDark.value, isDark.value))
|
|
35
63
|
|
|
36
|
-
// `sandboxDarkClass` is poveste's own deprecated config option, superseded by
|
|
37
|
-
// `theme.darkClass`. Still honoured on purpose: dropping it would silently stop
|
|
38
|
-
// applying the class for anyone who set it. Removing the option is a breaking
|
|
39
|
-
// config change and belongs to a major, not here.
|
|
40
64
|
/*
|
|
41
65
|
* The class also has to land *inside* the story's `@scope` root, not only on
|
|
42
66
|
* `<html>` (#101).
|
|
@@ -60,49 +84,72 @@ watch(previewDark, (value) => {
|
|
|
60
84
|
for (const el of darkClassTargets) {
|
|
61
85
|
if (!el) continue
|
|
62
86
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
87
|
+
for (const className of previewDarkClasses()) {
|
|
88
|
+
el.classList.toggle(className, value)
|
|
89
|
+
}
|
|
66
90
|
}
|
|
67
91
|
}, {
|
|
68
92
|
immediate: true,
|
|
69
93
|
})
|
|
70
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
|
+
|
|
71
100
|
const app = createApp({
|
|
72
101
|
name: 'SandboxApp',
|
|
73
102
|
|
|
74
103
|
setup() {
|
|
75
|
-
const story = computed(() => file.value
|
|
76
|
-
const variant = computed(() => story.value?.variants.find(v => v.id ===
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
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)
|
|
84
122
|
let mounted = false
|
|
85
123
|
|
|
86
124
|
window.addEventListener('message', (event) => {
|
|
87
125
|
// console.log('[sandbox] received message', event.data)
|
|
88
126
|
if (event.data?.type === STATE_SYNC) {
|
|
89
|
-
if (!mounted) return
|
|
90
|
-
|
|
127
|
+
if (!mounted || !variant.value) return
|
|
128
|
+
bridge.receive(variant.value.state, event.data.state)
|
|
91
129
|
}
|
|
92
130
|
else if (event.data?.type === PREVIEW_SETTINGS_SYNC) {
|
|
93
131
|
applyPreviewSettings(event.data.settings)
|
|
94
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
|
+
}
|
|
95
143
|
})
|
|
96
144
|
|
|
97
|
-
watch(() => variant.value
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
})
|
|
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))
|
|
106
153
|
}, {
|
|
107
154
|
deep: true,
|
|
108
155
|
})
|
|
@@ -118,18 +165,36 @@ const app = createApp({
|
|
|
118
165
|
},
|
|
119
166
|
|
|
120
167
|
render() {
|
|
168
|
+
if (!file.value) {
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
121
172
|
return [
|
|
122
173
|
h('div', { class: '__poveste-sandbox-hidden' }, [
|
|
123
174
|
h(GenericMountStory, {
|
|
124
175
|
key: file.value.story.id,
|
|
125
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,
|
|
126
182
|
}),
|
|
127
183
|
]),
|
|
128
184
|
this.story && this.variant
|
|
129
|
-
|
|
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.
|
|
130
190
|
window.parent?.postMessage({
|
|
131
191
|
type: SANDBOX_READY,
|
|
192
|
+
storyId: this.story.id,
|
|
193
|
+
variantId: this.variant.id,
|
|
132
194
|
})
|
|
195
|
+
// A retarget to a same-height story would otherwise never report:
|
|
196
|
+
// the observer only fires on a change.
|
|
197
|
+
scheduleReport()
|
|
133
198
|
} })
|
|
134
199
|
: null,
|
|
135
200
|
]
|
|
@@ -144,8 +209,6 @@ app.mount('#app')
|
|
|
144
209
|
// source-level rules that add overflow/min-height to the render root.
|
|
145
210
|
document.body.classList.add('__poveste-render-story', '__poveste-render-custom-controls')
|
|
146
211
|
|
|
147
|
-
let pendingFrame: number | null = null
|
|
148
|
-
let lastReportedHeight = -1
|
|
149
212
|
function reportHeight() {
|
|
150
213
|
pendingFrame = null
|
|
151
214
|
const renderRoot = document.querySelector('.__poveste-render-story')
|
|
@@ -156,7 +219,7 @@ function reportHeight() {
|
|
|
156
219
|
lastReportedHeight = h
|
|
157
220
|
// Same-origin: parent and iframe are both served by the poveste dev /
|
|
158
221
|
// build server, so scope the target rather than broadcasting.
|
|
159
|
-
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)
|
|
160
223
|
}
|
|
161
224
|
function scheduleReport() {
|
|
162
225
|
if (pendingFrame !== null) return
|
|
@@ -166,6 +229,11 @@ const ro = new ResizeObserver(scheduleReport)
|
|
|
166
229
|
ro.observe(document.body)
|
|
167
230
|
requestAnimationFrame(scheduleReport)
|
|
168
231
|
|
|
169
|
-
|
|
170
|
-
|
|
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())
|
|
171
239
|
}
|