@storystick/react 0.0.0-stage → 0.2.0-rc.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 +112 -0
- package/NOTICE +17 -0
- package/PUBLIC-API.md +36 -0
- package/README.md +226 -2
- package/dist/.source-hash +1 -0
- package/dist/file.d.ts +18 -0
- package/dist/file.js +44 -0
- package/dist/hydrate-slice.d.ts +3 -0
- package/dist/hydrate-slice.js +1 -0
- package/dist/image-component.d.ts +26 -0
- package/dist/image-component.js +79 -0
- package/dist/image-element.d.ts +6 -0
- package/dist/image-element.js +68 -0
- package/dist/image.d.ts +3 -0
- package/dist/image.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/link-adapter.d.ts +2 -0
- package/dist/link-adapter.js +2 -0
- package/dist/link-component.d.ts +53 -0
- package/dist/link-component.js +97 -0
- package/dist/link.d.ts +3 -0
- package/dist/link.js +3 -0
- package/dist/preview-generation.d.ts +21 -0
- package/dist/preview-generation.js +134 -0
- package/dist/preview-host/preview-assistant-continuation.d.ts +9 -0
- package/dist/preview-host/preview-assistant-continuation.js +39 -0
- package/dist/preview-host/preview-authoring-client.d.ts +12 -0
- package/dist/preview-host/preview-authoring-client.js +100 -0
- package/dist/preview-host/preview-editor-continuation.d.ts +13 -0
- package/dist/preview-host/preview-editor-continuation.js +68 -0
- package/dist/preview-host/preview-standalone-controls.d.ts +18 -0
- package/dist/preview-host/preview-standalone-controls.js +518 -0
- package/dist/preview-host/preview-studio-destination.d.ts +20 -0
- package/dist/preview-host/preview-studio-destination.js +55 -0
- package/dist/preview-host/use-standalone-presentation.d.ts +15 -0
- package/dist/preview-host/use-standalone-presentation.js +223 -0
- package/dist/preview-icons.d.ts +10 -0
- package/dist/preview-icons.js +8 -0
- package/dist/preview-interaction-control.d.ts +6 -0
- package/dist/preview-interaction-control.js +39 -0
- package/dist/preview-panel-state.d.ts +15 -0
- package/dist/preview-panel-state.js +28 -0
- package/dist/preview-provenance-publisher-model.d.ts +5 -0
- package/dist/preview-provenance-publisher-model.js +9 -0
- package/dist/preview-provenance-publisher.d.ts +15 -0
- package/dist/preview-provenance-publisher.js +68 -0
- package/dist/preview-provenance.d.ts +6 -0
- package/dist/preview-provenance.js +4 -0
- package/dist/preview-scope.d.ts +128 -0
- package/dist/preview-scope.js +161 -0
- package/dist/preview-slice-scope.d.ts +32 -0
- package/dist/preview-slice-scope.js +33 -0
- package/dist/preview-text-props.d.ts +18 -0
- package/dist/preview-text-props.js +13 -0
- package/dist/preview-toolbar-layout.d.ts +42 -0
- package/dist/preview-toolbar-layout.js +139 -0
- package/dist/preview-toolbar-panel.d.ts +13 -0
- package/dist/preview-toolbar-panel.js +6 -0
- package/dist/preview-toolbar-resize-handles.d.ts +9 -0
- package/dist/preview-toolbar-resize-handles.js +14 -0
- package/dist/preview-toolbar-shell.d.ts +18 -0
- package/dist/preview-toolbar-shell.js +138 -0
- package/dist/public-rich-text.d.ts +15 -0
- package/dist/public-rich-text.js +11 -0
- package/dist/public-slice-zone.d.ts +7 -0
- package/dist/public-slice-zone.js +6 -0
- package/dist/query-cache.d.ts +14 -0
- package/dist/query-cache.js +289 -0
- package/dist/query-preview-loader.d.ts +9 -0
- package/dist/query-preview-loader.js +28 -0
- package/dist/query-preview-runtime.d.ts +3 -0
- package/dist/query-preview-runtime.js +3 -0
- package/dist/rendering.d.ts +29 -0
- package/dist/rendering.js +17 -0
- package/dist/resize-edge.d.ts +8 -0
- package/dist/resize-edge.js +33 -0
- package/dist/rich-text.d.ts +150 -0
- package/dist/rich-text.js +618 -0
- package/dist/slice-render-failure.d.ts +8 -0
- package/dist/slice-render-failure.js +46 -0
- package/dist/slice-zone.d.ts +82 -0
- package/dist/slice-zone.js +273 -0
- package/dist/slice.d.ts +16 -0
- package/dist/slice.js +46 -0
- package/dist/storystick-mark.d.ts +3 -0
- package/dist/storystick-mark.js +5 -0
- package/dist/use-generated-query.d.ts +37 -0
- package/dist/use-generated-query.js +308 -0
- package/dist/use-momentary-preview-navigate-mode.d.ts +11 -0
- package/dist/use-momentary-preview-navigate-mode.js +158 -0
- package/dist/use-preview-perspective-intent.d.ts +53 -0
- package/dist/use-preview-perspective-intent.js +227 -0
- package/dist/use-preview-pointer-drag.d.ts +16 -0
- package/dist/use-preview-pointer-drag.js +98 -0
- package/dist/use-preview-toolbar-layout.d.ts +36 -0
- package/dist/use-preview-toolbar-layout.js +141 -0
- package/dist/use-render-mode-debounce.d.ts +31 -0
- package/dist/use-render-mode-debounce.js +143 -0
- package/dist/video.d.ts +21 -0
- package/dist/video.js +63 -0
- package/package.json +146 -3
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preview-scope — the SliceZone ↔ `Slice.*` prod-strip contract (ADR-129
|
|
3
|
+
* D3 / D4-amended).
|
|
4
|
+
*
|
|
5
|
+
* `Slice.section` emits the `data-ss-slice` binding attribute ONLY in
|
|
6
|
+
* preview. The runtime preview-mode signal cannot be a React context —
|
|
7
|
+
* a React Server Component cannot read context, so a context-gated
|
|
8
|
+
* `Slice.*` would be forced `'use client'` and stop being RSC-safe
|
|
9
|
+
* (empirically confirmed, ADR-129 vertical-slice de-risk, check C).
|
|
10
|
+
*
|
|
11
|
+
* Instead the signal travels on the one object `Slice.section` already
|
|
12
|
+
* receives: the `slice` prop. `<SliceZone>` is the single component
|
|
13
|
+
* that knows preview-vs-production (the `publishedAt`/`snapshotId`
|
|
14
|
+
* discriminator — see `slice-zone.tsx`). In preview it STAMPS each
|
|
15
|
+
* slice object it passes to a slice component with that slice's
|
|
16
|
+
* serialized per-slice model (ADR-129 D4/D9). `Slice.section` reads the
|
|
17
|
+
* stamp and emits it back as the `data-ss-model` attribute — on the
|
|
18
|
+
* slice's own root element, alongside `data-ss-slice`. Outside preview
|
|
19
|
+
* the slice is passed through unstamped and neither attribute is
|
|
20
|
+
* emitted — a true prod-strip, RSC-safe, zero hooks.
|
|
21
|
+
*
|
|
22
|
+
* D9 amendment (cross-model /review R5) — the per-slice model is a
|
|
23
|
+
* `data-ss-model` ATTRIBUTE, never a `<script>` node. A `<script>`
|
|
24
|
+
* sibling is `display:none` but still an element, so it shifts
|
|
25
|
+
* `:nth-child` / `first:` / `odd:` / `even:` for the customer's slices
|
|
26
|
+
* (preview ≠ production). An attribute on the slice's own element
|
|
27
|
+
* shifts nothing — the same rule that drove D4 from a marker-node to
|
|
28
|
+
* an attribute. With the island node gone, ADR-129 injects zero nodes.
|
|
29
|
+
*
|
|
30
|
+
* ── RSC AMENDMENT (2026-05-27) ──────────────────────────────────────
|
|
31
|
+
* The stamp was originally a module-local `Symbol` key. Next.js 15's
|
|
32
|
+
* RSC serializer rejects Symbol-keyed properties on objects passed
|
|
33
|
+
* from a Server Component to a Client Component with the error
|
|
34
|
+
* "Only plain objects can be passed to Client Components from
|
|
35
|
+
* Server Components. Objects with symbol properties like
|
|
36
|
+
* `storystick.previewScope` are not supported."
|
|
37
|
+
* Customer slice components are routinely `'use client'` (interactive
|
|
38
|
+
* forms, hooks, …); even when the slice component is RSC, its
|
|
39
|
+
* descendants are usually client components, so the stamped slice
|
|
40
|
+
* always ends up crossing the RSC→Client boundary in real apps. The
|
|
41
|
+
* Symbol was a non-starter on Next 15.
|
|
42
|
+
*
|
|
43
|
+
* The stamp is now a string-keyed property under a fresh, namespaced
|
|
44
|
+
* Storystick reserved key: `_ssPreview`. The earlier `_model` slot
|
|
45
|
+
* was rejected by codex R1 — `_model` is the ADR-053 model-linking
|
|
46
|
+
* sidecar key that `hydrateSlice` reads as a string model id, so
|
|
47
|
+
* reusing it would (a) overwrite the model link in preview and
|
|
48
|
+
* (b) false-positive `isPreviewScoped()` on every production-linked
|
|
49
|
+
* slice. The `_ss*` prefix mirrors the existing `data-ss-*` DOM
|
|
50
|
+
* attribute convention used by `Slice.*` (`data-ss-slice`,
|
|
51
|
+
* `data-ss-bind`, `data-ss-model`) — one namespace, no ambiguity.
|
|
52
|
+
*
|
|
53
|
+
* Codex R2 then raised a P2: a string key alone is forgeable — a
|
|
54
|
+
* customer could (in theory) define a slice field literally named
|
|
55
|
+
* `_ssPreview` of type `string`, and SliceZone would mistake it for
|
|
56
|
+
* a preview stamp. The mitigation is structural: the stamp value is
|
|
57
|
+
* not a bare string but a tagged object `{ v: 'ss-preview-1', model:
|
|
58
|
+
* <JSON> }`. `isPreviewScoped()` checks the tag, not just the key —
|
|
59
|
+
* a customer field with the same string-typed name is rejected by
|
|
60
|
+
* shape.
|
|
61
|
+
*
|
|
62
|
+
* Codex R3 P2 (accepted as documented tradeoff): a customer COULD
|
|
63
|
+
* still define a field literally named `_ssPreview` with an object
|
|
64
|
+
* value that exactly matches the tagged shape `{ v: 'ss-preview-1',
|
|
65
|
+
* model: <string> }`. Two reasons we accept this:
|
|
66
|
+
*
|
|
67
|
+
* 1. The `_<x>` prefix is the existing Storystick reserved
|
|
68
|
+
* namespace — `_type`, `_key`, `_variation`, `_model` all live
|
|
69
|
+
* there. Adding `_ssPreview` extends the established convention.
|
|
70
|
+
* The storyspec format documents leading-underscore field names
|
|
71
|
+
* as SDK-reserved; the schema generator doesn't emit them.
|
|
72
|
+
* 2. The Symbol-keyed alternative was the ONLY structurally
|
|
73
|
+
* unforgeable choice, and it is incompatible with Next 15's RSC
|
|
74
|
+
* serializer (the bug this fix addresses). There is no third
|
|
75
|
+
* design that satisfies BOTH "RSC-serializable" AND
|
|
76
|
+
* "structurally unforgeable from any customer field".
|
|
77
|
+
*
|
|
78
|
+
* If a customer in v∞ produces a field shaped exactly like the stamp,
|
|
79
|
+
* the failure mode is overwrite-in-preview only (production renders
|
|
80
|
+
* never call `stampPreviewScope`). Detection would be loud (the
|
|
81
|
+
* customer's component receives a model JSON instead of its data),
|
|
82
|
+
* and the schema reader is the right place to forbid `_<x>` field
|
|
83
|
+
* names — a follow-up scoped to the schema parser, not this SDK seam.
|
|
84
|
+
*
|
|
85
|
+
* `slice-zone.tsx::SLICE_META_KEYS` gains `_ssPreview` so
|
|
86
|
+
* `extractSliceFields` filters it out of the serialized model
|
|
87
|
+
* payload (no recursive self-embedding of the stamp into its own
|
|
88
|
+
* model). Customer iteration (`Object.keys`, `JSON.stringify` of the
|
|
89
|
+
* slice) sees one extra meta key under the same `_*` convention
|
|
90
|
+
* customers already filter when walking slice fields; in production
|
|
91
|
+
* the property is never stamped, so production renders are
|
|
92
|
+
* byte-identical to a slice with no preview machinery — the
|
|
93
|
+
* reference-stable fast path is preserved.
|
|
94
|
+
*
|
|
95
|
+
* Why this is solid:
|
|
96
|
+
* - Plain object property ⇒ RSC-safe by construction.
|
|
97
|
+
* - Fresh namespaced key + tagged value shape ⇒ unforgeable from a
|
|
98
|
+
* customer field of any type the schema generator can emit.
|
|
99
|
+
* - Filtered by `extractSliceFields` via `SLICE_META_KEYS` ⇒ doesn't
|
|
100
|
+
* recurse into the serialized model payload.
|
|
101
|
+
* - Production stays unstamped ⇒ no leak in prod, no reference churn.
|
|
102
|
+
* - The wire contract with `@storystick/preview` is the DOM
|
|
103
|
+
* `data-ss-model` attribute — that has not changed.
|
|
104
|
+
*/
|
|
105
|
+
/**
|
|
106
|
+
* The reserved string key the SliceZone stamps onto a slice. The
|
|
107
|
+
* VALUE under this key is a tagged record (see `STAMP_VERSION`), not
|
|
108
|
+
* a bare string — so a customer slice field literally named
|
|
109
|
+
* `_ssPreview` of any plain JSON type will NOT false-positive
|
|
110
|
+
* `isPreviewScoped()` (mitigation for codex R2 P2). MUST be listed
|
|
111
|
+
* in `slice-zone.tsx::SLICE_META_KEYS` so `extractSliceFields`
|
|
112
|
+
* filters it out of the serialized per-slice model.
|
|
113
|
+
*/
|
|
114
|
+
const PREVIEW_MODEL_KEY = '_ssPreview';
|
|
115
|
+
/**
|
|
116
|
+
* Tag value embedded in every preview stamp record. Bumping this
|
|
117
|
+
* literal would invalidate every existing stamp at runtime — useful
|
|
118
|
+
* if the stamp shape ever evolves (none planned in v1).
|
|
119
|
+
*/
|
|
120
|
+
const STAMP_VERSION = 'ss-preview-1';
|
|
121
|
+
/**
|
|
122
|
+
* Return a shallow copy of `slice` carrying the preview-scope stamp —
|
|
123
|
+
* the serialized per-slice model string `<SliceZone>` built for it.
|
|
124
|
+
* A copy (never a mutation) so `hydrateSlice`'s no-mutation invariant
|
|
125
|
+
* holds and the customer's query-cache entry is untouched. The copy
|
|
126
|
+
* costs one object identity per slice per render — accepted: it is
|
|
127
|
+
* stamped ONLY in preview, where slices already re-render on every
|
|
128
|
+
* live-content revision, so `React.memo` stability is not in play.
|
|
129
|
+
* Production renders never call this — there `hydrateSlice`'s
|
|
130
|
+
* reference-stable fast path is preserved intact.
|
|
131
|
+
*/
|
|
132
|
+
export function stampPreviewScope(slice, modelJson) {
|
|
133
|
+
const stamp = { v: STAMP_VERSION, model: modelJson };
|
|
134
|
+
return { ...slice, [PREVIEW_MODEL_KEY]: stamp };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The serialized per-slice model `<SliceZone>` stamped on this slice,
|
|
138
|
+
* or `undefined` when the slice is not preview-scoped (production, a
|
|
139
|
+
* bare slice, a test stub, or a customer field that happens to share
|
|
140
|
+
* the `_ssPreview` name but doesn't match the stamp shape).
|
|
141
|
+
*/
|
|
142
|
+
export function getPreviewModel(slice) {
|
|
143
|
+
if (slice === null || typeof slice !== 'object')
|
|
144
|
+
return undefined;
|
|
145
|
+
const v = slice[PREVIEW_MODEL_KEY];
|
|
146
|
+
// Shape-check, not just presence — the tagged value is what makes
|
|
147
|
+
// the stamp unforgeable from a string-named customer field.
|
|
148
|
+
if (v === null || typeof v !== 'object')
|
|
149
|
+
return undefined;
|
|
150
|
+
const stamp = v;
|
|
151
|
+
if (stamp.v !== STAMP_VERSION)
|
|
152
|
+
return undefined;
|
|
153
|
+
return typeof stamp.model === 'string' ? stamp.model : undefined;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Was this slice object stamped by `<SliceZone>` as preview-scoped?
|
|
157
|
+
* Equivalent to "has a per-slice model" — the two are co-stamped.
|
|
158
|
+
*/
|
|
159
|
+
export function isPreviewScoped(slice) {
|
|
160
|
+
return getPreviewModel(slice) !== undefined;
|
|
161
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { PreviewProvenanceRouteFrameContext } from './preview-provenance.js';
|
|
3
|
+
import type { SliceComponentProps, SliceData } from './slice-zone.js';
|
|
4
|
+
export interface PreviewSliceScopeDescriptor {
|
|
5
|
+
/** Optional overlay/provenance context; projection freshness does not depend on it. */
|
|
6
|
+
readonly routeFrame: PreviewProvenanceRouteFrameContext | null;
|
|
7
|
+
readonly documentId: string;
|
|
8
|
+
readonly sliceField: string;
|
|
9
|
+
readonly slicePath: string;
|
|
10
|
+
readonly sliceKey: string;
|
|
11
|
+
readonly sliceType: string;
|
|
12
|
+
readonly sliceIndex: number;
|
|
13
|
+
}
|
|
14
|
+
export interface PreviewSliceScopeInvocation {
|
|
15
|
+
readonly component: ComponentType<SliceComponentProps>;
|
|
16
|
+
readonly props: SliceComponentProps<SliceData>;
|
|
17
|
+
readonly scope: PreviewSliceScopeDescriptor;
|
|
18
|
+
}
|
|
19
|
+
export interface PreviewSliceScopeAdapter {
|
|
20
|
+
(input: PreviewSliceScopeInvocation): ReactNode | Promise<ReactNode>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Installs the framework adapter once. Re-registering the same function is
|
|
24
|
+
* idempotent; replacing it would make scope ownership import-order dependent.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export declare function registerPreviewSliceScopeAdapter(adapter: PreviewSliceScopeAdapter): void;
|
|
29
|
+
/** @internal */
|
|
30
|
+
export declare function readPreviewSliceScopeAdapter(): PreviewSliceScopeAdapter | undefined;
|
|
31
|
+
/** Async server functions are the only direct-invocation candidate. */
|
|
32
|
+
export declare function isPreviewSliceDirectInvocationCandidate(component: ComponentType<SliceComponentProps>): boolean;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
let installedAdapter;
|
|
2
|
+
/**
|
|
3
|
+
* Installs the framework adapter once. Re-registering the same function is
|
|
4
|
+
* idempotent; replacing it would make scope ownership import-order dependent.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export function registerPreviewSliceScopeAdapter(adapter) {
|
|
9
|
+
if (installedAdapter === undefined) {
|
|
10
|
+
installedAdapter = adapter;
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (installedAdapter !== adapter) {
|
|
14
|
+
throw new Error('StorystickError: a different preview slice-scope adapter is already registered.');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** @internal */
|
|
18
|
+
export function readPreviewSliceScopeAdapter() {
|
|
19
|
+
return installedAdapter;
|
|
20
|
+
}
|
|
21
|
+
/** Async server functions are the only direct-invocation candidate. */
|
|
22
|
+
export function isPreviewSliceDirectInvocationCandidate(component) {
|
|
23
|
+
const candidate = component;
|
|
24
|
+
if (candidate.$$typeof !== undefined)
|
|
25
|
+
return false;
|
|
26
|
+
if (typeof component === 'function' &&
|
|
27
|
+
component
|
|
28
|
+
.prototype?.isReactComponent) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return (typeof component === 'function' &&
|
|
32
|
+
component.constructor?.name === 'AsyncFunction');
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type PreviewTextValue = string | number | boolean | null | undefined;
|
|
2
|
+
export interface PreviewTextProps {
|
|
3
|
+
readonly children: PreviewTextValue;
|
|
4
|
+
readonly 'data-ss-p'?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Render a text field on an existing element without putting Preview metadata
|
|
8
|
+
* into its text nodes. The carrier remains plain data; the Preview runtime
|
|
9
|
+
* still validates its frame, read context and receipts before allowing edits.
|
|
10
|
+
*
|
|
11
|
+
* Only one complete field can own the element. Composed or partially encoded
|
|
12
|
+
* strings are cleaned without assigning an owner; render their fields on
|
|
13
|
+
* separate elements when each needs its own edit target.
|
|
14
|
+
*/
|
|
15
|
+
export declare function previewTextProps(value: string): PreviewTextProps & {
|
|
16
|
+
readonly children: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function previewTextProps(value: PreviewTextValue): PreviewTextProps;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { decodePreviewProvenanceText } from '@storystick/types/internal/preview-provenance';
|
|
2
|
+
export function previewTextProps(value) {
|
|
3
|
+
if (typeof value !== 'string')
|
|
4
|
+
return { children: value };
|
|
5
|
+
const { text, segments } = decodePreviewProvenanceText(value);
|
|
6
|
+
const segment = segments.length === 1 ? segments[0] : undefined;
|
|
7
|
+
return {
|
|
8
|
+
children: text,
|
|
9
|
+
...(segment?.markerStart === 0 && segment.markerEnd === value.length
|
|
10
|
+
? { 'data-ss-p': segment.markerId }
|
|
11
|
+
: {}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Geometry of our owned surfaces only. No customer elements participate. */
|
|
2
|
+
export interface Point {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
}
|
|
6
|
+
export interface Size {
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
export interface Rect extends Point, Size {
|
|
11
|
+
}
|
|
12
|
+
export type BubbleDock = {
|
|
13
|
+
edge: 'left' | 'right' | 'bottom';
|
|
14
|
+
offset: number;
|
|
15
|
+
};
|
|
16
|
+
export type PanelPlacement = Size & ({
|
|
17
|
+
kind: 'docked';
|
|
18
|
+
edge: 'left' | 'right';
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'stretched';
|
|
21
|
+
x: number;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'floating';
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
});
|
|
27
|
+
export interface ToolbarLayout {
|
|
28
|
+
bubble: BubbleDock;
|
|
29
|
+
panel: PanelPlacement;
|
|
30
|
+
}
|
|
31
|
+
export type ResizeEdge = 'n' | 'e' | 's' | 'w' | 'ne' | 'se' | 'sw' | 'nw';
|
|
32
|
+
export declare const TOOLBAR_GAP = 8;
|
|
33
|
+
export declare const PANEL_MIN_SIZE: Size;
|
|
34
|
+
export declare const DEFAULT_TOOLBAR_LAYOUT: ToolbarLayout;
|
|
35
|
+
export declare function bubbleRect(dock: BubbleDock, viewport: Rect): Rect;
|
|
36
|
+
export declare function snapBubble(point: Point, viewport: Rect): BubbleDock;
|
|
37
|
+
export declare function panelRect(placement: PanelPlacement, viewport: Rect, dock: BubbleDock): Rect;
|
|
38
|
+
/** Moving changes attachment; resizing never does. Preferred size survives docking. */
|
|
39
|
+
export declare function placePanel(point: Point, placement: PanelPlacement, viewport: Rect, dock: BubbleDock, snap: boolean): PanelPlacement;
|
|
40
|
+
export declare function panelResizeEdges(placement: PanelPlacement): readonly ResizeEdge[];
|
|
41
|
+
export declare function resizePanel(placement: PanelPlacement, start: Rect, edge: ResizeEdge, delta: Point, viewport: Rect, dock: BubbleDock): PanelPlacement;
|
|
42
|
+
export declare function parseToolbarLayout(value: string | null): ToolbarLayout;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export const TOOLBAR_GAP = 8;
|
|
2
|
+
export const PANEL_MIN_SIZE = { width: 360, height: 480 };
|
|
3
|
+
export const DEFAULT_TOOLBAR_LAYOUT = {
|
|
4
|
+
bubble: { edge: 'bottom', offset: 1 },
|
|
5
|
+
panel: { kind: 'docked', edge: 'right', width: 460, height: 720 },
|
|
6
|
+
};
|
|
7
|
+
const BUBBLE_SIZE = 40;
|
|
8
|
+
const DOCK_DISTANCE = 24;
|
|
9
|
+
const clamp = (n, min, max) => Math.max(min, Math.min(Math.max(min, max), n));
|
|
10
|
+
const fraction = (n, length) => length > 0 ? clamp(n / length, 0, 1) : 0;
|
|
11
|
+
export function bubbleRect(dock, viewport) {
|
|
12
|
+
const width = Math.min(BUBBLE_SIZE, viewport.width);
|
|
13
|
+
const height = Math.min(BUBBLE_SIZE, viewport.height);
|
|
14
|
+
const x = viewport.x + Math.min(TOOLBAR_GAP, (viewport.width - width) / 2);
|
|
15
|
+
const y = viewport.y + Math.min(TOOLBAR_GAP, (viewport.height - height) / 2);
|
|
16
|
+
const travelX = Math.max(0, viewport.width - width - TOOLBAR_GAP * 2);
|
|
17
|
+
const travelY = Math.max(0, viewport.height - height - TOOLBAR_GAP * 2);
|
|
18
|
+
return { x: x + (dock.edge === 'bottom' ? travelX * dock.offset : dock.edge === 'right' ? travelX : 0),
|
|
19
|
+
y: y + (dock.edge === 'bottom' ? travelY : travelY * dock.offset), width, height };
|
|
20
|
+
}
|
|
21
|
+
export function snapBubble(point, viewport) {
|
|
22
|
+
const left = bubbleRect({ edge: 'left', offset: 0 }, viewport);
|
|
23
|
+
const right = bubbleRect({ edge: 'right', offset: 1 }, viewport);
|
|
24
|
+
const x = clamp(point.x, left.x, right.x);
|
|
25
|
+
const y = clamp(point.y, left.y, right.y);
|
|
26
|
+
// Bottom wins a corner tie, preserving the initial panel-above-bubble layout.
|
|
27
|
+
const distance = Math.min(x - left.x, right.x - x, right.y - y);
|
|
28
|
+
if (distance === right.y - y)
|
|
29
|
+
return { edge: 'bottom', offset: fraction(x - left.x, right.x - left.x) };
|
|
30
|
+
return { edge: distance === x - left.x ? 'left' : 'right', offset: fraction(y - left.y, right.y - left.y) };
|
|
31
|
+
}
|
|
32
|
+
function panelArea(viewport, dock) {
|
|
33
|
+
const bubble = bubbleRect(dock, viewport);
|
|
34
|
+
const left = dock.edge === 'left' ? bubble.x + bubble.width + TOOLBAR_GAP : viewport.x + TOOLBAR_GAP;
|
|
35
|
+
const right = dock.edge === 'right' ? bubble.x - TOOLBAR_GAP : viewport.x + viewport.width - TOOLBAR_GAP;
|
|
36
|
+
return { x: left, y: viewport.y + TOOLBAR_GAP, width: Math.max(0, right - left), height: Math.max(0, viewport.height - TOOLBAR_GAP * 2) };
|
|
37
|
+
}
|
|
38
|
+
function panelSpace(x, width, viewport, dock) {
|
|
39
|
+
const area = panelArea(viewport, dock);
|
|
40
|
+
const bubble = bubbleRect(dock, viewport);
|
|
41
|
+
const overlapsBottom = dock.edge === 'bottom' && x < bubble.x + bubble.width + TOOLBAR_GAP && x + width > bubble.x - TOOLBAR_GAP;
|
|
42
|
+
const bottom = overlapsBottom ? bubble.y - TOOLBAR_GAP : area.y + area.height;
|
|
43
|
+
return { ...area, height: Math.max(0, bottom - area.y) };
|
|
44
|
+
}
|
|
45
|
+
export function panelRect(placement, viewport, dock) {
|
|
46
|
+
const area = panelArea(viewport, dock);
|
|
47
|
+
const width = Math.min(placement.width, area.width);
|
|
48
|
+
const x = area.x + (area.width - width) * (placement.kind === 'docked' ? placement.edge === 'right' ? 1 : 0 : placement.x);
|
|
49
|
+
const space = panelSpace(x, width, viewport, dock);
|
|
50
|
+
const height = placement.kind === 'floating' ? Math.min(placement.height, space.height) : space.height;
|
|
51
|
+
const y = space.y + (placement.kind === 'floating' ? (space.height - height) * placement.y : 0);
|
|
52
|
+
return { x, y, width, height };
|
|
53
|
+
}
|
|
54
|
+
/** Moving changes attachment; resizing never does. Preferred size survives docking. */
|
|
55
|
+
export function placePanel(point, placement, viewport, dock, snap) {
|
|
56
|
+
const area = panelArea(viewport, dock);
|
|
57
|
+
const width = Math.min(placement.width, area.width);
|
|
58
|
+
const travelX = area.width - width;
|
|
59
|
+
const x = clamp(point.x, area.x, area.x + travelX);
|
|
60
|
+
const space = panelSpace(x, width, viewport, dock);
|
|
61
|
+
const height = placement.kind === 'floating' ? placement.height
|
|
62
|
+
: Math.min(placement.height, Math.max(PANEL_MIN_SIZE.height, space.height * .8));
|
|
63
|
+
const size = { width: placement.width, height };
|
|
64
|
+
if (snap && x - area.x <= DOCK_DISTANCE)
|
|
65
|
+
return { ...size, kind: 'docked', edge: 'left' };
|
|
66
|
+
if (snap && area.x + travelX - x <= DOCK_DISTANCE)
|
|
67
|
+
return { ...size, kind: 'docked', edge: 'right' };
|
|
68
|
+
const travelY = Math.max(0, space.height - height);
|
|
69
|
+
const y = clamp(point.y, space.y, space.y + travelY);
|
|
70
|
+
const normalizedX = fraction(x - area.x, travelX);
|
|
71
|
+
if (snap && (y - space.y <= DOCK_DISTANCE || space.y + travelY - y <= DOCK_DISTANCE)) {
|
|
72
|
+
return { ...size, kind: 'stretched', x: normalizedX };
|
|
73
|
+
}
|
|
74
|
+
return { ...size, kind: 'floating', x: normalizedX, y: fraction(y - space.y, travelY) };
|
|
75
|
+
}
|
|
76
|
+
const FLOATING_EDGES = ['n', 'e', 's', 'w', 'ne', 'se', 'sw', 'nw'];
|
|
77
|
+
export function panelResizeEdges(placement) {
|
|
78
|
+
return placement.kind === 'docked' ? [placement.edge === 'left' ? 'e' : 'w']
|
|
79
|
+
: placement.kind === 'stretched' ? ['e', 'w'] : FLOATING_EDGES;
|
|
80
|
+
}
|
|
81
|
+
export function resizePanel(placement, start, edge, delta, viewport, dock) {
|
|
82
|
+
if (!panelResizeEdges(placement).includes(edge))
|
|
83
|
+
return placement;
|
|
84
|
+
const area = panelArea(viewport, dock);
|
|
85
|
+
const bubble = bubbleRect(dock, viewport);
|
|
86
|
+
let minX = area.x;
|
|
87
|
+
let maxX = area.x + area.width;
|
|
88
|
+
// A low floating window cannot grow sideways through the bottom bubble.
|
|
89
|
+
// Keep the opposite edge fixed; full-height panels instead adapt their height.
|
|
90
|
+
if (placement.kind === 'floating' && dock.edge === 'bottom' && start.y + start.height > bubble.y - TOOLBAR_GAP) {
|
|
91
|
+
if (start.x + start.width <= bubble.x - TOOLBAR_GAP)
|
|
92
|
+
maxX = bubble.x - TOOLBAR_GAP;
|
|
93
|
+
else if (start.x >= bubble.x + bubble.width + TOOLBAR_GAP)
|
|
94
|
+
minX = bubble.x + bubble.width + TOOLBAR_GAP;
|
|
95
|
+
}
|
|
96
|
+
let left = start.x;
|
|
97
|
+
let right = start.x + start.width;
|
|
98
|
+
const minWidth = Math.min(PANEL_MIN_SIZE.width, start.width);
|
|
99
|
+
if (edge.includes('w'))
|
|
100
|
+
left = clamp(left + delta.x, minX, right - minWidth);
|
|
101
|
+
if (edge.includes('e'))
|
|
102
|
+
right = clamp(right + delta.x, left + minWidth, maxX);
|
|
103
|
+
const width = right - left;
|
|
104
|
+
const x = fraction(left - area.x, area.width - width);
|
|
105
|
+
const preferredWidth = Math.max(PANEL_MIN_SIZE.width, width);
|
|
106
|
+
if (placement.kind === 'docked')
|
|
107
|
+
return { ...placement, width: preferredWidth };
|
|
108
|
+
if (placement.kind === 'stretched')
|
|
109
|
+
return { ...placement, x, width: preferredWidth };
|
|
110
|
+
const space = panelSpace(left, width, viewport, dock);
|
|
111
|
+
let top = start.y;
|
|
112
|
+
let bottom = start.y + start.height;
|
|
113
|
+
const minHeight = Math.min(PANEL_MIN_SIZE.height, start.height);
|
|
114
|
+
if (edge.includes('n'))
|
|
115
|
+
top = clamp(top + delta.y, space.y, bottom - minHeight);
|
|
116
|
+
if (edge.includes('s'))
|
|
117
|
+
bottom = clamp(bottom + delta.y, top + minHeight, space.y + space.height);
|
|
118
|
+
const height = bottom - top;
|
|
119
|
+
return { kind: 'floating', x, y: fraction(top - space.y, space.height - height),
|
|
120
|
+
width: preferredWidth, height: Math.max(PANEL_MIN_SIZE.height, height) };
|
|
121
|
+
}
|
|
122
|
+
export function parseToolbarLayout(value) {
|
|
123
|
+
try {
|
|
124
|
+
const data = JSON.parse(value ?? 'null');
|
|
125
|
+
const unit = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1;
|
|
126
|
+
const dimension = (n, min) => typeof n === 'number' && Number.isFinite(n) && n >= min;
|
|
127
|
+
if (!data || !['left', 'right', 'bottom'].includes(data.bubble?.edge) || !unit(data.bubble.offset))
|
|
128
|
+
return DEFAULT_TOOLBAR_LAYOUT;
|
|
129
|
+
const panel = data.panel;
|
|
130
|
+
if (!dimension(panel?.width, PANEL_MIN_SIZE.width) || !dimension(panel?.height, PANEL_MIN_SIZE.height))
|
|
131
|
+
return DEFAULT_TOOLBAR_LAYOUT;
|
|
132
|
+
if (panel.kind === 'docked' && ['left', 'right'].includes(panel.edge) ||
|
|
133
|
+
panel.kind === 'stretched' && unit(panel.x) ||
|
|
134
|
+
panel.kind === 'floating' && unit(panel.x) && unit(panel.y))
|
|
135
|
+
return { bubble: data.bubble, panel };
|
|
136
|
+
}
|
|
137
|
+
catch { /* An unavailable or stale preference does not prevent opening the UI. */ }
|
|
138
|
+
return DEFAULT_TOOLBAR_LAYOUT;
|
|
139
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode, RefObject } from 'react';
|
|
2
|
+
/** The same movable frame hosts the retained editor and assistant. */
|
|
3
|
+
export declare function PreviewToolbarPanel({ open, label, onClose, panelRef, handleProps, resizeHandles, leading, trailing, children, }: {
|
|
4
|
+
open: boolean;
|
|
5
|
+
label: string;
|
|
6
|
+
onClose(): void;
|
|
7
|
+
panelRef: RefObject<HTMLElement | null>;
|
|
8
|
+
handleProps: ComponentProps<'button'>;
|
|
9
|
+
resizeHandles: ReactNode;
|
|
10
|
+
leading?: ReactNode;
|
|
11
|
+
trailing?: ReactNode;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { X } from './preview-icons.js';
|
|
3
|
+
/** The same movable frame hosts the retained editor and assistant. */
|
|
4
|
+
export function PreviewToolbarPanel({ open, label, onClose, panelRef, handleProps, resizeHandles, leading, trailing, children, }) {
|
|
5
|
+
return (_jsxs("section", { ref: panelRef, className: "editor-panel", "aria-label": label, hidden: !open, inert: !open, children: [_jsxs("div", { className: "panel-contents", children: [_jsxs("header", { className: "panel-header", children: [leading, _jsx("button", { type: "button", className: "panel-drag-handle", "aria-label": `Move ${label} panel`, title: "Drag to move. Arrow keys move; Alt + Up/Down fills the height; Home and End dock to a side.", ...handleProps, children: label }), trailing, _jsx("button", { type: "button", className: "panel-close", "aria-label": `Close ${label}`, onClick: onClose, children: _jsx(X, { size: 16, strokeWidth: 1.25, absoluteStrokeWidth: true, "aria-hidden": "true" }) })] }), _jsx("div", { className: "panel-body", children: children })] }), resizeHandles] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react';
|
|
2
|
+
import type { ResizeEdge, Size } from './preview-toolbar-layout.js';
|
|
3
|
+
/** Resize affordances belong to the window frame, never to its hosted content. */
|
|
4
|
+
export declare function PreviewToolbarResizeHandles({ label, edges, size, handleProps }: {
|
|
5
|
+
label: string;
|
|
6
|
+
edges: readonly ResizeEdge[];
|
|
7
|
+
size: Size;
|
|
8
|
+
handleProps(edge: ResizeEdge): ComponentProps<'button'>;
|
|
9
|
+
}): import("react").JSX.Element[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { ResizeEdgeIndicator } from './resize-edge.js';
|
|
3
|
+
const EDGE_LABELS = {
|
|
4
|
+
n: 'top', e: 'right', s: 'bottom', w: 'left',
|
|
5
|
+
ne: 'top right', se: 'bottom right', sw: 'bottom left', nw: 'top left',
|
|
6
|
+
};
|
|
7
|
+
/** Resize affordances belong to the window frame, never to its hosted content. */
|
|
8
|
+
export function PreviewToolbarResizeHandles({ label, edges, size, handleProps }) {
|
|
9
|
+
return edges.map(edge => _jsx("button", { type: "button", className: "panel-resize-handle", "data-resize-edge": edge, "data-ss-resize-handle": "", "aria-label": `Resize ${label} from ${EDGE_LABELS[edge]}`, "aria-description": `${Math.round(size.width)} by ${Math.round(size.height)} pixels. Use arrow keys to resize; hold Shift for larger steps.`, ...handleProps(edge), children: edge === 'w' || edge === 'e'
|
|
10
|
+
? _jsx(ResizeEdgeIndicator, { style: { left: edge === 'w' ? 3 : 4 } })
|
|
11
|
+
: edge === 'n' || edge === 's'
|
|
12
|
+
? _jsx(ResizeEdgeIndicator, { orientation: "horizontal", style: { top: edge === 'n' ? 3 : 4 } })
|
|
13
|
+
: null }, edge));
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ReactNode, type RefObject } from "react";
|
|
2
|
+
/** The host owns admission and active content; this shell owns presentation. */
|
|
3
|
+
export declare function PreviewToolbarShell(props: {
|
|
4
|
+
menuOpen: boolean;
|
|
5
|
+
panelOpen: boolean;
|
|
6
|
+
menu: ReactNode;
|
|
7
|
+
panel: ReactNode;
|
|
8
|
+
panelLabel: string;
|
|
9
|
+
panelLeading?: ReactNode;
|
|
10
|
+
panelTrailing?: ReactNode;
|
|
11
|
+
onMenuOpenChange(open: boolean): void;
|
|
12
|
+
onPanelClose(): void;
|
|
13
|
+
onRoot?: (root: ShadowRoot) => void;
|
|
14
|
+
hostRef?: RefObject<HTMLDivElement | null>;
|
|
15
|
+
}): import("react").JSX.Element;
|
|
16
|
+
export declare function PreviewEditIcon(): import("react").JSX.Element;
|
|
17
|
+
export declare function PreviewAssistantIcon(): import("react").JSX.Element;
|
|
18
|
+
export declare function PreviewStudioIcon(): import("react").JSX.Element;
|