@brett_lamy/docstream-editor 0.1.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.
@@ -0,0 +1,746 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core"
2
+ import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"
3
+ import { common, createLowlight } from "lowlight"
4
+ import {
5
+ NodeViewContent,
6
+ NodeViewWrapper,
7
+ ReactNodeViewRenderer,
8
+ type NodeViewProps,
9
+ } from "@tiptap/react"
10
+ import {
11
+ AlertTriangle,
12
+ CheckCircle2,
13
+ ChevronDown,
14
+ Info,
15
+ Link2,
16
+ Plus,
17
+ X,
18
+ XCircle,
19
+ } from "lucide-react"
20
+
21
+ import { OpenApiOperation, resolveAsset, type HintStyle } from "@brett_lamy/docstream"
22
+
23
+ // ---------- Hint ----------
24
+
25
+ const HINT_META: Record<HintStyle, { icon: typeof Info; cls: string }> = {
26
+ info: { icon: Info, cls: "gb-hint-info" },
27
+ success: { icon: CheckCircle2, cls: "gb-hint-success" },
28
+ warning: { icon: AlertTriangle, cls: "gb-hint-warning" },
29
+ danger: { icon: XCircle, cls: "gb-hint-danger" },
30
+ }
31
+
32
+ function HintView({ node, updateAttributes, editor }: NodeViewProps) {
33
+ const style = (node.attrs.style as HintStyle) ?? "info"
34
+ const { icon: Icon, cls } = HINT_META[style] ?? HINT_META.info
35
+ return (
36
+ <NodeViewWrapper className={`gb-hint ${cls}`}>
37
+ <div className="gb-hint-gutter" contentEditable={false}>
38
+ <Icon className="size-4" />
39
+ {editor.isEditable && (
40
+ <select
41
+ className="gb-hint-style"
42
+ value={style}
43
+ onChange={(e) => updateAttributes({ style: e.target.value })}
44
+ >
45
+ {Object.keys(HINT_META).map((s) => (
46
+ <option key={s} value={s}>
47
+ {s}
48
+ </option>
49
+ ))}
50
+ </select>
51
+ )}
52
+ </div>
53
+ <NodeViewContent className="gb-hint-body" />
54
+ </NodeViewWrapper>
55
+ )
56
+ }
57
+
58
+ export const GbHint = Node.create({
59
+ name: "gbHint",
60
+ group: "block",
61
+ content: "block+",
62
+ defining: true,
63
+ addAttributes() {
64
+ return { style: { default: "info" } }
65
+ },
66
+ parseHTML() {
67
+ return [{ tag: "div[data-gb-hint]" }]
68
+ },
69
+ renderHTML({ HTMLAttributes, node }) {
70
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-hint": node.attrs.style }), 0]
71
+ },
72
+ addNodeView() {
73
+ return ReactNodeViewRenderer(HintView)
74
+ },
75
+ })
76
+
77
+ // ---------- Tabs ----------
78
+
79
+ function TabsView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
80
+ const active = Math.min(node.attrs.active ?? 0, node.childCount - 1)
81
+ const titles: string[] = []
82
+ node.forEach((child) => titles.push(child.attrs.title))
83
+
84
+ const childPos = (index: number) => {
85
+ let pos = getPos()! + 1
86
+ for (let k = 0; k < index; k++) pos += node.child(k).nodeSize
87
+ return pos
88
+ }
89
+
90
+ const renameTab = (index: number, title: string) => {
91
+ editor.view.dispatch(
92
+ editor.state.tr.setNodeAttribute(childPos(index), "title", title)
93
+ )
94
+ }
95
+
96
+ const addTab = () => {
97
+ const type = editor.schema.nodes.gbTab
98
+ const tab = type.create({ title: `Tab ${node.childCount + 1}` }, [
99
+ editor.schema.nodes.paragraph.create(),
100
+ ])
101
+ editor.view.dispatch(editor.state.tr.insert(childPos(node.childCount), tab))
102
+ updateAttributes({ active: node.childCount })
103
+ }
104
+
105
+ const removeTab = (index: number) => {
106
+ if (node.childCount <= 1) return
107
+ const pos = childPos(index)
108
+ editor.view.dispatch(editor.state.tr.delete(pos, pos + node.child(index).nodeSize))
109
+ updateAttributes({ active: Math.max(0, active - (index <= active ? 1 : 0)) })
110
+ }
111
+
112
+ return (
113
+ <NodeViewWrapper className="gb-tabs">
114
+ <div className="gb-tabs-header" contentEditable={false}>
115
+ {titles.map((t, idx) => (
116
+ <div
117
+ key={idx}
118
+ className={`gb-tabs-tab ${idx === active ? "gb-tabs-tab-active" : ""}`}
119
+ onClick={() => updateAttributes({ active: idx })}
120
+ >
121
+ {editor.isEditable && idx === active ? (
122
+ <input
123
+ className="gb-tabs-title-input"
124
+ value={t}
125
+ size={Math.max(t.length, 3)}
126
+ onChange={(e) => renameTab(idx, e.target.value)}
127
+ />
128
+ ) : (
129
+ <span>{t}</span>
130
+ )}
131
+ {editor.isEditable && titles.length > 1 && (
132
+ <button className="gb-icon-btn" onClick={() => removeTab(idx)} title="Remove tab">
133
+ <X className="size-3" />
134
+ </button>
135
+ )}
136
+ </div>
137
+ ))}
138
+ {editor.isEditable && (
139
+ <button className="gb-icon-btn" onClick={addTab} title="Add tab">
140
+ <Plus className="size-3.5" />
141
+ </button>
142
+ )}
143
+ </div>
144
+ <NodeViewContent className="gb-tabs-content" data-active={active} />
145
+ </NodeViewWrapper>
146
+ )
147
+ }
148
+
149
+ export const GbTabs = Node.create({
150
+ name: "gbTabs",
151
+ group: "block",
152
+ content: "gbTab+",
153
+ defining: true,
154
+ isolating: true,
155
+ addAttributes() {
156
+ return { active: { default: 0, rendered: false } }
157
+ },
158
+ parseHTML() {
159
+ return [{ tag: "div[data-gb-tabs]" }]
160
+ },
161
+ renderHTML({ HTMLAttributes }) {
162
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-tabs": "" }), 0]
163
+ },
164
+ addNodeView() {
165
+ return ReactNodeViewRenderer(TabsView)
166
+ },
167
+ })
168
+
169
+ export const GbTab = Node.create({
170
+ name: "gbTab",
171
+ content: "block+",
172
+ defining: true,
173
+ isolating: true,
174
+ addAttributes() {
175
+ return { title: { default: "Tab" } }
176
+ },
177
+ parseHTML() {
178
+ return [{ tag: "div[data-gb-tab]" }]
179
+ },
180
+ renderHTML({ HTMLAttributes, node }) {
181
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-tab": node.attrs.title, class: "gb-tab" }), 0]
182
+ },
183
+ })
184
+
185
+ // ---------- Expandable ----------
186
+
187
+ function ExpandableView({ node, updateAttributes, editor }: NodeViewProps) {
188
+ return (
189
+ <NodeViewWrapper className="gb-expandable">
190
+ <div className="gb-expandable-summary" contentEditable={false}>
191
+ <ChevronDown className="size-4" />
192
+ {editor.isEditable ? (
193
+ <input
194
+ className="gb-inline-input"
195
+ value={node.attrs.summary}
196
+ placeholder="Expandable title…"
197
+ onChange={(e) => updateAttributes({ summary: e.target.value })}
198
+ />
199
+ ) : (
200
+ <span>{node.attrs.summary}</span>
201
+ )}
202
+ </div>
203
+ <NodeViewContent className="gb-expandable-body" />
204
+ </NodeViewWrapper>
205
+ )
206
+ }
207
+
208
+ export const GbExpandable = Node.create({
209
+ name: "gbExpandable",
210
+ group: "block",
211
+ content: "block+",
212
+ defining: true,
213
+ addAttributes() {
214
+ return { summary: { default: "" } }
215
+ },
216
+ parseHTML() {
217
+ return [{ tag: "div[data-gb-expandable]" }]
218
+ },
219
+ renderHTML({ HTMLAttributes }) {
220
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-expandable": "" }), 0]
221
+ },
222
+ addNodeView() {
223
+ return ReactNodeViewRenderer(ExpandableView)
224
+ },
225
+ })
226
+
227
+ // ---------- Stepper ----------
228
+
229
+ export const GbStepper = Node.create({
230
+ name: "gbStepper",
231
+ group: "block",
232
+ content: "gbStep+",
233
+ defining: true,
234
+ parseHTML() {
235
+ return [{ tag: "div[data-gb-stepper]" }]
236
+ },
237
+ renderHTML({ HTMLAttributes }) {
238
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-stepper": "", class: "gb-stepper" }), 0]
239
+ },
240
+ })
241
+
242
+ function StepView({ node, updateAttributes, editor, getPos }: NodeViewProps) {
243
+ let index = 0
244
+ try {
245
+ const $pos = editor.state.doc.resolve(getPos()!)
246
+ index = $pos.index($pos.depth)
247
+ } catch {
248
+ /* position can be momentarily stale during edits */
249
+ }
250
+ return (
251
+ <NodeViewWrapper className="gb-step">
252
+ <div className="gb-step-rail" contentEditable={false}>
253
+ <div className="gb-step-badge">{index + 1}</div>
254
+ <div className="gb-step-line" />
255
+ </div>
256
+ <div className="gb-step-main">
257
+ <div contentEditable={false}>
258
+ {editor.isEditable ? (
259
+ <input
260
+ className="gb-inline-input gb-step-title"
261
+ value={node.attrs.title}
262
+ placeholder="Step title…"
263
+ onChange={(e) => updateAttributes({ title: e.target.value })}
264
+ />
265
+ ) : (
266
+ <div className="gb-step-title">{node.attrs.title}</div>
267
+ )}
268
+ </div>
269
+ <NodeViewContent className="gb-step-body" />
270
+ </div>
271
+ </NodeViewWrapper>
272
+ )
273
+ }
274
+
275
+ export const GbStep = Node.create({
276
+ name: "gbStep",
277
+ content: "block+",
278
+ defining: true,
279
+ isolating: true,
280
+ addAttributes() {
281
+ return { title: { default: "" } }
282
+ },
283
+ parseHTML() {
284
+ return [{ tag: "div[data-gb-step]" }]
285
+ },
286
+ renderHTML({ HTMLAttributes }) {
287
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-step": "" }), 0]
288
+ },
289
+ addNodeView() {
290
+ return ReactNodeViewRenderer(StepView)
291
+ },
292
+ })
293
+
294
+ // ---------- Embed ----------
295
+
296
+ function embedPreview(url: string): string | null {
297
+ const yt = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
298
+ if (yt) return `https://www.youtube.com/embed/${yt[1]}`
299
+ return null
300
+ }
301
+
302
+ function EmbedView({ node, updateAttributes, editor }: NodeViewProps) {
303
+ const url = node.attrs.url as string
304
+ const preview = embedPreview(url)
305
+ return (
306
+ <NodeViewWrapper className="gb-embed" contentEditable={false}>
307
+ {preview ? (
308
+ <iframe className="gb-embed-frame" src={preview} title={url} allowFullScreen />
309
+ ) : (
310
+ <a className="gb-embed-link" href={url} target="_blank" rel="noreferrer">
311
+ <Link2 className="size-4" /> {url || "Embed URL…"}
312
+ </a>
313
+ )}
314
+ {editor.isEditable && (
315
+ <input
316
+ className="gb-inline-input gb-embed-input"
317
+ value={url}
318
+ placeholder="https://…"
319
+ onChange={(e) => updateAttributes({ url: e.target.value })}
320
+ />
321
+ )}
322
+ </NodeViewWrapper>
323
+ )
324
+ }
325
+
326
+ export const GbEmbed = Node.create({
327
+ name: "gbEmbed",
328
+ group: "block",
329
+ atom: true,
330
+ addAttributes() {
331
+ return { url: { default: "" } }
332
+ },
333
+ parseHTML() {
334
+ return [{ tag: "div[data-gb-embed]" }]
335
+ },
336
+ renderHTML({ HTMLAttributes, node }) {
337
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-embed": node.attrs.url })]
338
+ },
339
+ addNodeView() {
340
+ return ReactNodeViewRenderer(EmbedView)
341
+ },
342
+ })
343
+
344
+ // ---------- Content ref ----------
345
+
346
+ function ContentRefView({ node, updateAttributes, editor }: NodeViewProps) {
347
+ return (
348
+ <NodeViewWrapper className="gb-content-ref" contentEditable={false}>
349
+ <Link2 className="size-4 shrink-0" />
350
+ {editor.isEditable ? (
351
+ <>
352
+ <input
353
+ className="gb-inline-input"
354
+ value={node.attrs.label}
355
+ placeholder="Label…"
356
+ onChange={(e) => updateAttributes({ label: e.target.value })}
357
+ />
358
+ <input
359
+ className="gb-inline-input gb-content-ref-url"
360
+ value={node.attrs.url}
361
+ placeholder="page.md or https://…"
362
+ onChange={(e) => updateAttributes({ url: e.target.value })}
363
+ />
364
+ </>
365
+ ) : (
366
+ <span>
367
+ {node.attrs.label} <span className="gb-content-ref-url">{node.attrs.url}</span>
368
+ </span>
369
+ )}
370
+ </NodeViewWrapper>
371
+ )
372
+ }
373
+
374
+ export const GbContentRef = Node.create({
375
+ name: "gbContentRef",
376
+ group: "block",
377
+ atom: true,
378
+ addAttributes() {
379
+ return { url: { default: "" }, label: { default: "" } }
380
+ },
381
+ parseHTML() {
382
+ return [{ tag: "div[data-gb-content-ref]" }]
383
+ },
384
+ renderHTML({ HTMLAttributes, node }) {
385
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-content-ref": node.attrs.url })]
386
+ },
387
+ addNodeView() {
388
+ return ReactNodeViewRenderer(ContentRefView)
389
+ },
390
+ })
391
+
392
+ // ---------- Columns ----------
393
+
394
+ export const GbColumns = Node.create({
395
+ name: "gbColumns",
396
+ group: "block",
397
+ content: "gbColumn{2,}",
398
+ defining: true,
399
+ parseHTML() {
400
+ return [{ tag: "div[data-gb-columns]" }]
401
+ },
402
+ renderHTML({ HTMLAttributes }) {
403
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-columns": "", class: "gb-columns" }), 0]
404
+ },
405
+ })
406
+
407
+ export const GbColumn = Node.create({
408
+ name: "gbColumn",
409
+ content: "block+",
410
+ defining: true,
411
+ isolating: true,
412
+ parseHTML() {
413
+ return [{ tag: "div[data-gb-column]" }]
414
+ },
415
+ renderHTML({ HTMLAttributes }) {
416
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-column": "", class: "gb-column" }), 0]
417
+ },
418
+ })
419
+
420
+ // ---------- Figure ----------
421
+
422
+ function FigureView({ node, updateAttributes, editor }: NodeViewProps) {
423
+ const { src, alt, caption } = node.attrs
424
+ return (
425
+ <NodeViewWrapper className="gb-figure" contentEditable={false}>
426
+ {src ? (
427
+ <img src={resolveAsset(src)} alt={alt} className="gb-figure-img" />
428
+ ) : (
429
+ <div className="gb-figure-placeholder">No image</div>
430
+ )}
431
+ {editor.isEditable ? (
432
+ <div className="gb-figure-fields">
433
+ <input
434
+ className="gb-inline-input"
435
+ value={src}
436
+ placeholder="Image URL…"
437
+ onChange={(e) => updateAttributes({ src: e.target.value })}
438
+ />
439
+ <input
440
+ className="gb-inline-input"
441
+ value={caption}
442
+ placeholder="Caption…"
443
+ onChange={(e) => updateAttributes({ caption: e.target.value })}
444
+ />
445
+ </div>
446
+ ) : (
447
+ caption && <figcaption className="gb-figure-caption">{caption}</figcaption>
448
+ )}
449
+ </NodeViewWrapper>
450
+ )
451
+ }
452
+
453
+ export const GbFigure = Node.create({
454
+ name: "gbFigure",
455
+ group: "block",
456
+ atom: true,
457
+ addAttributes() {
458
+ return { src: { default: "" }, alt: { default: "" }, caption: { default: "" } }
459
+ },
460
+ parseHTML() {
461
+ return [{ tag: "div[data-gb-figure]" }]
462
+ },
463
+ renderHTML({ HTMLAttributes, node }) {
464
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-figure": node.attrs.src })]
465
+ },
466
+ addNodeView() {
467
+ return ReactNodeViewRenderer(FigureView)
468
+ },
469
+ })
470
+
471
+ // ---------- Math ----------
472
+
473
+ function MathView({ node, updateAttributes, editor }: NodeViewProps) {
474
+ return (
475
+ <NodeViewWrapper className="gb-math" contentEditable={false}>
476
+ <div className="gb-math-label">TeX</div>
477
+ {editor.isEditable ? (
478
+ <textarea
479
+ className="gb-math-input"
480
+ value={node.attrs.formula}
481
+ rows={Math.max(1, String(node.attrs.formula).split("\n").length)}
482
+ onChange={(e) => updateAttributes({ formula: e.target.value })}
483
+ />
484
+ ) : (
485
+ <pre className="gb-math-formula">{node.attrs.formula}</pre>
486
+ )}
487
+ </NodeViewWrapper>
488
+ )
489
+ }
490
+
491
+ export const GbMath = Node.create({
492
+ name: "gbMath",
493
+ group: "block",
494
+ atom: true,
495
+ addAttributes() {
496
+ return { formula: { default: "" } }
497
+ },
498
+ parseHTML() {
499
+ return [{ tag: "div[data-gb-math]" }]
500
+ },
501
+ renderHTML({ HTMLAttributes, node }) {
502
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-math": "" }), node.attrs.formula]
503
+ },
504
+ addNodeView() {
505
+ return ReactNodeViewRenderer(MathView)
506
+ },
507
+ })
508
+
509
+ // ---------- Code block with GitBook title/lineNumbers ----------
510
+
511
+ function CodeView({ node, updateAttributes, editor }: NodeViewProps) {
512
+ return (
513
+ <NodeViewWrapper className="gb-code">
514
+ <div className="gb-code-header" contentEditable={false}>
515
+ {editor.isEditable ? (
516
+ <>
517
+ <input
518
+ className="gb-inline-input gb-code-title"
519
+ value={node.attrs.title ?? ""}
520
+ placeholder="Title (optional)"
521
+ onChange={(e) => updateAttributes({ title: e.target.value || null })}
522
+ />
523
+ <input
524
+ className="gb-inline-input gb-code-lang"
525
+ value={node.attrs.language ?? ""}
526
+ placeholder="lang"
527
+ onChange={(e) => updateAttributes({ language: e.target.value || null })}
528
+ />
529
+ <label className="gb-code-linenos">
530
+ <input
531
+ type="checkbox"
532
+ checked={!!node.attrs.lineNumbers}
533
+ onChange={(e) => updateAttributes({ lineNumbers: e.target.checked })}
534
+ />
535
+ #
536
+ </label>
537
+ </>
538
+ ) : (
539
+ <>
540
+ {node.attrs.title && <span className="gb-code-title">{node.attrs.title}</span>}
541
+ {node.attrs.language && <span className="gb-code-lang">{node.attrs.language}</span>}
542
+ </>
543
+ )}
544
+ </div>
545
+ <pre className={node.attrs.lineNumbers ? "gb-code-numbered" : ""}>
546
+ <NodeViewContent {...({ as: "code" } as object)} />
547
+ </pre>
548
+ </NodeViewWrapper>
549
+ )
550
+ }
551
+
552
+ export const GbCodeBlock = CodeBlockLowlight.extend({
553
+ addAttributes() {
554
+ return {
555
+ ...this.parent?.(),
556
+ title: { default: null },
557
+ lineNumbers: { default: false },
558
+ }
559
+ },
560
+ addNodeView() {
561
+ return ReactNodeViewRenderer(CodeView)
562
+ },
563
+ }).configure({ lowlight: createLowlight(common) })
564
+
565
+ // ---------- Inline image (GitHub badge style) ----------
566
+
567
+ function InlineImageView({ node }: NodeViewProps) {
568
+ const { src, alt, width, height, link } = node.attrs
569
+ const img = (
570
+ <img
571
+ src={resolveAsset(src)}
572
+ alt={alt}
573
+ className="gb-inline-img"
574
+ style={{ width: width || undefined, height: height || undefined }}
575
+ />
576
+ )
577
+ return (
578
+ <NodeViewWrapper as="span" className="gb-inline-img-wrap" contentEditable={false}>
579
+ {link ? <a href={link}>{img}</a> : img}
580
+ </NodeViewWrapper>
581
+ )
582
+ }
583
+
584
+ export const GbInlineImage = Node.create({
585
+ name: "gbInlineImage",
586
+ group: "inline",
587
+ inline: true,
588
+ atom: true,
589
+ addAttributes() {
590
+ return {
591
+ src: { default: "" },
592
+ alt: { default: "" },
593
+ width: { default: "" },
594
+ height: { default: "" },
595
+ link: { default: "" },
596
+ }
597
+ },
598
+ parseHTML() {
599
+ return [{ tag: "img[data-gb-inline-img]" }]
600
+ },
601
+ renderHTML({ HTMLAttributes, node }) {
602
+ return ["img", mergeAttributes(HTMLAttributes, { "data-gb-inline-img": "", src: node.attrs.src })]
603
+ },
604
+ addNodeView() {
605
+ return ReactNodeViewRenderer(InlineImageView)
606
+ },
607
+ })
608
+
609
+ // ---------- Updates (changelog) ----------
610
+
611
+ export const GbUpdates = Node.create({
612
+ name: "gbUpdates",
613
+ group: "block",
614
+ content: "gbUpdate+",
615
+ defining: true,
616
+ addAttributes() {
617
+ return { format: { default: null } }
618
+ },
619
+ parseHTML() {
620
+ return [{ tag: "div[data-gb-updates]" }]
621
+ },
622
+ renderHTML({ HTMLAttributes }) {
623
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-updates": "", class: "gb-updates" }), 0]
624
+ },
625
+ })
626
+
627
+ function UpdateView({ node, updateAttributes, editor }: NodeViewProps) {
628
+ return (
629
+ <NodeViewWrapper className="gb-update">
630
+ <div className="gb-update-date" contentEditable={false}>
631
+ {editor.isEditable ? (
632
+ <input
633
+ type="date"
634
+ className="gb-inline-input"
635
+ value={node.attrs.date}
636
+ onChange={(e) => updateAttributes({ date: e.target.value })}
637
+ />
638
+ ) : (
639
+ <span>{node.attrs.date}</span>
640
+ )}
641
+ </div>
642
+ <NodeViewContent className="gb-update-body" />
643
+ </NodeViewWrapper>
644
+ )
645
+ }
646
+
647
+ export const GbUpdate = Node.create({
648
+ name: "gbUpdate",
649
+ content: "block+",
650
+ defining: true,
651
+ isolating: true,
652
+ addAttributes() {
653
+ return { date: { default: "" } }
654
+ },
655
+ parseHTML() {
656
+ return [{ tag: "div[data-gb-update]" }]
657
+ },
658
+ renderHTML({ HTMLAttributes, node }) {
659
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-update": node.attrs.date }), 0]
660
+ },
661
+ addNodeView() {
662
+ return ReactNodeViewRenderer(UpdateView)
663
+ },
664
+ })
665
+
666
+ // ---------- OpenAPI operation ----------
667
+
668
+ function OpenapiView({ node, updateAttributes, editor }: NodeViewProps) {
669
+ const { spec, path, method, specUrl } = node.attrs
670
+ return (
671
+ <NodeViewWrapper className="gb-openapi" contentEditable={false}>
672
+ {editor.isEditable && (
673
+ <div className="gb-openapi-fields">
674
+ <input
675
+ className="gb-inline-input"
676
+ value={spec}
677
+ placeholder="spec name"
678
+ onChange={(e) => updateAttributes({ spec: e.target.value })}
679
+ />
680
+ <input
681
+ className="gb-inline-input"
682
+ value={method}
683
+ placeholder="get"
684
+ onChange={(e) => updateAttributes({ method: e.target.value })}
685
+ />
686
+ <input
687
+ className="gb-inline-input gb-openapi-path"
688
+ value={path}
689
+ placeholder="/path"
690
+ onChange={(e) => updateAttributes({ path: e.target.value })}
691
+ />
692
+ <input
693
+ className="gb-inline-input gb-openapi-url"
694
+ value={specUrl}
695
+ placeholder="https://…/openapi.yaml"
696
+ onChange={(e) => updateAttributes({ specUrl: e.target.value })}
697
+ />
698
+ </div>
699
+ )}
700
+ <OpenApiOperation specUrl={specUrl} path={path} method={method || "get"} />
701
+ </NodeViewWrapper>
702
+ )
703
+ }
704
+
705
+ export const GbOpenapi = Node.create({
706
+ name: "gbOpenapi",
707
+ group: "block",
708
+ atom: true,
709
+ addAttributes() {
710
+ return {
711
+ spec: { default: "" },
712
+ path: { default: "" },
713
+ method: { default: "get" },
714
+ specUrl: { default: "" },
715
+ label: { default: "" },
716
+ }
717
+ },
718
+ parseHTML() {
719
+ return [{ tag: "div[data-gb-openapi]" }]
720
+ },
721
+ renderHTML({ HTMLAttributes, node }) {
722
+ return ["div", mergeAttributes(HTMLAttributes, { "data-gb-openapi": node.attrs.path })]
723
+ },
724
+ addNodeView() {
725
+ return ReactNodeViewRenderer(OpenapiView)
726
+ },
727
+ })
728
+
729
+ export const gitbookNodes = [
730
+ GbHint,
731
+ GbTabs,
732
+ GbTab,
733
+ GbExpandable,
734
+ GbStepper,
735
+ GbStep,
736
+ GbEmbed,
737
+ GbContentRef,
738
+ GbColumns,
739
+ GbColumn,
740
+ GbFigure,
741
+ GbMath,
742
+ GbUpdates,
743
+ GbUpdate,
744
+ GbOpenapi,
745
+ GbInlineImage,
746
+ ]