@scaffold-ui-kit/preview 0.0.0-stage → 2.3.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/LICENSE +21 -0
- package/README.md +1868 -2
- package/application/host/bootstrap.ts +43 -0
- package/application/host/main.ts +31 -0
- package/application/host/shell-host.ts +47 -0
- package/application/index.html +12 -0
- package/application/lib/base-url.ts +15 -0
- package/application/lib/no-code-block.ts +7 -0
- package/application/lib/no-setup.ts +3 -0
- package/application/lib/no-views.ts +6 -0
- package/application/preview/code/code.html +12 -0
- package/application/preview/code/main.ts +89 -0
- package/application/preview/globals.ts +254 -0
- package/application/preview/html/main.ts +93 -0
- package/application/preview/html/preview.html +12 -0
- package/application/preview/react/main.tsx +146 -0
- package/application/preview/react/preview.html +12 -0
- package/application/preview/svelte/Preview.svelte +104 -0
- package/application/preview/svelte/main.ts +13 -0
- package/application/preview/svelte/preview.html +12 -0
- package/application/preview/svelte/story-props.ts +172 -0
- package/application/preview/view/main.ts +124 -0
- package/application/preview/view/view.html +12 -0
- package/application/preview/vue/main.ts +145 -0
- package/application/preview/vue/preview.html +12 -0
- package/application/shells/charcell/components/Application/index.css +37 -0
- package/application/shells/charcell/components/Application/index.tsx +65 -0
- package/application/shells/charcell/components/Bar/index.css +68 -0
- package/application/shells/charcell/components/Bar/index.tsx +60 -0
- package/application/shells/charcell/components/CodePane/index.css +60 -0
- package/application/shells/charcell/components/CodePane/index.tsx +113 -0
- package/application/shells/charcell/components/ComponentStage/index.css +26 -0
- package/application/shells/charcell/components/ComponentStage/index.tsx +110 -0
- package/application/shells/charcell/components/Control/index.css +20 -0
- package/application/shells/charcell/components/Control/index.tsx +118 -0
- package/application/shells/charcell/components/CopyLink/index.tsx +35 -0
- package/application/shells/charcell/components/Description/index.css +6 -0
- package/application/shells/charcell/components/Description/index.tsx +24 -0
- package/application/shells/charcell/components/EditorAddDialog/index.css +9 -0
- package/application/shells/charcell/components/EditorAddDialog/index.tsx +158 -0
- package/application/shells/charcell/components/EditorPanel/index.css +48 -0
- package/application/shells/charcell/components/EditorPanel/index.tsx +496 -0
- package/application/shells/charcell/components/EventsRow/index.css +42 -0
- package/application/shells/charcell/components/EventsRow/index.tsx +68 -0
- package/application/shells/charcell/components/HeadingRow/index.css +11 -0
- package/application/shells/charcell/components/HeadingRow/index.tsx +16 -0
- package/application/shells/charcell/components/Hint/index.css +16 -0
- package/application/shells/charcell/components/Hint/index.tsx +81 -0
- package/application/shells/charcell/components/HtmlWarnings/index.tsx +45 -0
- package/application/shells/charcell/components/KeyHints/index.css +27 -0
- package/application/shells/charcell/components/KeyHints/index.tsx +106 -0
- package/application/shells/charcell/components/KitCodeBlock/index.css +55 -0
- package/application/shells/charcell/components/KitCodeBlock/index.tsx +18 -0
- package/application/shells/charcell/components/Markdown/index.tsx +16 -0
- package/application/shells/charcell/components/Navigation/index.css +13 -0
- package/application/shells/charcell/components/Navigation/index.tsx +98 -0
- package/application/shells/charcell/components/NavigationSplit/index.css +16 -0
- package/application/shells/charcell/components/NavigationSplit/index.tsx +82 -0
- package/application/shells/charcell/components/OptionControls/index.tsx +93 -0
- package/application/shells/charcell/components/PaneStatus/index.css +7 -0
- package/application/shells/charcell/components/PaneStatus/index.tsx +47 -0
- package/application/shells/charcell/components/PaneWidth/index.tsx +45 -0
- package/application/shells/charcell/components/PreviewGrid/index.css +39 -0
- package/application/shells/charcell/components/PreviewGrid/index.tsx +344 -0
- package/application/shells/charcell/components/PreviewPane/index.css +127 -0
- package/application/shells/charcell/components/PreviewPane/index.tsx +182 -0
- package/application/shells/charcell/components/PromptBar/index.tsx +133 -0
- package/application/shells/charcell/components/ScenarioRow/index.css +36 -0
- package/application/shells/charcell/components/ScenarioRow/index.tsx +71 -0
- package/application/shells/charcell/components/Select/index.tsx +48 -0
- package/application/shells/charcell/components/SlotOutlet/index.css +7 -0
- package/application/shells/charcell/components/SlotOutlet/index.tsx +25 -0
- package/application/shells/charcell/components/StageBar/index.css +7 -0
- package/application/shells/charcell/components/StageBar/index.tsx +109 -0
- package/application/shells/charcell/components/StageSplit/index.css +11 -0
- package/application/shells/charcell/components/StageSplit/index.tsx +99 -0
- package/application/shells/charcell/components/StatusBar/index.tsx +30 -0
- package/application/shells/charcell/components/ViewControls/index.css +15 -0
- package/application/shells/charcell/components/ViewControls/index.tsx +58 -0
- package/application/shells/charcell/components/ViewHeading/index.css +13 -0
- package/application/shells/charcell/components/ViewHeading/index.tsx +8 -0
- package/application/shells/charcell/components/ViewStage/index.css +9 -0
- package/application/shells/charcell/components/ViewStage/index.tsx +25 -0
- package/application/shells/charcell/components/WrapToggle/index.tsx +27 -0
- package/application/shells/charcell/configuration.ts +45 -0
- package/application/shells/charcell/controller-context.ts +27 -0
- package/application/shells/charcell/fit-labels.ts +42 -0
- package/application/shells/charcell/html-warnings.ts +18 -0
- package/application/shells/charcell/index.tsx +82 -0
- package/application/shells/charcell/page-loading.html +57 -0
- package/application/shells/charcell/placement.ts +24 -0
- package/application/shells/charcell/shell-options.ts +23 -0
- package/application/shells/charcell/shell.css +37 -0
- package/application/shells/charcell/shell.tsx +25 -0
- package/application/shells/charcell/split-reset.ts +47 -0
- package/application/shells/charcell/stacked-rows.ts +117 -0
- package/application/shells/charcell/stage-toggles.ts +93 -0
- package/application/types/manifest.d.ts +6 -0
- package/application/types/preview-configuration.d.ts +10 -0
- package/application/types/preview-shell.d.ts +7 -0
- package/application/types/react-registry.d.ts +15 -0
- package/application/types/vue-registry.d.ts +6 -0
- package/dist/build.d.ts +24 -0
- package/dist/build.js +34 -0
- package/dist/check.d.ts +48 -0
- package/dist/check.js +781 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +88 -0
- package/dist/context.d.ts +179 -0
- package/dist/context.js +783 -0
- package/dist/controller/catalog.d.ts +339 -0
- package/dist/controller/catalog.js +367 -0
- package/dist/controller/create-preview-controller.d.ts +532 -0
- package/dist/controller/create-preview-controller.js +1707 -0
- package/dist/controller/editor-tree.d.ts +161 -0
- package/dist/controller/editor-tree.js +373 -0
- package/dist/controller/html-source.d.ts +62 -0
- package/dist/controller/html-source.js +61 -0
- package/dist/controller/index.d.ts +9 -0
- package/dist/controller/index.js +12 -0
- package/dist/controller/persistence.d.ts +191 -0
- package/dist/controller/persistence.js +315 -0
- package/dist/controller/regeneration.d.ts +30 -0
- package/dist/controller/regeneration.js +27 -0
- package/dist/controller/source.d.ts +80 -0
- package/dist/controller/source.js +236 -0
- package/dist/controller/store.d.ts +27 -0
- package/dist/controller/store.js +40 -0
- package/dist/declarations/application/shells/charcell/components/Application/index.d.ts +2 -0
- package/dist/declarations/application/shells/charcell/components/Bar/index.d.ts +11 -0
- package/dist/declarations/application/shells/charcell/components/CodePane/index.d.ts +5 -0
- package/dist/declarations/application/shells/charcell/components/ComponentStage/index.d.ts +2 -0
- package/dist/declarations/application/shells/charcell/components/Control/index.d.ts +9 -0
- package/dist/declarations/application/shells/charcell/components/CopyLink/index.d.ts +1 -0
- package/dist/declarations/application/shells/charcell/components/Description/index.d.ts +5 -0
- package/dist/declarations/application/shells/charcell/components/EditorAddDialog/index.d.ts +6 -0
- package/dist/declarations/application/shells/charcell/components/EditorPanel/index.d.ts +2 -0
- package/dist/declarations/application/shells/charcell/components/EventsRow/index.d.ts +4 -0
- package/dist/declarations/application/shells/charcell/components/HeadingRow/index.d.ts +6 -0
- package/dist/declarations/application/shells/charcell/components/Hint/index.d.ts +11 -0
- package/dist/declarations/application/shells/charcell/components/HtmlWarnings/index.d.ts +5 -0
- package/dist/declarations/application/shells/charcell/components/KeyHints/index.d.ts +2 -0
- package/dist/declarations/application/shells/charcell/components/KitCodeBlock/index.d.ts +5 -0
- package/dist/declarations/application/shells/charcell/components/Markdown/index.d.ts +3 -0
- package/dist/declarations/application/shells/charcell/components/Navigation/index.d.ts +8 -0
- package/dist/declarations/application/shells/charcell/components/NavigationSplit/index.d.ts +7 -0
- package/dist/declarations/application/shells/charcell/components/OptionControls/index.d.ts +7 -0
- package/dist/declarations/application/shells/charcell/components/PaneStatus/index.d.ts +2 -0
- package/dist/declarations/application/shells/charcell/components/PaneWidth/index.d.ts +4 -0
- package/dist/declarations/application/shells/charcell/components/PreviewGrid/index.d.ts +7 -0
- package/dist/declarations/application/shells/charcell/components/PreviewPane/index.d.ts +9 -0
- package/dist/declarations/application/shells/charcell/components/PromptBar/index.d.ts +1 -0
- package/dist/declarations/application/shells/charcell/components/ScenarioRow/index.d.ts +2 -0
- package/dist/declarations/application/shells/charcell/components/Select/index.d.ts +16 -0
- package/dist/declarations/application/shells/charcell/components/SlotOutlet/index.d.ts +5 -0
- package/dist/declarations/application/shells/charcell/components/StageBar/index.d.ts +2 -0
- package/dist/declarations/application/shells/charcell/components/StageSplit/index.d.ts +17 -0
- package/dist/declarations/application/shells/charcell/components/StatusBar/index.d.ts +1 -0
- package/dist/declarations/application/shells/charcell/components/ViewControls/index.d.ts +9 -0
- package/dist/declarations/application/shells/charcell/components/ViewHeading/index.d.ts +4 -0
- package/dist/declarations/application/shells/charcell/components/ViewStage/index.d.ts +4 -0
- package/dist/declarations/application/shells/charcell/components/WrapToggle/index.d.ts +1 -0
- package/dist/declarations/application/shells/charcell/configuration.d.ts +31 -0
- package/dist/declarations/application/shells/charcell/controller-context.d.ts +9 -0
- package/dist/declarations/application/shells/charcell/fit-labels.d.ts +18 -0
- package/dist/declarations/application/shells/charcell/html-warnings.d.ts +5 -0
- package/dist/declarations/application/shells/charcell/index.d.ts +39 -0
- package/dist/declarations/application/shells/charcell/placement.d.ts +7 -0
- package/dist/declarations/application/shells/charcell/shell-options.d.ts +15 -0
- package/dist/declarations/application/shells/charcell/shell.d.ts +9 -0
- package/dist/declarations/application/shells/charcell/split-reset.d.ts +30 -0
- package/dist/declarations/application/shells/charcell/stacked-rows.d.ts +58 -0
- package/dist/declarations/application/shells/charcell/stage-toggles.d.ts +28 -0
- package/dist/declarations/src/controller/catalog.d.ts +339 -0
- package/dist/declarations/src/controller/create-preview-controller.d.ts +532 -0
- package/dist/declarations/src/controller/editor-tree.d.ts +161 -0
- package/dist/declarations/src/controller/html-source.d.ts +62 -0
- package/dist/declarations/src/controller/index.d.ts +9 -0
- package/dist/declarations/src/controller/persistence.d.ts +191 -0
- package/dist/declarations/src/controller/regeneration.d.ts +30 -0
- package/dist/declarations/src/controller/source.d.ts +80 -0
- package/dist/declarations/src/controller/store.d.ts +27 -0
- package/dist/declarations/src/emit/common.d.ts +103 -0
- package/dist/declarations/src/emit/manifest.d.ts +138 -0
- package/dist/declarations/src/emit/source.d.ts +87 -0
- package/dist/declarations/src/lib/jsx-roots.d.ts +22 -0
- package/dist/declarations/src/lib/module-of-source.d.ts +18 -0
- package/dist/declarations/src/lib/naming.d.ts +29 -0
- package/dist/declarations/src/lib/react-spelling.d.ts +68 -0
- package/dist/declarations/src/lib/scenario-story.d.ts +41 -0
- package/dist/declarations/src/lib/slot-fill.d.ts +15 -0
- package/dist/declarations/src/lib/types.d.ts +741 -0
- package/dist/declarations/src/options.d.ts +224 -0
- package/dist/declarations/src/protocol.d.ts +541 -0
- package/dist/declarations/src/react-view.d.ts +22 -0
- package/dist/declarations/src/react.d.ts +17 -0
- package/dist/declarations/src/shell.d.ts +99 -0
- package/dist/declarations/src/views.d.ts +152 -0
- package/dist/development-server.d.ts +121 -0
- package/dist/development-server.js +331 -0
- package/dist/emit/common.d.ts +103 -0
- package/dist/emit/common.js +170 -0
- package/dist/emit/html-source.d.ts +46 -0
- package/dist/emit/html-source.js +322 -0
- package/dist/emit/manifest.d.ts +138 -0
- package/dist/emit/manifest.js +74 -0
- package/dist/emit/react.d.ts +16 -0
- package/dist/emit/react.js +210 -0
- package/dist/emit/scratch.d.ts +40 -0
- package/dist/emit/scratch.js +116 -0
- package/dist/emit/source.d.ts +87 -0
- package/dist/emit/source.js +789 -0
- package/dist/emit/svelte.d.ts +9 -0
- package/dist/emit/svelte.js +177 -0
- package/dist/emit/vue.d.ts +2 -0
- package/dist/emit/vue.js +152 -0
- package/dist/enumerate.d.ts +2 -0
- package/dist/enumerate.js +162 -0
- package/dist/generate.d.ts +43 -0
- package/dist/generate.js +302 -0
- package/dist/html-stylesheet.d.ts +43 -0
- package/dist/html-stylesheet.js +145 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +21 -0
- package/dist/landing.d.ts +16 -0
- package/dist/landing.js +45 -0
- package/dist/lib/exported-types.d.ts +42 -0
- package/dist/lib/exported-types.js +86 -0
- package/dist/lib/html-attributes.d.ts +2 -0
- package/dist/lib/html-attributes.js +111 -0
- package/dist/lib/html-warnings.d.ts +19 -0
- package/dist/lib/html-warnings.js +79 -0
- package/dist/lib/jsx-roots.d.ts +22 -0
- package/dist/lib/jsx-roots.js +243 -0
- package/dist/lib/module-of-source.d.ts +18 -0
- package/dist/lib/module-of-source.js +24 -0
- package/dist/lib/naming.d.ts +29 -0
- package/dist/lib/naming.js +56 -0
- package/dist/lib/parse-type.d.ts +32 -0
- package/dist/lib/parse-type.js +103 -0
- package/dist/lib/react-spelling.d.ts +68 -0
- package/dist/lib/react-spelling.js +197 -0
- package/dist/lib/scenario-story.d.ts +41 -0
- package/dist/lib/scenario-story.js +84 -0
- package/dist/lib/slot-fill.d.ts +15 -0
- package/dist/lib/slot-fill.js +92 -0
- package/dist/lib/template-files.d.ts +14 -0
- package/dist/lib/template-files.js +25 -0
- package/dist/lib/types.d.ts +741 -0
- package/dist/lib/types.js +13 -0
- package/dist/lib/void-elements.d.ts +7 -0
- package/dist/lib/void-elements.js +13 -0
- package/dist/load.d.ts +37 -0
- package/dist/load.js +82 -0
- package/dist/model.d.ts +44 -0
- package/dist/model.js +464 -0
- package/dist/options.d.ts +224 -0
- package/dist/options.js +223 -0
- package/dist/prop-types.d.ts +26 -0
- package/dist/prop-types.js +102 -0
- package/dist/props-from.d.ts +84 -0
- package/dist/props-from.js +510 -0
- package/dist/protocol.d.ts +541 -0
- package/dist/protocol.js +819 -0
- package/dist/react-view.d.ts +22 -0
- package/dist/react-view.js +36 -0
- package/dist/react.d.ts +17 -0
- package/dist/react.js +23 -0
- package/dist/sass-importers.d.ts +26 -0
- package/dist/sass-importers.js +32 -0
- package/dist/shell-acceptance.d.ts +21 -0
- package/dist/shell-acceptance.js +37 -0
- package/dist/shell-page.d.ts +36 -0
- package/dist/shell-page.js +90 -0
- package/dist/shell-peers.d.ts +38 -0
- package/dist/shell-peers.js +61 -0
- package/dist/shell.d.ts +99 -0
- package/dist/shell.js +52 -0
- package/dist/shells/charcell/page.d.ts +8 -0
- package/dist/shells/charcell/page.js +69 -0
- package/dist/story-source.d.ts +113 -0
- package/dist/story-source.js +271 -0
- package/dist/story.d.ts +5 -0
- package/dist/story.js +28 -0
- package/dist/testing.d.ts +218 -0
- package/dist/testing.js +284 -0
- package/dist/views.d.ts +152 -0
- package/dist/views.js +7 -0
- package/dist/vite-configuration.d.ts +114 -0
- package/dist/vite-configuration.js +578 -0
- package/package.json +123 -3
- package/src/build.ts +51 -0
- package/src/check.ts +1062 -0
- package/src/cli.ts +123 -0
- package/src/context.ts +1168 -0
- package/src/controller/catalog.ts +719 -0
- package/src/controller/create-preview-controller.ts +2683 -0
- package/src/controller/editor-tree.ts +530 -0
- package/src/controller/html-source.ts +114 -0
- package/src/controller/index.ts +105 -0
- package/src/controller/persistence.ts +403 -0
- package/src/controller/regeneration.ts +55 -0
- package/src/controller/source.ts +333 -0
- package/src/controller/store.ts +55 -0
- package/src/development-server.ts +442 -0
- package/src/emit/common.ts +201 -0
- package/src/emit/html-source.ts +402 -0
- package/src/emit/manifest.ts +241 -0
- package/src/emit/react.ts +263 -0
- package/src/emit/scratch.ts +175 -0
- package/src/emit/source.ts +1094 -0
- package/src/emit/svelte.ts +230 -0
- package/src/emit/vue.ts +194 -0
- package/src/enumerate.ts +220 -0
- package/src/generate.ts +443 -0
- package/src/html-stylesheet.ts +173 -0
- package/src/index.ts +60 -0
- package/src/landing.ts +58 -0
- package/src/lib/exported-types.ts +114 -0
- package/src/lib/html-attributes.ts +113 -0
- package/src/lib/html-warnings.ts +81 -0
- package/src/lib/jsx-roots.ts +238 -0
- package/src/lib/module-of-source.ts +26 -0
- package/src/lib/naming.ts +65 -0
- package/src/lib/parse-type.ts +143 -0
- package/src/lib/react-spelling.ts +235 -0
- package/src/lib/scenario-story.ts +97 -0
- package/src/lib/slot-fill.ts +102 -0
- package/src/lib/template-files.ts +31 -0
- package/src/lib/types.ts +778 -0
- package/src/lib/void-elements.ts +15 -0
- package/src/load.ts +144 -0
- package/src/model.ts +568 -0
- package/src/options.ts +435 -0
- package/src/prop-types.ts +133 -0
- package/src/props-from.ts +678 -0
- package/src/protocol.ts +1135 -0
- package/src/react-view.ts +45 -0
- package/src/react.ts +34 -0
- package/src/sass-importers.ts +45 -0
- package/src/shell-acceptance.ts +45 -0
- package/src/shell-page.ts +133 -0
- package/src/shell-peers.ts +70 -0
- package/src/shell.ts +194 -0
- package/src/shells/charcell/page.ts +82 -0
- package/src/story-source.ts +391 -0
- package/src/story.ts +42 -0
- package/src/testing.ts +497 -0
- package/src/views.ts +183 -0
- package/src/vite-configuration.ts +675 -0
|
@@ -0,0 +1,1707 @@
|
|
|
1
|
+
// The preview controller: the behavior of the preview with no UI of its own.
|
|
2
|
+
// It holds what is selected, the scenario, the option values, which target
|
|
3
|
+
// panes are shown (html's among them, where the kit builds html), whether each
|
|
4
|
+
// has loaded and how large its content is, the
|
|
5
|
+
// scenario editor's tree and the regeneration of the scratch story, and it owns the iframes: a shell
|
|
6
|
+
// hands it elements to mount them in and never speaks to them itself, the
|
|
7
|
+
// selection's panes and detached panes of any story alike. It keeps the
|
|
8
|
+
// selection, the scenario, the root props, the visible targets, the story
|
|
9
|
+
// panes' width and the option values in the host page's query parameters once
|
|
10
|
+
// a person acts, and
|
|
11
|
+
// the global option values in local storage when the kit asks.
|
|
12
|
+
// A shell's own options ride along, namespaced under `shell.`, and never
|
|
13
|
+
// reach the iframes. Every input is passed in, so it runs the same in a
|
|
14
|
+
// browser and in a test.
|
|
15
|
+
import { acceptsOptionValue, optionDefaults, shellOptionId, } from '../options.js';
|
|
16
|
+
import { catalogMessage, codeFrameUrl, codeMessage, componentFrameUrl, globalsMessage, isPreviewMessage, markupMessage, PREVIEW_MESSAGE, PREVIEW_TARGETS, propsMessage, readContentSize, setStoryMessage, standaloneFrameUrl, viewFrameUrl, } from '../protocol.js';
|
|
17
|
+
import { defaultScenarioId, nodeAddSlots, nodeSlots, storyFrameKey, } from './catalog.js';
|
|
18
|
+
import { cleanRecord, DEFAULT_SLOT, ensureIds, insertAt, isContainer, moveNode, nodeAt, pathKey, removeAt, resolveSortableMove, seedComponentNode, seedRoot, slotsKey, stableStringify, toPayload, updateAt, } from './editor-tree.js';
|
|
19
|
+
import { isPlaceholder, printSourceStory, resolveSourceStory, } from './source.js';
|
|
20
|
+
import { changedProps, isPaneWidth, parseTargets, parseOptionValue, parsePaneWidth, parseProps, readStoredOptions, readUrlState, storageKey, writeStoredOptions, writeUrlState, } from './persistence.js';
|
|
21
|
+
/** The time edits coalesce for before the preview regenerates what they
|
|
22
|
+
* change, in milliseconds: the scratch story, and a story's html from the
|
|
23
|
+
* development server. */
|
|
24
|
+
export const REGENERATION_DEBOUNCE_MILLISECONDS = 250;
|
|
25
|
+
/** The story id the regenerated scratch story is mounted under. */
|
|
26
|
+
export const SCRATCH_STORY_ID = 'scratch';
|
|
27
|
+
/**
|
|
28
|
+
* The attribute the controller sets on an iframe it mounted once the iframe
|
|
29
|
+
* shows its first content, and takes away while it loads again. Until then
|
|
30
|
+
* the iframe is hidden; a shell's CSS can show a loading note beside one
|
|
31
|
+
* without it.
|
|
32
|
+
*/
|
|
33
|
+
export const PREVIEW_FRAME_SHOWN_ATTRIBUTE = 'data-preview-shown';
|
|
34
|
+
/** How many recorded events `state.events` keeps. */
|
|
35
|
+
const EVENT_LOG_LENGTH = 100;
|
|
36
|
+
/** Each target's source language, as a code block's highlighter names it. */
|
|
37
|
+
const CODE_LANGUAGES = {
|
|
38
|
+
react: 'tsx',
|
|
39
|
+
vue: 'vue',
|
|
40
|
+
svelte: 'svelte',
|
|
41
|
+
html: 'html',
|
|
42
|
+
};
|
|
43
|
+
/** A story's props without its slot content, which the Empty scenario strips. */
|
|
44
|
+
function withoutContent(props) {
|
|
45
|
+
const { children: _children, ...rest } = props;
|
|
46
|
+
return rest;
|
|
47
|
+
}
|
|
48
|
+
/** The text of a tree's root when its default slot holds one text node and
|
|
49
|
+
* nothing else. */
|
|
50
|
+
function rootText(tree) {
|
|
51
|
+
const nodes = tree.slots[DEFAULT_SLOT];
|
|
52
|
+
return nodes?.length === 1 && nodes[0].kind === 'text'
|
|
53
|
+
? nodes[0].value
|
|
54
|
+
: undefined;
|
|
55
|
+
}
|
|
56
|
+
function withoutUndefined(props) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [name, value] of Object.entries(props))
|
|
59
|
+
if (value !== undefined)
|
|
60
|
+
out[name] = value;
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function isRecord(value) {
|
|
64
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
function sameTargets(a, b) {
|
|
67
|
+
return (a.length === b.length && a.every((target, index) => target === b[index]));
|
|
68
|
+
}
|
|
69
|
+
const DEFAULT_TIMERS = {
|
|
70
|
+
setTimeout: (callback, milliseconds) => setTimeout(callback, milliseconds),
|
|
71
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* The props a scripted pane mounts its story with: the story's, less each
|
|
75
|
+
* prop it unsets with `null`, which the pane leaves out as the story's code
|
|
76
|
+
* does, so the component's own default applies. A two-way prop given `null`
|
|
77
|
+
* stays: the story controls it at `null`, as its code binds it.
|
|
78
|
+
*
|
|
79
|
+
* @param props - The story's root props.
|
|
80
|
+
* @param component - The story's root component, for its two-way props.
|
|
81
|
+
* @returns The props without those unset with `null`.
|
|
82
|
+
*/
|
|
83
|
+
function paneProps(props, component) {
|
|
84
|
+
const twoWay = new Set((component?.bindings ?? []).map((binding) => binding.prop));
|
|
85
|
+
return Object.fromEntries(Object.entries(props).filter(([name, value]) => value !== null || twoWay.has(name)));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Create a preview controller.
|
|
89
|
+
*
|
|
90
|
+
* @param controllerOptions - The catalog, the options and the environment.
|
|
91
|
+
* @returns The controller.
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* const controller = createPreviewController({
|
|
95
|
+
* catalog: catalogFromManifest(manifest, views),
|
|
96
|
+
* options: previewOptions,
|
|
97
|
+
* regeneration: createFetchRegenerationTransport(),
|
|
98
|
+
* });
|
|
99
|
+
* const stop = controller.subscribe((state) => render(state));
|
|
100
|
+
* controller.actions.selectComponent('components-button');
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export function createPreviewController(controllerOptions) {
|
|
104
|
+
const { catalog, regeneration } = controllerOptions;
|
|
105
|
+
const options = [
|
|
106
|
+
...controllerOptions.options.map(({ apply: _apply, ...option }) => ({
|
|
107
|
+
...option,
|
|
108
|
+
origin: 'kit',
|
|
109
|
+
})),
|
|
110
|
+
...(controllerOptions.shellOptions ?? []).map((option) => ({
|
|
111
|
+
...option,
|
|
112
|
+
id: shellOptionId(option.id),
|
|
113
|
+
scope: 'global',
|
|
114
|
+
origin: 'shell',
|
|
115
|
+
})),
|
|
116
|
+
];
|
|
117
|
+
const shellOptionIds = new Set(options
|
|
118
|
+
.filter((option) => option.origin === 'shell')
|
|
119
|
+
.map((option) => option.id));
|
|
120
|
+
const hostWindow = controllerOptions.window ?? globalThis.window;
|
|
121
|
+
const timers = controllerOptions.timers ?? DEFAULT_TIMERS;
|
|
122
|
+
const baseUrl = controllerOptions.baseUrl ?? '/';
|
|
123
|
+
const storage = controllerOptions.persistence === 'url-and-local-storage'
|
|
124
|
+
? resolveStorage()
|
|
125
|
+
: undefined;
|
|
126
|
+
const storedOptionsKey = storageKey(catalog.kitName);
|
|
127
|
+
function resolveStorage() {
|
|
128
|
+
if (controllerOptions.storage)
|
|
129
|
+
return controllerOptions.storage;
|
|
130
|
+
try {
|
|
131
|
+
// Reading `localStorage` itself throws where storage is blocked.
|
|
132
|
+
return hostWindow?.localStorage ?? undefined;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const listeners = new Set();
|
|
139
|
+
const frames = [];
|
|
140
|
+
const stories = catalog.components.filter((component) => component.standalone);
|
|
141
|
+
// The stories a reader browses: an unlisted one is reachable at its address
|
|
142
|
+
// and selectable by id, but never listed or selected first.
|
|
143
|
+
const listedStories = stories.filter((component) => !component.unlisted);
|
|
144
|
+
const componentById = (id) => catalog.components.find((component) => component.id === id);
|
|
145
|
+
const componentByName = (name) => catalog.components.find((component) => component.name === name);
|
|
146
|
+
// The catalog a custom view builds its context from: the browseable stories.
|
|
147
|
+
const catalogPayload = {
|
|
148
|
+
kitName: catalog.kitName,
|
|
149
|
+
sources: catalog.sources,
|
|
150
|
+
components: listedStories.map((component) => ({
|
|
151
|
+
id: component.id,
|
|
152
|
+
title: component.title,
|
|
153
|
+
source: component.source,
|
|
154
|
+
})),
|
|
155
|
+
};
|
|
156
|
+
// --- what the state derives from --------------------------------------------
|
|
157
|
+
let globalValues = optionDefaults(options, 'global');
|
|
158
|
+
let storyOverrides = {};
|
|
159
|
+
const kitTargets = PREVIEW_TARGETS.filter((target) => catalog.targets.includes(target));
|
|
160
|
+
const visibleTargets = new Set(PREVIEW_TARGETS);
|
|
161
|
+
const storyOptionIds = options
|
|
162
|
+
.filter((option) => option.scope === 'story')
|
|
163
|
+
.map((option) => option.id);
|
|
164
|
+
// The root props the selected scenario seeded: the address carries the
|
|
165
|
+
// props only while they differ from these.
|
|
166
|
+
let seededProps = {};
|
|
167
|
+
// The editor tree's slot content as it was seeded, for `editor.modified`.
|
|
168
|
+
let seededSlots = '';
|
|
169
|
+
// Whether the panes render the regenerated scratch story or the real one.
|
|
170
|
+
let scratchActive = false;
|
|
171
|
+
// The panes' story mounts so far: each set-story the host broadcasts is one.
|
|
172
|
+
let storyMount = 0;
|
|
173
|
+
let reloadCount = 0;
|
|
174
|
+
let regenerationTimer = null;
|
|
175
|
+
let regenerationSequence = 0;
|
|
176
|
+
function storyDefaultsFor(component) {
|
|
177
|
+
const base = optionDefaults(options, 'story');
|
|
178
|
+
for (const [id, value] of Object.entries(component?.options ?? {})) {
|
|
179
|
+
if (value !== false)
|
|
180
|
+
base[id] = value;
|
|
181
|
+
}
|
|
182
|
+
return base;
|
|
183
|
+
}
|
|
184
|
+
function unavailableOptionsFor(component) {
|
|
185
|
+
return Object.entries(component?.options ?? {})
|
|
186
|
+
.filter(([, value]) => value === false)
|
|
187
|
+
.map(([id]) => id);
|
|
188
|
+
}
|
|
189
|
+
function mergedOptions(component) {
|
|
190
|
+
return {
|
|
191
|
+
...globalValues,
|
|
192
|
+
...storyDefaultsFor(component),
|
|
193
|
+
...storyOverrides,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** The values the iframes apply: the kit's options alone. */
|
|
197
|
+
function kitValues(values) {
|
|
198
|
+
if (shellOptionIds.size === 0)
|
|
199
|
+
return values;
|
|
200
|
+
const kit = {};
|
|
201
|
+
for (const [id, value] of Object.entries(values))
|
|
202
|
+
if (!shellOptionIds.has(id))
|
|
203
|
+
kit[id] = value;
|
|
204
|
+
return kit;
|
|
205
|
+
}
|
|
206
|
+
/** The kit's global option values, for a code block: it shows source, not
|
|
207
|
+
* a story, so a story option has nothing there to set. */
|
|
208
|
+
function codeValues(values) {
|
|
209
|
+
const global = { ...kitValues(values) };
|
|
210
|
+
for (const id of storyOptionIds)
|
|
211
|
+
delete global[id];
|
|
212
|
+
return global;
|
|
213
|
+
}
|
|
214
|
+
/** The values a detached pane of `component` applies: the global values,
|
|
215
|
+
* the story options as selecting the component would seed them, and the
|
|
216
|
+
* values its scenario sets over those. */
|
|
217
|
+
function detachedOptions(component, scenarioValues = {}) {
|
|
218
|
+
return kitValues({
|
|
219
|
+
...globalValues,
|
|
220
|
+
...storyDefaultsFor(component),
|
|
221
|
+
...scenarioValues,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** The option values a scenario of `component` sets that apply to it: the
|
|
225
|
+
* kit's options it names, with a value each accepts, less those the
|
|
226
|
+
* component makes unavailable. */
|
|
227
|
+
function scenarioOptionValues(component, scenarioId) {
|
|
228
|
+
const carried = component.scenarios.find((candidate) => candidate.id === scenarioId)?.options;
|
|
229
|
+
const values = {};
|
|
230
|
+
if (!carried)
|
|
231
|
+
return values;
|
|
232
|
+
const unavailable = unavailableOptionsFor(component);
|
|
233
|
+
for (const [id, value] of Object.entries(carried)) {
|
|
234
|
+
const option = options.find((candidate) => candidate.id === id && candidate.origin === 'kit');
|
|
235
|
+
if (option &&
|
|
236
|
+
!unavailable.includes(id) &&
|
|
237
|
+
acceptsOptionValue(option, value))
|
|
238
|
+
values[id] = value;
|
|
239
|
+
}
|
|
240
|
+
return values;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Set the option values a scenario of `component` carries, the way
|
|
244
|
+
* `setOption` sets a value: a global one for the session, a story one over
|
|
245
|
+
* the story's own until another story is selected. Picking a scenario
|
|
246
|
+
* that carries none leaves every value as it is.
|
|
247
|
+
*
|
|
248
|
+
* @returns Which kinds of value changed: whether any did, and whether a
|
|
249
|
+
* global one did, which detached panes follow.
|
|
250
|
+
*/
|
|
251
|
+
function applyScenarioOptions(component, scenarioId) {
|
|
252
|
+
const values = scenarioOptionValues(component, scenarioId);
|
|
253
|
+
let globalChanged = false;
|
|
254
|
+
for (const [id, value] of Object.entries(values)) {
|
|
255
|
+
const option = options.find((candidate) => candidate.id === id);
|
|
256
|
+
if (option.scope === 'story') {
|
|
257
|
+
storyOverrides = { ...storyOverrides, [id]: value };
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
globalChanged ||= globalValues[id] !== value;
|
|
261
|
+
globalValues = { ...globalValues, [id]: value };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { changed: Object.keys(values).length > 0, globalChanged };
|
|
265
|
+
}
|
|
266
|
+
function targetsFor(component) {
|
|
267
|
+
// Every story has an html pane where the kit builds html: one with no
|
|
268
|
+
// markup says why.
|
|
269
|
+
const supported = kitTargets.filter((target) => target === 'html' || (component?.targets.includes(target) ?? true));
|
|
270
|
+
const visible = kitTargets.filter((target) => visibleTargets.has(target));
|
|
271
|
+
return {
|
|
272
|
+
supported,
|
|
273
|
+
visible,
|
|
274
|
+
panes: visible.filter((target) => supported.includes(target)),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/** The props an address may set: those the component declares, and the
|
|
278
|
+
* text of its default slot as `children` when it has one. */
|
|
279
|
+
function addressableProps(component) {
|
|
280
|
+
const declaresChildren = component.propTypes.some((propType) => propType.name === 'children');
|
|
281
|
+
return component.slots.default && !declaresChildren
|
|
282
|
+
? [...component.propTypes, { name: 'children', control: 'text' }]
|
|
283
|
+
: component.propTypes;
|
|
284
|
+
}
|
|
285
|
+
/** The props as the address holds them: those that differ from the
|
|
286
|
+
* scenario's, and each prop of the scenario's the story leaves out as
|
|
287
|
+
* `null`, so that a link opens on what renders and stays short however
|
|
288
|
+
* much data the story's props hold. While the story renders from a tree
|
|
289
|
+
* (the editor, or Empty), `children` is the tree's content, which the
|
|
290
|
+
* address does not hold, so a missing `children` is not marked. */
|
|
291
|
+
function addressedProps(component, props) {
|
|
292
|
+
const addressable = new Set(addressableProps(component).map(({ name }) => name));
|
|
293
|
+
return changedProps(props, seededProps, (name) => addressable.has(name) && !(name === 'children' && state.editor.tree));
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Whether the story of the tree's root draws its default slot from the
|
|
297
|
+
* string `children` prop, having no default-slot content of its own. The
|
|
298
|
+
* editor holds that `children` as the root's text, so it can be edited,
|
|
299
|
+
* wrapped or replaced like any node, and `state.props` shows the root's
|
|
300
|
+
* lone text as `children`. A story with content of its own renders that
|
|
301
|
+
* content and not `children`, so its tree's text is never `children`.
|
|
302
|
+
*/
|
|
303
|
+
function drawsChildren(tree) {
|
|
304
|
+
const root = componentByName(tree.root);
|
|
305
|
+
const selected = selectedComponent();
|
|
306
|
+
// The selection's own story is read as its scenario shows it.
|
|
307
|
+
const shown = root !== undefined && selected?.name === root.name
|
|
308
|
+
? onScenario(root, state.scenario)
|
|
309
|
+
: root;
|
|
310
|
+
return !shown?.content?.default;
|
|
311
|
+
}
|
|
312
|
+
/** The root props a tree shows: its own, and, where the story draws its
|
|
313
|
+
* default slot from `children`, its root's lone text as `children`. */
|
|
314
|
+
function treeProps(tree) {
|
|
315
|
+
const text = drawsChildren(tree) ? rootText(tree) : undefined;
|
|
316
|
+
return text === undefined ? tree.props : { ...tree.props, children: text };
|
|
317
|
+
}
|
|
318
|
+
function seedProps(component, scenarioId) {
|
|
319
|
+
const scenario = component.scenarios.find((candidate) => candidate.id === scenarioId);
|
|
320
|
+
const props = { ...component.defaultProps, ...(scenario?.props ?? {}) };
|
|
321
|
+
// The placeholder text a component with no content starts from is the
|
|
322
|
+
// preview's own; a scenario that fills the default slot replaces it.
|
|
323
|
+
if (scenario?.content?.default !== undefined &&
|
|
324
|
+
component.previewDefaults?.children !== undefined &&
|
|
325
|
+
!('children' in (scenario.props ?? {}))) {
|
|
326
|
+
delete props.children;
|
|
327
|
+
}
|
|
328
|
+
return props;
|
|
329
|
+
}
|
|
330
|
+
/** The scenario of `component` with slot content of its own, by id. */
|
|
331
|
+
function contentScenario(component, scenarioId) {
|
|
332
|
+
const scenario = component.scenarios.find((candidate) => candidate.id === scenarioId);
|
|
333
|
+
return scenario?.content === undefined ? undefined : scenario;
|
|
334
|
+
}
|
|
335
|
+
/** The id the panes mount `component`'s story by on a scenario: the
|
|
336
|
+
* scenario's own story when it has content of its own, else the
|
|
337
|
+
* component's. */
|
|
338
|
+
function paneStoryId(component, scenarioId) {
|
|
339
|
+
return contentScenario(component, scenarioId)?.story ?? component.id;
|
|
340
|
+
}
|
|
341
|
+
/** `component` as a scenario shows it: with the scenario's content in
|
|
342
|
+
* place of the story's, for a scenario that has its own. */
|
|
343
|
+
function onScenario(component, scenarioId) {
|
|
344
|
+
const scenario = contentScenario(component, scenarioId);
|
|
345
|
+
return scenario ? { ...component, content: scenario.content } : component;
|
|
346
|
+
}
|
|
347
|
+
/** The component each scenario story renders, by the story's id. */
|
|
348
|
+
const scenarioStoryComponents = new Map(stories.flatMap((component) => component.scenarios.flatMap((scenario) => scenario.story === undefined
|
|
349
|
+
? []
|
|
350
|
+
: [[scenario.story, component]])));
|
|
351
|
+
const first = listedStories[0] ?? stories[0];
|
|
352
|
+
seededProps = first ? seedProps(first, defaultScenarioId(first)) : {};
|
|
353
|
+
let state = {
|
|
354
|
+
selection: { kind: 'component', id: first?.id ?? '' },
|
|
355
|
+
scenario: defaultScenarioId(first),
|
|
356
|
+
props: seededProps,
|
|
357
|
+
options: mergedOptions(first),
|
|
358
|
+
unavailableOptions: unavailableOptionsFor(first),
|
|
359
|
+
targets: targetsFor(first),
|
|
360
|
+
frames: {},
|
|
361
|
+
storyFrames: {},
|
|
362
|
+
frameSizes: {},
|
|
363
|
+
frameErrors: {},
|
|
364
|
+
events: [],
|
|
365
|
+
storyFrameSizes: {},
|
|
366
|
+
editor: { open: false, modified: false },
|
|
367
|
+
regeneration: { status: 'idle' },
|
|
368
|
+
};
|
|
369
|
+
function selectedComponent() {
|
|
370
|
+
return state.selection.kind === 'component'
|
|
371
|
+
? componentById(state.selection.id)
|
|
372
|
+
: undefined;
|
|
373
|
+
}
|
|
374
|
+
function commit(changes) {
|
|
375
|
+
state = withCode({ ...state, ...changes });
|
|
376
|
+
persist();
|
|
377
|
+
postHtml();
|
|
378
|
+
postCode();
|
|
379
|
+
for (const listener of listeners)
|
|
380
|
+
listener(state);
|
|
381
|
+
}
|
|
382
|
+
// --- the source ------------------------------------------------------------
|
|
383
|
+
// The scripted targets' source is printed whenever what the panes show changes; the
|
|
384
|
+
// html is asked of the server, or read from what the static build printed,
|
|
385
|
+
// once per story, and a request for a story since replaced is dropped.
|
|
386
|
+
let sourcedFrom;
|
|
387
|
+
let htmlKey;
|
|
388
|
+
let htmlTimer = null;
|
|
389
|
+
let htmlAbort;
|
|
390
|
+
let htmlSequence = 0;
|
|
391
|
+
const STATIC_EDITED_REASON = 'The static build prints html for the scenarios alone: there is none for changed props or edited content.';
|
|
392
|
+
function cancelHtml() {
|
|
393
|
+
if (htmlTimer !== null) {
|
|
394
|
+
timers.clearTimeout(htmlTimer);
|
|
395
|
+
htmlTimer = null;
|
|
396
|
+
}
|
|
397
|
+
htmlAbort?.abort();
|
|
398
|
+
htmlAbort = undefined;
|
|
399
|
+
htmlSequence += 1;
|
|
400
|
+
}
|
|
401
|
+
function htmlFrom(response) {
|
|
402
|
+
if (response && 'markup' in response)
|
|
403
|
+
return {
|
|
404
|
+
status: 'ready',
|
|
405
|
+
source: response.markup,
|
|
406
|
+
...(response.stylesheet === undefined
|
|
407
|
+
? {}
|
|
408
|
+
: { stylesheet: response.stylesheet }),
|
|
409
|
+
...(response.warnings === undefined || response.warnings.length === 0
|
|
410
|
+
? {}
|
|
411
|
+
: { warnings: response.warnings }),
|
|
412
|
+
};
|
|
413
|
+
if (response && 'missing' in response)
|
|
414
|
+
return {
|
|
415
|
+
status: 'unavailable',
|
|
416
|
+
reason: `This story has no html: it holds ${response.missing.join('; ')}.`,
|
|
417
|
+
};
|
|
418
|
+
return {
|
|
419
|
+
status: 'unavailable',
|
|
420
|
+
reason: 'No html was printed for this scenario.',
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/** Land an html answer, unless the story changed since it was asked. */
|
|
424
|
+
function settleHtml(sequence, html) {
|
|
425
|
+
if (sequence !== htmlSequence || !state.code)
|
|
426
|
+
return;
|
|
427
|
+
commit({ code: { ...state.code, html } });
|
|
428
|
+
}
|
|
429
|
+
/** Start getting the html of a story, returning what the state shows meanwhile. */
|
|
430
|
+
function requestHtml(component, request, scenarioId, edited) {
|
|
431
|
+
cancelHtml();
|
|
432
|
+
const sequence = htmlSequence;
|
|
433
|
+
const htmlSource = controllerOptions.htmlSource;
|
|
434
|
+
const scenarioHtmlSources = controllerOptions.scenarioHtmlSources;
|
|
435
|
+
if (htmlSource) {
|
|
436
|
+
htmlTimer = timers.setTimeout(() => {
|
|
437
|
+
htmlTimer = null;
|
|
438
|
+
const abort = new AbortController();
|
|
439
|
+
htmlAbort = abort;
|
|
440
|
+
htmlSource(request, abort.signal).then((response) => settleHtml(sequence, htmlFrom(response)), (reason) => {
|
|
441
|
+
if (abort.signal.aborted)
|
|
442
|
+
return;
|
|
443
|
+
settleHtml(sequence, {
|
|
444
|
+
status: 'unavailable',
|
|
445
|
+
reason: reason instanceof Error ? reason.message : String(reason),
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
}, REGENERATION_DEBOUNCE_MILLISECONDS);
|
|
449
|
+
return { status: 'loading' };
|
|
450
|
+
}
|
|
451
|
+
if (scenarioHtmlSources) {
|
|
452
|
+
if (edited)
|
|
453
|
+
return { status: 'unavailable', reason: STATIC_EDITED_REASON };
|
|
454
|
+
scenarioHtmlSources(component.id).then((byScenario) => settleHtml(sequence, htmlFrom(byScenario[scenarioId])), (reason) => settleHtml(sequence, {
|
|
455
|
+
status: 'unavailable',
|
|
456
|
+
reason: `No html was printed for this story: ${reason instanceof Error ? reason.message : String(reason)}`,
|
|
457
|
+
}));
|
|
458
|
+
return { status: 'loading' };
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
status: 'unavailable',
|
|
462
|
+
reason: 'This preview has no way to print html.',
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
/** `next` with the code of what its panes show, printed afresh when that changed. */
|
|
466
|
+
function withCode(next) {
|
|
467
|
+
const tree = next.editor.tree;
|
|
468
|
+
if (sourcedFrom &&
|
|
469
|
+
sourcedFrom.selection === next.selection &&
|
|
470
|
+
sourcedFrom.scenario === next.scenario &&
|
|
471
|
+
sourcedFrom.props === next.props &&
|
|
472
|
+
sourcedFrom.tree === tree)
|
|
473
|
+
return next;
|
|
474
|
+
sourcedFrom = {
|
|
475
|
+
selection: next.selection,
|
|
476
|
+
scenario: next.scenario,
|
|
477
|
+
props: next.props,
|
|
478
|
+
tree,
|
|
479
|
+
};
|
|
480
|
+
const component = next.selection.kind === 'component'
|
|
481
|
+
? componentById(next.selection.id)
|
|
482
|
+
: undefined;
|
|
483
|
+
if (!component) {
|
|
484
|
+
cancelHtml();
|
|
485
|
+
htmlKey = undefined;
|
|
486
|
+
return next.code === undefined ? next : { ...next, code: undefined };
|
|
487
|
+
}
|
|
488
|
+
const scenario = component.scenarios.find((candidate) => candidate.id === next.scenario);
|
|
489
|
+
const story = resolveSourceStory(component, {
|
|
490
|
+
props: next.props,
|
|
491
|
+
scenario,
|
|
492
|
+
tree,
|
|
493
|
+
});
|
|
494
|
+
const printed = printSourceStory(catalog, story, component.targets);
|
|
495
|
+
const previous = next.code;
|
|
496
|
+
const targets = previous &&
|
|
497
|
+
Object.keys(printed).length === Object.keys(previous.targets).length &&
|
|
498
|
+
component.targets.every((target) => printed[target] === previous.targets[target])
|
|
499
|
+
? previous.targets
|
|
500
|
+
: printed;
|
|
501
|
+
// The html pane shows an unfilled default slot's placeholder, as the
|
|
502
|
+
// scripted panes do, though the printed code leaves it out.
|
|
503
|
+
const placeholder = !tree &&
|
|
504
|
+
!story.children &&
|
|
505
|
+
typeof next.props.children === 'string' &&
|
|
506
|
+
isPlaceholder(component, next.props.children, { scenario })
|
|
507
|
+
? { kind: 'text', value: next.props.children }
|
|
508
|
+
: undefined;
|
|
509
|
+
const shownChildren = story.children ?? placeholder;
|
|
510
|
+
const request = {
|
|
511
|
+
rootComponent: story.component,
|
|
512
|
+
props: story.props,
|
|
513
|
+
...(shownChildren || Object.keys(story.named).length > 0
|
|
514
|
+
? {
|
|
515
|
+
slots: {
|
|
516
|
+
...(shownChildren ? { default: shownChildren } : {}),
|
|
517
|
+
...(Object.keys(story.named).length > 0
|
|
518
|
+
? { named: story.named }
|
|
519
|
+
: {}),
|
|
520
|
+
},
|
|
521
|
+
}
|
|
522
|
+
: {}),
|
|
523
|
+
...(story.around ? { around: story.around } : {}),
|
|
524
|
+
};
|
|
525
|
+
// The static build printed the scenarios as they are seeded.
|
|
526
|
+
const edited = tree !== undefined ||
|
|
527
|
+
stableStringify(next.props) !== stableStringify(seededProps);
|
|
528
|
+
const key = stableStringify([component.id, next.scenario, edited, request]);
|
|
529
|
+
let html = previous?.html;
|
|
530
|
+
if (key !== htmlKey || !html) {
|
|
531
|
+
htmlKey = key;
|
|
532
|
+
html = requestHtml(component, request, next.scenario, edited);
|
|
533
|
+
}
|
|
534
|
+
if (previous && previous.targets === targets && previous.html === html)
|
|
535
|
+
return next;
|
|
536
|
+
return { ...next, code: { targets, html } };
|
|
537
|
+
}
|
|
538
|
+
// --- persistence -----------------------------------------------------------
|
|
539
|
+
// Off while the starting state is read back.
|
|
540
|
+
let persisting = false;
|
|
541
|
+
// Above zero while a shell's action runs. The address is the controller's to
|
|
542
|
+
// write only from the first action on, so a page that is only opened, or a
|
|
543
|
+
// shell's own page the controller has not been asked to change, keeps the
|
|
544
|
+
// address it was opened with.
|
|
545
|
+
let acting = 0;
|
|
546
|
+
let ownsAddress = false;
|
|
547
|
+
let lastStored;
|
|
548
|
+
/** Run `work` as a shell's action: a change it commits claims the address. */
|
|
549
|
+
function action(work) {
|
|
550
|
+
return (...parameters) => {
|
|
551
|
+
acting += 1;
|
|
552
|
+
try {
|
|
553
|
+
return work(...parameters);
|
|
554
|
+
}
|
|
555
|
+
finally {
|
|
556
|
+
acting -= 1;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
/** The global values that differ from their defaults: what is remembered. */
|
|
561
|
+
function changedGlobalValues() {
|
|
562
|
+
const changed = {};
|
|
563
|
+
for (const option of options) {
|
|
564
|
+
const value = globalValues[option.id];
|
|
565
|
+
if (option.scope === 'global' &&
|
|
566
|
+
value !== undefined &&
|
|
567
|
+
value !== option.default)
|
|
568
|
+
changed[option.id] = value;
|
|
569
|
+
}
|
|
570
|
+
return changed;
|
|
571
|
+
}
|
|
572
|
+
/** The option values the URL keeps: the changed globals, and the selected
|
|
573
|
+
* story's own overrides that differ from its defaults. */
|
|
574
|
+
function urlOptionValues() {
|
|
575
|
+
const values = changedGlobalValues();
|
|
576
|
+
const component = selectedComponent();
|
|
577
|
+
if (!component)
|
|
578
|
+
return values;
|
|
579
|
+
const defaults = storyDefaultsFor(component);
|
|
580
|
+
const unavailable = unavailableOptionsFor(component);
|
|
581
|
+
for (const [id, value] of Object.entries(storyOverrides)) {
|
|
582
|
+
if (!unavailable.includes(id) && value !== defaults[id])
|
|
583
|
+
values[id] = value;
|
|
584
|
+
}
|
|
585
|
+
// A value the scenario sets that was changed back to its default since
|
|
586
|
+
// goes in too, or opening the address would set the scenario's again.
|
|
587
|
+
const merged = mergedOptions(component);
|
|
588
|
+
for (const [id, value] of Object.entries(scenarioOptionValues(component, state.scenario))) {
|
|
589
|
+
if (merged[id] !== value)
|
|
590
|
+
values[id] = merged[id];
|
|
591
|
+
}
|
|
592
|
+
return values;
|
|
593
|
+
}
|
|
594
|
+
function persist() {
|
|
595
|
+
if (!persisting)
|
|
596
|
+
return;
|
|
597
|
+
if (acting > 0)
|
|
598
|
+
ownsAddress = true;
|
|
599
|
+
if (ownsAddress)
|
|
600
|
+
writeUrl();
|
|
601
|
+
storeOptions();
|
|
602
|
+
}
|
|
603
|
+
function storeOptions() {
|
|
604
|
+
if (!storage)
|
|
605
|
+
return;
|
|
606
|
+
const changed = changedGlobalValues();
|
|
607
|
+
const serialized = JSON.stringify(changed);
|
|
608
|
+
if (serialized === lastStored)
|
|
609
|
+
return;
|
|
610
|
+
lastStored = serialized;
|
|
611
|
+
writeStoredOptions(storage, storedOptionsKey, changed);
|
|
612
|
+
}
|
|
613
|
+
function writeUrl() {
|
|
614
|
+
const location = hostWindow?.location;
|
|
615
|
+
const history = hostWindow?.history;
|
|
616
|
+
if (!location || typeof history?.replaceState !== 'function')
|
|
617
|
+
return;
|
|
618
|
+
const component = selectedComponent();
|
|
619
|
+
const scenario = component && state.scenario !== defaultScenarioId(component)
|
|
620
|
+
? state.scenario
|
|
621
|
+
: undefined;
|
|
622
|
+
const changed = component
|
|
623
|
+
? addressedProps(component, state.props)
|
|
624
|
+
: undefined;
|
|
625
|
+
const props = changed && Object.keys(changed).length > 0 ? changed : undefined;
|
|
626
|
+
const targets = kitTargets.every((target) => visibleTargets.has(target))
|
|
627
|
+
? undefined
|
|
628
|
+
: kitTargets.filter((target) => visibleTargets.has(target));
|
|
629
|
+
const search = writeUrlState(location.search, {
|
|
630
|
+
selection: state.selection,
|
|
631
|
+
scenario,
|
|
632
|
+
props,
|
|
633
|
+
targets,
|
|
634
|
+
width: state.paneWidth,
|
|
635
|
+
options: urlOptionValues(),
|
|
636
|
+
});
|
|
637
|
+
if (search === location.search.replace(/^\?/, ''))
|
|
638
|
+
return;
|
|
639
|
+
try {
|
|
640
|
+
// Replace, not push: a change of option is no step to go back to.
|
|
641
|
+
history.replaceState(history.state, '', `${location.pathname}${search ? `?${search}` : ''}${location.hash}`);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
// A page that cannot rewrite its URL keeps the state in memory alone.
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/** Whether the kit's landing entry is a view the catalog has. One it
|
|
648
|
+
* lacks is told of once, in the console, and the first story shows. */
|
|
649
|
+
function landsOnView() {
|
|
650
|
+
const landing = catalog.landing;
|
|
651
|
+
if (landing?.kind !== 'view')
|
|
652
|
+
return false;
|
|
653
|
+
if (catalog.views.some((view) => view.id === landing.id))
|
|
654
|
+
return true;
|
|
655
|
+
console.warn(`[preview] preview.landing names view '${landing.id}', which the views module does not register; the preview opens on the first story. The views: ${catalog.views.map((view) => view.id).join(', ') || 'none'}.`);
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
/** Whether the kit's landing entry is a story the catalog has. */
|
|
659
|
+
function landsOnComponent() {
|
|
660
|
+
const landing = catalog.landing;
|
|
661
|
+
return (landing?.kind === 'component' &&
|
|
662
|
+
stories.some((component) => component.id === landing.id));
|
|
663
|
+
}
|
|
664
|
+
/** Start from what the URL and storage say: stored globals, then the URL's
|
|
665
|
+
* values over them, the visible targets, its selection and scenario,
|
|
666
|
+
* its story options and its props. Where the address names no view or
|
|
667
|
+
* story, the kit's landing entry shows, else the first listed story. The
|
|
668
|
+
* address itself is left as it is. */
|
|
669
|
+
function restore() {
|
|
670
|
+
const fromUrl = readUrlState(hostWindow?.location?.search ?? '');
|
|
671
|
+
const globalOptions = options.filter((option) => option.scope === 'global');
|
|
672
|
+
const stored = storage
|
|
673
|
+
? readStoredOptions(storage, storedOptionsKey, globalOptions)
|
|
674
|
+
: {};
|
|
675
|
+
lastStored = storage ? JSON.stringify(stored) : undefined;
|
|
676
|
+
const fromUrlGlobals = {};
|
|
677
|
+
for (const option of globalOptions) {
|
|
678
|
+
const raw = fromUrl.options[option.id];
|
|
679
|
+
const value = raw === undefined ? undefined : parseOptionValue(option, raw);
|
|
680
|
+
if (value !== undefined)
|
|
681
|
+
fromUrlGlobals[option.id] = value;
|
|
682
|
+
}
|
|
683
|
+
globalValues = { ...globalValues, ...stored, ...fromUrlGlobals };
|
|
684
|
+
const visible = fromUrl.targets === undefined
|
|
685
|
+
? undefined
|
|
686
|
+
: parseTargets(fromUrl.targets, kitTargets);
|
|
687
|
+
if (visible) {
|
|
688
|
+
for (const target of kitTargets) {
|
|
689
|
+
if (visible.includes(target))
|
|
690
|
+
visibleTargets.add(target);
|
|
691
|
+
else
|
|
692
|
+
visibleTargets.delete(target);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const paneWidth = fromUrl.width === undefined ? undefined : parsePaneWidth(fromUrl.width);
|
|
696
|
+
if (paneWidth !== undefined)
|
|
697
|
+
state = { ...state, paneWidth };
|
|
698
|
+
if (fromUrl.view !== undefined &&
|
|
699
|
+
catalog.views.some((view) => view.id === fromUrl.view)) {
|
|
700
|
+
selectView(fromUrl.view);
|
|
701
|
+
}
|
|
702
|
+
else if (fromUrl.component !== undefined &&
|
|
703
|
+
stories.some((component) => component.id === fromUrl.component)) {
|
|
704
|
+
selectComponent(fromUrl.component);
|
|
705
|
+
const component = selectedComponent();
|
|
706
|
+
const scenario = fromUrl.scenario;
|
|
707
|
+
const known = component.scenarios.find((candidate) => candidate.id === scenario);
|
|
708
|
+
// Empty renders through a regeneration, which a static build cannot do.
|
|
709
|
+
if (scenario === '' ||
|
|
710
|
+
(scenario !== undefined && known && (!known.empty || regeneration)))
|
|
711
|
+
pickScenario(scenario);
|
|
712
|
+
// The address's values win over those the scenario sets.
|
|
713
|
+
globalValues = { ...globalValues, ...fromUrlGlobals };
|
|
714
|
+
const unavailable = unavailableOptionsFor(component);
|
|
715
|
+
for (const option of options) {
|
|
716
|
+
const raw = fromUrl.options[option.id];
|
|
717
|
+
if (option.scope !== 'story' ||
|
|
718
|
+
raw === undefined ||
|
|
719
|
+
unavailable.includes(option.id))
|
|
720
|
+
continue;
|
|
721
|
+
const value = parseOptionValue(option, raw);
|
|
722
|
+
if (value !== undefined)
|
|
723
|
+
storyOverrides = { ...storyOverrides, [option.id]: value };
|
|
724
|
+
}
|
|
725
|
+
const props = fromUrl.props === undefined
|
|
726
|
+
? undefined
|
|
727
|
+
: parseProps(fromUrl.props, addressableProps(component),
|
|
728
|
+
// The address holds the props that differ from the
|
|
729
|
+
// scenario's, which a story rendering from a tree (Empty)
|
|
730
|
+
// seeds without its content.
|
|
731
|
+
seededProps, seededProps, catalog.colorTokens);
|
|
732
|
+
if (props)
|
|
733
|
+
setProps(props);
|
|
734
|
+
}
|
|
735
|
+
else if (landsOnView()) {
|
|
736
|
+
selectView(catalog.landing.id);
|
|
737
|
+
globalValues = { ...globalValues, ...fromUrlGlobals };
|
|
738
|
+
}
|
|
739
|
+
else if (landsOnComponent()) {
|
|
740
|
+
// The kit's landing story, on its scenario or its baseline, with the
|
|
741
|
+
// values that scenario sets, the address's winning.
|
|
742
|
+
const landing = catalog.landing;
|
|
743
|
+
selectComponent(landing.id);
|
|
744
|
+
const scenario = landing.kind === 'component' ? landing.scenario : undefined;
|
|
745
|
+
const known = selectedComponent().scenarios.find((candidate) => candidate.id === scenario);
|
|
746
|
+
// Empty renders through a regeneration, which a static build cannot
|
|
747
|
+
// do: the story opens on its baseline, and the console says why.
|
|
748
|
+
if (known && (!known.empty || regeneration))
|
|
749
|
+
pickScenario(known.id);
|
|
750
|
+
else if (known?.empty)
|
|
751
|
+
console.warn(`[preview] preview.landing names the scenario '${known.id}' of '${landing.id}', which renders through the development server alone; the static build opens the story on its baseline.`);
|
|
752
|
+
globalValues = { ...globalValues, ...fromUrlGlobals };
|
|
753
|
+
}
|
|
754
|
+
else if (first) {
|
|
755
|
+
// The first story shows on its baseline scenario, with the values that
|
|
756
|
+
// scenario sets, the address's winning.
|
|
757
|
+
applyScenarioOptions(first, defaultScenarioId(first));
|
|
758
|
+
globalValues = { ...globalValues, ...fromUrlGlobals };
|
|
759
|
+
}
|
|
760
|
+
state = withCode({
|
|
761
|
+
...state,
|
|
762
|
+
options: mergedOptions(selectedComponent()),
|
|
763
|
+
targets: targetsFor(selectedComponent() ?? first),
|
|
764
|
+
});
|
|
765
|
+
persisting = true;
|
|
766
|
+
storeOptions();
|
|
767
|
+
}
|
|
768
|
+
// --- the iframes -----------------------------------------------------------
|
|
769
|
+
function postTo(frame, message) {
|
|
770
|
+
frame.iframe.contentWindow?.postMessage(message, '*');
|
|
771
|
+
}
|
|
772
|
+
/** The selection's set-story message: the story, its props, its events
|
|
773
|
+
* and the two-way props a pane writes back itself, the scratch story's
|
|
774
|
+
* those of the editor's root component. Each message but a pane's answer
|
|
775
|
+
* to its ready (`fresh` false) is a new mount of the story, which the
|
|
776
|
+
* panes tag their events with: an event of a story since replaced writes
|
|
777
|
+
* nothing back. */
|
|
778
|
+
function storyMessage(id, props, fresh = true) {
|
|
779
|
+
if (fresh)
|
|
780
|
+
storyMount += 1;
|
|
781
|
+
const component = storyComponent(id);
|
|
782
|
+
return setStoryMessage(id, paneProps(props, component), component?.events,
|
|
783
|
+
// Only a binding whose change prop is a recorded event: a pane writes
|
|
784
|
+
// back only what it then reports, so the host can answer each write.
|
|
785
|
+
component?.bindings.flatMap((binding) => binding.changeProp === undefined ||
|
|
786
|
+
!component.events.includes(binding.changeProp)
|
|
787
|
+
? []
|
|
788
|
+
: [{ prop: binding.prop, changeProp: binding.changeProp }]), storyMount);
|
|
789
|
+
}
|
|
790
|
+
/** The id the panes mount the selected story by: its scenario's own
|
|
791
|
+
* story when that has content of its own, else the selection's. */
|
|
792
|
+
function selectionStoryId() {
|
|
793
|
+
const component = selectedComponent();
|
|
794
|
+
return component
|
|
795
|
+
? paneStoryId(component, state.scenario)
|
|
796
|
+
: state.selection.id;
|
|
797
|
+
}
|
|
798
|
+
/** The component the story `id` renders at its root: the story's own, or,
|
|
799
|
+
* for the scratch story, the editor's root component. */
|
|
800
|
+
function storyComponent(id) {
|
|
801
|
+
return id === SCRATCH_STORY_ID
|
|
802
|
+
? state.editor.tree
|
|
803
|
+
? componentByName(state.editor.tree.root)
|
|
804
|
+
: undefined
|
|
805
|
+
: (componentById(id) ?? scenarioStoryComponents.get(id));
|
|
806
|
+
}
|
|
807
|
+
/** The panes' story's root component: the selection's, or the editor's
|
|
808
|
+
* root while the scratch story shows. */
|
|
809
|
+
function shownComponent() {
|
|
810
|
+
if (state.selection.kind !== 'component')
|
|
811
|
+
return undefined;
|
|
812
|
+
return storyComponent(scratchActive ? SCRATCH_STORY_ID : state.selection.id);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Take the value a pane reports through a two-way prop's change callback
|
|
816
|
+
* as the story's new prop, when the story gives the prop: a controlled
|
|
817
|
+
* story then follows a click in any pane, every pane and the html with
|
|
818
|
+
* it, as `setProps` does. A story that leaves the prop out lets the
|
|
819
|
+
* component keep its own value, and an unchanged value changes nothing.
|
|
820
|
+
*/
|
|
821
|
+
function writeBack(name, data) {
|
|
822
|
+
if (!('value' in data))
|
|
823
|
+
return;
|
|
824
|
+
// An event the pane recorded under a story it has since been told to
|
|
825
|
+
// replace.
|
|
826
|
+
if (typeof data.mount === 'number' && data.mount !== storyMount)
|
|
827
|
+
return;
|
|
828
|
+
// Every two-way prop the story gives that reports through this change
|
|
829
|
+
// prop and holds another value.
|
|
830
|
+
const changed = (shownComponent()?.bindings ?? []).filter((binding) => binding.changeProp === name &&
|
|
831
|
+
state.props[binding.prop] !== undefined &&
|
|
832
|
+
stableStringify(state.props[binding.prop]) !==
|
|
833
|
+
stableStringify(data.value));
|
|
834
|
+
if (changed.length === 0)
|
|
835
|
+
return;
|
|
836
|
+
// A change the story's reader made: it claims the address, as a shell's
|
|
837
|
+
// action does.
|
|
838
|
+
action(setProps)({
|
|
839
|
+
...state.props,
|
|
840
|
+
...Object.fromEntries(changed.map((binding) => [binding.prop, data.value])),
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
/** Post to the selection's scripted panes; a detached pane keeps its own
|
|
844
|
+
* story, and the html pane shows the markup `postHtml` sends it. */
|
|
845
|
+
function broadcast(message) {
|
|
846
|
+
for (const frame of frames)
|
|
847
|
+
if (!frame.story && frame.target !== 'html')
|
|
848
|
+
postTo(frame, message);
|
|
849
|
+
}
|
|
850
|
+
/** Post the story's new root props to the selection's scripted panes,
|
|
851
|
+
* each message naming the newest of that pane's own write-backs the
|
|
852
|
+
* host has taken, so a pane keeps a later write of its own over them. */
|
|
853
|
+
function broadcastProps(props) {
|
|
854
|
+
const shown = paneProps(props, shownComponent());
|
|
855
|
+
for (const frame of frames)
|
|
856
|
+
if (!frame.story && frame.target !== 'html')
|
|
857
|
+
postTo(frame, propsMessage(shown, frame.answered ?? 0));
|
|
858
|
+
}
|
|
859
|
+
/** Post option values to the selection's panes, the html pane's included. */
|
|
860
|
+
function broadcastGlobals(values) {
|
|
861
|
+
for (const frame of frames)
|
|
862
|
+
if (!frame.story)
|
|
863
|
+
postTo(frame, globalsMessage(kitValues(values)));
|
|
864
|
+
for (const frame of codeFrames)
|
|
865
|
+
if (frame.ready)
|
|
866
|
+
frame.iframe.contentWindow?.postMessage(globalsMessage(codeValues(values)), '*');
|
|
867
|
+
}
|
|
868
|
+
/** The html pane's message for the html the state holds. */
|
|
869
|
+
function htmlPaneMessage() {
|
|
870
|
+
const html = state.code?.html;
|
|
871
|
+
if (html?.status === 'ready')
|
|
872
|
+
return markupMessage(html.stylesheet === undefined
|
|
873
|
+
? { markup: html.source }
|
|
874
|
+
: { markup: html.source, stylesheet: html.stylesheet });
|
|
875
|
+
if (html?.status === 'unavailable')
|
|
876
|
+
return markupMessage({ reason: html.reason });
|
|
877
|
+
return markupMessage({});
|
|
878
|
+
}
|
|
879
|
+
// The html the html panes were last sent, so each change is sent once.
|
|
880
|
+
let postedHtml;
|
|
881
|
+
/** Send the html panes the state's html when it changed since last sent. */
|
|
882
|
+
function postHtml() {
|
|
883
|
+
const html = state.code?.html;
|
|
884
|
+
if (html === postedHtml)
|
|
885
|
+
return;
|
|
886
|
+
postedHtml = html;
|
|
887
|
+
if (!html)
|
|
888
|
+
return;
|
|
889
|
+
// A pane still loading gets the html when it answers the handshake.
|
|
890
|
+
const message = htmlPaneMessage();
|
|
891
|
+
for (const frame of frames)
|
|
892
|
+
if (frame.target === 'html' && readinessOf(frame) === 'ready')
|
|
893
|
+
postTo(frame, message);
|
|
894
|
+
}
|
|
895
|
+
// The events recorded so far, numbering each.
|
|
896
|
+
let eventCount = 0;
|
|
897
|
+
const codeFrames = [];
|
|
898
|
+
/** Hide an iframe until it shows its first content: a frame's page would
|
|
899
|
+
* otherwise show the browser's blank canvas, then whatever it paints on the
|
|
900
|
+
* way. Hidden, it leaves the shell's ground (and any loading note the shell
|
|
901
|
+
* draws beneath it) in view. */
|
|
902
|
+
function conceal(iframe) {
|
|
903
|
+
iframe.style.visibility = 'hidden';
|
|
904
|
+
iframe.removeAttribute(PREVIEW_FRAME_SHOWN_ATTRIBUTE);
|
|
905
|
+
}
|
|
906
|
+
/** Show an iframe whose first content has painted, marked for a shell's CSS. */
|
|
907
|
+
function reveal(iframe) {
|
|
908
|
+
iframe.style.removeProperty('visibility');
|
|
909
|
+
iframe.setAttribute(PREVIEW_FRAME_SHOWN_ATTRIBUTE, '');
|
|
910
|
+
}
|
|
911
|
+
// Whether the shell wraps its code panes, which the code blocks follow.
|
|
912
|
+
let codeWrap = false;
|
|
913
|
+
/** What a code block shows for `target`: its source, or why it has none;
|
|
914
|
+
* with neither, the source is being printed. */
|
|
915
|
+
function codeContent(target) {
|
|
916
|
+
const language = CODE_LANGUAGES[target];
|
|
917
|
+
const wrap = codeWrap;
|
|
918
|
+
const code = state.code;
|
|
919
|
+
if (target === 'html') {
|
|
920
|
+
const html = code?.html;
|
|
921
|
+
if (html?.status === 'ready')
|
|
922
|
+
return { target, language, wrap, source: html.source };
|
|
923
|
+
if (html?.status === 'unavailable')
|
|
924
|
+
return { target, language, wrap, reason: html.reason };
|
|
925
|
+
return { target, language, wrap };
|
|
926
|
+
}
|
|
927
|
+
const printed = code?.targets[target];
|
|
928
|
+
return printed === undefined
|
|
929
|
+
? {
|
|
930
|
+
target,
|
|
931
|
+
language,
|
|
932
|
+
wrap,
|
|
933
|
+
reason: `The story does not render in ${target}.`,
|
|
934
|
+
}
|
|
935
|
+
: { target, language, wrap, source: printed };
|
|
936
|
+
}
|
|
937
|
+
// The source the code blocks were last sent, so each change is sent once.
|
|
938
|
+
let postedCode;
|
|
939
|
+
/** Send each code block its target's source when it changed since last
|
|
940
|
+
* sent, or every block its content when `force`d. */
|
|
941
|
+
function postCode(force = false) {
|
|
942
|
+
if (!force && state.code === postedCode)
|
|
943
|
+
return;
|
|
944
|
+
postedCode = state.code;
|
|
945
|
+
for (const frame of codeFrames)
|
|
946
|
+
if (frame.ready)
|
|
947
|
+
frame.iframe.contentWindow?.postMessage(codeMessage(codeContent(frame.target)), '*');
|
|
948
|
+
}
|
|
949
|
+
function mountCode(element, target) {
|
|
950
|
+
const iframe = element.ownerDocument.createElement('iframe');
|
|
951
|
+
iframe.setAttribute('title', `${target} code`);
|
|
952
|
+
iframe.setAttribute('src', codeFrameUrl(target, catalog.codeBlock?.framework, baseUrl));
|
|
953
|
+
conceal(iframe);
|
|
954
|
+
element.appendChild(iframe);
|
|
955
|
+
const frame = { target, iframe, ready: false };
|
|
956
|
+
codeFrames.push(frame);
|
|
957
|
+
return () => {
|
|
958
|
+
const index = codeFrames.indexOf(frame);
|
|
959
|
+
if (index === -1)
|
|
960
|
+
return;
|
|
961
|
+
codeFrames.splice(index, 1);
|
|
962
|
+
iframe.remove();
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/** Bring each detached pane up to the current global values. */
|
|
966
|
+
function postDetachedGlobals() {
|
|
967
|
+
for (const frame of frames) {
|
|
968
|
+
if (frame.story)
|
|
969
|
+
postTo(frame, globalsMessage(detachedOptions(frame.story.component, frame.story.options)));
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function readinessOf(frame) {
|
|
973
|
+
return frame.story
|
|
974
|
+
? state.storyFrames[frame.story.key]
|
|
975
|
+
: state.frames[frame.target];
|
|
976
|
+
}
|
|
977
|
+
/** The state change that sets a frame's readiness, or clears it. */
|
|
978
|
+
function withReadiness(frame, readiness) {
|
|
979
|
+
if (frame.story) {
|
|
980
|
+
const storyFrames = { ...state.storyFrames };
|
|
981
|
+
if (readiness)
|
|
982
|
+
storyFrames[frame.story.key] = readiness;
|
|
983
|
+
else
|
|
984
|
+
delete storyFrames[frame.story.key];
|
|
985
|
+
return { storyFrames };
|
|
986
|
+
}
|
|
987
|
+
const next = { ...state.frames };
|
|
988
|
+
if (readiness)
|
|
989
|
+
next[frame.target] = readiness;
|
|
990
|
+
else
|
|
991
|
+
delete next[frame.target];
|
|
992
|
+
return { frames: next };
|
|
993
|
+
}
|
|
994
|
+
function sizeOf(frame) {
|
|
995
|
+
return frame.story
|
|
996
|
+
? state.storyFrameSizes[frame.story.key]
|
|
997
|
+
: state.frameSizes[frame.target];
|
|
998
|
+
}
|
|
999
|
+
/** The state change that sets the size of what a frame shows, or clears it. */
|
|
1000
|
+
function withSize(frame, size) {
|
|
1001
|
+
if (frame.story) {
|
|
1002
|
+
const storyFrameSizes = { ...state.storyFrameSizes };
|
|
1003
|
+
if (size)
|
|
1004
|
+
storyFrameSizes[frame.story.key] = size;
|
|
1005
|
+
else
|
|
1006
|
+
delete storyFrameSizes[frame.story.key];
|
|
1007
|
+
return { storyFrameSizes };
|
|
1008
|
+
}
|
|
1009
|
+
const frameSizes = { ...state.frameSizes };
|
|
1010
|
+
if (size)
|
|
1011
|
+
frameSizes[frame.target] = size;
|
|
1012
|
+
else
|
|
1013
|
+
delete frameSizes[frame.target];
|
|
1014
|
+
return { frameSizes };
|
|
1015
|
+
}
|
|
1016
|
+
/** The state change that sets what a selection's pane threw, or clears it. */
|
|
1017
|
+
function withError(target, message) {
|
|
1018
|
+
const frameErrors = { ...state.frameErrors };
|
|
1019
|
+
if (message === undefined)
|
|
1020
|
+
delete frameErrors[target];
|
|
1021
|
+
else
|
|
1022
|
+
frameErrors[target] = message;
|
|
1023
|
+
return { frameErrors };
|
|
1024
|
+
}
|
|
1025
|
+
/** Whether two frames report their readiness under the same key. */
|
|
1026
|
+
function sameReadinessKey(a, b) {
|
|
1027
|
+
return a.story
|
|
1028
|
+
? b.story?.key === a.story.key
|
|
1029
|
+
: !b.story && b.target === a.target;
|
|
1030
|
+
}
|
|
1031
|
+
function mountFrame(element, description, title, source) {
|
|
1032
|
+
const iframe = element.ownerDocument.createElement('iframe');
|
|
1033
|
+
iframe.setAttribute('title', title);
|
|
1034
|
+
iframe.setAttribute('src', source);
|
|
1035
|
+
conceal(iframe);
|
|
1036
|
+
element.appendChild(iframe);
|
|
1037
|
+
const frame = { ...description, iframe };
|
|
1038
|
+
frames.push(frame);
|
|
1039
|
+
// The new readiness is reported after the shell's mount call returns, so
|
|
1040
|
+
// no subscriber runs inside it: a shell that mounts in an effect reading
|
|
1041
|
+
// the state would otherwise see that state change within its own effect.
|
|
1042
|
+
// A pane unmounted before then is never reported.
|
|
1043
|
+
queueMicrotask(() => {
|
|
1044
|
+
if (!frames.includes(frame) || readinessOf(frame) === 'loading')
|
|
1045
|
+
return;
|
|
1046
|
+
commit(withReadiness(frame, 'loading'));
|
|
1047
|
+
});
|
|
1048
|
+
return () => {
|
|
1049
|
+
const index = frames.indexOf(frame);
|
|
1050
|
+
if (index === -1)
|
|
1051
|
+
return;
|
|
1052
|
+
frames.splice(index, 1);
|
|
1053
|
+
iframe.remove();
|
|
1054
|
+
if (frames.some((candidate) => sameReadinessKey(frame, candidate)) ||
|
|
1055
|
+
(readinessOf(frame) === undefined &&
|
|
1056
|
+
sizeOf(frame) === undefined &&
|
|
1057
|
+
(frame.story || state.frameErrors[frame.target] === undefined)))
|
|
1058
|
+
return;
|
|
1059
|
+
commit({
|
|
1060
|
+
...withReadiness(frame, undefined),
|
|
1061
|
+
...withSize(frame, undefined),
|
|
1062
|
+
...(frame.story ? {} : withError(frame.target, undefined)),
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
function mountStory(element, target, story) {
|
|
1067
|
+
const component = stories.find((candidate) => candidate.id === story.component);
|
|
1068
|
+
if (!component)
|
|
1069
|
+
throw new Error(`The catalog has no story "${story.component}" to mount: a detached pane takes the id of a standalone component.`);
|
|
1070
|
+
if (!component.targets.includes(target))
|
|
1071
|
+
throw new Error(`The story "${component.id}" does not render in ${target}: its targets are ${component.targets.join(', ')}.`);
|
|
1072
|
+
const scenarioId = story.scenario ?? defaultScenarioId(component);
|
|
1073
|
+
const scenario = component.scenarios.find((candidate) => candidate.id === scenarioId);
|
|
1074
|
+
if (scenarioId !== '' && !scenario)
|
|
1075
|
+
throw new Error(`The story "${component.id}" has no scenario "${scenarioId}".`);
|
|
1076
|
+
if (scenario?.empty)
|
|
1077
|
+
throw new Error(`The Empty scenario of "${component.id}" renders through the selection's regenerated story and cannot be mounted apart from it: leave out the scenarios marked empty.`);
|
|
1078
|
+
const props = {
|
|
1079
|
+
...seedProps(component, scenarioId),
|
|
1080
|
+
...withoutUndefined(story.props ?? {}),
|
|
1081
|
+
};
|
|
1082
|
+
return mountFrame(element, {
|
|
1083
|
+
target: target,
|
|
1084
|
+
story: {
|
|
1085
|
+
key: storyFrameKey(target, story),
|
|
1086
|
+
component,
|
|
1087
|
+
id: paneStoryId(component, scenarioId),
|
|
1088
|
+
props,
|
|
1089
|
+
options: scenarioOptionValues(component, scenarioId),
|
|
1090
|
+
},
|
|
1091
|
+
}, `${component.shortTitle}${scenario ? `: ${scenario.label}` : ''} (${target})`, componentFrameUrl(target, reloadCount, baseUrl));
|
|
1092
|
+
}
|
|
1093
|
+
function reloadPanes() {
|
|
1094
|
+
reloadCount += 1;
|
|
1095
|
+
const next = { ...state.frames };
|
|
1096
|
+
for (const frame of frames) {
|
|
1097
|
+
// A view, the html pane and a detached pane show no scratch story.
|
|
1098
|
+
if (frame.target === 'view' || frame.target === 'html' || frame.story)
|
|
1099
|
+
continue;
|
|
1100
|
+
conceal(frame.iframe);
|
|
1101
|
+
frame.iframe.setAttribute('src', componentFrameUrl(frame.target, reloadCount, baseUrl));
|
|
1102
|
+
next[frame.target] = 'loading';
|
|
1103
|
+
}
|
|
1104
|
+
commit({ frames: next });
|
|
1105
|
+
}
|
|
1106
|
+
function answerReady(frame) {
|
|
1107
|
+
// A pane that announces ready again was reloaded, and counts its
|
|
1108
|
+
// writes from zero.
|
|
1109
|
+
frame.answered = 0;
|
|
1110
|
+
if (frame.story) {
|
|
1111
|
+
postTo(frame, setStoryMessage(frame.story.id, paneProps(frame.story.props, frame.story.component)));
|
|
1112
|
+
postTo(frame, globalsMessage(detachedOptions(frame.story.component, frame.story.options)));
|
|
1113
|
+
postTo(frame, catalogMessage(catalogPayload));
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (frame.target === 'html') {
|
|
1117
|
+
postTo(frame, globalsMessage(kitValues(state.options)));
|
|
1118
|
+
postTo(frame, htmlPaneMessage());
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
// The scratch story holds the root's text as content, not as a prop.
|
|
1122
|
+
const tree = state.editor.tree;
|
|
1123
|
+
if (scratchActive && tree)
|
|
1124
|
+
postTo(frame, storyMessage(SCRATCH_STORY_ID, tree.props, false));
|
|
1125
|
+
else
|
|
1126
|
+
postTo(frame, storyMessage(selectionStoryId(), state.props, false));
|
|
1127
|
+
postTo(frame, globalsMessage(kitValues(state.options)));
|
|
1128
|
+
postTo(frame, catalogMessage(catalogPayload));
|
|
1129
|
+
}
|
|
1130
|
+
function onMessage(event) {
|
|
1131
|
+
const data = event.data;
|
|
1132
|
+
if (isPreviewMessage(data, PREVIEW_MESSAGE.shown)) {
|
|
1133
|
+
const shown = [...frames, ...codeFrames].find((candidate) => candidate.iframe.contentWindow === event.source);
|
|
1134
|
+
if (shown)
|
|
1135
|
+
reveal(shown.iframe);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
if (isPreviewMessage(data, PREVIEW_MESSAGE.ready)) {
|
|
1139
|
+
const code = codeFrames.find((candidate) => candidate.iframe.contentWindow === event.source);
|
|
1140
|
+
if (code) {
|
|
1141
|
+
code.ready = true;
|
|
1142
|
+
const codeWindow = code.iframe.contentWindow;
|
|
1143
|
+
codeWindow?.postMessage(globalsMessage(codeValues(state.options)), '*');
|
|
1144
|
+
codeWindow?.postMessage(codeMessage(codeContent(code.target)), '*');
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const frame = frames.find((candidate) => candidate.iframe.contentWindow === event.source);
|
|
1148
|
+
if (!frame)
|
|
1149
|
+
return;
|
|
1150
|
+
answerReady(frame);
|
|
1151
|
+
commit(withReadiness(frame, 'ready'));
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (isPreviewMessage(data, PREVIEW_MESSAGE.select)) {
|
|
1155
|
+
// Only the view pane the controller mounted may select a story: a
|
|
1156
|
+
// component pane, the selection's or a detached one, never asks.
|
|
1157
|
+
const frame = frames.find((candidate) => candidate.target === 'view' &&
|
|
1158
|
+
candidate.iframe.contentWindow === event.source);
|
|
1159
|
+
if (!frame || typeof data.id !== 'string')
|
|
1160
|
+
return;
|
|
1161
|
+
if (!stories.some((component) => component.id === data.id))
|
|
1162
|
+
return;
|
|
1163
|
+
action(selectComponent)(data.id, isRecord(data.props) ? data.props : undefined);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (isPreviewMessage(data, PREVIEW_MESSAGE.event)) {
|
|
1167
|
+
// The selection's scripted panes record their story's events; a
|
|
1168
|
+
// detached pane's stay its own.
|
|
1169
|
+
const frame = frames.find((candidate) => !candidate.story && candidate.iframe.contentWindow === event.source);
|
|
1170
|
+
if (!frame || typeof data.name !== 'string')
|
|
1171
|
+
return;
|
|
1172
|
+
eventCount += 1;
|
|
1173
|
+
const recorded = {
|
|
1174
|
+
id: eventCount,
|
|
1175
|
+
target: frame.target,
|
|
1176
|
+
name: data.name,
|
|
1177
|
+
payload: typeof data.payload === 'string' ? data.payload : '',
|
|
1178
|
+
time: Date.now(),
|
|
1179
|
+
};
|
|
1180
|
+
commit({ events: [...state.events, recorded].slice(-EVENT_LOG_LENGTH) });
|
|
1181
|
+
// The write the event reports is the host's from here on, whether or
|
|
1182
|
+
// not it changes the story: the next props to the pane answer it.
|
|
1183
|
+
if (typeof data.write === 'number' && data.write > (frame.answered ?? 0))
|
|
1184
|
+
frame.answered = data.write;
|
|
1185
|
+
writeBack(data.name, data);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (isPreviewMessage(data, PREVIEW_MESSAGE.renderError)) {
|
|
1189
|
+
// A detached story pane's error stays its own; the selection's panes
|
|
1190
|
+
// report theirs.
|
|
1191
|
+
const frame = frames.find((candidate) => !candidate.story && candidate.iframe.contentWindow === event.source);
|
|
1192
|
+
if (!frame)
|
|
1193
|
+
return;
|
|
1194
|
+
const message = typeof data.message === 'string' ? data.message : undefined;
|
|
1195
|
+
if (state.frameErrors[frame.target] === message)
|
|
1196
|
+
return;
|
|
1197
|
+
commit(withError(frame.target, message));
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
if (isPreviewMessage(data, PREVIEW_MESSAGE.contentSize)) {
|
|
1201
|
+
const frame = frames.find((candidate) => candidate.iframe.contentWindow === event.source);
|
|
1202
|
+
const size = readContentSize(data);
|
|
1203
|
+
if (!frame || !size)
|
|
1204
|
+
return;
|
|
1205
|
+
const current = sizeOf(frame);
|
|
1206
|
+
if (current?.width === size.width && current.height === size.height)
|
|
1207
|
+
return;
|
|
1208
|
+
commit(withSize(frame, size));
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
hostWindow?.addEventListener('message', onMessage);
|
|
1212
|
+
// --- regeneration ----------------------------------------------------------
|
|
1213
|
+
function dropPendingRegeneration() {
|
|
1214
|
+
if (regenerationTimer !== null) {
|
|
1215
|
+
timers.clearTimeout(regenerationTimer);
|
|
1216
|
+
regenerationTimer = null;
|
|
1217
|
+
}
|
|
1218
|
+
regenerationSequence += 1;
|
|
1219
|
+
return state.regeneration.status === 'pending'
|
|
1220
|
+
? { status: 'idle' }
|
|
1221
|
+
: state.regeneration;
|
|
1222
|
+
}
|
|
1223
|
+
/** Regenerate `tree` once edits pause. The returned status is the one to commit. */
|
|
1224
|
+
function scheduleRegeneration(tree) {
|
|
1225
|
+
dropPendingRegeneration();
|
|
1226
|
+
if (!regeneration) {
|
|
1227
|
+
return {
|
|
1228
|
+
status: 'error',
|
|
1229
|
+
message: 'This preview cannot regenerate a story: it has no regeneration endpoint.',
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
const generation = regenerationSequence;
|
|
1233
|
+
regenerationTimer = timers.setTimeout(() => {
|
|
1234
|
+
regenerationTimer = null;
|
|
1235
|
+
// The tree as it stands when the edits pause: the root's props may have
|
|
1236
|
+
// changed live since the last structural edit.
|
|
1237
|
+
const current = state.editor.tree ?? tree;
|
|
1238
|
+
// The content beside the root is the story's as the catalog has it now,
|
|
1239
|
+
// which no edit reaches.
|
|
1240
|
+
const around = componentById(state.selection.id)?.around;
|
|
1241
|
+
regeneration({
|
|
1242
|
+
...toPayload(current),
|
|
1243
|
+
...(around ? { around } : {}),
|
|
1244
|
+
}).then(() => {
|
|
1245
|
+
if (generation !== regenerationSequence)
|
|
1246
|
+
return;
|
|
1247
|
+
reloadPanes();
|
|
1248
|
+
commit({ regeneration: { status: 'idle' } });
|
|
1249
|
+
}, (reason) => {
|
|
1250
|
+
if (generation !== regenerationSequence)
|
|
1251
|
+
return;
|
|
1252
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
1253
|
+
commit({ regeneration: { status: 'error', message } });
|
|
1254
|
+
});
|
|
1255
|
+
}, REGENERATION_DEBOUNCE_MILLISECONDS);
|
|
1256
|
+
return { status: 'pending' };
|
|
1257
|
+
}
|
|
1258
|
+
// --- selection -------------------------------------------------------------
|
|
1259
|
+
function selectComponent(id, props) {
|
|
1260
|
+
const component = stories.find((candidate) => candidate.id === id);
|
|
1261
|
+
if (!component)
|
|
1262
|
+
return;
|
|
1263
|
+
const regenerationStatus = dropPendingRegeneration();
|
|
1264
|
+
scratchActive = false;
|
|
1265
|
+
const baseline = props
|
|
1266
|
+
? undefined
|
|
1267
|
+
: component.scenarios.find((scenario) => scenario.default);
|
|
1268
|
+
seededProps = seedProps(component, baseline?.id ?? '');
|
|
1269
|
+
const nextProps = { ...seededProps, ...(props ?? {}) };
|
|
1270
|
+
broadcast(storyMessage(paneStoryId(component, baseline?.id ?? ''), nextProps));
|
|
1271
|
+
storyOverrides = {};
|
|
1272
|
+
const { globalChanged } = baseline
|
|
1273
|
+
? applyScenarioOptions(component, baseline.id)
|
|
1274
|
+
: { globalChanged: false };
|
|
1275
|
+
const merged = mergedOptions(component);
|
|
1276
|
+
broadcastGlobals(merged);
|
|
1277
|
+
if (globalChanged)
|
|
1278
|
+
postDetachedGlobals();
|
|
1279
|
+
const targets = targetsFor(component);
|
|
1280
|
+
commit({
|
|
1281
|
+
selection: { kind: 'component', id },
|
|
1282
|
+
events: [],
|
|
1283
|
+
scenario: baseline?.id ?? '',
|
|
1284
|
+
props: nextProps,
|
|
1285
|
+
options: merged,
|
|
1286
|
+
unavailableOptions: unavailableOptionsFor(component),
|
|
1287
|
+
targets: sameTargets(targets.supported, state.targets.supported)
|
|
1288
|
+
? state.targets
|
|
1289
|
+
: targets,
|
|
1290
|
+
editor: { open: false, modified: false },
|
|
1291
|
+
regeneration: regenerationStatus,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
function selectView(id) {
|
|
1295
|
+
if (!catalog.views.some((view) => view.id === id))
|
|
1296
|
+
return;
|
|
1297
|
+
const regenerationStatus = dropPendingRegeneration();
|
|
1298
|
+
scratchActive = false;
|
|
1299
|
+
commit({
|
|
1300
|
+
selection: { kind: 'view', id },
|
|
1301
|
+
events: [],
|
|
1302
|
+
options: mergedOptions(undefined),
|
|
1303
|
+
// A view has no story to apply a story option to.
|
|
1304
|
+
unavailableOptions: storyOptionIds,
|
|
1305
|
+
editor: { open: false, modified: false },
|
|
1306
|
+
regeneration: regenerationStatus,
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
function pickScenario(id) {
|
|
1310
|
+
const component = selectedComponent();
|
|
1311
|
+
if (!component)
|
|
1312
|
+
return;
|
|
1313
|
+
const scenario = id
|
|
1314
|
+
? component.scenarios.find((candidate) => candidate.id === id)
|
|
1315
|
+
: undefined;
|
|
1316
|
+
// Empty renders through a regeneration, which a static build cannot do.
|
|
1317
|
+
if (scenario?.empty && !regeneration)
|
|
1318
|
+
return;
|
|
1319
|
+
if (scenario?.empty) {
|
|
1320
|
+
// Empty is content the real story cannot show, so it renders through the
|
|
1321
|
+
// regenerated scratch story: the story's props, no slot content.
|
|
1322
|
+
const tree = {
|
|
1323
|
+
root: component.name,
|
|
1324
|
+
props: withoutContent(seedProps(component, id)),
|
|
1325
|
+
slots: {},
|
|
1326
|
+
};
|
|
1327
|
+
scratchActive = true;
|
|
1328
|
+
seededProps = tree.props;
|
|
1329
|
+
seededSlots = slotsKey(tree.slots);
|
|
1330
|
+
broadcast(storyMessage(SCRATCH_STORY_ID, cleanRecord(tree.props)));
|
|
1331
|
+
const regenerationStatus = scheduleRegeneration(tree);
|
|
1332
|
+
commit({
|
|
1333
|
+
scenario: id,
|
|
1334
|
+
props: tree.props,
|
|
1335
|
+
editor: { open: state.editor.open, tree, modified: false },
|
|
1336
|
+
regeneration: regenerationStatus,
|
|
1337
|
+
});
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
const regenerationStatus = dropPendingRegeneration();
|
|
1341
|
+
scratchActive = false;
|
|
1342
|
+
const props = seedProps(component, id);
|
|
1343
|
+
seededProps = props;
|
|
1344
|
+
broadcast(storyMessage(paneStoryId(component, id), props));
|
|
1345
|
+
const carried = applyScenarioOptions(component, id);
|
|
1346
|
+
const merged = carried.changed ? mergedOptions(component) : state.options;
|
|
1347
|
+
if (carried.changed)
|
|
1348
|
+
broadcastGlobals(merged);
|
|
1349
|
+
if (carried.globalChanged)
|
|
1350
|
+
postDetachedGlobals();
|
|
1351
|
+
let tree;
|
|
1352
|
+
if (state.editor.open) {
|
|
1353
|
+
// The props read from the tree, as `open` seeds it.
|
|
1354
|
+
tree = seedRoot(onScenario(component, id), props);
|
|
1355
|
+
ensureIds(tree.slots);
|
|
1356
|
+
seededProps = treeProps(tree);
|
|
1357
|
+
seededSlots = slotsKey(tree.slots);
|
|
1358
|
+
}
|
|
1359
|
+
commit({
|
|
1360
|
+
scenario: id,
|
|
1361
|
+
props: seededProps,
|
|
1362
|
+
options: merged,
|
|
1363
|
+
editor: {
|
|
1364
|
+
open: state.editor.open,
|
|
1365
|
+
...(tree ? { tree } : {}),
|
|
1366
|
+
modified: false,
|
|
1367
|
+
},
|
|
1368
|
+
regeneration: regenerationStatus,
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
function setOption(id, value) {
|
|
1372
|
+
const descriptor = options.find((candidate) => candidate.id === id);
|
|
1373
|
+
if (descriptor?.scope === 'story')
|
|
1374
|
+
storyOverrides = { ...storyOverrides, [id]: value };
|
|
1375
|
+
else
|
|
1376
|
+
globalValues = { ...globalValues, [id]: value };
|
|
1377
|
+
const merged = mergedOptions(selectedComponent());
|
|
1378
|
+
// A shell option's value never reaches the iframes, so they are told
|
|
1379
|
+
// nothing of it.
|
|
1380
|
+
if (descriptor?.origin !== 'shell')
|
|
1381
|
+
broadcastGlobals(merged);
|
|
1382
|
+
if (descriptor?.scope === 'global' && descriptor.origin === 'kit')
|
|
1383
|
+
postDetachedGlobals();
|
|
1384
|
+
commit({ options: merged });
|
|
1385
|
+
}
|
|
1386
|
+
function setTargetVisible(target, visible) {
|
|
1387
|
+
if (visible)
|
|
1388
|
+
visibleTargets.add(target);
|
|
1389
|
+
else
|
|
1390
|
+
visibleTargets.delete(target);
|
|
1391
|
+
commit({ targets: targetsFor(selectedComponent()) });
|
|
1392
|
+
}
|
|
1393
|
+
function setPaneWidth(width) {
|
|
1394
|
+
if (width !== undefined && !isPaneWidth(width))
|
|
1395
|
+
return;
|
|
1396
|
+
if (width === state.paneWidth)
|
|
1397
|
+
return;
|
|
1398
|
+
const { paneWidth: _paneWidth, ...rest } = state;
|
|
1399
|
+
state = rest;
|
|
1400
|
+
commit(width === undefined ? {} : { paneWidth: width });
|
|
1401
|
+
}
|
|
1402
|
+
function setProps(props) {
|
|
1403
|
+
if (state.selection.kind !== 'component')
|
|
1404
|
+
return;
|
|
1405
|
+
const next = withoutUndefined(props);
|
|
1406
|
+
const tree = state.editor.tree;
|
|
1407
|
+
if (!tree) {
|
|
1408
|
+
// The root renders with its props as they are: they reach the panes live.
|
|
1409
|
+
broadcastProps(cleanRecord(next));
|
|
1410
|
+
commit({ props: next });
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
// With a tree, where the story draws its default slot from `children`,
|
|
1414
|
+
// `children` is the root's text: a new one replaces the root's default
|
|
1415
|
+
// slot with that text, and one left out removes a lone text. Content
|
|
1416
|
+
// other than a lone text carries no `children` and stays. A story with
|
|
1417
|
+
// default-slot content of its own takes no `children`.
|
|
1418
|
+
const { children, ...rootProps } = next;
|
|
1419
|
+
const text = typeof children === 'string' ? children : undefined;
|
|
1420
|
+
if (drawsChildren(tree) && text !== rootText(tree)) {
|
|
1421
|
+
const slots = { ...tree.slots };
|
|
1422
|
+
if (text === undefined)
|
|
1423
|
+
delete slots[DEFAULT_SLOT];
|
|
1424
|
+
else
|
|
1425
|
+
slots[DEFAULT_SLOT] = [{ kind: 'text', value: text }];
|
|
1426
|
+
editTree({ ...tree, props: rootProps, slots });
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
const nextTree = { ...tree, props: rootProps };
|
|
1430
|
+
const shown = treeProps(nextTree);
|
|
1431
|
+
// The root's props reach the panes live; the scratch story holds the
|
|
1432
|
+
// root's text as content, not as a prop.
|
|
1433
|
+
broadcastProps(cleanRecord(scratchActive ? rootProps : shown));
|
|
1434
|
+
commit({
|
|
1435
|
+
props: sameProps(shown),
|
|
1436
|
+
editor: { ...state.editor, tree: nextTree },
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
/** `props`, or the state's own when they are equal, so an unchanged field
|
|
1440
|
+
* keeps its reference. */
|
|
1441
|
+
function sameProps(props) {
|
|
1442
|
+
return stableStringify(props) === stableStringify(state.props)
|
|
1443
|
+
? state.props
|
|
1444
|
+
: props;
|
|
1445
|
+
}
|
|
1446
|
+
// --- the editor ------------------------------------------------------------
|
|
1447
|
+
function open() {
|
|
1448
|
+
if (state.editor.open)
|
|
1449
|
+
return;
|
|
1450
|
+
const component = selectedComponent();
|
|
1451
|
+
if (!component)
|
|
1452
|
+
return;
|
|
1453
|
+
let tree = state.editor.tree;
|
|
1454
|
+
if (!tree) {
|
|
1455
|
+
// The root starts from the props the panes show, so a prop set before
|
|
1456
|
+
// the editor opened stays set; the address still measures them against
|
|
1457
|
+
// the scenario's.
|
|
1458
|
+
const shown = onScenario(component, state.scenario);
|
|
1459
|
+
tree = seedRoot(shown, state.props);
|
|
1460
|
+
ensureIds(tree.slots);
|
|
1461
|
+
seededProps = treeProps(seedRoot(shown, seedProps(component, state.scenario)));
|
|
1462
|
+
seededSlots = slotsKey(tree.slots);
|
|
1463
|
+
// The props now read from the tree, which shows what the panes show:
|
|
1464
|
+
// a string `children` stays as the root's text. Where the story's own
|
|
1465
|
+
// content stands in its default slot, the panes render that content
|
|
1466
|
+
// and not the `children` prop, so `props` leaves it out and nothing on
|
|
1467
|
+
// screen changes, with nothing to post.
|
|
1468
|
+
commit({
|
|
1469
|
+
props: sameProps(treeProps(tree)),
|
|
1470
|
+
editor: { open: true, tree, modified: false },
|
|
1471
|
+
});
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
commit({ editor: { ...state.editor, open: true, tree } });
|
|
1475
|
+
}
|
|
1476
|
+
function close() {
|
|
1477
|
+
if (!state.editor.open)
|
|
1478
|
+
return;
|
|
1479
|
+
commit({ editor: { ...state.editor, open: false } });
|
|
1480
|
+
}
|
|
1481
|
+
function createNode(request) {
|
|
1482
|
+
if (request.kind === 'text')
|
|
1483
|
+
return { kind: 'text', value: request.value };
|
|
1484
|
+
if (request.kind === 'html')
|
|
1485
|
+
return { kind: 'html', tag: request.tag, props: {}, slots: {} };
|
|
1486
|
+
const component = componentByName(request.name);
|
|
1487
|
+
if (!component)
|
|
1488
|
+
throw new Error(`The catalog has no component named "${request.name}".`);
|
|
1489
|
+
const baseline = component.scenarios.find((scenario) => scenario.default);
|
|
1490
|
+
const named = component.scenarios.filter((scenario) => !scenario.empty && !scenario.default);
|
|
1491
|
+
// A component with nothing to strip and no scenario to pick has no choice to
|
|
1492
|
+
// make: it comes in with its defaults.
|
|
1493
|
+
const hasChoice = component.slots.default ||
|
|
1494
|
+
component.slots.named.length > 0 ||
|
|
1495
|
+
named.length > 0;
|
|
1496
|
+
const importAs = hasChoice ? (request.importAs ?? 'empty') : 'default';
|
|
1497
|
+
const withDefaults = (over) => ({
|
|
1498
|
+
...component.defaultProps,
|
|
1499
|
+
...(over ?? {}),
|
|
1500
|
+
});
|
|
1501
|
+
if (importAs === 'empty')
|
|
1502
|
+
return seedComponentNode(component);
|
|
1503
|
+
// A scenario with content of its own comes in with that content.
|
|
1504
|
+
if (importAs === 'default')
|
|
1505
|
+
return seedComponentNode(onScenario(component, baseline?.id ?? ''), withDefaults(baseline?.props));
|
|
1506
|
+
const scenario = named.find((candidate) => candidate.id === importAs.scenario);
|
|
1507
|
+
return seedComponentNode(onScenario(component, scenario?.id ?? ''), withDefaults(scenario?.props));
|
|
1508
|
+
}
|
|
1509
|
+
/** Apply a change to the tree's slot content: the panes switch to the scratch
|
|
1510
|
+
* story, which regenerates once edits pause. An unchanged tree does nothing. */
|
|
1511
|
+
/**
|
|
1512
|
+
* Whether the slot `slot` of the node at `parent` takes `node`, moved from
|
|
1513
|
+
* `from` or newly inserted. A scoped fill fills the one slot it names, so
|
|
1514
|
+
* it moves only within its own list; a slot whose content must take its
|
|
1515
|
+
* scope takes nothing else.
|
|
1516
|
+
*/
|
|
1517
|
+
function slotTakes(tree, parent, slot, node, from) {
|
|
1518
|
+
if (node.kind === 'opaque' && node.fill.kind === 'scoped')
|
|
1519
|
+
return (from !== undefined &&
|
|
1520
|
+
pathKey(from.slice(0, -1)) === pathKey(parent) &&
|
|
1521
|
+
from[from.length - 1].slot === slot);
|
|
1522
|
+
const holder = parent.length === 0 ? tree : nodeAt(tree.slots, parent);
|
|
1523
|
+
if (!holder)
|
|
1524
|
+
return true;
|
|
1525
|
+
return (!nodeSlots(catalog, holder).includes(slot) ||
|
|
1526
|
+
nodeAddSlots(catalog, holder).includes(slot));
|
|
1527
|
+
}
|
|
1528
|
+
function editContent(transform) {
|
|
1529
|
+
const tree = state.editor.tree;
|
|
1530
|
+
if (!tree)
|
|
1531
|
+
return;
|
|
1532
|
+
const slots = transform(tree.slots);
|
|
1533
|
+
if (slots === tree.slots)
|
|
1534
|
+
return;
|
|
1535
|
+
editTree({ ...tree, slots });
|
|
1536
|
+
}
|
|
1537
|
+
/** Show `next`, whose content differs from the current tree's, through the
|
|
1538
|
+
* scratch story; the root's text, if it changed, reaches `props`. */
|
|
1539
|
+
function editTree(next) {
|
|
1540
|
+
ensureIds(next.slots);
|
|
1541
|
+
scratchActive = true;
|
|
1542
|
+
broadcast(storyMessage(SCRATCH_STORY_ID, cleanRecord(next.props)));
|
|
1543
|
+
const regenerationStatus = scheduleRegeneration(next);
|
|
1544
|
+
commit({
|
|
1545
|
+
props: sameProps(treeProps(next)),
|
|
1546
|
+
editor: {
|
|
1547
|
+
...state.editor,
|
|
1548
|
+
tree: next,
|
|
1549
|
+
modified: slotsKey(next.slots) !== seededSlots,
|
|
1550
|
+
},
|
|
1551
|
+
regeneration: regenerationStatus,
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
function setNodeProps(path, props) {
|
|
1555
|
+
if (path.length === 0) {
|
|
1556
|
+
// The root's text is content: its props leave it, and `children`, as it is.
|
|
1557
|
+
const tree = state.editor.tree;
|
|
1558
|
+
if (!tree) {
|
|
1559
|
+
setProps(props);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const { children: _children, ...rootProps } = props;
|
|
1563
|
+
const text = treeProps(tree).children;
|
|
1564
|
+
setProps(text === undefined ? rootProps : { ...rootProps, children: text });
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
const nextProps = withoutUndefined(props);
|
|
1568
|
+
editContent((slots) => updateAt(slots, path, (node) => isContainer(node) ? { ...node, props: nextProps } : node));
|
|
1569
|
+
}
|
|
1570
|
+
function dispose() {
|
|
1571
|
+
hostWindow?.removeEventListener('message', onMessage);
|
|
1572
|
+
dropPendingRegeneration();
|
|
1573
|
+
cancelHtml();
|
|
1574
|
+
for (const frame of [...frames])
|
|
1575
|
+
frame.iframe.remove();
|
|
1576
|
+
frames.length = 0;
|
|
1577
|
+
for (const frame of [...codeFrames])
|
|
1578
|
+
frame.iframe.remove();
|
|
1579
|
+
codeFrames.length = 0;
|
|
1580
|
+
listeners.clear();
|
|
1581
|
+
}
|
|
1582
|
+
restore();
|
|
1583
|
+
return {
|
|
1584
|
+
apiVersion: 1,
|
|
1585
|
+
catalog,
|
|
1586
|
+
options,
|
|
1587
|
+
capabilities: { regeneration: regeneration !== undefined },
|
|
1588
|
+
getState: () => state,
|
|
1589
|
+
subscribe(listener) {
|
|
1590
|
+
listeners.add(listener);
|
|
1591
|
+
return () => {
|
|
1592
|
+
listeners.delete(listener);
|
|
1593
|
+
};
|
|
1594
|
+
},
|
|
1595
|
+
actions: {
|
|
1596
|
+
selectComponent: action(selectComponent),
|
|
1597
|
+
selectView: action(selectView),
|
|
1598
|
+
select: action((selection) => {
|
|
1599
|
+
if (selection.kind === 'view')
|
|
1600
|
+
selectView(selection.id);
|
|
1601
|
+
else
|
|
1602
|
+
selectComponent(selection.id);
|
|
1603
|
+
}),
|
|
1604
|
+
pickScenario: action(pickScenario),
|
|
1605
|
+
setOption: action(setOption),
|
|
1606
|
+
setTargetVisible: action(setTargetVisible),
|
|
1607
|
+
setPaneWidth: action(setPaneWidth),
|
|
1608
|
+
setProps: action(setProps),
|
|
1609
|
+
clearEvents: action(() => {
|
|
1610
|
+
if (state.events.length > 0)
|
|
1611
|
+
commit({ events: [] });
|
|
1612
|
+
}),
|
|
1613
|
+
},
|
|
1614
|
+
editor: {
|
|
1615
|
+
open: action(open),
|
|
1616
|
+
close: action(close),
|
|
1617
|
+
createNode,
|
|
1618
|
+
insert: action((target, node) => {
|
|
1619
|
+
const tree = state.editor.tree;
|
|
1620
|
+
if (tree && !slotTakes(tree, target.parent, target.slot, node))
|
|
1621
|
+
return;
|
|
1622
|
+
editContent((slots) => insertAt(slots, target.parent, target.slot, node, target.index));
|
|
1623
|
+
}),
|
|
1624
|
+
move: action((from, to) => {
|
|
1625
|
+
const tree = state.editor.tree;
|
|
1626
|
+
const node = tree && from.length > 0 ? nodeAt(tree.slots, from) : undefined;
|
|
1627
|
+
if (tree && node && !slotTakes(tree, to.parent, to.slot, node, from))
|
|
1628
|
+
return;
|
|
1629
|
+
editContent((slots) => moveNode(slots, from, to));
|
|
1630
|
+
}),
|
|
1631
|
+
moveBy: action((path, offset) => {
|
|
1632
|
+
if (path.length === 0)
|
|
1633
|
+
return;
|
|
1634
|
+
editContent((slots) => {
|
|
1635
|
+
const parent = path.slice(0, -1);
|
|
1636
|
+
const { slot, index } = path[path.length - 1];
|
|
1637
|
+
const holder = parent.length === 0 ? undefined : nodeAt(slots, parent);
|
|
1638
|
+
const list = holder === undefined
|
|
1639
|
+
? slots[slot]
|
|
1640
|
+
: holder && isContainer(holder)
|
|
1641
|
+
? holder.slots[slot]
|
|
1642
|
+
: undefined;
|
|
1643
|
+
if (!list || index >= list.length)
|
|
1644
|
+
return slots;
|
|
1645
|
+
const destination = Math.min(Math.max(index + Math.trunc(offset), 0), list.length - 1);
|
|
1646
|
+
if (destination === index)
|
|
1647
|
+
return slots;
|
|
1648
|
+
// `moveNode` counts the target index before the node is taken out.
|
|
1649
|
+
const before = destination > index ? destination + 1 : destination;
|
|
1650
|
+
return moveNode(slots, path, { parent, slot, index: before });
|
|
1651
|
+
});
|
|
1652
|
+
}),
|
|
1653
|
+
replace: action((path, node) => {
|
|
1654
|
+
if (path.length === 0)
|
|
1655
|
+
return;
|
|
1656
|
+
editContent((slots) => updateAt(slots, path, () => node));
|
|
1657
|
+
}),
|
|
1658
|
+
remove: action((path) => {
|
|
1659
|
+
if (path.length === 0)
|
|
1660
|
+
return;
|
|
1661
|
+
editContent((slots) => removeAt(slots, path));
|
|
1662
|
+
}),
|
|
1663
|
+
setNodeProps: action(setNodeProps),
|
|
1664
|
+
setText: action((path, text) => {
|
|
1665
|
+
editContent((slots) => updateAt(slots, path, (node) => node.kind === 'text' ? { ...node, value: text } : node));
|
|
1666
|
+
}),
|
|
1667
|
+
resolveDrop(drop) {
|
|
1668
|
+
const move = resolveSortableMove(drop.source, drop.target, drop.sameList, drop.fromIndex, drop.toIndex);
|
|
1669
|
+
return { from: move.from, to: move.target };
|
|
1670
|
+
},
|
|
1671
|
+
},
|
|
1672
|
+
frames: {
|
|
1673
|
+
mount: (element, target) => mountFrame(element, { target }, target, componentFrameUrl(target, reloadCount, baseUrl)),
|
|
1674
|
+
mountView: (element, viewId) => mountFrame(element, { target: 'view' }, viewId, viewFrameUrl(viewId, catalog.views.find((view) => view.id === viewId)?.framework, baseUrl)),
|
|
1675
|
+
mountStory,
|
|
1676
|
+
mountCode,
|
|
1677
|
+
wrapCode(wrap) {
|
|
1678
|
+
if (wrap === codeWrap)
|
|
1679
|
+
return;
|
|
1680
|
+
codeWrap = wrap;
|
|
1681
|
+
postCode(true);
|
|
1682
|
+
},
|
|
1683
|
+
reload: action((target) => {
|
|
1684
|
+
const next = { ...state.frames };
|
|
1685
|
+
for (const frame of frames) {
|
|
1686
|
+
if (frame.story || frame.target !== target)
|
|
1687
|
+
continue;
|
|
1688
|
+
conceal(frame.iframe);
|
|
1689
|
+
frame.iframe.contentWindow?.location.reload();
|
|
1690
|
+
next[target] = 'loading';
|
|
1691
|
+
}
|
|
1692
|
+
commit({ frames: next });
|
|
1693
|
+
}),
|
|
1694
|
+
standaloneUrl: (target) => {
|
|
1695
|
+
// The scratch story holds the root's text as content, not as a prop.
|
|
1696
|
+
const tree = state.editor.tree;
|
|
1697
|
+
const scratch = scratchActive && tree !== undefined;
|
|
1698
|
+
return standaloneFrameUrl(target, {
|
|
1699
|
+
id: scratch ? SCRATCH_STORY_ID : selectionStoryId(),
|
|
1700
|
+
props: paneProps(scratch ? tree.props : state.props, scratch ? componentByName(tree.root) : selectedComponent()),
|
|
1701
|
+
options: kitValues(state.options),
|
|
1702
|
+
}, baseUrl);
|
|
1703
|
+
},
|
|
1704
|
+
},
|
|
1705
|
+
dispose,
|
|
1706
|
+
};
|
|
1707
|
+
}
|