sanity-plugin-iframe-pane 4.0.0 → 5.0.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/src/Iframe.tsx DELETED
@@ -1,443 +0,0 @@
1
- import {WarningOutlineIcon} from '@sanity/icons'
2
- import {createPreviewSecret} from '@sanity/preview-url-secret/create-secret'
3
- import {definePreviewUrl} from '@sanity/preview-url-secret/define-preview-url'
4
- import {Box, Card, Container, Flex, Spinner, Stack, Text, usePrefersReducedMotion} from '@sanity/ui'
5
- import {AnimatePresence, motion, MotionConfig} from 'framer-motion'
6
- import type {HTMLAttributeReferrerPolicy} from 'react'
7
- import {
8
- forwardRef,
9
- memo,
10
- Suspense,
11
- useCallback,
12
- useEffect,
13
- useMemo,
14
- useRef,
15
- useState,
16
- useTransition,
17
- } from 'react'
18
- import {
19
- type SanityDocument,
20
- useActiveWorkspace,
21
- useClient,
22
- useCurrentUser,
23
- usePerspective,
24
- } from 'sanity'
25
- import {suspend} from 'suspend-react'
26
-
27
- import {DEFAULT_SIZE, sizes, Toolbar} from './Toolbar'
28
- import type {IframeSizeKey} from './types'
29
-
30
- export type UrlResolver = (
31
- document: SanityDocument | null,
32
- perspective: Pick<
33
- ReturnType<typeof usePerspective>,
34
- 'selectedPerspectiveName' | 'perspectiveStack'
35
- >,
36
- ) => string | Error | undefined | Promise<string | Error | undefined>
37
-
38
- export type {IframeSizeKey}
39
-
40
- export interface IframeOptions {
41
- /**
42
- * If you have multiple iframe instances side-by-side you need to give each a unique key.
43
- */
44
- key?: string
45
- url:
46
- | string
47
- | UrlResolver
48
- | {
49
- /**
50
- * The URL origin of where the preview is hosted, for example `https://example.com`.
51
- * If it's an embedded Studio then set it to `'same-origin'`.
52
- */
53
- origin: 'same-origin' | string
54
- /**
55
- * The route to redirect to after enabling Draft Mode.
56
- * If you don't have enough data to build the URL, return an `Error` instance to show an error message.
57
- * @example `return new Error('Missing slug')`
58
- * To prolong the loading state, return `undefined`
59
- */
60
- preview: string | UrlResolver
61
- /**
62
- * The route that enables Draft Mode
63
- * @example '/api/draft'
64
- */
65
- draftMode: string
66
- }
67
- defaultSize?: IframeSizeKey
68
- showDisplayUrl?: boolean
69
- reload?: {
70
- button?: boolean
71
- }
72
- attributes?: Partial<{
73
- allow: string
74
- referrerPolicy: HTMLAttributeReferrerPolicy | undefined
75
- sandbox: string
76
- onLoad: () => void
77
- }>
78
- }
79
-
80
- const MotionFlex = motion.create(Flex)
81
-
82
- export interface IframeProps {
83
- document: {
84
- displayed: SanityDocument
85
- draft: SanityDocument | null
86
- published: SanityDocument | null
87
- }
88
- options: IframeOptions
89
- }
90
-
91
- function encodeStudioPerspective(studioPerspective: string[] | string): string {
92
- return Array.isArray(studioPerspective) ? studioPerspective.join(',') : studioPerspective
93
- }
94
-
95
- export function Iframe(props: IframeProps): React.JSX.Element {
96
- const {document, options} = props
97
- const draft = document.draft || document.published || document.displayed
98
-
99
- const {defaultSize = DEFAULT_SIZE, reload, attributes, showDisplayUrl = true, key} = options
100
-
101
- const workspace = useActiveWorkspace()
102
- const basePath = workspace?.activeWorkspace?.basePath || '/'
103
- const urlRef = useRef(options.url)
104
- const [draftSnapshot, setDraftSnapshot] = useState(() => ({key, draft}))
105
- useEffect(() => {
106
- urlRef.current = options.url
107
- }, [options.url])
108
- useEffect(() => {
109
- if (JSON.stringify({key, draft}) !== JSON.stringify(draftSnapshot)) {
110
- startTransition(() => setDraftSnapshot({key, draft}))
111
- }
112
- }, [draft, draftSnapshot, key])
113
- const currentUser = useCurrentUser()
114
- const client = useClient({apiVersion: '2023-10-16'})
115
- const [expiresAt, setExpiresAt] = useState<number | undefined>()
116
- const previewSecretRef = useRef<string | undefined>(undefined)
117
- const [isResolvingUrl, startTransition] = useTransition()
118
- const {perspectiveStack, selectedPerspectiveName} = usePerspective()
119
- const perspective = useMemo(
120
- () => ({
121
- perspectiveStack,
122
- selectedPerspectiveName,
123
- }),
124
- [perspectiveStack, selectedPerspectiveName],
125
- )
126
-
127
- const url = useCallback(
128
- // eslint-disable-next-line @typescript-eslint/no-shadow
129
- async (draft: SanityDocument | null) => {
130
- if (typeof location === 'undefined') {
131
- return undefined
132
- }
133
- const urlProp = urlRef.current
134
- if (typeof urlProp === 'string') {
135
- return new URL(urlProp, location.origin)
136
- }
137
- if (typeof urlProp === 'function') {
138
- // eslint-disable-next-line @typescript-eslint/no-shadow
139
- const url = await urlProp(draft, perspective)
140
- return typeof url === 'string' ? new URL(url, location.origin) : url
141
- }
142
- if (typeof urlProp === 'object') {
143
- const preview =
144
- typeof urlProp.preview === 'function'
145
- ? await urlProp.preview(draft, perspective)
146
- : urlProp.preview
147
- if (typeof preview !== 'string') {
148
- return preview
149
- }
150
- if (!previewSecretRef.current) {
151
- // eslint-disable-next-line @typescript-eslint/no-shadow
152
- const {secret, expiresAt} = await createPreviewSecret(
153
- client,
154
- 'sanity-plugin-iframe-pane',
155
- location.href,
156
- currentUser?.id,
157
- )
158
- previewSecretRef.current = secret
159
- startTransition(() => setExpiresAt(expiresAt.getTime()))
160
- }
161
-
162
- const resolvePreviewUrl = definePreviewUrl({
163
- origin: urlProp.origin === 'same-origin' ? location.origin : urlProp.origin,
164
- preview,
165
- draftMode: {
166
- enable: urlProp.draftMode,
167
- },
168
- })
169
- // eslint-disable-next-line @typescript-eslint/no-shadow
170
- const url = await resolvePreviewUrl({
171
- client,
172
- previewUrlSecret: previewSecretRef.current,
173
- previewSearchParam: null,
174
- studioBasePath: basePath,
175
- studioPreviewPerspective: encodeStudioPerspective(perspective.perspectiveStack),
176
- })
177
- return new URL(url, location.origin)
178
- }
179
- return undefined
180
- },
181
- [basePath, client, currentUser?.id, perspective],
182
- )
183
- useEffect(() => {
184
- if (expiresAt) {
185
- const timeout = setTimeout(
186
- () => {
187
- startTransition(() => setExpiresAt(undefined))
188
- previewSecretRef.current = undefined
189
- },
190
- Math.max(0, expiresAt - Date.now()),
191
- )
192
- return () => clearTimeout(timeout)
193
- }
194
- return undefined
195
- }, [expiresAt])
196
-
197
- return (
198
- <Suspense fallback={<Loading iframeSize="desktop" />}>
199
- <IframeInner
200
- key={`${draftSnapshot.key}-${selectedPerspectiveName || 'draft'}`}
201
- _key={draftSnapshot.key}
202
- draftSnapshot={draftSnapshot.draft}
203
- url={url}
204
- isResolvingUrl={isResolvingUrl}
205
- attributes={attributes}
206
- perspective={perspective}
207
- defaultSize={defaultSize}
208
- reload={reload}
209
- showDisplayUrl={showDisplayUrl}
210
- userId={currentUser?.id}
211
- />
212
- </Suspense>
213
- )
214
- }
215
-
216
- export interface IframeInnerProps extends Omit<IframeOptions, 'url'> {
217
- url: (draftSnapshot: SanityDocument | null) => Promise<URL | Error | undefined>
218
- isResolvingUrl: boolean
219
- draftSnapshot: SanityDocument | null
220
- perspective: Parameters<UrlResolver>[1]
221
- userId?: string
222
- expiresAt?: number
223
- _key?: string
224
- }
225
- const IframeInner = memo(function IframeInner(props: IframeInnerProps) {
226
- const {
227
- isResolvingUrl,
228
- defaultSize = DEFAULT_SIZE,
229
- reload,
230
- attributes = {},
231
- showDisplayUrl = true,
232
- draftSnapshot,
233
- userId,
234
- expiresAt,
235
- perspective: {selectedPerspectiveName, perspectiveStack},
236
- _key,
237
- } = props
238
- const [iframeSize, setIframeSize] = useState(sizes?.[defaultSize] ? defaultSize : DEFAULT_SIZE)
239
-
240
- const prefersReducedMotion = usePrefersReducedMotion()
241
-
242
- const url = suspend(
243
- () => props.url(draftSnapshot),
244
- [
245
- // Cache based on a few specific conditions
246
- 'sanity-plugin-iframe-pane',
247
- draftSnapshot,
248
- selectedPerspectiveName,
249
- perspectiveStack,
250
- userId,
251
- expiresAt,
252
- _key,
253
- resolveUUID,
254
- ],
255
- )
256
-
257
- const [loading, setLoading] = useState(true)
258
- const [_reloading, setReloading] = useState(false)
259
- const reloading = _reloading || isResolvingUrl
260
-
261
- const iframe = useRef<HTMLIFrameElement>(null)
262
-
263
- const handleReload = useCallback(() => {
264
- if (!iframe?.current) {
265
- return
266
- }
267
-
268
- // Funky way to reload an iframe without CORS issues
269
- // eslint-disable-next-line no-self-assign
270
- iframe.current.src = iframe.current.src
271
-
272
- setReloading(true)
273
- }, [])
274
-
275
- return (
276
- <MotionConfig transition={prefersReducedMotion ? {duration: 0} : undefined}>
277
- <Flex direction="column" style={{height: '100%'}}>
278
- <Toolbar
279
- url={url}
280
- iframeSize={iframeSize}
281
- reloading={reloading}
282
- setIframeSize={setIframeSize}
283
- showUrl={showDisplayUrl}
284
- reloadButton={!!reload?.button}
285
- handleReload={handleReload}
286
- />
287
- {url instanceof Error ? (
288
- <ErrorCard error={url} />
289
- ) : (
290
- <Card tone="transparent" style={{height: '100%'}}>
291
- <Frame
292
- ref={iframe}
293
- loading={loading}
294
- reloading={reloading}
295
- iframeSize={iframeSize}
296
- setReloading={setReloading}
297
- setLoading={setLoading}
298
- url={url}
299
- attributes={attributes}
300
- />
301
- </Card>
302
- )}
303
- </Flex>
304
- </MotionConfig>
305
- )
306
- })
307
-
308
- interface FrameProps extends Required<Pick<IframeOptions, 'attributes'>> {
309
- loading: boolean
310
- reloading: boolean
311
- setLoading: (loading: boolean) => void
312
- setReloading: (reloading: boolean) => void
313
- iframeSize: IframeSizeKey
314
- url: URL | undefined
315
- }
316
- const Frame = forwardRef(function Frame(
317
- props: FrameProps,
318
- iframe: React.ForwardedRef<HTMLIFrameElement>,
319
- ) {
320
- const {loading, setLoading, iframeSize, attributes, reloading, url, setReloading} = props
321
-
322
- function handleIframeLoad() {
323
- setLoading(false)
324
- setReloading(false)
325
- // Run onLoad from attributes
326
- if (attributes.onLoad && typeof attributes.onLoad === 'function') {
327
- attributes.onLoad()
328
- }
329
- }
330
-
331
- return (
332
- <Flex align="center" justify="center" style={{height: '100%', position: 'relative'}}>
333
- <AnimatePresence>
334
- {!url ||
335
- (loading && (
336
- <MotionFlex
337
- initial="initial"
338
- animate="animate"
339
- exit="exit"
340
- variants={spinnerVariants}
341
- justify="center"
342
- align="center"
343
- style={{inset: '0', position: 'absolute'}}
344
- >
345
- <Loading iframeSize={iframeSize} />
346
- </MotionFlex>
347
- ))}
348
- </AnimatePresence>
349
- {url && (
350
- <motion.iframe
351
- ref={iframe}
352
- title="preview"
353
- frameBorder="0"
354
- style={{maxHeight: '100%'}}
355
- src={url.toString()}
356
- initial={['background', iframeSize]}
357
- variants={iframeVariants}
358
- animate={[
359
- loading ? 'background' : 'active',
360
- reloading ? 'reloading' : 'idle',
361
- iframeSize,
362
- ]}
363
- {...attributes}
364
- onLoad={handleIframeLoad}
365
- />
366
- )}
367
- </Flex>
368
- )
369
- })
370
-
371
- const spinnerVariants = {
372
- initial: {opacity: 1},
373
- animate: {opacity: [0, 0, 1]},
374
- exit: {opacity: [1, 0, 0]},
375
- }
376
-
377
- const iframeVariants = {
378
- ...sizes,
379
- desktop: {
380
- ...sizes.desktop,
381
- boxShadow: '0 0 0 0px var(--card-shadow-outline-color)',
382
- },
383
- mobile: {
384
- ...sizes.mobile,
385
- boxShadow: '0 0 0 1px var(--card-shadow-outline-color)',
386
- },
387
- background: {
388
- opacity: 0,
389
- scale: 1,
390
- },
391
- idle: {
392
- scale: 1,
393
- },
394
- reloading: {
395
- scale: [1, 1, 1, 0.98],
396
- },
397
- active: {
398
- opacity: [0, 0, 1],
399
- scale: 1,
400
- },
401
- }
402
-
403
- function Loading({iframeSize}: {iframeSize: IframeSizeKey}) {
404
- return (
405
- <Flex style={{...sizes[iframeSize]}} justify="center" align="center" direction="column" gap={4}>
406
- <Spinner muted />
407
- <Text muted size={1}>
408
- Loading…
409
- </Text>
410
- </Flex>
411
- )
412
- }
413
-
414
- export function ErrorCard({error}: {error: Error}) {
415
- return (
416
- <Card height="fill">
417
- <Flex align="center" height="fill" justify="center" padding={4} sizing="border">
418
- <Container width={0}>
419
- <Card padding={4} radius={2} shadow={1} tone="caution">
420
- <Flex>
421
- <Box>
422
- <Text size={1}>
423
- <WarningOutlineIcon />
424
- </Text>
425
- </Box>
426
- <Stack flex={1} marginLeft={3} space={3}>
427
- <Text as="h1" size={1} weight="bold">
428
- {error.name}
429
- </Text>
430
- <Text as="p" muted size={1}>
431
- {error.message}
432
- </Text>
433
- </Stack>
434
- </Flex>
435
- </Card>
436
- </Container>
437
- </Flex>
438
- </Card>
439
- )
440
- }
441
-
442
- // https://github.com/pmndrs/suspend-react?tab=readme-ov-file#making-cache-keys-unique
443
- const resolveUUID = Symbol()
package/src/Toolbar.tsx DELETED
@@ -1,185 +0,0 @@
1
- import {CopyIcon, LaunchIcon, MobileDeviceIcon, RefreshIcon} from '@sanity/icons'
2
- import {Box, Button, Card, Flex, Text, Tooltip, useToast} from '@sanity/ui'
3
- import {useCallback, useRef, useState} from 'react'
4
-
5
- import {DisplayUrl} from './DisplayUrl'
6
- import type {IframeSizeKey, SizeProps} from './types'
7
-
8
- export const sizes: SizeProps = {
9
- desktop: {
10
- width: '100%',
11
- height: '100%',
12
- },
13
- mobile: {
14
- width: 414,
15
- height: 746,
16
- },
17
- }
18
-
19
- export const DEFAULT_SIZE = 'desktop'
20
-
21
- export interface ToolbarProps {
22
- url: URL | Error | undefined
23
- iframeSize: IframeSizeKey
24
- setIframeSize: (size: IframeSizeKey) => void
25
- showUrl: boolean
26
- reloading: boolean
27
- reloadButton: boolean
28
- handleReload: () => void
29
- }
30
- export function Toolbar(props: ToolbarProps) {
31
- const {url, iframeSize, setIframeSize, reloading, showUrl, reloadButton, handleReload} = props
32
- const validUrl = url instanceof URL
33
-
34
- const input = useRef<HTMLTextAreaElement>(null)
35
- const {push: pushToast} = useToast()
36
- const [, copy] = useCopyToClipboard()
37
-
38
- return (
39
- <>
40
- <textarea
41
- style={{position: 'absolute', pointerEvents: 'none', opacity: 0}}
42
- ref={input}
43
- value={validUrl ? url.toString() : ''}
44
- readOnly
45
- tabIndex={-1}
46
- />
47
- <Card padding={2} borderBottom>
48
- <Flex align="center" gap={2}>
49
- <Flex align="center" gap={1}>
50
- <Tooltip
51
- animate
52
- content={
53
- <Text size={1} style={{whiteSpace: 'nowrap'}}>
54
- {iframeSize === 'mobile' ? 'Exit mobile preview' : 'Preview mobile viewport'}
55
- </Text>
56
- }
57
- padding={2}
58
- placement="bottom-start"
59
- >
60
- <Button
61
- disabled={!validUrl}
62
- fontSize={[1]}
63
- padding={2}
64
- mode={iframeSize === 'mobile' ? 'default' : 'ghost'}
65
- icon={MobileDeviceIcon}
66
- onClick={() => setIframeSize(iframeSize === 'mobile' ? 'desktop' : 'mobile')}
67
- />
68
- </Tooltip>
69
- </Flex>
70
- <Box flex={1}>{showUrl && validUrl && <DisplayUrl url={url} />}</Box>
71
- <Flex align="center" gap={1}>
72
- {reloadButton ? (
73
- <Tooltip
74
- animate
75
- content={
76
- <Text size={1} style={{whiteSpace: 'nowrap'}}>
77
- {reloading ? 'Reloading…' : 'Reload'}
78
- </Text>
79
- }
80
- padding={2}
81
- >
82
- <Button
83
- disabled={!validUrl}
84
- mode="bleed"
85
- fontSize={[1]}
86
- padding={2}
87
- icon={RefreshIcon}
88
- loading={reloading}
89
- aria-label="Reload"
90
- onClick={() => handleReload()}
91
- />
92
- </Tooltip>
93
- ) : null}
94
- <Tooltip
95
- animate
96
- content={
97
- <Text size={1} style={{whiteSpace: 'nowrap'}}>
98
- Copy URL
99
- </Text>
100
- }
101
- padding={2}
102
- >
103
- <Button
104
- mode="bleed"
105
- disabled={!validUrl}
106
- fontSize={[1]}
107
- icon={CopyIcon}
108
- padding={[2]}
109
- aria-label="Copy URL"
110
- onClick={() => {
111
- if (!input?.current?.value) return
112
-
113
- copy(input.current.value).then((copied) => {
114
- if (copied) {
115
- pushToast({
116
- closable: true,
117
- status: 'success',
118
- title: 'The URL is copied to the clipboard',
119
- })
120
- } else {
121
- pushToast({
122
- closable: true,
123
- status: 'error',
124
- title: 'Failed to copy the URL to the clipboard',
125
- })
126
- }
127
- })
128
- }}
129
- />
130
- </Tooltip>
131
- <Tooltip
132
- animate
133
- content={
134
- <Text size={1} style={{whiteSpace: 'nowrap'}}>
135
- Open URL in a new tab
136
- </Text>
137
- }
138
- padding={2}
139
- placement="bottom-end"
140
- >
141
- <Button
142
- disabled={!validUrl}
143
- fontSize={[1]}
144
- icon={LaunchIcon}
145
- mode="ghost"
146
- paddingY={[2]}
147
- text="Open"
148
- aria-label="Open URL in a new tab"
149
- onClick={validUrl ? () => window.open(url.toString()) : undefined}
150
- />
151
- </Tooltip>
152
- </Flex>
153
- </Flex>
154
- </Card>
155
- </>
156
- )
157
- }
158
-
159
- type CopiedValue = string | null
160
-
161
- type CopyFn = (text: string) => Promise<boolean>
162
-
163
- function useCopyToClipboard(): [CopiedValue, CopyFn] {
164
- const [copiedText, setCopiedText] = useState<CopiedValue>(null)
165
-
166
- const copy: CopyFn = useCallback(async (text) => {
167
- if (!navigator?.clipboard) {
168
- console.warn('Clipboard not supported')
169
- return false
170
- }
171
-
172
- // Try to save to clipboard then save it in the state if worked
173
- try {
174
- await navigator.clipboard.writeText(text)
175
- setCopiedText(text)
176
- return true
177
- } catch (error) {
178
- console.warn('Copy failed', error)
179
- setCopiedText(null)
180
- return false
181
- }
182
- }, [])
183
-
184
- return [copiedText, copy]
185
- }
package/src/index.ts DELETED
@@ -1,8 +0,0 @@
1
- export {
2
- Iframe,
3
- type IframeOptions,
4
- type IframeProps,
5
- type IframeSizeKey,
6
- type UrlResolver,
7
- } from './Iframe'
8
- export type {Size, SizeProps} from './types'
package/src/types.ts DELETED
@@ -1,11 +0,0 @@
1
- export type IframeSizeKey = keyof SizeProps
2
-
3
- export type Size = 'desktop' | 'mobile'
4
-
5
- export type SizeProps = {
6
- // eslint-disable-next-line no-unused-vars
7
- [key in Size]: {
8
- width: string | number
9
- height: string | number
10
- }
11
- }
@@ -1,11 +0,0 @@
1
- const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin')
2
- const {name, version, sanityExchangeUrl} = require('./package.json')
3
-
4
- export default showIncompatiblePluginDialog({
5
- name: name,
6
- versions: {
7
- v3: version,
8
- v2: '^1.1.4',
9
- },
10
- sanityExchangeUrl,
11
- })