sanity-plugin-iframe-pane 2.3.1-beta.1 → 2.3.2-canary.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/lib/_chunks/is-valid-secret-3aaae7ae.js +35 -0
- package/lib/_chunks/is-valid-secret-3aaae7ae.js.map +1 -0
- package/lib/_chunks/is-valid-secret-71f6ff4a.cjs +41 -0
- package/lib/_chunks/is-valid-secret-71f6ff4a.cjs.map +1 -0
- package/lib/_chunks/types-43de0b73.cjs +38 -0
- package/lib/_chunks/types-43de0b73.cjs.map +1 -0
- package/lib/_chunks/types-4860e7b4.js +34 -0
- package/lib/_chunks/types-4860e7b4.js.map +1 -0
- package/lib/index.cjs +461 -154
- package/lib/index.cjs.js +2 -1
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.ts +38 -13
- package/lib/index.js +462 -158
- package/lib/index.js.map +1 -1
- package/lib/is-valid-secret.cjs +8 -0
- package/lib/is-valid-secret.cjs.js +4 -0
- package/lib/is-valid-secret.cjs.map +1 -0
- package/lib/is-valid-secret.d.ts +41 -0
- package/lib/is-valid-secret.js +2 -0
- package/lib/is-valid-secret.js.map +1 -0
- package/lib/preview-url.cjs +77 -0
- package/lib/preview-url.cjs.js +4 -0
- package/lib/preview-url.cjs.map +1 -0
- package/lib/preview-url.d.ts +17 -0
- package/lib/preview-url.js +72 -0
- package/lib/preview-url.js.map +1 -0
- package/package.json +34 -10
- package/src/DisplayUrl.tsx +21 -0
- package/src/GetUrlSecret.tsx +80 -0
- package/src/Iframe.tsx +272 -158
- package/src/Toolbar.tsx +153 -0
- package/src/defineUrlResolver.tsx +31 -0
- package/src/index.ts +3 -5
- package/src/is-valid-secret.ts +2 -0
- package/src/isValidSecret.tsx +68 -0
- package/src/preview-url.ts +2 -0
- package/src/previewUrl.ts +62 -0
- package/src/types.ts +17 -0
- package/src/utils.ts +45 -0
package/src/Iframe.tsx
CHANGED
|
@@ -1,43 +1,28 @@
|
|
|
1
1
|
/* eslint-disable react/jsx-no-bind */
|
|
2
|
-
import {
|
|
3
|
-
import {Box,
|
|
4
|
-
import
|
|
2
|
+
import {WarningOutlineIcon} from '@sanity/icons'
|
|
3
|
+
import {Box, Card, Container, Flex, Spinner, Stack, Text, usePrefersReducedMotion} from '@sanity/ui'
|
|
4
|
+
import {AnimatePresence, motion, MotionConfig} from 'framer-motion'
|
|
5
|
+
import React, {forwardRef, useCallback, useDeferredValue, useEffect, useRef, useState} from 'react'
|
|
5
6
|
import {HTMLAttributeReferrerPolicy} from 'react'
|
|
6
7
|
import {SanityDocumentLike} from 'sanity'
|
|
7
|
-
import {useCopyToClipboard} from 'usehooks-ts'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
import {UrlResolver} from './defineUrlResolver'
|
|
10
|
+
import {GetUrlSecret} from './GetUrlSecret'
|
|
11
|
+
import {UrlSecretId} from './isValidSecret'
|
|
12
|
+
import {DEFAULT_SIZE, sizes, Toolbar} from './Toolbar'
|
|
13
|
+
import {IframeSizeKey, MissingSlug, SetError, type UrlState} from './types'
|
|
10
14
|
|
|
11
|
-
type
|
|
12
|
-
// eslint-disable-next-line no-unused-vars
|
|
13
|
-
[key in Size]: {
|
|
14
|
-
width: string | number
|
|
15
|
-
height: string | number
|
|
16
|
-
maxHeight: string | number
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const sizes: SizeProps = {
|
|
21
|
-
desktop: {
|
|
22
|
-
width: `100%`,
|
|
23
|
-
height: `100%`,
|
|
24
|
-
maxHeight: `100%`,
|
|
25
|
-
},
|
|
26
|
-
mobile: {
|
|
27
|
-
width: 414,
|
|
28
|
-
height: `100%`,
|
|
29
|
-
maxHeight: 736,
|
|
30
|
-
},
|
|
31
|
-
}
|
|
15
|
+
export type {UrlResolver, UrlSecretId}
|
|
32
16
|
|
|
33
17
|
export type IframeOptions = {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
18
|
+
urlSecretId?: UrlSecretId
|
|
19
|
+
url: UrlState | UrlResolver
|
|
20
|
+
defaultSize?: IframeSizeKey
|
|
21
|
+
loader?: string | boolean
|
|
37
22
|
showDisplayUrl?: boolean
|
|
38
|
-
reload
|
|
39
|
-
revision
|
|
40
|
-
button
|
|
23
|
+
reload?: {
|
|
24
|
+
revision?: boolean | number
|
|
25
|
+
button?: boolean
|
|
41
26
|
}
|
|
42
27
|
attributes?: Partial<{
|
|
43
28
|
allow: string
|
|
@@ -47,187 +32,316 @@ export type IframeOptions = {
|
|
|
47
32
|
}>
|
|
48
33
|
}
|
|
49
34
|
|
|
50
|
-
|
|
35
|
+
const MotionFlex = motion(Flex)
|
|
36
|
+
|
|
37
|
+
export interface IframeProps {
|
|
51
38
|
document: {
|
|
52
39
|
displayed: SanityDocumentLike
|
|
53
40
|
}
|
|
54
41
|
options: IframeOptions
|
|
55
42
|
}
|
|
56
43
|
|
|
57
|
-
|
|
44
|
+
export function Iframe(props: IframeProps) {
|
|
45
|
+
const [error, setError] = useState<unknown>(null)
|
|
46
|
+
if (error) {
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
58
49
|
|
|
59
|
-
function Iframe(props: IframeProps) {
|
|
60
50
|
const {document: sanityDocument, options} = props
|
|
61
51
|
const {
|
|
62
52
|
url,
|
|
53
|
+
urlSecretId,
|
|
63
54
|
defaultSize = DEFAULT_SIZE,
|
|
64
55
|
reload,
|
|
65
|
-
loader,
|
|
56
|
+
loader = 'Loading…',
|
|
66
57
|
attributes = {},
|
|
67
58
|
showDisplayUrl = true,
|
|
68
59
|
} = options
|
|
69
|
-
const [displayUrl, setDisplayUrl] = useState(url && typeof url === 'string' ? url : ``)
|
|
70
60
|
const [iframeSize, setIframeSize] = useState(sizes?.[defaultSize] ? defaultSize : DEFAULT_SIZE)
|
|
71
|
-
const [loading, setLoading] = useState(false)
|
|
72
|
-
const input = useRef<HTMLTextAreaElement>(null)
|
|
73
|
-
const iframe = useRef<HTMLIFrameElement>(null)
|
|
74
|
-
const {displayed} = sanityDocument
|
|
75
|
-
const [, copy] = useCopyToClipboard()
|
|
76
61
|
|
|
77
|
-
|
|
78
|
-
|
|
62
|
+
// Workaround documents that initially appears to be an empty new document but just hasen't loaded yet
|
|
63
|
+
const [workaroundEmptyDocument, setWorkaroundEmptyDocument] = useState(true)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const timeout = setTimeout(() => setWorkaroundEmptyDocument(false), 1000)
|
|
66
|
+
return () => clearTimeout(timeout)
|
|
67
|
+
}, [])
|
|
68
|
+
|
|
69
|
+
const prefersReducedMotion = usePrefersReducedMotion()
|
|
70
|
+
const [urlState, setUrlState] = useState<UrlState>(() => (typeof url === 'function' ? '' : url))
|
|
79
71
|
|
|
80
|
-
|
|
81
|
-
|
|
72
|
+
const [loading, setLoading] = useState(true)
|
|
73
|
+
const [reloading, setReloading] = useState(false)
|
|
74
|
+
|
|
75
|
+
const iframe = useRef<HTMLIFrameElement>(null)
|
|
76
|
+
const {displayed} = sanityDocument
|
|
82
77
|
|
|
83
|
-
|
|
78
|
+
const handleReload = useCallback(() => {
|
|
84
79
|
if (!iframe?.current) {
|
|
85
80
|
return
|
|
86
81
|
}
|
|
87
82
|
|
|
88
|
-
// Funky way to reload an iframe without CORS
|
|
83
|
+
// Funky way to reload an iframe without CORS issues
|
|
89
84
|
// eslint-disable-next-line no-self-assign
|
|
90
85
|
iframe.current.src = iframe.current.src
|
|
91
86
|
|
|
92
|
-
|
|
93
|
-
}
|
|
87
|
+
setReloading(true)
|
|
88
|
+
}, [])
|
|
89
|
+
|
|
90
|
+
const deferredRevision = useDeferredValue(displayed._rev)
|
|
91
|
+
const displayUrl = typeof urlState === 'string' ? urlState : ''
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<MotionConfig transition={prefersReducedMotion ? {duration: 0} : undefined}>
|
|
95
|
+
<Flex direction="column" style={{height: `100%`}}>
|
|
96
|
+
<Toolbar
|
|
97
|
+
displayUrl={displayUrl}
|
|
98
|
+
iframeSize={iframeSize}
|
|
99
|
+
reloading={reloading}
|
|
100
|
+
setIframeSize={setIframeSize}
|
|
101
|
+
showDisplayUrl={showDisplayUrl}
|
|
102
|
+
reloadButton={!!reload?.button}
|
|
103
|
+
handleReload={handleReload}
|
|
104
|
+
/>
|
|
105
|
+
{urlState === MissingSlug && !workaroundEmptyDocument ? (
|
|
106
|
+
<MissingSlugScreen />
|
|
107
|
+
) : (
|
|
108
|
+
<Card tone="transparent" style={{height: `100%`}}>
|
|
109
|
+
<Frame
|
|
110
|
+
ref={iframe}
|
|
111
|
+
loader={loader}
|
|
112
|
+
loading={loading}
|
|
113
|
+
reloading={reloading}
|
|
114
|
+
iframeSize={iframeSize}
|
|
115
|
+
setReloading={setReloading}
|
|
116
|
+
setLoading={setLoading}
|
|
117
|
+
displayUrl={displayUrl}
|
|
118
|
+
attributes={attributes}
|
|
119
|
+
/>
|
|
120
|
+
</Card>
|
|
121
|
+
)}
|
|
122
|
+
{typeof url === 'function' && (
|
|
123
|
+
<AsyncUrl
|
|
124
|
+
// We use the revision as a key, to force a re-render when the revision changes
|
|
125
|
+
// This allows us to respond to changed props (maybe the url function itself changes)
|
|
126
|
+
// But avoid calling async logic on every render accidentally
|
|
127
|
+
key={deferredRevision}
|
|
128
|
+
url={url}
|
|
129
|
+
displayed={displayed}
|
|
130
|
+
urlSecretId={urlSecretId}
|
|
131
|
+
setDisplayUrl={setUrlState}
|
|
132
|
+
setError={setError}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
{displayUrl && (reload?.revision || reload?.revision === 0) && (
|
|
136
|
+
<ReloadOnRevision
|
|
137
|
+
revision={reload.revision}
|
|
138
|
+
_rev={deferredRevision}
|
|
139
|
+
handleReload={handleReload}
|
|
140
|
+
/>
|
|
141
|
+
)}
|
|
142
|
+
</Flex>
|
|
143
|
+
</MotionConfig>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface FrameProps extends Required<Pick<IframeOptions, 'loader' | 'attributes'>> {
|
|
148
|
+
loader: string | boolean
|
|
149
|
+
loading: boolean
|
|
150
|
+
reloading: boolean
|
|
151
|
+
setLoading: (loading: boolean) => void
|
|
152
|
+
setReloading: (reloading: boolean) => void
|
|
153
|
+
iframeSize: IframeSizeKey
|
|
154
|
+
displayUrl: string
|
|
155
|
+
}
|
|
156
|
+
const Frame = forwardRef(function Frame(
|
|
157
|
+
props: FrameProps,
|
|
158
|
+
iframe: React.ForwardedRef<HTMLIFrameElement>,
|
|
159
|
+
) {
|
|
160
|
+
const {loader, loading, setLoading, iframeSize, attributes, reloading, displayUrl, setReloading} =
|
|
161
|
+
props
|
|
94
162
|
|
|
95
163
|
function handleIframeLoad() {
|
|
96
164
|
setLoading(false)
|
|
165
|
+
setReloading(false)
|
|
97
166
|
// Run onLoad from attributes
|
|
98
167
|
if (attributes.onLoad && typeof attributes.onLoad === 'function') {
|
|
99
168
|
attributes.onLoad()
|
|
100
169
|
}
|
|
101
170
|
}
|
|
102
171
|
|
|
172
|
+
return (
|
|
173
|
+
<Flex align="center" justify="center" style={{height: `100%`, position: `relative`}}>
|
|
174
|
+
<AnimatePresence>
|
|
175
|
+
{loader && loading && (
|
|
176
|
+
<MotionFlex
|
|
177
|
+
initial="initial"
|
|
178
|
+
animate="animate"
|
|
179
|
+
exit="exit"
|
|
180
|
+
variants={spinnerVariants}
|
|
181
|
+
justify="center"
|
|
182
|
+
align="center"
|
|
183
|
+
style={{inset: `0`, position: `absolute`}}
|
|
184
|
+
>
|
|
185
|
+
<Flex
|
|
186
|
+
style={{...sizes[iframeSize]}}
|
|
187
|
+
justify="center"
|
|
188
|
+
align="center"
|
|
189
|
+
direction="column"
|
|
190
|
+
gap={4}
|
|
191
|
+
>
|
|
192
|
+
<Spinner muted />
|
|
193
|
+
{loader && typeof loader === 'string' && (
|
|
194
|
+
<Text muted size={1}>
|
|
195
|
+
{loader}
|
|
196
|
+
</Text>
|
|
197
|
+
)}
|
|
198
|
+
</Flex>
|
|
199
|
+
</MotionFlex>
|
|
200
|
+
)}
|
|
201
|
+
</AnimatePresence>
|
|
202
|
+
<motion.iframe
|
|
203
|
+
ref={iframe}
|
|
204
|
+
title="preview"
|
|
205
|
+
frameBorder="0"
|
|
206
|
+
style={{maxHeight: '100%'}}
|
|
207
|
+
src={displayUrl}
|
|
208
|
+
initial={['background', iframeSize]}
|
|
209
|
+
variants={iframeVariants}
|
|
210
|
+
animate={[
|
|
211
|
+
loader && loading ? 'background' : 'active',
|
|
212
|
+
reloading ? 'reloading' : 'idle',
|
|
213
|
+
iframeSize,
|
|
214
|
+
]}
|
|
215
|
+
{...attributes}
|
|
216
|
+
onLoad={handleIframeLoad}
|
|
217
|
+
/>
|
|
218
|
+
</Flex>
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const spinnerVariants = {
|
|
223
|
+
initial: {opacity: 1},
|
|
224
|
+
animate: {opacity: [0, 0, 1]},
|
|
225
|
+
exit: {opacity: [1, 0, 0]},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const iframeVariants = {
|
|
229
|
+
...sizes,
|
|
230
|
+
desktop: {
|
|
231
|
+
...sizes.desktop,
|
|
232
|
+
boxShadow: '0 0 0 0px var(--card-shadow-outline-color)',
|
|
233
|
+
},
|
|
234
|
+
mobile: {
|
|
235
|
+
...sizes.mobile,
|
|
236
|
+
boxShadow: '0 0 0 1px var(--card-shadow-outline-color)',
|
|
237
|
+
},
|
|
238
|
+
background: {
|
|
239
|
+
opacity: 0,
|
|
240
|
+
scale: 1,
|
|
241
|
+
},
|
|
242
|
+
idle: {
|
|
243
|
+
scale: 1,
|
|
244
|
+
},
|
|
245
|
+
reloading: {
|
|
246
|
+
scale: [1, 1, 1, 0.98],
|
|
247
|
+
},
|
|
248
|
+
active: {
|
|
249
|
+
opacity: [0, 0, 1],
|
|
250
|
+
scale: 1,
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface ReloadOnRevisionProps {
|
|
255
|
+
_rev?: string
|
|
256
|
+
revision: number | boolean
|
|
257
|
+
handleReload: () => void
|
|
258
|
+
}
|
|
259
|
+
function ReloadOnRevision(props: ReloadOnRevisionProps) {
|
|
260
|
+
const {revision, handleReload, _rev} = props
|
|
261
|
+
const [initialRev] = useState(_rev)
|
|
103
262
|
// Reload on new revisions
|
|
263
|
+
// eslint-disable-next-line consistent-return
|
|
104
264
|
useEffect(() => {
|
|
105
|
-
if (
|
|
106
|
-
setTimeout((
|
|
107
|
-
|
|
108
|
-
}, Number(reload?.revision))
|
|
265
|
+
if (_rev !== initialRev) {
|
|
266
|
+
const timeout = setTimeout(handleReload, Number(revision === true ? 300 : revision))
|
|
267
|
+
return () => clearTimeout(timeout)
|
|
109
268
|
}
|
|
110
|
-
}, [
|
|
269
|
+
}, [_rev, revision, handleReload, initialRev])
|
|
270
|
+
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface AsyncUrlProps {
|
|
275
|
+
displayed: SanityDocumentLike
|
|
276
|
+
url: UrlResolver
|
|
277
|
+
urlSecretId?: UrlSecretId
|
|
278
|
+
setDisplayUrl: (url: UrlState) => void
|
|
279
|
+
setError: SetError
|
|
280
|
+
}
|
|
281
|
+
function AsyncUrl(props: AsyncUrlProps) {
|
|
282
|
+
const {urlSecretId, setDisplayUrl, setError} = props
|
|
283
|
+
// Snapshot values we only care about when the revision changes, done by changing the `key` prop
|
|
284
|
+
const [displayed] = useState(props.displayed)
|
|
285
|
+
const [url] = useState(() => props.url)
|
|
286
|
+
const [urlSecret, setUrlSecret] = useState<null | string>(null)
|
|
111
287
|
|
|
112
288
|
// Set initial URL and refresh on new revisions
|
|
113
289
|
useEffect(() => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
290
|
+
if (urlSecretId && !urlSecret) return
|
|
291
|
+
|
|
292
|
+
const getUrl = async (signal: AbortSignal) => {
|
|
293
|
+
const resolveUrl = await url(displayed, urlSecret, abort.signal)
|
|
117
294
|
|
|
118
295
|
// Only update state if URL has changed
|
|
119
|
-
if (
|
|
296
|
+
if (!signal.aborted && resolveUrl) {
|
|
120
297
|
setDisplayUrl(resolveUrl)
|
|
121
298
|
}
|
|
122
299
|
}
|
|
123
300
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}, [displayed
|
|
301
|
+
const abort = new AbortController()
|
|
302
|
+
getUrl(abort.signal).catch((error) => error.name !== 'AbortError' && setError(error))
|
|
303
|
+
// eslint-disable-next-line consistent-return
|
|
304
|
+
return () => abort.abort()
|
|
305
|
+
}, [displayed, setDisplayUrl, setError, url, urlSecret, urlSecretId])
|
|
129
306
|
|
|
130
|
-
if (
|
|
307
|
+
if (urlSecretId) {
|
|
131
308
|
return (
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
309
|
+
<GetUrlSecret
|
|
310
|
+
urlSecretId={urlSecretId}
|
|
311
|
+
urlSecret={urlSecret}
|
|
312
|
+
setUrlSecret={setUrlSecret}
|
|
313
|
+
setError={setError}
|
|
314
|
+
/>
|
|
137
315
|
)
|
|
138
316
|
}
|
|
139
317
|
|
|
318
|
+
return null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function MissingSlugScreen() {
|
|
140
322
|
return (
|
|
141
|
-
<
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
<Flex direction="column" style={{height: `100%`}}>
|
|
150
|
-
<Card padding={2} borderBottom>
|
|
151
|
-
<Flex align="center" gap={2}>
|
|
152
|
-
<Flex align="center" gap={1}>
|
|
153
|
-
<Button
|
|
154
|
-
fontSize={[1]}
|
|
155
|
-
padding={2}
|
|
156
|
-
tone="primary"
|
|
157
|
-
mode={iframeSize === 'mobile' ? 'default' : 'ghost'}
|
|
158
|
-
icon={MobileDeviceIcon}
|
|
159
|
-
onClick={() => setIframeSize(iframeSize === 'mobile' ? 'desktop' : 'mobile')}
|
|
160
|
-
/>
|
|
161
|
-
</Flex>
|
|
162
|
-
<Box flex={1}>
|
|
163
|
-
{showDisplayUrl && (
|
|
164
|
-
<Text size={0} textOverflow="ellipsis">
|
|
165
|
-
{displayUrl}
|
|
323
|
+
<Card height="fill">
|
|
324
|
+
<Flex align="center" height="fill" justify="center" padding={4} sizing="border">
|
|
325
|
+
<Container width={0}>
|
|
326
|
+
<Card padding={4} radius={2} shadow={1} tone="caution">
|
|
327
|
+
<Flex>
|
|
328
|
+
<Box>
|
|
329
|
+
<Text size={1}>
|
|
330
|
+
<WarningOutlineIcon />
|
|
166
331
|
</Text>
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
aria-label="Reload"
|
|
177
|
-
onClick={() => handleReload()}
|
|
178
|
-
/>
|
|
179
|
-
) : null}
|
|
180
|
-
<Button
|
|
181
|
-
fontSize={[1]}
|
|
182
|
-
icon={CopyIcon}
|
|
183
|
-
padding={[2]}
|
|
184
|
-
title="Copy"
|
|
185
|
-
aria-label="Copy"
|
|
186
|
-
onClick={() => handleCopy()}
|
|
187
|
-
/>
|
|
188
|
-
<Button
|
|
189
|
-
fontSize={[1]}
|
|
190
|
-
icon={LeaveIcon}
|
|
191
|
-
padding={[2]}
|
|
192
|
-
text="Open"
|
|
193
|
-
tone="primary"
|
|
194
|
-
onClick={() => window.open(displayUrl)}
|
|
195
|
-
/>
|
|
332
|
+
</Box>
|
|
333
|
+
<Stack flex={1} marginLeft={3} space={3}>
|
|
334
|
+
<Text as="h1" size={1} weight="bold">
|
|
335
|
+
Missing slug
|
|
336
|
+
</Text>
|
|
337
|
+
<Text as="p" muted size={1}>
|
|
338
|
+
Add a slug to see the preview.
|
|
339
|
+
</Text>
|
|
340
|
+
</Stack>
|
|
196
341
|
</Flex>
|
|
197
|
-
</
|
|
198
|
-
</
|
|
199
|
-
<Card tone="transparent" padding={iframeSize === 'mobile' ? 2 : 0} style={{height: `100%`}}>
|
|
200
|
-
<Flex align="center" justify="center" style={{height: `100%`, position: `relative`}}>
|
|
201
|
-
{loader && loading && (
|
|
202
|
-
<Flex justify="center" align="center" style={{inset: `0`, position: `absolute`}}>
|
|
203
|
-
<Flex
|
|
204
|
-
style={{...sizes[iframeSize], backgroundColor: `rgba(0,0,0,0.2)`}}
|
|
205
|
-
justify="center"
|
|
206
|
-
align="center"
|
|
207
|
-
>
|
|
208
|
-
<Card padding={4} radius={2} shadow={1}>
|
|
209
|
-
<Flex align="center" direction="column" gap={3} height="fill" justify="center">
|
|
210
|
-
<Spinner />
|
|
211
|
-
{loader && typeof loader === 'string' && <Text size={1}>{loader}</Text>}
|
|
212
|
-
</Flex>
|
|
213
|
-
</Card>
|
|
214
|
-
</Flex>
|
|
215
|
-
</Flex>
|
|
216
|
-
)}
|
|
217
|
-
<iframe
|
|
218
|
-
ref={iframe}
|
|
219
|
-
title="preview"
|
|
220
|
-
style={sizes[iframeSize]}
|
|
221
|
-
frameBorder="0"
|
|
222
|
-
src={displayUrl}
|
|
223
|
-
{...attributes}
|
|
224
|
-
onLoad={handleIframeLoad}
|
|
225
|
-
/>
|
|
226
|
-
</Flex>
|
|
227
|
-
</Card>
|
|
342
|
+
</Card>
|
|
343
|
+
</Container>
|
|
228
344
|
</Flex>
|
|
229
|
-
</
|
|
345
|
+
</Card>
|
|
230
346
|
)
|
|
231
347
|
}
|
|
232
|
-
|
|
233
|
-
export default Iframe
|
package/src/Toolbar.tsx
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/* eslint-disable react/jsx-no-bind */
|
|
2
|
+
import {ClipboardIcon, LaunchIcon, MobileDeviceIcon, UndoIcon} from '@sanity/icons'
|
|
3
|
+
import {Box, Button, Card, Flex, Text, Tooltip, useToast} from '@sanity/ui'
|
|
4
|
+
import React, {useRef} from 'react'
|
|
5
|
+
import {useCopyToClipboard} from 'usehooks-ts'
|
|
6
|
+
|
|
7
|
+
import {DisplayUrl} from './DisplayUrl'
|
|
8
|
+
import {IframeSizeKey, type SizeProps} from './types'
|
|
9
|
+
|
|
10
|
+
export const sizes: SizeProps = {
|
|
11
|
+
desktop: {
|
|
12
|
+
width: '100%',
|
|
13
|
+
height: '100%',
|
|
14
|
+
},
|
|
15
|
+
mobile: {
|
|
16
|
+
width: 414,
|
|
17
|
+
height: 746,
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_SIZE = `desktop`
|
|
22
|
+
|
|
23
|
+
export interface ToolbarProps {
|
|
24
|
+
displayUrl: string
|
|
25
|
+
iframeSize: IframeSizeKey
|
|
26
|
+
setIframeSize: (size: IframeSizeKey) => void
|
|
27
|
+
showDisplayUrl: boolean
|
|
28
|
+
reloading: boolean
|
|
29
|
+
reloadButton: boolean
|
|
30
|
+
handleReload: () => void
|
|
31
|
+
}
|
|
32
|
+
export function Toolbar(props: ToolbarProps) {
|
|
33
|
+
const {
|
|
34
|
+
displayUrl,
|
|
35
|
+
iframeSize,
|
|
36
|
+
setIframeSize,
|
|
37
|
+
reloading,
|
|
38
|
+
showDisplayUrl,
|
|
39
|
+
reloadButton,
|
|
40
|
+
handleReload,
|
|
41
|
+
} = props
|
|
42
|
+
|
|
43
|
+
const input = useRef<HTMLTextAreaElement>(null)
|
|
44
|
+
const {push: pushToast} = useToast()
|
|
45
|
+
const [, copy] = useCopyToClipboard()
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<textarea
|
|
50
|
+
style={{position: `absolute`, pointerEvents: `none`, opacity: 0}}
|
|
51
|
+
ref={input}
|
|
52
|
+
value={displayUrl}
|
|
53
|
+
readOnly
|
|
54
|
+
tabIndex={-1}
|
|
55
|
+
/>
|
|
56
|
+
<Card padding={2} borderBottom>
|
|
57
|
+
<Flex align="center" gap={2}>
|
|
58
|
+
<Flex align="center" gap={1}>
|
|
59
|
+
<Tooltip
|
|
60
|
+
content={
|
|
61
|
+
<Text size={1} style={{whiteSpace: 'nowrap'}}>
|
|
62
|
+
{iframeSize === 'mobile' ? 'Exit mobile preview' : 'Preview mobile viewport'}
|
|
63
|
+
</Text>
|
|
64
|
+
}
|
|
65
|
+
padding={2}
|
|
66
|
+
>
|
|
67
|
+
<Button
|
|
68
|
+
disabled={!displayUrl}
|
|
69
|
+
fontSize={[1]}
|
|
70
|
+
padding={2}
|
|
71
|
+
mode={iframeSize === 'mobile' ? 'default' : 'ghost'}
|
|
72
|
+
icon={MobileDeviceIcon}
|
|
73
|
+
onClick={() => setIframeSize(iframeSize === 'mobile' ? 'desktop' : 'mobile')}
|
|
74
|
+
/>
|
|
75
|
+
</Tooltip>
|
|
76
|
+
</Flex>
|
|
77
|
+
<Box flex={1}>
|
|
78
|
+
{showDisplayUrl && displayUrl && <DisplayUrl displayUrl={displayUrl} />}
|
|
79
|
+
</Box>
|
|
80
|
+
<Flex align="center" gap={1}>
|
|
81
|
+
{reloadButton ? (
|
|
82
|
+
<Tooltip
|
|
83
|
+
content={
|
|
84
|
+
<Text size={1} style={{whiteSpace: 'nowrap'}}>
|
|
85
|
+
{reloading ? 'Reloading…' : 'Reload'}
|
|
86
|
+
</Text>
|
|
87
|
+
}
|
|
88
|
+
padding={2}
|
|
89
|
+
>
|
|
90
|
+
<Button
|
|
91
|
+
disabled={!displayUrl}
|
|
92
|
+
mode="bleed"
|
|
93
|
+
fontSize={[1]}
|
|
94
|
+
padding={2}
|
|
95
|
+
icon={<UndoIcon style={{transform: 'rotate(90deg) scaleY(-1)'}} />}
|
|
96
|
+
loading={reloading}
|
|
97
|
+
aria-label="Reload"
|
|
98
|
+
onClick={() => handleReload()}
|
|
99
|
+
/>
|
|
100
|
+
</Tooltip>
|
|
101
|
+
) : null}
|
|
102
|
+
<Tooltip
|
|
103
|
+
content={
|
|
104
|
+
<Text size={1} style={{whiteSpace: 'nowrap'}}>
|
|
105
|
+
Copy URL
|
|
106
|
+
</Text>
|
|
107
|
+
}
|
|
108
|
+
padding={2}
|
|
109
|
+
>
|
|
110
|
+
<Button
|
|
111
|
+
mode="bleed"
|
|
112
|
+
disabled={!displayUrl}
|
|
113
|
+
fontSize={[1]}
|
|
114
|
+
icon={ClipboardIcon}
|
|
115
|
+
padding={[2]}
|
|
116
|
+
aria-label="Copy URL"
|
|
117
|
+
onClick={() => {
|
|
118
|
+
if (!input?.current?.value) return
|
|
119
|
+
|
|
120
|
+
copy(input.current.value)
|
|
121
|
+
pushToast({
|
|
122
|
+
closable: true,
|
|
123
|
+
status: 'success',
|
|
124
|
+
title: 'The URL is copied to the clipboard',
|
|
125
|
+
})
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
</Tooltip>
|
|
129
|
+
<Tooltip
|
|
130
|
+
content={
|
|
131
|
+
<Text size={1} style={{whiteSpace: 'nowrap'}}>
|
|
132
|
+
Open URL in a new tab
|
|
133
|
+
</Text>
|
|
134
|
+
}
|
|
135
|
+
padding={2}
|
|
136
|
+
>
|
|
137
|
+
<Button
|
|
138
|
+
disabled={!displayUrl}
|
|
139
|
+
fontSize={[1]}
|
|
140
|
+
icon={LaunchIcon}
|
|
141
|
+
mode="ghost"
|
|
142
|
+
paddingY={[2]}
|
|
143
|
+
text="Open"
|
|
144
|
+
aria-label="Open URL in a new tab"
|
|
145
|
+
onClick={() => window.open(displayUrl)}
|
|
146
|
+
/>
|
|
147
|
+
</Tooltip>
|
|
148
|
+
</Flex>
|
|
149
|
+
</Flex>
|
|
150
|
+
</Card>
|
|
151
|
+
</>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {SanityDocumentLike} from 'sanity'
|
|
2
|
+
|
|
3
|
+
import {MissingSlug, UrlState} from './types'
|
|
4
|
+
|
|
5
|
+
export type UrlResolver = (
|
|
6
|
+
document: SanityDocumentLike,
|
|
7
|
+
urlSecret: string | null | undefined,
|
|
8
|
+
signal?: AbortSignal,
|
|
9
|
+
) => UrlState | Promise<UrlState>
|
|
10
|
+
|
|
11
|
+
export interface DefineUrlResolverOptions {
|
|
12
|
+
base: string | URL
|
|
13
|
+
requiresSlug?: string[]
|
|
14
|
+
}
|
|
15
|
+
export function defineUrlResolver(options: DefineUrlResolverOptions): UrlResolver {
|
|
16
|
+
const {base, requiresSlug = []} = options
|
|
17
|
+
return (document, urlSecret) => {
|
|
18
|
+
const url = new URL(base, location.origin)
|
|
19
|
+
url.searchParams.set('type', document._type)
|
|
20
|
+
const slug = (document?.slug as any)?.current
|
|
21
|
+
if (slug) {
|
|
22
|
+
url.searchParams.set('slug', slug)
|
|
23
|
+
} else if (requiresSlug.includes(document._type)) {
|
|
24
|
+
return MissingSlug
|
|
25
|
+
}
|
|
26
|
+
if (urlSecret) {
|
|
27
|
+
url.searchParams.set('secret', urlSecret)
|
|
28
|
+
}
|
|
29
|
+
return url.toString()
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.ts
CHANGED