@brett_lamy/docstream-editor 1.0.1 → 1.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.
- package/README.md +24 -1
- package/package.json +2 -2
- package/src/editor/Editor.tsx +6 -1
- package/src/editor/convert.ts +44 -0
- package/src/editor/nodes.tsx +83 -1
- package/src/editor/runtime.tsx +2 -0
- package/src/editor/slash-menu.tsx +7 -0
- package/src/styles.css +37 -0
package/README.md
CHANGED
|
@@ -80,6 +80,7 @@ export interface GitbookEditorProps {
|
|
|
80
80
|
onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
|
|
81
81
|
onAttachmentRemove?: (ids: string[]) => void
|
|
82
82
|
onAttachmentOpen?: (attachment: EditorAttachment) => void
|
|
83
|
+
demoResolver?: DemoResolver
|
|
83
84
|
}
|
|
84
85
|
```
|
|
85
86
|
|
|
@@ -89,6 +90,8 @@ export interface GitbookEditorProps {
|
|
|
89
90
|
return `true` when the application handled the event.
|
|
90
91
|
- `imagePaste`, `attachments`, `onAttachmentAdd`, `onAttachmentRemove`, `onAttachmentOpen`:
|
|
91
92
|
how pasted/dropped images are handled — see [Pasted and dropped images](#pasted-and-dropped-images).
|
|
93
|
+
- `demoResolver`: resolves `{% demo src %}` ids so demo blocks show a live, read-only
|
|
94
|
+
preview — see [Demo blocks](#demo-blocks).
|
|
92
95
|
|
|
93
96
|
The editor tracks the last markdown it emitted so normal controlled updates do not continuously reset the TipTap document. Passing a different external `markdown` value replaces the editor content.
|
|
94
97
|
|
|
@@ -104,12 +107,13 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
|
|
|
104
107
|
- Code blocks
|
|
105
108
|
- Tables
|
|
106
109
|
- Hints
|
|
107
|
-
- Tabs
|
|
110
|
+
- Tabs (including `{% tabs sync="key" %}` sync groups)
|
|
108
111
|
- Expandables
|
|
109
112
|
- Steppers
|
|
110
113
|
- Embeds
|
|
111
114
|
- Content references
|
|
112
115
|
- Component and Storybook source references
|
|
116
|
+
- File-backed live demos (`{% demo %}`)
|
|
113
117
|
- Columns
|
|
114
118
|
- Figures and images
|
|
115
119
|
- OpenAPI operation placeholders
|
|
@@ -201,6 +205,25 @@ provenance, and refreshes the live component/story preview. Configure the mount
|
|
|
201
205
|
with `docstreamSources()` from `@brett_lamy/docstream/vite` as shown in the
|
|
202
206
|
Docstream README.
|
|
203
207
|
|
|
208
|
+
## Demo blocks
|
|
209
|
+
|
|
210
|
+
`{% demo src="<page>/<example>" %}` becomes a `gbDemo` atom node. Every tag
|
|
211
|
+
attribute — `src`, `title`, `description`, `height`, `layout`, `variants`,
|
|
212
|
+
`viewport` — is kept on the node, so the tag serializes back exactly as written.
|
|
213
|
+
The node shows a compact header with editable `src` and `title` inputs (insert
|
|
214
|
+
one with `/demo`). Pass the same `DemoResolver` you give the Docstream renderer
|
|
215
|
+
to also show the live `DemoViewer` preview (read-only) under the header;
|
|
216
|
+
without one, only the header is shown.
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
import { createGlobDemoResolver } from "@brett_lamy/docstream/demo"
|
|
220
|
+
import { GitbookEditor } from "@brett_lamy/docstream-editor"
|
|
221
|
+
|
|
222
|
+
const demoResolver = createGlobDemoResolver({ /* see the Docstream README */ })
|
|
223
|
+
|
|
224
|
+
<GitbookEditor markdown={markdown} onChange={setMarkdown} demoResolver={demoResolver} />
|
|
225
|
+
```
|
|
226
|
+
|
|
204
227
|
## Slash Menu
|
|
205
228
|
|
|
206
229
|
The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream-editor",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "TipTap editor for Docstream GitBook-style markdown documents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"./styles.css": "./src/styles.css"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@brett_lamy/docstream": "1.
|
|
36
|
+
"@brett_lamy/docstream": "1.1.1",
|
|
37
37
|
"gpu-lexer": "0.0.2",
|
|
38
38
|
"lucide-react": "^1.17.0"
|
|
39
39
|
},
|
package/src/editor/Editor.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "lucide-react"
|
|
12
12
|
|
|
13
13
|
import { parseMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
|
|
14
|
+
import type { DemoResolver } from "@brett_lamy/docstream/demo"
|
|
14
15
|
import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
|
|
15
16
|
import {
|
|
16
17
|
attachmentName,
|
|
@@ -86,6 +87,8 @@ export interface GitbookEditorProps {
|
|
|
86
87
|
onSourceSaved?: (snapshot: SourceFileSnapshot) => void
|
|
87
88
|
/** Called when reading, writing, or previewing a file-backed source fails. */
|
|
88
89
|
onSourceError?: (error: Error) => void
|
|
90
|
+
/** Resolves `{% demo src %}` ids; when set, demo blocks show a live (read-only) preview under their header. */
|
|
91
|
+
demoResolver?: DemoResolver
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
function ToolbarButton({
|
|
@@ -173,6 +176,7 @@ export function GitbookEditor({
|
|
|
173
176
|
sourceAutoSave = true,
|
|
174
177
|
onSourceSaved,
|
|
175
178
|
onSourceError,
|
|
179
|
+
demoResolver,
|
|
176
180
|
}: GitbookEditorProps) {
|
|
177
181
|
// Tracks the markdown the editor itself produced, so external updates
|
|
178
182
|
// (file switches) reset content but our own onChange echoes don't.
|
|
@@ -312,9 +316,10 @@ export function GitbookEditor({
|
|
|
312
316
|
sourceAutoSave,
|
|
313
317
|
...(onSourceSaved ? { onSourceSaved } : {}),
|
|
314
318
|
...(onSourceError ? { onSourceError } : {}),
|
|
319
|
+
...(demoResolver ? { demoResolver } : {}),
|
|
315
320
|
...(attachments ? { attachments } : {}),
|
|
316
321
|
...(onAttachmentOpen ? { onAttachmentOpen } : {}),
|
|
317
|
-
}), [attachments, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
|
|
322
|
+
}), [attachments, demoResolver, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
|
|
318
323
|
|
|
319
324
|
if (!editor) return null
|
|
320
325
|
|
package/src/editor/convert.ts
CHANGED
|
@@ -2,6 +2,9 @@ import {
|
|
|
2
2
|
plainText,
|
|
3
3
|
serializeMarkdown,
|
|
4
4
|
type Block,
|
|
5
|
+
type DemoLayout,
|
|
6
|
+
type DemoNode,
|
|
7
|
+
type DemoViewport,
|
|
5
8
|
type DocumentNode,
|
|
6
9
|
type Inline,
|
|
7
10
|
type ListItemNode,
|
|
@@ -101,6 +104,7 @@ function blockToPM(b: Block): PMNode {
|
|
|
101
104
|
case "tabs":
|
|
102
105
|
return {
|
|
103
106
|
type: "gbTabs",
|
|
107
|
+
...(b.sync ? { attrs: { sync: b.sync } } : {}),
|
|
104
108
|
content: b.tabs.map((t) => ({
|
|
105
109
|
type: "gbTab",
|
|
106
110
|
attrs: { title: t.title },
|
|
@@ -145,6 +149,19 @@ function blockToPM(b: Block): PMNode {
|
|
|
145
149
|
title: b.title ?? "",
|
|
146
150
|
},
|
|
147
151
|
}
|
|
152
|
+
case "demo":
|
|
153
|
+
return {
|
|
154
|
+
type: "gbDemo",
|
|
155
|
+
attrs: {
|
|
156
|
+
src: b.src,
|
|
157
|
+
title: b.title ?? "",
|
|
158
|
+
description: b.description ?? "",
|
|
159
|
+
height: b.height ?? "",
|
|
160
|
+
layout: b.layout ?? null,
|
|
161
|
+
variants: b.variants?.length ? b.variants.map((v) => ({ id: v.id, label: v.label })) : null,
|
|
162
|
+
viewport: b.viewport ?? null,
|
|
163
|
+
},
|
|
164
|
+
}
|
|
148
165
|
case "columns":
|
|
149
166
|
return {
|
|
150
167
|
type: "gbColumns",
|
|
@@ -310,6 +327,7 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
310
327
|
title: String(t.attrs?.title ?? "Tab"),
|
|
311
328
|
children: pmToBlocks(t.content),
|
|
312
329
|
})),
|
|
330
|
+
...(n.attrs?.sync ? { sync: String(n.attrs.sync) } : {}),
|
|
313
331
|
}
|
|
314
332
|
case "gbExpandable":
|
|
315
333
|
return {
|
|
@@ -354,6 +372,8 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
354
372
|
kind: n.attrs?.kind === "story" ? "story" : "component",
|
|
355
373
|
...(n.attrs?.title ? { title: String(n.attrs.title) } : {}),
|
|
356
374
|
}
|
|
375
|
+
case "gbDemo":
|
|
376
|
+
return pmToDemo(n.attrs ?? {})
|
|
357
377
|
case "gbColumns":
|
|
358
378
|
return {
|
|
359
379
|
type: "columns",
|
|
@@ -423,6 +443,30 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
423
443
|
}
|
|
424
444
|
}
|
|
425
445
|
|
|
446
|
+
const DEMO_LAYOUTS: readonly string[] = ["auto", "single", "multi"] satisfies DemoLayout[]
|
|
447
|
+
const DEMO_VIEWPORTS: readonly string[] = ["desktop", "tablet", "phone"] satisfies DemoViewport[]
|
|
448
|
+
|
|
449
|
+
/** `gbDemo` attrs → `DemoNode`; also used by the node view to build the preview. */
|
|
450
|
+
export function pmToDemo(a: Record<string, unknown>): DemoNode {
|
|
451
|
+
const variants = Array.isArray(a.variants)
|
|
452
|
+
? (a.variants as Array<{ id?: unknown; label?: unknown }>)
|
|
453
|
+
.filter((v) => v && v.id)
|
|
454
|
+
.map((v) => ({ id: String(v.id), label: String(v.label || v.id) }))
|
|
455
|
+
: []
|
|
456
|
+
const layout = String(a.layout ?? "")
|
|
457
|
+
const viewport = String(a.viewport ?? "")
|
|
458
|
+
return {
|
|
459
|
+
type: "demo",
|
|
460
|
+
src: String(a.src ?? ""),
|
|
461
|
+
...(a.title ? { title: String(a.title) } : {}),
|
|
462
|
+
...(a.description ? { description: String(a.description) } : {}),
|
|
463
|
+
...(a.height ? { height: String(a.height) } : {}),
|
|
464
|
+
...(DEMO_LAYOUTS.includes(layout) ? { layout: layout as DemoLayout } : {}),
|
|
465
|
+
...(variants.length ? { variants } : {}),
|
|
466
|
+
...(DEMO_VIEWPORTS.includes(viewport) ? { viewport: viewport as DemoViewport } : {}),
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
426
470
|
function pmToBlocks(nodes: PMNode[] | undefined): Block[] {
|
|
427
471
|
return (nodes ?? []).map(pmToBlock).filter((b): b is Block => b !== null)
|
|
428
472
|
}
|
package/src/editor/nodes.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "@tiptap/react"
|
|
11
11
|
import {
|
|
12
12
|
AlertTriangle,
|
|
13
|
+
AppWindow,
|
|
13
14
|
AtSign,
|
|
14
15
|
CheckCircle2,
|
|
15
16
|
ChevronDown,
|
|
@@ -30,6 +31,8 @@ import type { DocumentNode, HintStyle, SourceRefNode } from "@brett_lamy/docstre
|
|
|
30
31
|
import { ReactCodePreview } from "@brett_lamy/docstream/playground"
|
|
31
32
|
import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
|
|
32
33
|
import { VideoEmbed } from "@brett_lamy/docstream/video"
|
|
34
|
+
import { DemoViewer } from "@brett_lamy/docstream/demo"
|
|
35
|
+
import { pmToDemo } from "./convert"
|
|
33
36
|
import { SourceFileEditor } from "./SourceFileEditor"
|
|
34
37
|
import { useEditorRuntime } from "./runtime"
|
|
35
38
|
import { GbAttachment } from "./attachments"
|
|
@@ -176,7 +179,15 @@ export const GbTabs = Node.create({
|
|
|
176
179
|
defining: true,
|
|
177
180
|
isolating: true,
|
|
178
181
|
addAttributes() {
|
|
179
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
active: { default: 0, rendered: false },
|
|
184
|
+
// `{% tabs sync="key" %}` — tab sets sharing a key follow the reader's last choice.
|
|
185
|
+
sync: {
|
|
186
|
+
default: null,
|
|
187
|
+
parseHTML: (element) => element.getAttribute("data-sync") || null,
|
|
188
|
+
renderHTML: (attributes) => (attributes.sync ? { "data-sync": attributes.sync } : {}),
|
|
189
|
+
},
|
|
190
|
+
}
|
|
180
191
|
},
|
|
181
192
|
parseHTML() {
|
|
182
193
|
return [{ tag: "div[data-gb-tabs]" }]
|
|
@@ -515,6 +526,76 @@ export const GbSourceRef = Node.create({
|
|
|
515
526
|
},
|
|
516
527
|
})
|
|
517
528
|
|
|
529
|
+
// ---------- Demo ----------
|
|
530
|
+
|
|
531
|
+
function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
532
|
+
const editable = editor.isEditable
|
|
533
|
+
const { demoResolver } = useEditorRuntime()
|
|
534
|
+
const demo = useMemo(() => pmToDemo(node.attrs), [node.attrs])
|
|
535
|
+
// Typing into `src` would otherwise resolve every intermediate id.
|
|
536
|
+
const src = useDebouncedValue(demo.src)
|
|
537
|
+
return (
|
|
538
|
+
<NodeViewWrapper className={demoResolver && src ? "gb-demo-block gb-demo-block-preview" : "gb-demo-block"} contentEditable={false}>
|
|
539
|
+
<div className="gb-demo">
|
|
540
|
+
<AppWindow className="size-4 shrink-0" />
|
|
541
|
+
{editable ? (
|
|
542
|
+
<>
|
|
543
|
+
<input className="gb-inline-input gb-demo-src" value={node.attrs.src} placeholder="page/example" onChange={(event) => updateAttributes({ src: event.target.value })} />
|
|
544
|
+
<input className="gb-inline-input gb-demo-title" value={node.attrs.title} placeholder="Title" onChange={(event) => updateAttributes({ title: event.target.value })} />
|
|
545
|
+
</>
|
|
546
|
+
) : (
|
|
547
|
+
<>
|
|
548
|
+
<code className="gb-demo-src">{demo.src || "demo"}</code>
|
|
549
|
+
{demo.title ? <span className="gb-demo-title">{demo.title}</span> : null}
|
|
550
|
+
</>
|
|
551
|
+
)}
|
|
552
|
+
</div>
|
|
553
|
+
{demoResolver && src ? (
|
|
554
|
+
<div className="gb-demo-preview">
|
|
555
|
+
<DemoViewer
|
|
556
|
+
key={src}
|
|
557
|
+
resolver={demoResolver}
|
|
558
|
+
src={src}
|
|
559
|
+
title={demo.title}
|
|
560
|
+
description={demo.description}
|
|
561
|
+
height={demo.height}
|
|
562
|
+
layout={demo.layout}
|
|
563
|
+
variants={demo.variants}
|
|
564
|
+
viewport={demo.viewport}
|
|
565
|
+
/>
|
|
566
|
+
</div>
|
|
567
|
+
) : null}
|
|
568
|
+
</NodeViewWrapper>
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** `{% demo %}` — every tag attribute is kept on the node so it round-trips losslessly. */
|
|
573
|
+
export const GbDemo = Node.create({
|
|
574
|
+
name: "gbDemo",
|
|
575
|
+
group: "block",
|
|
576
|
+
atom: true,
|
|
577
|
+
addAttributes() {
|
|
578
|
+
return {
|
|
579
|
+
src: { default: "" },
|
|
580
|
+
title: { default: "" },
|
|
581
|
+
description: { default: "" },
|
|
582
|
+
height: { default: "" },
|
|
583
|
+
layout: { default: null },
|
|
584
|
+
variants: { default: null, rendered: false },
|
|
585
|
+
viewport: { default: null },
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
parseHTML() {
|
|
589
|
+
return [{ tag: "div[data-gb-demo]" }]
|
|
590
|
+
},
|
|
591
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
592
|
+
return ["div", mergeAttributes(HTMLAttributes, { "data-gb-demo": node.attrs.src })]
|
|
593
|
+
},
|
|
594
|
+
addNodeView() {
|
|
595
|
+
return ReactNodeViewRenderer(DemoView)
|
|
596
|
+
},
|
|
597
|
+
})
|
|
598
|
+
|
|
518
599
|
// ---------- Columns ----------
|
|
519
600
|
|
|
520
601
|
export const GbColumns = Node.create({
|
|
@@ -975,6 +1056,7 @@ export const gitbookNodes = [
|
|
|
975
1056
|
GbEmbed,
|
|
976
1057
|
GbContentRef,
|
|
977
1058
|
GbSourceRef,
|
|
1059
|
+
GbDemo,
|
|
978
1060
|
GbColumns,
|
|
979
1061
|
GbColumn,
|
|
980
1062
|
GbFigure,
|
package/src/editor/runtime.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from "react"
|
|
2
2
|
|
|
3
|
+
import type { DemoResolver } from "@brett_lamy/docstream/demo"
|
|
3
4
|
import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
|
|
4
5
|
import type { EditorAttachment } from "./attachments"
|
|
5
6
|
|
|
@@ -9,6 +10,7 @@ export interface EditorRuntimeOptions {
|
|
|
9
10
|
sourceAutoSave?: boolean | number
|
|
10
11
|
onSourceSaved?: (snapshot: SourceFileSnapshot) => void
|
|
11
12
|
onSourceError?: (error: Error) => void
|
|
13
|
+
demoResolver?: DemoResolver
|
|
12
14
|
attachments?: EditorAttachment[]
|
|
13
15
|
onAttachmentOpen?: (attachment: EditorAttachment) => void
|
|
14
16
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Extension, type Editor, type Range } from "@tiptap/core"
|
|
2
2
|
import Suggestion from "@tiptap/suggestion"
|
|
3
3
|
import {
|
|
4
|
+
AppWindow,
|
|
4
5
|
Columns2,
|
|
5
6
|
FileCode2,
|
|
6
7
|
Heading1,
|
|
@@ -101,6 +102,12 @@ export const SLASH_ITEMS: SlashItem[] = [
|
|
|
101
102
|
icon: Link2,
|
|
102
103
|
run: insertBlock({ type: "gbContentRef", attrs: { url: "", label: "Page link" } }),
|
|
103
104
|
},
|
|
105
|
+
{
|
|
106
|
+
title: "Demo",
|
|
107
|
+
keywords: "example live preview component playground",
|
|
108
|
+
icon: AppWindow,
|
|
109
|
+
run: insertBlock({ type: "gbDemo", attrs: { src: "", title: "" } }),
|
|
110
|
+
},
|
|
104
111
|
{
|
|
105
112
|
title: "Columns",
|
|
106
113
|
keywords: "two column layout",
|
package/src/styles.css
CHANGED
|
@@ -88,6 +88,43 @@
|
|
|
88
88
|
width: 8rem;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
.gb-demo {
|
|
92
|
+
display: flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: 0.5rem;
|
|
95
|
+
border: 1px solid var(--gb-border);
|
|
96
|
+
border-radius: var(--gb-radius);
|
|
97
|
+
padding: 0.65rem 0.8rem;
|
|
98
|
+
background: var(--gb-muted);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.gb-demo-block {
|
|
102
|
+
margin: 0.85em 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.gb-demo-block-preview > .gb-demo {
|
|
106
|
+
border-radius: var(--gb-radius) var(--gb-radius) 0 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.gb-demo-src {
|
|
110
|
+
flex: 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.gb-demo-title {
|
|
114
|
+
width: 12rem;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.gb-demo-preview {
|
|
118
|
+
border: 1px solid var(--gb-border);
|
|
119
|
+
border-top: 0;
|
|
120
|
+
border-radius: 0 0 var(--gb-radius) var(--gb-radius);
|
|
121
|
+
overflow: hidden;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.gb-demo-preview > * {
|
|
125
|
+
margin: 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
91
128
|
.gb-source-file-editor {
|
|
92
129
|
border: 1px solid var(--gb-border);
|
|
93
130
|
border-radius: var(--gb-radius);
|