sanity-plugin-graph-view 1.0.8 → 2.0.1

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.
@@ -0,0 +1,489 @@
1
+ import {rgba} from 'polished'
2
+ import deepEqual from 'deep-equal'
3
+ import React, {useCallback, useEffect, useState} from 'react'
4
+ import {ForceGraph2D} from 'react-force-graph'
5
+ import {v4 as uuidv4} from 'uuid'
6
+ import BezierEasing from 'bezier-easing'
7
+ import {useFetchDocuments, useListen} from './hooks'
8
+ import {sortBy, loadImage, sizeOf, truncate} from './utils'
9
+ import {SanityDocument, SanityClient} from '@sanity/client'
10
+ import {useClient, useUserColorManager} from 'sanity'
11
+ import {useRouter} from 'sanity/router'
12
+ import {GraphRoot, GraphWrapper, HoverNode, Legend, LegendBadge, LegendRow} from './GraphViewStyle'
13
+ import {useTheme} from '@sanity/ui'
14
+ import {black, COLOR_HUES, gray, white, hues} from '@sanity/color'
15
+
16
+ const DEFAULT_QUERY = `
17
+ *[
18
+ !(_id in path("_.*")) &&
19
+ !(_type match "system.*") &&
20
+ !(_type match "sanity.*")
21
+ ]
22
+ `
23
+
24
+ const fadeEasing = BezierEasing(0, 0.9, 1, 1)
25
+ const softEasing = BezierEasing(0.25, 0.1, 0.0, 1.0)
26
+ const idleTimeout = 10000
27
+ const imageSize = 40
28
+
29
+ function getTopDocTypes(counts: Record<string, number>) {
30
+ return sortBy(Object.keys(counts), (docType) => counts[docType] || 0)
31
+ .reverse()
32
+ .slice(0, 10)
33
+ }
34
+
35
+ function formatDocType(docType: string) {
36
+ return (docType.substring(0, 1).toUpperCase() + docType.substring(1))
37
+ .replace(/\./g, ' ')
38
+ .replace(/[A-Z]/g, ' $&')
39
+ .trim()
40
+ }
41
+
42
+ function getDocTypeCounts(docs: SanityDocument[]) {
43
+ const types: Record<string, number> = {}
44
+ for (const doc of docs) {
45
+ types[doc._type] = (types[doc._type] || 0) + 1
46
+ }
47
+ return types
48
+ }
49
+
50
+ function labelFor(doc: SanityDocument) {
51
+ return `${doc.title || doc.name || doc._id}`.trim()
52
+ }
53
+
54
+ function valueFor(doc: any, maxSize: number) {
55
+ return 5 + 100 * (sizeOf(doc) / maxSize)
56
+ }
57
+
58
+ function findRefs(obj: any, dest: any[] = []) {
59
+ if (obj !== null) {
60
+ if (typeof obj === 'object') {
61
+ for (const [k, v] of Object.entries(obj)) {
62
+ if (k === '_ref' && typeof v === 'string' && v.length > 0) {
63
+ dest.push(stripDraftId(v))
64
+ }
65
+ findRefs(v, dest)
66
+ }
67
+ } else if (Array.isArray(obj)) {
68
+ for (const v of obj) {
69
+ findRefs(v, dest)
70
+ }
71
+ }
72
+ }
73
+ return dest
74
+ }
75
+
76
+ function stripDraftId(id: string) {
77
+ return id.replace(/^drafts\./, '')
78
+ }
79
+
80
+ function deduplicateDrafts(docs: SanityDocument[]) {
81
+ const deduped: Record<string, SanityDocument> = {}
82
+ for (const doc of docs) {
83
+ if (!/^drafts\./.test(doc._id)) {
84
+ deduped[doc._id] = doc
85
+ }
86
+ }
87
+ for (const doc of docs) {
88
+ if (/^drafts\./.test(doc._id)) {
89
+ const id = stripDraftId(doc._id)
90
+ deduped[id] = Object.assign(doc, {_id: id})
91
+ }
92
+ }
93
+ return Object.values(deduped)
94
+ }
95
+
96
+ class Users {
97
+ _users: any[] = []
98
+
99
+ async getById(id: string, client: SanityClient) {
100
+ let user = this._users.find((u) => u._id === id)
101
+ if (!user) {
102
+ user = await client.users.getById(id)
103
+ this._users.push(user)
104
+ user.image = await loadImage(
105
+ user.imageUrl ||
106
+ 'https://raw.githubusercontent.com/sanity-io/sanity-plugin-graph-view/main/assets/head-silhouette.jpg',
107
+ imageSize,
108
+ imageSize
109
+ )
110
+ }
111
+ return user
112
+ }
113
+ }
114
+
115
+ class Session {
116
+ id: string | null = null
117
+ user: any = null
118
+ doc: SanityDocument | null = null
119
+ lastActive: number = 0
120
+ startTime: number = 0
121
+ angle: number = 0
122
+ }
123
+
124
+ class GraphData {
125
+ sessions: Session[] = []
126
+ data: any
127
+
128
+ constructor(docs: SanityDocument[] = []) {
129
+ const docsById: Record<string, SanityDocument> = {}
130
+ for (const doc of docs) {
131
+ docsById[doc._id] = doc
132
+ }
133
+
134
+ this.data = {
135
+ nodes: docs.map((d) => Object.assign({id: d._id, type: 'document', doc: d})),
136
+ links: docs
137
+ .flatMap((doc) => findRefs(doc).map((ref) => ({source: doc._id, target: ref})))
138
+ .filter((link) => docsById[link.source] && docsById[link.target]),
139
+ }
140
+ }
141
+
142
+ setSession(user, docNode) {
143
+ let session = this.sessions.find((s) => s.user.id === user.id && s.doc?._id === docNode.doc._id)
144
+ if (!session) {
145
+ session = new Session()
146
+ session.id = uuidv4()
147
+ session.user = user
148
+ session.startTime = Date.now()
149
+ session.doc = docNode.doc
150
+ session.angle = Math.random() * 2 * Math.PI
151
+ this.sessions.push(session)
152
+ }
153
+ session.lastActive = Date.now()
154
+ }
155
+
156
+ reapSessions() {
157
+ for (let i = 0; i < this.sessions.length; i++) {
158
+ const session = this.sessions[i]
159
+ if (Date.now() - session.lastActive > idleTimeout) {
160
+ this.sessions = [...this.sessions.slice(0, i), ...this.sessions.slice(i + 1)]
161
+ i--
162
+ }
163
+ }
164
+ }
165
+
166
+ clone() {
167
+ const copy = new GraphData()
168
+ Object.assign(copy, this)
169
+ copy.data = {
170
+ nodes: [...this.data.nodes],
171
+ links: [...this.data.links],
172
+ }
173
+ return copy
174
+ }
175
+ }
176
+
177
+ const users = new Users()
178
+
179
+ interface GraphViewConfig {
180
+ query?: string
181
+ /** default: '2022-09-01' */
182
+ apiVersion?: string
183
+ }
184
+
185
+ export function GraphView(props: GraphViewConfig) {
186
+ const query = props.query || DEFAULT_QUERY
187
+ const apiVersion = props.apiVersion ?? '2022-09-01'
188
+
189
+ const userColorManager = useUserColorManager()
190
+ const [maxSize, setMaxSize] = useState(0)
191
+ const [hoverNode, setHoverNode] = useState<any>(null)
192
+ const [documents, setDocuments] = useState<SanityDocument[]>([])
193
+ const [docTypes, setDocTypes] = useState<Record<string, number>>({})
194
+ const [graph, setGraph] = useState(() => new GraphData())
195
+ const router = useRouter()
196
+ const client = useClient({apiVersion})
197
+
198
+ const fetchCallback = useCallback((_docs) => {
199
+ const docs = deduplicateDrafts(_docs)
200
+ setMaxSize(Math.max(...docs.map(sizeOf)))
201
+ setDocuments(docs)
202
+ setDocTypes(getDocTypeCounts(docs))
203
+ setGraph(new GraphData(docs))
204
+ }, [])
205
+
206
+ const listenCallback = useCallback(
207
+ async (update) => {
208
+ const doc = update.result
209
+ if (doc) {
210
+ doc._id = stripDraftId(doc._id)
211
+
212
+ const docsById: Record<string, SanityDocument> = {}
213
+ for (const d of documents) {
214
+ docsById[d._id] = d
215
+ }
216
+
217
+ let oldDoc
218
+ const docs = [...documents]
219
+ const idx = documents.findIndex((d) => d._id === doc._id)
220
+ if (idx >= 0) {
221
+ oldDoc = docs[idx]
222
+ docs[idx] = doc
223
+ } else {
224
+ docs.push(doc)
225
+ }
226
+ setDocuments(docs)
227
+ setDocTypes(getDocTypeCounts(docs))
228
+ setMaxSize(Math.max(...docs.map(sizeOf)))
229
+
230
+ const newGraph = graph.clone()
231
+
232
+ const oldRefs = findRefs(oldDoc || {}).filter(
233
+ (id) => id === doc._id || docsById[id] !== null
234
+ )
235
+ const newRefs = findRefs(doc).filter((id) => id === doc._id || docsById[id] !== null)
236
+
237
+ let graphChanged = !deepEqual(oldRefs, newRefs)
238
+ if (graphChanged) {
239
+ newGraph.data.links = newGraph.data.links
240
+ .filter((l) => l.source.id !== doc._id)
241
+ .concat(newRefs.map((ref) => ({source: doc._id, target: ref})))
242
+ }
243
+
244
+ let docNode
245
+ const nodeIdx = graph.data.nodes.findIndex((n) => n.doc && n.doc._id === doc._id)
246
+ if (nodeIdx >= 0) {
247
+ docNode = graph.data.nodes[nodeIdx]
248
+ docNode.doc = doc
249
+ } else {
250
+ docNode = {id: doc._id, type: 'document', doc: doc}
251
+ newGraph.data.nodes.push(docNode)
252
+ graphChanged = true
253
+ }
254
+ if (graphChanged) {
255
+ setGraph(newGraph)
256
+ }
257
+
258
+ const user = await users.getById(update.identity, client)
259
+ graph.setSession(user, docNode)
260
+ } else if (update.transition === 'disappear') {
261
+ const docId = stripDraftId(update.documentId)
262
+
263
+ const docs = documents.filter((d) => d._id !== docId)
264
+ setDocuments(docs)
265
+ setDocTypes(getDocTypeCounts(docs))
266
+ setMaxSize(Math.max(...docs.map(sizeOf)))
267
+
268
+ const newGraph = graph.clone()
269
+ newGraph.data.links = newGraph.data.links.filter(
270
+ (l) => l.source.id !== docId && l.target.id !== docId
271
+ )
272
+ newGraph.data.nodes = newGraph.data.nodes.filter((n) => n.id !== docId)
273
+ setGraph(newGraph)
274
+ }
275
+ },
276
+ [documents, graph, client]
277
+ )
278
+ useFetchDocuments(query, fetchCallback, [], client)
279
+ useListen(query, {}, {}, listenCallback, [documents, graph], client)
280
+ useEffect(() => {
281
+ const interval = setInterval(() => graph.reapSessions(), 1000)
282
+ return () => clearInterval(interval)
283
+ }, [graph])
284
+
285
+ const theme = useTheme().sanity
286
+
287
+ return (
288
+ <GraphWrapper theme={theme}>
289
+ <GraphRoot theme={theme}>
290
+ <Legend theme={theme}>
291
+ {getTopDocTypes(docTypes).map((docType) => (
292
+ <LegendRow
293
+ className={'legend__row'}
294
+ key={docType}
295
+ style={{color: getDocTypeColor(docType).fill}}
296
+ >
297
+ <LegendBadge theme={theme} />
298
+ <div>{formatDocType(docType)}</div>
299
+ </LegendRow>
300
+ ))}
301
+ </Legend>
302
+ {hoverNode && <HoverNode theme={theme}>{labelFor(hoverNode.doc)}</HoverNode>}
303
+
304
+ <ForceGraph2D
305
+ graphData={graph.data}
306
+ nodeAutoColorBy="group"
307
+ enableNodeDrag={false}
308
+ onNodeHover={(node: any) => setHoverNode(node)}
309
+ onNodeClick={(node: any) => {
310
+ router.navigateIntent('edit', {id: node.doc._id, documentType: node.doc._type})
311
+ }}
312
+ linkColor={() => rgba(gray[500].hex, 0.25)}
313
+ nodeLabel={() => ''}
314
+ nodeRelSize={1}
315
+ nodeVal={(node: any) => valueFor(node.doc, maxSize)}
316
+ onRenderFramePost={(ctx, globalScale) => {
317
+ for (const session of graph.sessions) {
318
+ const node = graph.data.nodes.find((n) => n.doc && n.doc._id === session?.doc?._id)
319
+ if (node) {
320
+ const idleFactorRange = idleTimeout
321
+ const angle = session.angle
322
+ const radius = Math.sqrt(valueFor(node.doc, maxSize))
323
+ const image = session.user.image
324
+ const userColor = userColorManager.get(session.user.displayName).tints[400].hex
325
+ const distance = radius * globalScale + 40
326
+ const imgW = image ? image.width : 0
327
+ const imgH = image ? image.height : 0
328
+ const x = node.x + (Math.sin(angle) * distance) / globalScale
329
+ const y = node.y + (Math.cos(angle) * distance) / globalScale
330
+
331
+ ctx.save()
332
+ try {
333
+ ctx.globalAlpha = fadeEasing(
334
+ 1 - Math.min(idleFactorRange, Date.now() - session.lastActive) / idleFactorRange
335
+ )
336
+ ctx.font = `bold ${Math.round(12 / globalScale)}px sans-serif`
337
+
338
+ ctx.beginPath()
339
+ ctx.strokeStyle = rgba(white.hex, 1.0)
340
+ ctx.lineWidth = 2 / globalScale
341
+ ctx.moveTo(
342
+ node.x + (Math.sin(angle) * (distance - imgW / 2)) / globalScale,
343
+ node.y + (Math.cos(angle) * (distance - imgH / 2)) / globalScale
344
+ )
345
+ ctx.lineTo(node.x + Math.sin(angle) * radius, node.y + Math.cos(angle) * radius)
346
+ ctx.stroke()
347
+
348
+ ctx.beginPath()
349
+ ctx.strokeStyle = rgba(white.hex, 1.0)
350
+ ctx.lineWidth = 2 / globalScale
351
+ ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false)
352
+ ctx.stroke()
353
+
354
+ if (image) {
355
+ ctx.save()
356
+ // eslint-disable-next-line max-depth
357
+ try {
358
+ const dur = 700
359
+ const f = softEasing(
360
+ Math.max(0, (dur - (Date.now() - session.startTime)) / dur)
361
+ )
362
+ // eslint-disable-next-line max-depth
363
+ if (f > 0) {
364
+ ctx.beginPath()
365
+ ctx.fillStyle = rgba(userColor, f)
366
+ ctx.arc(x, y, (imgW / 2 + 10) / globalScale, 0, 2 * Math.PI, false)
367
+ ctx.fill()
368
+ }
369
+
370
+ ctx.beginPath()
371
+ ctx.fillStyle = rgba(white.hex, 1.0)
372
+ ctx.arc(x, y, imgW / globalScale / 2, 0, 2 * Math.PI, false)
373
+ ctx.clip()
374
+
375
+ ctx.drawImage(
376
+ image,
377
+ x - imgW / globalScale / 2,
378
+ y - imgH / globalScale / 2,
379
+ imgW / globalScale,
380
+ imgH / globalScale
381
+ )
382
+
383
+ ctx.strokeStyle = black.hex
384
+ ctx.lineWidth = 6 / globalScale
385
+ ctx.stroke()
386
+
387
+ ctx.strokeStyle = userColor
388
+ ctx.lineWidth = 4 / globalScale
389
+ ctx.stroke()
390
+ } finally {
391
+ ctx.restore()
392
+ }
393
+ }
394
+
395
+ ctx.beginPath()
396
+ ctx.strokeStyle = rgba(black.hex, 1)
397
+ ctx.lineWidth = 0.5 / globalScale
398
+ ctx.arc(x, y, imgW / globalScale / 2, 0, 2 * Math.PI, false)
399
+ ctx.stroke()
400
+
401
+ const above = angle >= Math.PI / 2 && angle < Math.PI * 1.5
402
+ const textY = above
403
+ ? y - (imgH / 2 + 5) / globalScale
404
+ : y + (imgH / 2 + 5) / globalScale
405
+ ctx.fillStyle = rgba(white.hex, 1.0)
406
+ ctx.textAlign = 'center'
407
+ ctx.textBaseline = above ? 'bottom' : 'top'
408
+ ctx.fillText(session.user.displayName, x, textY)
409
+ } finally {
410
+ ctx.restore()
411
+ }
412
+ }
413
+ }
414
+ }}
415
+ nodeCanvasObject={(node: any, ctx, globalScale) => {
416
+ // eslint-disable-next-line default-case
417
+ switch (node.type) {
418
+ case 'document': {
419
+ const nodeColor = getDocTypeColor(node.doc._type)
420
+ const radius = Math.sqrt(valueFor(node.doc, maxSize))
421
+ const fontSize = Math.min(100, 10.0 / globalScale)
422
+
423
+ ctx.beginPath()
424
+ ctx.fillStyle =
425
+ hoverNode !== null && node.doc._id === hoverNode.doc._id
426
+ ? rgba(gray[500].hex, 0.8)
427
+ : nodeColor.fill
428
+ ctx.strokeStyle = nodeColor.border
429
+ ctx.lineWidth = 0.5
430
+ ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false)
431
+ ctx.stroke()
432
+ ctx.fill()
433
+
434
+ if (radius * globalScale > 10) {
435
+ ctx.font = `${fontSize}px sans-serif`
436
+ const w = radius * 2 + 30 / globalScale
437
+ for (let len = 50; len >= 5; len /= 1.2) {
438
+ const label = truncate(labelFor(node.doc), Math.round(len))
439
+ const textMetrics = ctx.measureText(label)
440
+ if (textMetrics.width < w) {
441
+ // ctx.fillStyle = rgba(color.white.hex, 1.0)
442
+ ctx.textAlign = 'center'
443
+ ctx.textBaseline = 'top'
444
+
445
+ ctx.strokeStyle = rgba(black.hex, 0.5)
446
+ ctx.lineWidth = 2 / globalScale
447
+ ctx.strokeText(label, node.x, node.y + radius + 5 / globalScale)
448
+
449
+ ctx.fillText(label, node.x, node.y + radius + 5 / globalScale)
450
+ break
451
+ }
452
+ }
453
+ }
454
+ }
455
+ }
456
+ }}
457
+ linkCanvasObject={(link: any, ctx, globalScale) => {
458
+ ctx.beginPath()
459
+ ctx.strokeStyle = rgba(gray[500].hex, 0.125)
460
+ ctx.lineWidth = 2 / globalScale
461
+ ctx.moveTo(link.source.x, link.source.y)
462
+ ctx.lineTo(link.target.x, link.target.y)
463
+ ctx.stroke()
464
+ }}
465
+ />
466
+ </GraphRoot>
467
+ </GraphWrapper>
468
+ )
469
+ }
470
+
471
+ const colorCache = {}
472
+ let typeColorNum = 0
473
+
474
+ function getDocTypeColor(docType) {
475
+ if (colorCache[docType]) {
476
+ return colorCache[docType]
477
+ }
478
+
479
+ const hue = COLOR_HUES[typeColorNum % COLOR_HUES.length]
480
+
481
+ typeColorNum += 1
482
+
483
+ colorCache[docType] = {
484
+ fill: hues[hue][400].hex,
485
+ border: rgba(black.hex, 0.5),
486
+ }
487
+
488
+ return colorCache[docType]
489
+ }
@@ -0,0 +1,18 @@
1
+ import React from 'react'
2
+
3
+ /**
4
+ * Couple of things to note:
5
+ * - width and height is set to 1em
6
+ * - fill is `currentColor` - this will ensure that the icon looks uniform and
7
+ * that the hover/active state works. You can of course render anything you
8
+ * would like here, but for plugins that are to be used in more than one
9
+ * studio, we suggest these rules are followed
10
+ **/
11
+ export const GraphViewIcon = () => (
12
+ <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 46.063 46.063">
13
+ <path
14
+ fill="currentColor"
15
+ d="M18.022 38.676v2.813h-1.21q-4.864 0-6.525-1.447-1.64-1.445-1.64-5.762v-4.666q0-2.95-1.055-4.083-1.055-1.13-3.828-1.13h-1.19v-2.794h1.19q2.793 0 3.828-1.114 1.055-1.132 1.055-4.043V11.76q0-4.316 1.64-5.742 1.66-1.446 6.524-1.446h1.212v2.793h-1.328q-2.754 0-3.594.86-.84.86-.84 3.613v4.844q0 3.066-.898 4.453-.88 1.387-3.028 1.875 2.168.527 3.047 1.914.88 1.387.88 4.434v4.843q0 2.754.84 3.614.84.86 3.594.86h1.328zM28.04 38.676h1.368q2.735 0 3.555-.84.84-.84.84-3.633V29.36q0-3.047.88-4.434.878-1.387 3.046-1.914-2.17-.488-3.048-1.875-.88-1.387-.88-4.453V11.84q0-2.773-.84-3.613-.82-.86-3.554-.86H28.04V4.574h1.232q4.863 0 6.484 1.446 1.64 1.426 1.64 5.742v4.687q0 2.91 1.055 4.042 1.056 1.114 3.83 1.114h1.21V24.4h-1.21q-2.774 0-3.83 1.13-1.053 1.134-1.053 4.084v4.667q0 4.318-1.64 5.763-1.622 1.446-6.485 1.446h-1.23v-2.814z"
16
+ />
17
+ </svg>
18
+ )
@@ -0,0 +1,63 @@
1
+ import styled from 'styled-components'
2
+ import {Theme} from '@sanity/ui'
3
+ import {black} from '@sanity/color'
4
+ import React, {PropsWithChildren} from 'react'
5
+
6
+ type Style = PropsWithChildren<{theme: SanityTheme}>
7
+ type SanityTheme = Theme['sanity']
8
+
9
+ export const GraphRoot: React.FC<Style> = styled.div`
10
+ font-family: ${({theme}: Style) => theme.fonts.text.family};
11
+ position: absolute;
12
+ top: 0;
13
+ left: 0;
14
+ width: 100%;
15
+ height: 100%;
16
+ background: ${black.hex};
17
+ `
18
+
19
+ export const GraphWrapper: React.FC<Style> = styled.div`
20
+ position: relative;
21
+ width: 100%;
22
+ height: 100%;
23
+ ` as React.FC<Style>
24
+
25
+ export const HoverNode: React.FC<Style> = styled.div`
26
+ font-family: ${({theme}: Style) => theme.fonts.text.family};
27
+ display: none;
28
+ position: absolute;
29
+ bottom: ${({theme}: Style) => theme.space[0]}px;
30
+ left: 50%;
31
+ transform: translate3d(-50%, 0, 0);
32
+ background: var(--component-bg);
33
+ border-radius: ${({theme}: Style) => theme.radius[2]}px;
34
+ padding: ${({theme}: Style) => theme.space[2]}px;
35
+ z-index: 1000;
36
+
37
+ &:empty {
38
+ display: none;
39
+ }
40
+ `
41
+
42
+ export const Legend: React.FC<Style> = styled.div`
43
+ color: #ccc;
44
+ position: absolute;
45
+ top: ${({theme}: Style) => theme.space[4]}px;
46
+ left: ${({theme}: Style) => theme.space[4]}px;
47
+
48
+ & > div {
49
+ margin: 5px 0;
50
+ }
51
+ `
52
+
53
+ export const LegendRow = styled.div`
54
+ display: flex;
55
+ `
56
+
57
+ export const LegendBadge: React.FC<Style> = styled.div`
58
+ width: 1.25em;
59
+ height: 1.25em;
60
+ background: currentColor;
61
+ border-radius: 50%;
62
+ margin-right: ${({theme}: Style) => theme.space[2]}px;
63
+ `
@@ -0,0 +1,36 @@
1
+ import {useEffect} from 'react'
2
+ import {ListenEvent, ListenOptions, SanityClient} from '@sanity/client'
3
+
4
+ // eslint-disable-next-line max-params
5
+ export function useListen(
6
+ query: string,
7
+ params: {[key: string]: any},
8
+ options: ListenOptions,
9
+ onUpdate: (event: ListenEvent<any>) => void,
10
+ dependencies: unknown[],
11
+ client: SanityClient
12
+ ): void {
13
+ useEffect(() => {
14
+ const subscription = client.listen(query, params, options).subscribe((update) => {
15
+ onUpdate(update)
16
+ })
17
+ return () => {
18
+ subscription.unsubscribe()
19
+ }
20
+ // eslint-disable-next-line react-hooks/exhaustive-deps
21
+ }, [...dependencies, client])
22
+ }
23
+
24
+ export function useFetchDocuments(
25
+ query: string,
26
+ onFetch: (event: ListenEvent<any>) => void,
27
+ dependencies: unknown[],
28
+ client: SanityClient
29
+ ): void {
30
+ useEffect(() => {
31
+ client.fetch(query).then((result) => {
32
+ onFetch(result)
33
+ })
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ }, [...dependencies, client])
36
+ }
@@ -0,0 +1,51 @@
1
+ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
2
+ export function sizeOf(value: any): number {
3
+ if (value === null) {
4
+ return 0
5
+ }
6
+
7
+ if (typeof value === 'object') {
8
+ return Object.entries(value).reduce((total, [k, v]) => total + sizeOf(k) + sizeOf(v), 0)
9
+ }
10
+
11
+ if (Array.isArray(value)) {
12
+ return Object.entries(value).reduce((total, v) => total + sizeOf(v), 0)
13
+ }
14
+
15
+ if (typeof value === 'string') {
16
+ return value.length
17
+ }
18
+
19
+ return 1
20
+ }
21
+
22
+ export function loadImage(url: string, w: number, h: number): Promise<HTMLImageElement | null> {
23
+ return new Promise((resolve) => {
24
+ const img = new Image(w, h)
25
+ img.onload = () => {
26
+ resolve(img)
27
+ }
28
+ img.onerror = (event) => {
29
+ // eslint-disable-next-line no-console
30
+ console.log('Image error', event)
31
+ resolve(null)
32
+ }
33
+ img.src = url
34
+ })
35
+ }
36
+
37
+ export function sortBy<T>(array: T[], f: (t: T) => number): T[] {
38
+ return array.sort((a, b) => {
39
+ const va = f(a)
40
+ const vb = f(b)
41
+ // eslint-disable-next-line no-nested-ternary
42
+ return va < vb ? -1 : va > vb ? 1 : 0
43
+ })
44
+ }
45
+
46
+ export function truncate(s: string, limit: number): string {
47
+ if (s.length > limit) {
48
+ return `${s.substring(0, limit)}…`
49
+ }
50
+ return s
51
+ }
@@ -0,0 +1,11 @@
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.0.7',
9
+ },
10
+ sanityExchangeUrl,
11
+ })
package/.editorconfig DELETED
@@ -1,16 +0,0 @@
1
- ; editorconfig.org
2
- root = true
3
- charset= utf8
4
-
5
- [*]
6
- end_of_line = lf
7
- insert_final_newline = true
8
- trim_trailing_whitespace = true
9
- indent_style = space
10
- indent_size = 2
11
-
12
- [*.md]
13
- trim_trailing_whitespace = false
14
-
15
- [*.snap]
16
- trim_trailing_whitespace = false
package/.eslintignore DELETED
@@ -1 +0,0 @@
1
- /lib