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.
- package/LICENSE +1 -1
- package/README.md +67 -46
- package/lib/index.esm.js +2 -0
- package/lib/index.esm.js.map +1 -0
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -0
- package/lib/src/index.d.ts +9 -0
- package/package.json +73 -31
- package/sanity.json +2 -6
- package/src/index.ts +1 -0
- package/src/plugin.tsx +30 -0
- package/src/tool/GraphView.tsx +489 -0
- package/src/tool/GraphViewIcon.tsx +18 -0
- package/src/tool/GraphViewStyle.tsx +63 -0
- package/src/tool/hooks.ts +36 -0
- package/src/tool/utils.ts +51 -0
- package/v2-incompatible.js +11 -0
- package/.editorconfig +0 -16
- package/.eslintignore +0 -1
- package/.eslintrc.js +0 -18
- package/.github/workflows/main.yml +0 -133
- package/.prettierrc +0 -6
- package/.releaserc.json +0 -4
- package/.semantic-release/sanity-plugin-graph-view-1.0.8.tgz +0 -0
- package/CHANGELOG.md +0 -13
- package/assets/head-silhouette.jpg +0 -0
- package/assets/sanity-logo.png +0 -0
- package/assets/screengrab.gif +0 -0
- package/babel.config.js +0 -13
- package/config.dist.json +0 -1
- package/lib/tool/GraphView.css +0 -56
- package/lib/tool/GraphView.js +0 -615
- package/lib/tool/GraphViewIcon.js +0 -28
- package/lib/tool/hooks.js +0 -27
- package/lib/tool/index.js +0 -17
- package/lib/tool/utils.js +0 -60
|
@@ -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
|