@brett_lamy/docstream-editor 0.1.0 → 0.3.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 +220 -0
- package/package.json +9 -7
- package/src/editor/Editor.tsx +65 -37
- package/src/editor/ReactDemoEditor.tsx +108 -0
- package/src/editor/convert.ts +10 -2
- package/src/editor/extensions.ts +74 -0
- package/src/editor/nodes.tsx +31 -3
- package/src/editor/slash-menu.tsx +59 -24
- package/src/index.ts +10 -0
- package/src/styles.css +658 -1
package/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# @brett_lamy/docstream-editor
|
|
2
|
+
|
|
3
|
+
TipTap editor components for Docstream GitBook-style markdown documents.
|
|
4
|
+
|
|
5
|
+
`@brett_lamy/docstream-editor` provides the editable layer for the same document model rendered by `@brett_lamy/docstream`. It is intended for docs apps that need rich editing while preserving GitBook-flavored markdown blocks for preview, storage, and AI stream rendering.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Controlled React editor component for GitBook-style markdown.
|
|
10
|
+
- TipTap extensions for common writing flows.
|
|
11
|
+
- Conversion helpers between the Docstream AST and TipTap JSON.
|
|
12
|
+
- Slash menu for inserting supported blocks.
|
|
13
|
+
- Code block support through `lowlight`.
|
|
14
|
+
- Live React/JSX/TSX code blocks backed by almost-node.
|
|
15
|
+
- `ReactDemoEditor` for editing and running a complete multi-file project.
|
|
16
|
+
- Table, task list, heading, quote, and inline formatting support.
|
|
17
|
+
- Preserves Docstream/GitBook block semantics when converting back to markdown.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install @brett_lamy/docstream-editor @brett_lamy/docstream react
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
React is a peer dependency. `@brett_lamy/docstream` is a runtime dependency because the editor uses the same parser, serializer, OpenAPI rendering, and asset helpers.
|
|
26
|
+
|
|
27
|
+
## Basic Setup
|
|
28
|
+
|
|
29
|
+
Import both package styles near your app entrypoint:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import "@brett_lamy/docstream/styles.css"
|
|
33
|
+
import "@brett_lamy/docstream-editor/styles.css"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If your TypeScript app checks CSS side-effect imports, include Vite's standard environment declaration or an equivalent CSS module declaration:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
/// <reference types="vite/client" />
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
`GitbookEditor` is a controlled component. Pass the current markdown and receive updated markdown through `onChange`.
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { useState } from "react"
|
|
48
|
+
import { GitbookEditor } from "@brett_lamy/docstream-editor"
|
|
49
|
+
import "@brett_lamy/docstream/styles.css"
|
|
50
|
+
import "@brett_lamy/docstream-editor/styles.css"
|
|
51
|
+
|
|
52
|
+
const initialMarkdown = `# Getting started
|
|
53
|
+
|
|
54
|
+
{% hint style="info" %}
|
|
55
|
+
Type / to insert supported blocks.
|
|
56
|
+
{% endhint %}
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
export function EditorPage() {
|
|
60
|
+
const [markdown, setMarkdown] = useState(initialMarkdown)
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<GitbookEditor
|
|
64
|
+
markdown={markdown}
|
|
65
|
+
onChange={setMarkdown}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Props
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
export interface GitbookEditorProps {
|
|
75
|
+
markdown: string
|
|
76
|
+
onChange: (markdown: string) => void
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- `markdown`: Source markdown to load into the editor.
|
|
81
|
+
- `onChange`: Called with serialized markdown whenever TipTap content changes.
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
## Supported Editing Surface
|
|
86
|
+
|
|
87
|
+
The editor supports common ProseMirror/TipTap content plus GitBook-flavored blocks from Docstream.
|
|
88
|
+
|
|
89
|
+
- Headings
|
|
90
|
+
- Paragraphs
|
|
91
|
+
- Bold, italic, strike, inline code, and links
|
|
92
|
+
- Bullet, ordered, and task lists
|
|
93
|
+
- Blockquotes
|
|
94
|
+
- Code blocks
|
|
95
|
+
- Tables
|
|
96
|
+
- Hints
|
|
97
|
+
- Tabs
|
|
98
|
+
- Expandables
|
|
99
|
+
- Steppers
|
|
100
|
+
- Embeds
|
|
101
|
+
- Content references
|
|
102
|
+
- Columns
|
|
103
|
+
- Figures and images
|
|
104
|
+
- OpenAPI operation placeholders
|
|
105
|
+
- Video and public Replay QA previews inside embed blocks
|
|
106
|
+
|
|
107
|
+
Some blocks are intentionally represented as structured nodes rather than fully bespoke editing controls. They are preserved through parse, edit, and serialize flows so the document can continue to round-trip as GitBook-style markdown.
|
|
108
|
+
|
|
109
|
+
### Video and Replay QA embeds
|
|
110
|
+
|
|
111
|
+
Insert an embed block from the slash menu, then enter a direct video URL or a
|
|
112
|
+
public Loop QA project URL. The editor renders video assets through Docstream's
|
|
113
|
+
`VideoEmbed` and converts Loop QA project paths to their chrome-free public
|
|
114
|
+
preview route. The serialized markdown remains portable:
|
|
115
|
+
|
|
116
|
+
```md
|
|
117
|
+
{% embed url="https://loop-qa.replay.io/projects/project-id/tasks/task-id" /%}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Live React code
|
|
121
|
+
|
|
122
|
+
Code blocks can opt into a live preview by setting `live="true"`. The block
|
|
123
|
+
must be a complete entry module (including its `createRoot` call); ordinary
|
|
124
|
+
code snippets remain read-only:
|
|
125
|
+
|
|
126
|
+
````md
|
|
127
|
+
{% code language="jsx" live="true" entry="/src/main.jsx" %}
|
|
128
|
+
```jsx
|
|
129
|
+
import { createRoot } from "react-dom/client"
|
|
130
|
+
createRoot(document.getElementById("root")).render(<h1>Hello</h1>)
|
|
131
|
+
```
|
|
132
|
+
{% endcode %}
|
|
133
|
+
````
|
|
134
|
+
|
|
135
|
+
For multi-file demos, use the standalone editor component:
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
import { ReactDemoEditor } from "@brett_lamy/docstream-editor"
|
|
139
|
+
|
|
140
|
+
<ReactDemoEditor
|
|
141
|
+
files={{
|
|
142
|
+
"/src/main.jsx": mainSource,
|
|
143
|
+
"/src/App.jsx": appSource,
|
|
144
|
+
}}
|
|
145
|
+
entry="/src/main.jsx"
|
|
146
|
+
/>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The host app must install `@agent-wasm/core` and expose its service worker with
|
|
150
|
+
`almostnodePlugin()` from `@agent-wasm/core/vite`.
|
|
151
|
+
|
|
152
|
+
## Slash Menu
|
|
153
|
+
|
|
154
|
+
The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
|
|
155
|
+
|
|
156
|
+
## Conversion Helpers
|
|
157
|
+
|
|
158
|
+
Use the conversion helpers when you need to inspect or transform editor state directly.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { astToTiptap, tiptapToAst } from "@brett_lamy/docstream-editor"
|
|
162
|
+
import { parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream"
|
|
163
|
+
|
|
164
|
+
const ast = parseMarkdown(markdown)
|
|
165
|
+
const tiptapJson = astToTiptap(ast)
|
|
166
|
+
const markdownAgain = serializeMarkdown(tiptapToAst(tiptapJson))
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Exports:
|
|
170
|
+
|
|
171
|
+
- `GitbookEditor`
|
|
172
|
+
- `GitbookEditorProps`
|
|
173
|
+
- `astToTiptap`
|
|
174
|
+
- `tiptapToAst`
|
|
175
|
+
- `PMNode`
|
|
176
|
+
|
|
177
|
+
## Styling and Theming
|
|
178
|
+
|
|
179
|
+
The editor CSS is designed to sit beside `@brett_lamy/docstream/styles.css` and inherit the same application theme tokens. In a shadcn-style app, define your theme variables globally and import both CSS entrypoints once.
|
|
180
|
+
|
|
181
|
+
```css
|
|
182
|
+
:root {
|
|
183
|
+
--background: 0 0% 100%;
|
|
184
|
+
--foreground: 222.2 84% 4.9%;
|
|
185
|
+
--border: 214.3 31.8% 91.4%;
|
|
186
|
+
--muted: 210 40% 96.1%;
|
|
187
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
188
|
+
--primary: 221.2 83.2% 53.3%;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The editor exposes classes such as `gb`, `gb-toolbar`, `gb-tool`, `gb-tool-active`, and `gb-content` for host app overrides.
|
|
193
|
+
|
|
194
|
+
## Bundler Notes
|
|
195
|
+
|
|
196
|
+
This release ships TypeScript and TSX source through ESM exports:
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
{
|
|
200
|
+
"exports": {
|
|
201
|
+
".": {
|
|
202
|
+
"types": "./src/index.ts",
|
|
203
|
+
"import": "./src/index.ts"
|
|
204
|
+
},
|
|
205
|
+
"./styles.css": "./src/styles.css"
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
It is validated with Vite and modern TypeScript `moduleResolution: "Bundler"`. Plain Node.js, CommonJS, or tooling that does not transpile TypeScript in dependencies may need a future precompiled JS build.
|
|
211
|
+
|
|
212
|
+
## Renderer Pairing
|
|
213
|
+
|
|
214
|
+
For read-only previews or AI stream output, pair this package with `@brett_lamy/docstream`:
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
import { GitbookStreamdown } from "@brett_lamy/docstream"
|
|
218
|
+
|
|
219
|
+
<GitbookStreamdown markdown={markdown} isStreaming={false} />
|
|
220
|
+
```
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "TipTap editor for Docstream GitBook-style markdown documents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
|
8
8
|
"files": [
|
|
9
|
-
"src"
|
|
9
|
+
"src",
|
|
10
|
+
"README.md"
|
|
10
11
|
],
|
|
11
12
|
"sideEffects": [
|
|
12
13
|
"**/*.css"
|
|
@@ -19,19 +20,20 @@
|
|
|
19
20
|
"./styles.css": "./src/styles.css"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
23
|
+
"@brett_lamy/docstream": "0.3.0",
|
|
24
|
+
"lowlight": "^3.3.0",
|
|
25
|
+
"lucide-react": "^1.17.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
22
28
|
"@tiptap/core": "^3.26.1",
|
|
23
29
|
"@tiptap/extension-code-block-lowlight": "^3.26.1",
|
|
24
30
|
"@tiptap/extension-list": "^3.26.1",
|
|
25
31
|
"@tiptap/extension-placeholder": "^3.26.1",
|
|
26
32
|
"@tiptap/extension-table": "^3.26.1",
|
|
33
|
+
"@tiptap/pm": "^3.26.1",
|
|
27
34
|
"@tiptap/react": "^3.26.1",
|
|
28
35
|
"@tiptap/starter-kit": "^3.26.1",
|
|
29
36
|
"@tiptap/suggestion": "^3.26.1",
|
|
30
|
-
"@brett_lamy/docstream": "0.1.0",
|
|
31
|
-
"lowlight": "^3.3.0",
|
|
32
|
-
"lucide-react": "^1.17.0"
|
|
33
|
-
},
|
|
34
|
-
"peerDependencies": {
|
|
35
37
|
"react": ">=18"
|
|
36
38
|
}
|
|
37
39
|
}
|
package/src/editor/Editor.tsx
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react"
|
|
2
2
|
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"
|
|
3
|
-
import
|
|
4
|
-
import Placeholder from "@tiptap/extension-placeholder"
|
|
5
|
-
import { TaskItem, TaskList } from "@tiptap/extension-list"
|
|
6
|
-
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table"
|
|
3
|
+
import type { AnyExtension } from "@tiptap/core"
|
|
7
4
|
import {
|
|
8
5
|
Bold,
|
|
9
6
|
Code as CodeIcon,
|
|
@@ -13,23 +10,39 @@ import {
|
|
|
13
10
|
Strikethrough,
|
|
14
11
|
} from "lucide-react"
|
|
15
12
|
|
|
16
|
-
import { parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream"
|
|
13
|
+
import { parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream/gitbook"
|
|
17
14
|
import { astToTiptap, tiptapToAst, type PMNode } from "./convert"
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
15
|
+
import { createGitbookExtensions } from "./extensions"
|
|
16
|
+
import type { SlashItem } from "./slash-menu"
|
|
20
17
|
|
|
21
18
|
export interface GitbookEditorProps {
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
/** Markdown source. When provided, the editor stays in sync with it (controlled). */
|
|
20
|
+
markdown?: string
|
|
21
|
+
/** Called with serialized GitBook markdown on every edit. */
|
|
22
|
+
onChange?: (markdown: string) => void
|
|
23
|
+
/** Show the built-in formatting toolbar (default true). */
|
|
24
|
+
toolbar?: boolean
|
|
25
|
+
/** Built-in "/" slash menu: true (default), false, or a custom item list. */
|
|
26
|
+
slashMenu?: boolean | { items?: SlashItem[] }
|
|
27
|
+
/** Whether the document is editable (default true). */
|
|
28
|
+
editable?: boolean
|
|
29
|
+
/** Placeholder shown in an empty document. */
|
|
30
|
+
placeholder?: string
|
|
31
|
+
/** Class applied to the editor wrapper. */
|
|
32
|
+
className?: string
|
|
33
|
+
/** Focus the editor on mount. */
|
|
34
|
+
autofocus?: boolean
|
|
35
|
+
/**
|
|
36
|
+
* Disable StarterKit's undo/redo — required when supplying a Yjs Collaboration
|
|
37
|
+
* extension via `extensions`, which manages shared history itself.
|
|
38
|
+
*/
|
|
39
|
+
disableHistory?: boolean
|
|
40
|
+
/** Extra TipTap extensions appended last (e.g. Collaboration, CollaborationCursor). */
|
|
41
|
+
extensions?: AnyExtension[]
|
|
42
|
+
/** Receive the underlying TipTap editor instance (and null on teardown). */
|
|
43
|
+
onEditorReady?: (editor: TiptapEditor | null) => void
|
|
24
44
|
}
|
|
25
45
|
|
|
26
|
-
// Carries GitBook's data-view (e.g. "cards") through the editor untouched.
|
|
27
|
-
const GbTable = Table.extend({
|
|
28
|
-
addAttributes() {
|
|
29
|
-
return { ...this.parent?.(), view: { default: null } }
|
|
30
|
-
},
|
|
31
|
-
})
|
|
32
|
-
|
|
33
46
|
function ToolbarButton({
|
|
34
47
|
onClick,
|
|
35
48
|
active,
|
|
@@ -90,45 +103,60 @@ function Toolbar({ editor }: { editor: TiptapEditor }) {
|
|
|
90
103
|
)
|
|
91
104
|
}
|
|
92
105
|
|
|
93
|
-
export function GitbookEditor({
|
|
106
|
+
export function GitbookEditor({
|
|
107
|
+
markdown,
|
|
108
|
+
onChange,
|
|
109
|
+
toolbar = true,
|
|
110
|
+
slashMenu = true,
|
|
111
|
+
editable = true,
|
|
112
|
+
placeholder,
|
|
113
|
+
className,
|
|
114
|
+
autofocus = false,
|
|
115
|
+
disableHistory,
|
|
116
|
+
extensions,
|
|
117
|
+
onEditorReady,
|
|
118
|
+
}: GitbookEditorProps) {
|
|
94
119
|
// Tracks the markdown the editor itself produced, so external updates
|
|
95
120
|
// (file switches) reset content but our own onChange echoes don't.
|
|
96
121
|
const lastEmitted = useRef<string | null>(null)
|
|
122
|
+
const controlled = markdown !== undefined
|
|
97
123
|
|
|
98
124
|
const editor = useEditor({
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
Placeholder.configure({ placeholder: "Write, or type / to insert a block…" }),
|
|
109
|
-
SlashMenu,
|
|
110
|
-
...gitbookNodes,
|
|
111
|
-
],
|
|
112
|
-
content: astToTiptap(parseMarkdown(markdown)),
|
|
125
|
+
editable,
|
|
126
|
+
autofocus,
|
|
127
|
+
extensions: createGitbookExtensions({
|
|
128
|
+
...(placeholder !== undefined ? { placeholder } : {}),
|
|
129
|
+
slashMenu,
|
|
130
|
+
...(disableHistory !== undefined ? { disableHistory } : {}),
|
|
131
|
+
...(extensions ? { extensions } : {}),
|
|
132
|
+
}),
|
|
133
|
+
...(controlled ? { content: astToTiptap(parseMarkdown(markdown as string)) } : {}),
|
|
113
134
|
onUpdate({ editor }) {
|
|
135
|
+
if (!onChange) return
|
|
114
136
|
const md = serializeMarkdown(tiptapToAst(editor.getJSON() as PMNode))
|
|
115
137
|
lastEmitted.current = md
|
|
116
138
|
onChange(md)
|
|
117
139
|
},
|
|
118
140
|
})
|
|
119
141
|
|
|
142
|
+
// Surface the editor instance to the host (for awareness, commands, etc.).
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
onEditorReady?.(editor ?? null)
|
|
145
|
+
return () => onEditorReady?.(null)
|
|
146
|
+
}, [editor, onEditorReady])
|
|
147
|
+
|
|
120
148
|
useEffect(() => {
|
|
121
|
-
if (!editor) return
|
|
149
|
+
if (!editor || !controlled) return
|
|
122
150
|
if (markdown === lastEmitted.current) return
|
|
123
|
-
lastEmitted.current = markdown
|
|
124
|
-
editor.commands.setContent(astToTiptap(parseMarkdown(markdown)), { emitUpdate: false })
|
|
125
|
-
}, [editor, markdown])
|
|
151
|
+
lastEmitted.current = markdown as string
|
|
152
|
+
editor.commands.setContent(astToTiptap(parseMarkdown(markdown as string)), { emitUpdate: false })
|
|
153
|
+
}, [editor, controlled, markdown])
|
|
126
154
|
|
|
127
155
|
if (!editor) return null
|
|
128
156
|
|
|
129
157
|
return (
|
|
130
|
-
<div className="gb">
|
|
131
|
-
<Toolbar editor={editor} />
|
|
158
|
+
<div className={className ? `gb ${className}` : "gb"}>
|
|
159
|
+
{toolbar && <Toolbar editor={editor} />}
|
|
132
160
|
<EditorContent editor={editor} className="gb-content" />
|
|
133
161
|
</div>
|
|
134
162
|
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { FileCode2, Play } from "lucide-react"
|
|
3
|
+
|
|
4
|
+
import { ReactCodePreview } from "@brett_lamy/docstream"
|
|
5
|
+
|
|
6
|
+
export interface ReactDemoEditorProps {
|
|
7
|
+
/** Source files keyed by their project-relative or absolute paths. */
|
|
8
|
+
files: Readonly<Record<string, string>>
|
|
9
|
+
/** Vite entry file. Defaults to `/src/main.jsx`. */
|
|
10
|
+
entry?: string
|
|
11
|
+
/** Preview height after the user runs the demo. */
|
|
12
|
+
height?: number | string
|
|
13
|
+
className?: string
|
|
14
|
+
/** Called whenever a file is edited. */
|
|
15
|
+
onChange?: (files: Record<string, string>) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function nextFile(files: Readonly<Record<string, string>>): string {
|
|
19
|
+
return Object.keys(files).sort()[0] ?? ""
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A small multi-file source editor paired with the Docstream React preview. */
|
|
23
|
+
export function ReactDemoEditor({
|
|
24
|
+
files,
|
|
25
|
+
entry = "/src/main.jsx",
|
|
26
|
+
height = 320,
|
|
27
|
+
className,
|
|
28
|
+
onChange,
|
|
29
|
+
}: ReactDemoEditorProps) {
|
|
30
|
+
const [draftFiles, setDraftFiles] = useState<Record<string, string>>(() => ({ ...files }))
|
|
31
|
+
const [selectedFile, setSelectedFile] = useState(() => nextFile(files))
|
|
32
|
+
const [previewFiles, setPreviewFiles] = useState<Record<string, string> | null>(null)
|
|
33
|
+
const [previewRevision, setPreviewRevision] = useState(0)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setDraftFiles({ ...files })
|
|
37
|
+
setSelectedFile((current) => (current && files[current] !== undefined ? current : nextFile(files)))
|
|
38
|
+
}, [files])
|
|
39
|
+
|
|
40
|
+
const updateFile = (content: string) => {
|
|
41
|
+
if (!selectedFile) return
|
|
42
|
+
const next = { ...draftFiles, [selectedFile]: content }
|
|
43
|
+
setDraftFiles(next)
|
|
44
|
+
onChange?.(next)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const wrapperClass = className ? `gb-react-demo-editor ${className}` : "gb-react-demo-editor"
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section className={wrapperClass} data-docstream-react-demo-editor="">
|
|
51
|
+
<div className="gb-react-demo-editor-source">
|
|
52
|
+
<aside className="gb-react-demo-files" aria-label="Demo files">
|
|
53
|
+
<div className="gb-react-demo-files-header">
|
|
54
|
+
<span>Files</span>
|
|
55
|
+
<FileCode2 className="size-4" />
|
|
56
|
+
</div>
|
|
57
|
+
{Object.keys(draftFiles)
|
|
58
|
+
.sort()
|
|
59
|
+
.map((path) => (
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
key={path}
|
|
63
|
+
className={path === selectedFile ? "gb-react-demo-file gb-react-demo-file-active" : "gb-react-demo-file"}
|
|
64
|
+
onClick={() => setSelectedFile(path)}
|
|
65
|
+
>
|
|
66
|
+
{path.replace(/^\//, "")}
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</aside>
|
|
70
|
+
<div className="gb-react-demo-editor-pane">
|
|
71
|
+
<div className="gb-react-demo-editor-toolbar">
|
|
72
|
+
<code>{selectedFile || "No file selected"}</code>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
className="gb-react-demo-preview-button"
|
|
76
|
+
disabled={!selectedFile}
|
|
77
|
+
onClick={() => {
|
|
78
|
+
setPreviewFiles({ ...draftFiles })
|
|
79
|
+
setPreviewRevision((value) => value + 1)
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<Play className="size-3.5" /> Run demo
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
<textarea
|
|
86
|
+
className="gb-react-demo-textarea"
|
|
87
|
+
value={selectedFile ? draftFiles[selectedFile] ?? "" : ""}
|
|
88
|
+
onChange={(event) => updateFile(event.target.value)}
|
|
89
|
+
spellCheck={false}
|
|
90
|
+
disabled={!selectedFile}
|
|
91
|
+
aria-label={selectedFile ? `Edit ${selectedFile}` : "No file selected"}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
{previewFiles ? (
|
|
96
|
+
<ReactCodePreview
|
|
97
|
+
key={previewRevision}
|
|
98
|
+
files={previewFiles}
|
|
99
|
+
entry={entry}
|
|
100
|
+
height={height}
|
|
101
|
+
title="React demo preview"
|
|
102
|
+
/>
|
|
103
|
+
) : (
|
|
104
|
+
<div className="gb-react-demo-editor-empty">Edit a file, then run the demo to preview the complete project.</div>
|
|
105
|
+
)}
|
|
106
|
+
</section>
|
|
107
|
+
)
|
|
108
|
+
}
|
package/src/editor/convert.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { plainText, type Block, type DocumentNode, type Inline, type ListItemNode } from "@brett_lamy/docstream"
|
|
1
|
+
import { plainText, type Block, type DocumentNode, type Inline, type ListItemNode } from "@brett_lamy/docstream/gitbook"
|
|
2
2
|
|
|
3
3
|
// TipTap/ProseMirror JSON shape (loosely typed on purpose)
|
|
4
4
|
export interface PMNode {
|
|
@@ -63,7 +63,13 @@ function blockToPM(b: Block): PMNode {
|
|
|
63
63
|
case "code":
|
|
64
64
|
return {
|
|
65
65
|
type: "codeBlock",
|
|
66
|
-
attrs: {
|
|
66
|
+
attrs: {
|
|
67
|
+
language: b.language,
|
|
68
|
+
title: b.title,
|
|
69
|
+
lineNumbers: b.lineNumbers,
|
|
70
|
+
live: !!b.live,
|
|
71
|
+
entry: b.entry ?? null,
|
|
72
|
+
},
|
|
67
73
|
content: b.code ? [{ type: "text", text: b.code }] : [],
|
|
68
74
|
}
|
|
69
75
|
case "hint":
|
|
@@ -215,6 +221,8 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
215
221
|
language: (n.attrs?.language as string) || null,
|
|
216
222
|
title: (n.attrs?.title as string) || null,
|
|
217
223
|
lineNumbers: !!n.attrs?.lineNumbers,
|
|
224
|
+
live: !!n.attrs?.live,
|
|
225
|
+
entry: (n.attrs?.entry as string) || null,
|
|
218
226
|
code: n.content?.map((c) => c.text ?? "").join("") ?? "",
|
|
219
227
|
}
|
|
220
228
|
case "gbHint":
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import StarterKit from "@tiptap/starter-kit"
|
|
2
|
+
import Placeholder from "@tiptap/extension-placeholder"
|
|
3
|
+
import { TaskItem, TaskList } from "@tiptap/extension-list"
|
|
4
|
+
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table"
|
|
5
|
+
import { getSchema, type AnyExtension } from "@tiptap/core"
|
|
6
|
+
import type { Schema } from "@tiptap/pm/model"
|
|
7
|
+
import { GbCodeBlock, gitbookNodes } from "./nodes"
|
|
8
|
+
import { SlashMenu, createSlashMenu, type SlashItem } from "./slash-menu"
|
|
9
|
+
|
|
10
|
+
// Carries GitBook's data-view (e.g. "cards") through the editor untouched.
|
|
11
|
+
export const GbTable = Table.extend({
|
|
12
|
+
addAttributes() {
|
|
13
|
+
return { ...this.parent?.(), view: { default: null } }
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export interface GitbookExtensionOptions {
|
|
18
|
+
placeholder?: string
|
|
19
|
+
/** Built-in "/" slash menu: true (default), false, or a custom item list. */
|
|
20
|
+
slashMenu?: boolean | { items?: SlashItem[] }
|
|
21
|
+
/**
|
|
22
|
+
* Disable StarterKit's built-in undo/redo. Required when wiring Yjs
|
|
23
|
+
* Collaboration, which provides its own shared history.
|
|
24
|
+
*/
|
|
25
|
+
disableHistory?: boolean
|
|
26
|
+
/** Extra TipTap extensions appended last (e.g. Collaboration, CollaborationCursor). */
|
|
27
|
+
extensions?: AnyExtension[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The full GitBook editing extension set, composable by host apps. Use this to
|
|
32
|
+
* build a custom `useEditor(...)` (e.g. with Yjs collaboration) instead of the
|
|
33
|
+
* batteries-included `GitbookEditor` component.
|
|
34
|
+
*/
|
|
35
|
+
export function createGitbookExtensions(options: GitbookExtensionOptions = {}): AnyExtension[] {
|
|
36
|
+
const {
|
|
37
|
+
placeholder = "Write, or type / to insert a block…",
|
|
38
|
+
slashMenu = true,
|
|
39
|
+
disableHistory = false,
|
|
40
|
+
extensions = [],
|
|
41
|
+
} = options
|
|
42
|
+
|
|
43
|
+
const list: AnyExtension[] = [
|
|
44
|
+
StarterKit.configure({
|
|
45
|
+
codeBlock: false,
|
|
46
|
+
...(disableHistory ? { undoRedo: false } : {}),
|
|
47
|
+
}),
|
|
48
|
+
GbCodeBlock,
|
|
49
|
+
TaskList,
|
|
50
|
+
TaskItem.configure({ nested: true }),
|
|
51
|
+
GbTable.configure({ resizable: false }),
|
|
52
|
+
TableRow,
|
|
53
|
+
TableHeader,
|
|
54
|
+
TableCell,
|
|
55
|
+
Placeholder.configure({ placeholder }),
|
|
56
|
+
...gitbookNodes,
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
if (slashMenu) {
|
|
60
|
+
list.push(slashMenu === true ? SlashMenu : createSlashMenu(slashMenu.items))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
list.push(...extensions)
|
|
64
|
+
return list
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The ProseMirror schema for GitBook documents — identical to what GitbookEditor
|
|
69
|
+
* uses. Hosts wiring Yjs collaboration (y-prosemirror) need this exact schema to
|
|
70
|
+
* convert between a Y.XmlFragment and ProseMirror/markdown safely.
|
|
71
|
+
*/
|
|
72
|
+
export function getGitbookSchema(): Schema {
|
|
73
|
+
return getSchema(createGitbookExtensions({ slashMenu: false }))
|
|
74
|
+
}
|
package/src/editor/nodes.tsx
CHANGED
|
@@ -18,7 +18,10 @@ import {
|
|
|
18
18
|
XCircle,
|
|
19
19
|
} from "lucide-react"
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import { resolveAsset } from "@brett_lamy/docstream/assets"
|
|
22
|
+
import { OpenApiOperation } from "@brett_lamy/docstream/openapi"
|
|
23
|
+
import type { HintStyle } from "@brett_lamy/docstream/gitbook"
|
|
24
|
+
import { ReplayPreview, VideoEmbed, isReplayQaUrl } from "@brett_lamy/docstream"
|
|
22
25
|
|
|
23
26
|
// ---------- Hint ----------
|
|
24
27
|
|
|
@@ -299,13 +302,19 @@ function embedPreview(url: string): string | null {
|
|
|
299
302
|
return null
|
|
300
303
|
}
|
|
301
304
|
|
|
305
|
+
function isDirectVideo(url: string): boolean {
|
|
306
|
+
return /\.(?:mp4|webm|ogg)(?:[?#]|$)/i.test(url)
|
|
307
|
+
}
|
|
308
|
+
|
|
302
309
|
function EmbedView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
303
310
|
const url = node.attrs.url as string
|
|
304
311
|
const preview = embedPreview(url)
|
|
305
312
|
return (
|
|
306
313
|
<NodeViewWrapper className="gb-embed" contentEditable={false}>
|
|
307
|
-
{
|
|
308
|
-
<
|
|
314
|
+
{isReplayQaUrl(url) ? (
|
|
315
|
+
<ReplayPreview source={url} title="Replay preview" />
|
|
316
|
+
) : preview || isDirectVideo(url) ? (
|
|
317
|
+
<VideoEmbed src={preview ?? url} className="gb-embed-frame" title={url} />
|
|
309
318
|
) : (
|
|
310
319
|
<a className="gb-embed-link" href={url} target="_blank" rel="noreferrer">
|
|
311
320
|
<Link2 className="size-4" /> {url || "Embed URL…"}
|
|
@@ -534,11 +543,28 @@ function CodeView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
|
534
543
|
/>
|
|
535
544
|
#
|
|
536
545
|
</label>
|
|
546
|
+
<label className="gb-code-live">
|
|
547
|
+
<input
|
|
548
|
+
type="checkbox"
|
|
549
|
+
checked={!!node.attrs.live}
|
|
550
|
+
onChange={(e) => updateAttributes({ live: e.target.checked })}
|
|
551
|
+
/>
|
|
552
|
+
Live
|
|
553
|
+
</label>
|
|
554
|
+
{node.attrs.live && (
|
|
555
|
+
<input
|
|
556
|
+
className="gb-inline-input gb-code-entry"
|
|
557
|
+
value={node.attrs.entry ?? ""}
|
|
558
|
+
placeholder="entry (e.g. src/main.jsx)"
|
|
559
|
+
onChange={(e) => updateAttributes({ entry: e.target.value || null })}
|
|
560
|
+
/>
|
|
561
|
+
)}
|
|
537
562
|
</>
|
|
538
563
|
) : (
|
|
539
564
|
<>
|
|
540
565
|
{node.attrs.title && <span className="gb-code-title">{node.attrs.title}</span>}
|
|
541
566
|
{node.attrs.language && <span className="gb-code-lang">{node.attrs.language}</span>}
|
|
567
|
+
{node.attrs.live && <span className="gb-code-live-badge">Live</span>}
|
|
542
568
|
</>
|
|
543
569
|
)}
|
|
544
570
|
</div>
|
|
@@ -555,6 +581,8 @@ export const GbCodeBlock = CodeBlockLowlight.extend({
|
|
|
555
581
|
...this.parent?.(),
|
|
556
582
|
title: { default: null },
|
|
557
583
|
lineNumbers: { default: false },
|
|
584
|
+
live: { default: false },
|
|
585
|
+
entry: { default: null },
|
|
558
586
|
}
|
|
559
587
|
},
|
|
560
588
|
addNodeView() {
|