@brett_lamy/docstream-editor 1.1.0 → 1.2.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 +44 -7
- package/package.json +3 -2
- package/src/editor/Editor.tsx +55 -10
- package/src/editor/convert.ts +103 -11
- package/src/editor/extensions.ts +25 -1
- package/src/editor/nodes.tsx +324 -13
- package/src/editor/runtime.tsx +3 -1
- package/src/editor/slash-menu.tsx +7 -0
- package/src/index.ts +3 -3
- package/src/styles.css +161 -0
package/README.md
CHANGED
|
@@ -13,6 +13,9 @@ TipTap editor components for Docstream GitBook-style markdown documents.
|
|
|
13
13
|
- Code block syntax highlighting through [`gpu-lexer`](https://gpu-lexer.vercel.app/) (language-agnostic, WebGPU; plain text where WebGPU is unavailable).
|
|
14
14
|
- Table, task list, heading, quote, and inline formatting support.
|
|
15
15
|
- Preserves Docstream/GitBook block semantics when converting back to markdown.
|
|
16
|
+
- Copy a docs page (docstream's "Copy page") and paste it in: the editor shows the same page and
|
|
17
|
+
serializes it back byte-for-byte — demos with their inline files, install command boxes, titled
|
|
18
|
+
tab sets and all.
|
|
16
19
|
- Preserves mounted source-file/export provenance and can write edits to the real file through Vite.
|
|
17
20
|
|
|
18
21
|
## Installation
|
|
@@ -81,6 +84,9 @@ export interface GitbookEditorProps {
|
|
|
81
84
|
onAttachmentRemove?: (ids: string[]) => void
|
|
82
85
|
onAttachmentOpen?: (attachment: EditorAttachment) => void
|
|
83
86
|
demoResolver?: DemoResolver
|
|
87
|
+
demoRuntime?: InlineDemoRuntime
|
|
88
|
+
demoDependencies?: Record<string, string>
|
|
89
|
+
markdownPaste?: boolean
|
|
84
90
|
}
|
|
85
91
|
```
|
|
86
92
|
|
|
@@ -92,6 +98,12 @@ export interface GitbookEditorProps {
|
|
|
92
98
|
how pasted/dropped images are handled — see [Pasted and dropped images](#pasted-and-dropped-images).
|
|
93
99
|
- `demoResolver`: resolves `{% demo src %}` ids so demo blocks show a live, read-only
|
|
94
100
|
preview — see [Demo blocks](#demo-blocks).
|
|
101
|
+
- `demoRuntime` / `demoDependencies`: run demos that carry their files inline and have no
|
|
102
|
+
resolver behind them — the same options as Docstream's `DocRenderOptions`.
|
|
103
|
+
- `markdownPaste` (default `true`): plain-text Markdown pasted into the editor that contains
|
|
104
|
+
blocks (a "Copy page" export, `{% … %}` tags, fences, lists, headings …) is parsed into
|
|
105
|
+
editor blocks instead of pasted as literal text. Plain prose pastes as usual; HTML clipboards
|
|
106
|
+
keep TipTap's own handling (except VS Code's, which is treated as plain text).
|
|
95
107
|
|
|
96
108
|
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.
|
|
97
109
|
|
|
@@ -101,19 +113,23 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
|
|
|
101
113
|
|
|
102
114
|
- Headings
|
|
103
115
|
- Paragraphs
|
|
104
|
-
- Bold, italic, strike, inline code, and links
|
|
116
|
+
- Bold, italic, strike, inline code, and links (a link inside emphasis, `**[x](url)**`, stays
|
|
117
|
+
distinct from emphasis inside a link, `[**x**](url)`)
|
|
105
118
|
- Bullet, ordered, and task lists
|
|
106
119
|
- Blockquotes
|
|
107
120
|
- Code blocks
|
|
108
121
|
- Tables
|
|
109
122
|
- Hints
|
|
110
|
-
- Tabs (including `{% tabs sync="key" %}` sync groups
|
|
123
|
+
- Tabs (including `{% tabs sync="key" %}` sync groups, and titled sets
|
|
124
|
+
`{% tabs title="Installation" level="3" %}` rendered as a section heading with the switch on
|
|
125
|
+
its row — click the heading icon in the tab strip to add a title)
|
|
126
|
+
- Package-manager command boxes (`{% command %}npm install x{% endcommand %}`, `/install command`)
|
|
111
127
|
- Expandables
|
|
112
128
|
- Steppers
|
|
113
129
|
- Embeds
|
|
114
130
|
- Content references
|
|
115
131
|
- Component and Storybook source references
|
|
116
|
-
-
|
|
132
|
+
- Live demos (`{% demo %}`), resolver-backed or carrying their files inline
|
|
117
133
|
- Columns
|
|
118
134
|
- Figures and images
|
|
119
135
|
- OpenAPI operation placeholders
|
|
@@ -209,11 +225,22 @@ Docstream README.
|
|
|
209
225
|
|
|
210
226
|
`{% demo src="<page>/<example>" %}` becomes a `gbDemo` atom node. Every tag
|
|
211
227
|
attribute — `src`, `title`, `description`, `height`, `layout`, `variants`,
|
|
212
|
-
`viewport` — is kept on the node, so the
|
|
228
|
+
`viewport`, `entry` — is kept on the node, and so are the files the block form
|
|
229
|
+
carries inline (the titled fences between `{% demo … %}` and `{% enddemo %}`, as
|
|
230
|
+
`files: { path, content, language? }[]`), so the block serializes back exactly as
|
|
231
|
+
written. Docstream's "Copy page" writes every demo this way.
|
|
232
|
+
|
|
213
233
|
The node shows a compact header with editable `src` and `title` inputs (insert
|
|
214
|
-
one with `/demo`)
|
|
215
|
-
|
|
216
|
-
|
|
234
|
+
one with `/demo`) and a **Files** toggle that opens a small file editor: a tab
|
|
235
|
+
per file (rename it in place, mark it the entry, remove it), a code area, and
|
|
236
|
+
`+` to add a file. Removing every file gives the one-line tag form again.
|
|
237
|
+
|
|
238
|
+
Under the header, Docstream's read-only `DemoViewer` renders the demo: from the
|
|
239
|
+
`demoResolver` when it knows `src`, otherwise from the inline files — so a
|
|
240
|
+
pasted docs page looks like the docs page. Inline files run in the Preview
|
|
241
|
+
through `demoRuntime` (e.g. `createAlmostNodeDemoRuntime()` from
|
|
242
|
+
`@brett_lamy/docstream/playground`) with `demoDependencies`; without a runtime
|
|
243
|
+
the viewer shows their code.
|
|
217
244
|
|
|
218
245
|
```tsx
|
|
219
246
|
import { createGlobDemoResolver } from "@brett_lamy/docstream/demo"
|
|
@@ -224,6 +251,15 @@ const demoResolver = createGlobDemoResolver({ /* see the Docstream README */ })
|
|
|
224
251
|
<GitbookEditor markdown={markdown} onChange={setMarkdown} demoResolver={demoResolver} />
|
|
225
252
|
```
|
|
226
253
|
|
|
254
|
+
## Command boxes
|
|
255
|
+
|
|
256
|
+
`{% command %}npm install @brett_lamy/ui{% endcommand %}` (or the block form, for
|
|
257
|
+
several lines) becomes a `gbCommand` atom node rendered with Docstream's own
|
|
258
|
+
command box: terminal glyph, pnpm / npm / yarn / bun switch (synced page-wide),
|
|
259
|
+
copy button. When editable, the npm command and per-manager overrides
|
|
260
|
+
(`pnpm="…"`, `yarn="…"`, `bun="…"`; the placeholder shows the derived command)
|
|
261
|
+
are edited under the box. `sync` is preserved. Insert one with `/install command`.
|
|
262
|
+
|
|
227
263
|
## Slash Menu
|
|
228
264
|
|
|
229
265
|
The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
|
|
@@ -250,6 +286,7 @@ Exports:
|
|
|
250
286
|
- `PMNode`
|
|
251
287
|
- `EditorAttachment`
|
|
252
288
|
- `serializeEditorMarkdown`
|
|
289
|
+
- `markdownPasteContent` (the Markdown-paste parser behind `markdownPaste`)
|
|
253
290
|
- `SourceFileEditor`
|
|
254
291
|
- `SourceFileEditorProps`
|
|
255
292
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream-editor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "TipTap editor for Docstream GitBook-style markdown documents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"src",
|
|
14
14
|
"!src/**/*.test.ts",
|
|
15
|
+
"!src/**/__fixtures__",
|
|
15
16
|
"README.md"
|
|
16
17
|
],
|
|
17
18
|
"sideEffects": [
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"./styles.css": "./src/styles.css"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@brett_lamy/docstream": "1.
|
|
37
|
+
"@brett_lamy/docstream": "1.2.1",
|
|
37
38
|
"gpu-lexer": "0.0.2",
|
|
38
39
|
"lucide-react": "^1.17.0"
|
|
39
40
|
},
|
package/src/editor/Editor.tsx
CHANGED
|
@@ -11,7 +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
|
+
import type { DemoResolver, InlineDemoRuntime } from "@brett_lamy/docstream/demo"
|
|
15
15
|
import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
|
|
16
16
|
import {
|
|
17
17
|
attachmentName,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
readFileAsDataURL,
|
|
22
22
|
type EditorAttachment,
|
|
23
23
|
} from "./attachments"
|
|
24
|
-
import { astToTiptap, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
|
|
24
|
+
import { astToTiptap, markdownPasteContent, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
|
|
25
25
|
import { createGitbookExtensions, type ReferenceSources } from "./extensions"
|
|
26
26
|
import { EditorRuntimeProvider } from "./runtime"
|
|
27
27
|
import type { SlashItem } from "./slash-menu"
|
|
@@ -87,8 +87,25 @@ export interface GitbookEditorProps {
|
|
|
87
87
|
onSourceSaved?: (snapshot: SourceFileSnapshot) => void
|
|
88
88
|
/** Called when reading, writing, or previewing a file-backed source fails. */
|
|
89
89
|
onSourceError?: (error: Error) => void
|
|
90
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* Resolves `{% demo src %}` ids. Demo blocks show docstream's (read-only) DemoViewer under
|
|
92
|
+
* their header: from the resolver when it knows `src`, otherwise from the files the block
|
|
93
|
+
* carries inline (a pasted Copy-page export), so the block looks like the docs page.
|
|
94
|
+
*/
|
|
91
95
|
demoResolver?: DemoResolver
|
|
96
|
+
/**
|
|
97
|
+
* Runs the Preview of demos that carry their files inline and have no resolver behind them
|
|
98
|
+
* (as `DocRenderOptions.demoRuntime`), e.g. `createAlmostNodeDemoRuntime()` from
|
|
99
|
+
* `@brett_lamy/docstream/playground`. Without one, such demos show their code with a note.
|
|
100
|
+
*/
|
|
101
|
+
demoRuntime?: InlineDemoRuntime
|
|
102
|
+
/** Extra npm dependencies inline demos may import (as `DocRenderOptions.demoDependencies`). */
|
|
103
|
+
demoDependencies?: Record<string, string>
|
|
104
|
+
/**
|
|
105
|
+
* Parse pasted plain-text Markdown that contains blocks (a docs page's "Copy page", a
|
|
106
|
+
* `{% … %}` block, a fence, a list …) into editor blocks instead of literal text (default true).
|
|
107
|
+
*/
|
|
108
|
+
markdownPaste?: boolean
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
function ToolbarButton({
|
|
@@ -177,6 +194,9 @@ export function GitbookEditor({
|
|
|
177
194
|
onSourceSaved,
|
|
178
195
|
onSourceError,
|
|
179
196
|
demoResolver,
|
|
197
|
+
demoRuntime,
|
|
198
|
+
demoDependencies,
|
|
199
|
+
markdownPaste = true,
|
|
180
200
|
}: GitbookEditorProps) {
|
|
181
201
|
// Tracks the markdown the editor itself produced, so external updates
|
|
182
202
|
// (file switches) reset content but our own onChange echoes don't.
|
|
@@ -188,8 +208,8 @@ export function GitbookEditor({
|
|
|
188
208
|
// Ids of the attachment chips in the document, to report removals.
|
|
189
209
|
const attachmentIds = useRef<Set<string>>(new Set())
|
|
190
210
|
// Latest callbacks for handlers the editor captured at creation.
|
|
191
|
-
const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove })
|
|
192
|
-
latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove }
|
|
211
|
+
const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove, markdownPaste })
|
|
212
|
+
latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove, markdownPaste }
|
|
193
213
|
const editorRef = useRef<TiptapEditor | null>(null)
|
|
194
214
|
|
|
195
215
|
// Pasted/dropped image files: image blocks ("inline") or attachment chips ("chip").
|
|
@@ -236,6 +256,27 @@ export function GitbookEditor({
|
|
|
236
256
|
})
|
|
237
257
|
}
|
|
238
258
|
|
|
259
|
+
// Plain-text Markdown (e.g. a docs page's "Copy page") becomes blocks, not literal text.
|
|
260
|
+
// Rich HTML clipboards keep ProseMirror's own handling; code editors (VS Code) also
|
|
261
|
+
// put styled HTML there, so their payload still counts as plain text.
|
|
262
|
+
const pasteMarkdown = (event: ClipboardEvent): boolean => {
|
|
263
|
+
const data = event.clipboardData
|
|
264
|
+
const ed = editorRef.current
|
|
265
|
+
if (!data || !ed) return false
|
|
266
|
+
const types = Array.from(data.types ?? [])
|
|
267
|
+
if (types.includes("text/html") && !types.includes("vscode-editor-data")) return false
|
|
268
|
+
const pasted = markdownPasteContent(data.getData("text/plain"))
|
|
269
|
+
if (!pasted?.content.length) return false
|
|
270
|
+
event.preventDefault()
|
|
271
|
+
if (pasted.citations) {
|
|
272
|
+
const known = new Map((citationsRef.current ?? []).map((c) => [c.id, c]))
|
|
273
|
+
for (const c of pasted.citations) known.set(c.id, c)
|
|
274
|
+
citationsRef.current = [...known.values()]
|
|
275
|
+
}
|
|
276
|
+
ed.chain().focus().insertContent(pasted.content).run()
|
|
277
|
+
return true
|
|
278
|
+
}
|
|
279
|
+
|
|
239
280
|
const parseAndTrack = (md: string) => {
|
|
240
281
|
const doc = parseMarkdown(md)
|
|
241
282
|
citationsRef.current = doc.citations
|
|
@@ -258,10 +299,12 @@ export function GitbookEditor({
|
|
|
258
299
|
if (latest.current.onPaste?.(event)) return true
|
|
259
300
|
if (!view.editable || view.state.selection.$from.parent.type.spec.code) return false
|
|
260
301
|
const files = imageFilesFrom(event.clipboardData, { ignoreWithText: true })
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
302
|
+
if (files.length) {
|
|
303
|
+
event.preventDefault()
|
|
304
|
+
insertImageFiles(files)
|
|
305
|
+
return true
|
|
306
|
+
}
|
|
307
|
+
return latest.current.markdownPaste ? pasteMarkdown(event) : false
|
|
265
308
|
},
|
|
266
309
|
handleDrop: (view, event, _slice, moved) => {
|
|
267
310
|
if (moved || !view.editable) return false
|
|
@@ -317,9 +360,11 @@ export function GitbookEditor({
|
|
|
317
360
|
...(onSourceSaved ? { onSourceSaved } : {}),
|
|
318
361
|
...(onSourceError ? { onSourceError } : {}),
|
|
319
362
|
...(demoResolver ? { demoResolver } : {}),
|
|
363
|
+
...(demoRuntime ? { demoRuntime } : {}),
|
|
364
|
+
...(demoDependencies ? { demoDependencies } : {}),
|
|
320
365
|
...(attachments ? { attachments } : {}),
|
|
321
366
|
...(onAttachmentOpen ? { onAttachmentOpen } : {}),
|
|
322
|
-
}), [attachments, demoResolver, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
|
|
367
|
+
}), [attachments, demoDependencies, demoResolver, demoRuntime, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
|
|
323
368
|
|
|
324
369
|
if (!editor) return null
|
|
325
370
|
|
package/src/editor/convert.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
+
parseMarkdown,
|
|
2
3
|
plainText,
|
|
3
4
|
serializeMarkdown,
|
|
4
5
|
type Block,
|
|
6
|
+
type CitationDef,
|
|
5
7
|
type DemoLayout,
|
|
8
|
+
type CommandNode,
|
|
9
|
+
type DemoInlineFile,
|
|
6
10
|
type DemoNode,
|
|
7
11
|
type DemoViewport,
|
|
8
12
|
type DocumentNode,
|
|
@@ -59,7 +63,8 @@ function inlineToPM(nodes: Inline[]): PMNode[] {
|
|
|
59
63
|
if (n.italic) marks.push({ type: "italic" })
|
|
60
64
|
if (n.strike) marks.push({ type: "strike" })
|
|
61
65
|
if (n.code) marks.push({ type: "code" })
|
|
62
|
-
|
|
66
|
+
// `inner` keeps `**[x](url)**` (link inside the emphasis) apart from `[**x**](url)`.
|
|
67
|
+
if (n.link) marks.push({ type: "link", attrs: { href: n.link, ...(n.linkInner ? { inner: true } : {}) } })
|
|
63
68
|
return { type: "text", text: n.text, ...(marks.length ? { marks } : {}) }
|
|
64
69
|
})
|
|
65
70
|
}
|
|
@@ -96,21 +101,38 @@ function blockToPM(b: Block): PMNode {
|
|
|
96
101
|
lineNumbers: b.lineNumbers,
|
|
97
102
|
live: !!b.live,
|
|
98
103
|
entry: b.entry ?? null,
|
|
104
|
+
collapsedCodeLines: b.collapsedCodeLines ?? null,
|
|
105
|
+
expandedCodeLines: b.expandedCodeLines ?? null,
|
|
99
106
|
},
|
|
100
107
|
content: b.code ? [{ type: "text", text: b.code }] : [],
|
|
101
108
|
}
|
|
102
109
|
case "hint":
|
|
103
110
|
return { type: "gbHint", attrs: { style: b.style }, content: blocksPM(b.children) }
|
|
104
|
-
case "tabs":
|
|
111
|
+
case "tabs": {
|
|
112
|
+
const attrs = {
|
|
113
|
+
...(b.sync ? { sync: b.sync } : {}),
|
|
114
|
+
...(b.title ? { title: b.title } : {}),
|
|
115
|
+
...(b.level ? { level: b.level } : {}),
|
|
116
|
+
}
|
|
105
117
|
return {
|
|
106
118
|
type: "gbTabs",
|
|
107
|
-
...(
|
|
119
|
+
...(Object.keys(attrs).length ? { attrs } : {}),
|
|
108
120
|
content: b.tabs.map((t) => ({
|
|
109
121
|
type: "gbTab",
|
|
110
122
|
attrs: { title: t.title },
|
|
111
123
|
content: blocksPM(t.children),
|
|
112
124
|
})),
|
|
113
125
|
}
|
|
126
|
+
}
|
|
127
|
+
case "command":
|
|
128
|
+
return {
|
|
129
|
+
type: "gbCommand",
|
|
130
|
+
attrs: {
|
|
131
|
+
command: b.command,
|
|
132
|
+
overrides: b.overrides && Object.keys(b.overrides).length ? { ...b.overrides } : null,
|
|
133
|
+
sync: b.sync ?? null,
|
|
134
|
+
},
|
|
135
|
+
}
|
|
114
136
|
case "expandable":
|
|
115
137
|
return {
|
|
116
138
|
type: "gbExpandable",
|
|
@@ -136,8 +158,13 @@ function blockToPM(b: Block): PMNode {
|
|
|
136
158
|
if (b.controls !== undefined) attrs.controls = b.controls
|
|
137
159
|
return { type: "gbEmbed", attrs }
|
|
138
160
|
}
|
|
139
|
-
case "content-ref":
|
|
140
|
-
|
|
161
|
+
case "content-ref": {
|
|
162
|
+
// GitBook writes the label as a link to the page (`[Card](card.md)`); remember
|
|
163
|
+
// whether this one was, so a plain label stays plain.
|
|
164
|
+
const only = b.children.length === 1 ? b.children[0] : undefined
|
|
165
|
+
const linked = only?.type === "text" && only.link === b.url && !only.bold && !only.italic && !only.strike && !only.code
|
|
166
|
+
return { type: "gbContentRef", attrs: { url: b.url, label: plainText(b.children), linked } }
|
|
167
|
+
}
|
|
141
168
|
case "source-ref":
|
|
142
169
|
return {
|
|
143
170
|
type: "gbSourceRef",
|
|
@@ -160,6 +187,8 @@ function blockToPM(b: Block): PMNode {
|
|
|
160
187
|
layout: b.layout ?? null,
|
|
161
188
|
variants: b.variants?.length ? b.variants.map((v) => ({ id: v.id, label: v.label })) : null,
|
|
162
189
|
viewport: b.viewport ?? null,
|
|
190
|
+
entry: b.entry ?? null,
|
|
191
|
+
files: b.files?.length ? b.files.map((f) => ({ ...f })) : null,
|
|
163
192
|
},
|
|
164
193
|
}
|
|
165
194
|
case "columns":
|
|
@@ -279,8 +308,12 @@ function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
|
|
|
279
308
|
if (mark.type === "italic") inline.italic = true
|
|
280
309
|
if (mark.type === "strike") inline.strike = true
|
|
281
310
|
if (mark.type === "code") inline.code = true
|
|
282
|
-
if (mark.type === "link")
|
|
311
|
+
if (mark.type === "link") {
|
|
312
|
+
inline.link = String(mark.attrs?.href ?? "")
|
|
313
|
+
if (mark.attrs?.inner) inline.linkInner = true
|
|
314
|
+
}
|
|
283
315
|
}
|
|
316
|
+
if (inline.linkInner && !(inline.bold || inline.italic || inline.strike)) delete inline.linkInner
|
|
284
317
|
return inline
|
|
285
318
|
})
|
|
286
319
|
}
|
|
@@ -303,7 +336,9 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
303
336
|
level: (Number(n.attrs?.level) || 1) as 1 | 2 | 3 | 4 | 5 | 6,
|
|
304
337
|
children: pmTextToInline(n.content),
|
|
305
338
|
}
|
|
306
|
-
case "codeBlock":
|
|
339
|
+
case "codeBlock": {
|
|
340
|
+
const collapsed = Number(n.attrs?.collapsedCodeLines) || 0
|
|
341
|
+
const expanded = Number(n.attrs?.expandedCodeLines) || 0
|
|
307
342
|
return {
|
|
308
343
|
type: "code",
|
|
309
344
|
language: (n.attrs?.language as string) || null,
|
|
@@ -311,15 +346,19 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
311
346
|
lineNumbers: !!n.attrs?.lineNumbers,
|
|
312
347
|
live: !!n.attrs?.live,
|
|
313
348
|
entry: (n.attrs?.entry as string) || null,
|
|
349
|
+
...(collapsed ? { collapsedCodeLines: collapsed } : {}),
|
|
350
|
+
...(expanded ? { expandedCodeLines: expanded } : {}),
|
|
314
351
|
code: n.content?.map((c) => c.text ?? "").join("") ?? "",
|
|
315
352
|
}
|
|
353
|
+
}
|
|
316
354
|
case "gbHint":
|
|
317
355
|
return {
|
|
318
356
|
type: "hint",
|
|
319
357
|
style: (n.attrs?.style as never) ?? "info",
|
|
320
358
|
children: pmToBlocks(n.content),
|
|
321
359
|
}
|
|
322
|
-
case "gbTabs":
|
|
360
|
+
case "gbTabs": {
|
|
361
|
+
const level = Number(n.attrs?.level)
|
|
323
362
|
return {
|
|
324
363
|
type: "tabs",
|
|
325
364
|
tabs: (n.content ?? []).map((t) => ({
|
|
@@ -328,7 +367,12 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
328
367
|
children: pmToBlocks(t.content),
|
|
329
368
|
})),
|
|
330
369
|
...(n.attrs?.sync ? { sync: String(n.attrs.sync) } : {}),
|
|
370
|
+
...(n.attrs?.title ? { title: String(n.attrs.title) } : {}),
|
|
371
|
+
...(level === 2 || level === 3 || level === 4 ? { level } : {}),
|
|
331
372
|
}
|
|
373
|
+
}
|
|
374
|
+
case "gbCommand":
|
|
375
|
+
return pmToCommand(n.attrs ?? {})
|
|
332
376
|
case "gbExpandable":
|
|
333
377
|
return {
|
|
334
378
|
type: "expandable",
|
|
@@ -357,12 +401,16 @@ function pmToBlock(n: PMNode): Block | null {
|
|
|
357
401
|
if (typeof n.attrs?.controls === "boolean") embed.controls = n.attrs.controls
|
|
358
402
|
return embed
|
|
359
403
|
}
|
|
360
|
-
case "gbContentRef":
|
|
404
|
+
case "gbContentRef": {
|
|
405
|
+
const url = String(n.attrs?.url ?? "")
|
|
406
|
+
const label = String(n.attrs?.label ?? "")
|
|
407
|
+
const linked = n.attrs?.linked !== false && !!url && !!label
|
|
361
408
|
return {
|
|
362
409
|
type: "content-ref",
|
|
363
|
-
url
|
|
364
|
-
children: [{ type: "text", text:
|
|
410
|
+
url,
|
|
411
|
+
children: label ? [{ type: "text", text: label, ...(linked ? { link: url } : {}) }] : [],
|
|
365
412
|
}
|
|
413
|
+
}
|
|
366
414
|
case "gbSourceRef":
|
|
367
415
|
return {
|
|
368
416
|
type: "source-ref",
|
|
@@ -454,6 +502,7 @@ export function pmToDemo(a: Record<string, unknown>): DemoNode {
|
|
|
454
502
|
.map((v) => ({ id: String(v.id), label: String(v.label || v.id) }))
|
|
455
503
|
: []
|
|
456
504
|
const layout = String(a.layout ?? "")
|
|
505
|
+
const files = pmDemoFiles(a.files)
|
|
457
506
|
const viewport = String(a.viewport ?? "")
|
|
458
507
|
return {
|
|
459
508
|
type: "demo",
|
|
@@ -464,6 +513,35 @@ export function pmToDemo(a: Record<string, unknown>): DemoNode {
|
|
|
464
513
|
...(DEMO_LAYOUTS.includes(layout) ? { layout: layout as DemoLayout } : {}),
|
|
465
514
|
...(variants.length ? { variants } : {}),
|
|
466
515
|
...(DEMO_VIEWPORTS.includes(viewport) ? { viewport: viewport as DemoViewport } : {}),
|
|
516
|
+
...(a.entry ? { entry: String(a.entry) } : {}),
|
|
517
|
+
...(files.length ? { files } : {}),
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** `gbDemo`'s `files` attr → inline demo files (malformed entries dropped). */
|
|
522
|
+
export function pmDemoFiles(value: unknown): DemoInlineFile[] {
|
|
523
|
+
if (!Array.isArray(value)) return []
|
|
524
|
+
return (value as Array<Record<string, unknown> | null>)
|
|
525
|
+
.filter((f): f is Record<string, unknown> => !!f && typeof f === "object")
|
|
526
|
+
.map((f) => ({
|
|
527
|
+
path: String(f.path ?? ""),
|
|
528
|
+
content: String(f.content ?? ""),
|
|
529
|
+
...(f.language ? { language: String(f.language) } : {}),
|
|
530
|
+
}))
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const COMMAND_OVERRIDES = ["pnpm", "yarn", "bun"] as const
|
|
534
|
+
|
|
535
|
+
/** `gbCommand` attrs → `CommandNode`; also used by the node view to render the box. */
|
|
536
|
+
export function pmToCommand(a: Record<string, unknown>): CommandNode {
|
|
537
|
+
const raw = (a.overrides && typeof a.overrides === "object" ? a.overrides : {}) as Record<string, unknown>
|
|
538
|
+
const overrides: NonNullable<CommandNode["overrides"]> = {}
|
|
539
|
+
for (const pm of COMMAND_OVERRIDES) if (raw[pm]) overrides[pm] = String(raw[pm])
|
|
540
|
+
return {
|
|
541
|
+
type: "command",
|
|
542
|
+
command: String(a.command ?? ""),
|
|
543
|
+
...(Object.keys(overrides).length ? { overrides } : {}),
|
|
544
|
+
...(a.sync ? { sync: String(a.sync) } : {}),
|
|
467
545
|
}
|
|
468
546
|
}
|
|
469
547
|
|
|
@@ -490,3 +568,17 @@ export function serializeEditorMarkdown(doc: DocumentNode): string {
|
|
|
490
568
|
alt.includes("]") ? img : ``
|
|
491
569
|
)
|
|
492
570
|
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Plain-text Markdown pasted into the editor (a docs page's "Copy page", a `{% … %}`
|
|
574
|
+
* block, a fence, a list, a heading…) as editor blocks, or null when the text is just
|
|
575
|
+
* prose — which the editor then pastes as text, as usual.
|
|
576
|
+
*/
|
|
577
|
+
export function markdownPasteContent(text: string): { content: PMNode[]; citations?: CitationDef[] } | null {
|
|
578
|
+
if (!text.trim()) return null
|
|
579
|
+
const doc = parseMarkdown(text)
|
|
580
|
+
const blocks = doc.children.some((b) => b.type !== "paragraph") || /(^|\n)\s*\{%\s*[a-z-]+/.test(text)
|
|
581
|
+
if (!blocks && !doc.citations?.length) return null
|
|
582
|
+
const content = astToTiptap(doc).content ?? []
|
|
583
|
+
return { content, ...(doc.citations?.length ? { citations: doc.citations } : {}) }
|
|
584
|
+
}
|
package/src/editor/extensions.ts
CHANGED
|
@@ -2,7 +2,7 @@ import StarterKit from "@tiptap/starter-kit"
|
|
|
2
2
|
import Placeholder from "@tiptap/extension-placeholder"
|
|
3
3
|
import { TaskItem, TaskList } from "@tiptap/extension-list"
|
|
4
4
|
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table"
|
|
5
|
-
import { getSchema, type AnyExtension } from "@tiptap/core"
|
|
5
|
+
import { Extension, getSchema, type AnyExtension } from "@tiptap/core"
|
|
6
6
|
import type { Schema } from "@tiptap/pm/model"
|
|
7
7
|
import { GbCodeBlock, gitbookNodes } from "./nodes"
|
|
8
8
|
import { SlashMenu, createSlashMenu, type SlashItem } from "./slash-menu"
|
|
@@ -15,6 +15,29 @@ export const GbTable = Table.extend({
|
|
|
15
15
|
},
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* `inner` on the link mark: the link sits inside its emphasis (`**[x](url)**`)
|
|
20
|
+
* rather than around it (`[**x**](url)`). Kept so pasted Markdown round-trips
|
|
21
|
+
* byte-for-byte; newly created links default to the outer form.
|
|
22
|
+
*/
|
|
23
|
+
export const GbLinkInner = Extension.create({
|
|
24
|
+
name: "gbLinkInner",
|
|
25
|
+
addGlobalAttributes() {
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
types: ["link"],
|
|
29
|
+
attributes: {
|
|
30
|
+
inner: {
|
|
31
|
+
default: null,
|
|
32
|
+
parseHTML: (element) => (element.hasAttribute("data-link-inner") ? true : null),
|
|
33
|
+
renderHTML: (attributes) => (attributes.inner ? { "data-link-inner": "" } : {}),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
18
41
|
export interface ReferenceSources {
|
|
19
42
|
/** "@" — people, agents, … */
|
|
20
43
|
mentions?: ReferenceSource
|
|
@@ -71,6 +94,7 @@ export function createGitbookExtensions(options: GitbookExtensionOptions = {}):
|
|
|
71
94
|
TableHeader,
|
|
72
95
|
TableCell,
|
|
73
96
|
Placeholder.configure({ placeholder }),
|
|
97
|
+
GbLinkInner,
|
|
74
98
|
...gitbookNodes,
|
|
75
99
|
]
|
|
76
100
|
|
package/src/editor/nodes.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
ChevronDown,
|
|
17
17
|
FolderGit2,
|
|
18
18
|
Hash,
|
|
19
|
+
Heading,
|
|
19
20
|
Info,
|
|
20
21
|
Link2,
|
|
21
22
|
FileCode2,
|
|
@@ -27,12 +28,12 @@ import {
|
|
|
27
28
|
import { DocsRenderer } from "@brett_lamy/docstream"
|
|
28
29
|
import { resolveAsset } from "@brett_lamy/docstream/assets"
|
|
29
30
|
import { OpenApiOperation } from "@brett_lamy/docstream/openapi"
|
|
30
|
-
import type
|
|
31
|
+
import { packageManagerCommands, type DemoInlineFile, type DocumentNode, type HintStyle, type SourceRefNode } from "@brett_lamy/docstream/gitbook"
|
|
31
32
|
import { ReactCodePreview } from "@brett_lamy/docstream/playground"
|
|
32
33
|
import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
|
|
33
34
|
import { VideoEmbed } from "@brett_lamy/docstream/video"
|
|
34
35
|
import { DemoViewer } from "@brett_lamy/docstream/demo"
|
|
35
|
-
import { pmToDemo } from "./convert"
|
|
36
|
+
import { pmToCommand, pmToDemo } from "./convert"
|
|
36
37
|
import { SourceFileEditor } from "./SourceFileEditor"
|
|
37
38
|
import { useEditorRuntime } from "./runtime"
|
|
38
39
|
import { GbAttachment } from "./attachments"
|
|
@@ -135,9 +136,14 @@ function TabsView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
|
|
|
135
136
|
updateAttributes({ active: Math.max(0, active - (index <= active ? 1 : 0)) })
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
const editable = editor.isEditable
|
|
140
|
+
const title = typeof node.attrs.title === "string" ? node.attrs.title : ""
|
|
141
|
+
const level = node.attrs.level === 3 || node.attrs.level === 4 ? node.attrs.level : 2
|
|
142
|
+
// `{% tabs title="…" %}`: the set renders as a section — heading, then the switch on its row.
|
|
143
|
+
const section = !!title || node.attrs.titled === true
|
|
144
|
+
|
|
145
|
+
const tabStrip = (
|
|
146
|
+
<>
|
|
141
147
|
{titles.map((t, idx) => (
|
|
142
148
|
<div
|
|
143
149
|
key={idx}
|
|
@@ -161,11 +167,67 @@ function TabsView({ node, editor, getPos, updateAttributes }: NodeViewProps) {
|
|
|
161
167
|
)}
|
|
162
168
|
</div>
|
|
163
169
|
))}
|
|
164
|
-
{
|
|
170
|
+
{editable && (
|
|
165
171
|
<button className="gb-icon-btn" onClick={addTab} title="Add tab">
|
|
166
172
|
<Plus className="size-3.5" />
|
|
167
173
|
</button>
|
|
168
174
|
)}
|
|
175
|
+
{editable && !section && (
|
|
176
|
+
<button
|
|
177
|
+
className="gb-icon-btn gb-tabs-add-title"
|
|
178
|
+
onClick={() => updateAttributes({ titled: true })}
|
|
179
|
+
title="Add a section title (renders the tabs as a titled section)"
|
|
180
|
+
>
|
|
181
|
+
<Heading className="size-3.5" />
|
|
182
|
+
</button>
|
|
183
|
+
)}
|
|
184
|
+
</>
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if (section) {
|
|
188
|
+
const HeadingTag = `h${level}` as "h2" | "h3" | "h4"
|
|
189
|
+
return (
|
|
190
|
+
<NodeViewWrapper className="gb-tabs gb-tabs-section" data-level={level}>
|
|
191
|
+
<div className="gb-tabs-section-head" contentEditable={false}>
|
|
192
|
+
<HeadingTag className="gb-tabs-section-title">
|
|
193
|
+
{editable ? (
|
|
194
|
+
<input
|
|
195
|
+
className="gb-inline-input gb-tabs-section-input"
|
|
196
|
+
value={title}
|
|
197
|
+
placeholder="Section title…"
|
|
198
|
+
autoFocus={!title}
|
|
199
|
+
onChange={(e) => updateAttributes({ title: e.target.value || null })}
|
|
200
|
+
onBlur={(e) => {
|
|
201
|
+
if (!e.target.value) updateAttributes({ title: null, titled: false })
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
) : (
|
|
205
|
+
title
|
|
206
|
+
)}
|
|
207
|
+
</HeadingTag>
|
|
208
|
+
{editable && (
|
|
209
|
+
<select
|
|
210
|
+
className="gb-tabs-level"
|
|
211
|
+
value={level}
|
|
212
|
+
title="Heading level"
|
|
213
|
+
onChange={(e) => updateAttributes({ level: Number(e.target.value) === 2 ? null : Number(e.target.value) })}
|
|
214
|
+
>
|
|
215
|
+
<option value={2}>H2</option>
|
|
216
|
+
<option value={3}>H3</option>
|
|
217
|
+
<option value={4}>H4</option>
|
|
218
|
+
</select>
|
|
219
|
+
)}
|
|
220
|
+
<div className="gb-tabs-pills">{tabStrip}</div>
|
|
221
|
+
</div>
|
|
222
|
+
<NodeViewContent className="gb-tabs-content" data-active={active} />
|
|
223
|
+
</NodeViewWrapper>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<NodeViewWrapper className="gb-tabs">
|
|
229
|
+
<div className="gb-tabs-header" contentEditable={false}>
|
|
230
|
+
{tabStrip}
|
|
169
231
|
</div>
|
|
170
232
|
<NodeViewContent className="gb-tabs-content" data-active={active} />
|
|
171
233
|
</NodeViewWrapper>
|
|
@@ -187,6 +249,19 @@ export const GbTabs = Node.create({
|
|
|
187
249
|
parseHTML: (element) => element.getAttribute("data-sync") || null,
|
|
188
250
|
renderHTML: (attributes) => (attributes.sync ? { "data-sync": attributes.sync } : {}),
|
|
189
251
|
},
|
|
252
|
+
// `{% tabs title="Installation" level="3" %}` — a titled set renders as a section heading.
|
|
253
|
+
title: {
|
|
254
|
+
default: null,
|
|
255
|
+
parseHTML: (element) => element.getAttribute("data-title") || null,
|
|
256
|
+
renderHTML: (attributes) => (attributes.title ? { "data-title": attributes.title } : {}),
|
|
257
|
+
},
|
|
258
|
+
level: {
|
|
259
|
+
default: null,
|
|
260
|
+
parseHTML: (element) => Number(element.getAttribute("data-level")) || null,
|
|
261
|
+
renderHTML: (attributes) => (attributes.level ? { "data-level": attributes.level } : {}),
|
|
262
|
+
},
|
|
263
|
+
// UI only: a title field is open but still empty.
|
|
264
|
+
titled: { default: false, rendered: false },
|
|
190
265
|
}
|
|
191
266
|
},
|
|
192
267
|
parseHTML() {
|
|
@@ -443,7 +518,8 @@ export const GbContentRef = Node.create({
|
|
|
443
518
|
group: "block",
|
|
444
519
|
atom: true,
|
|
445
520
|
addAttributes() {
|
|
446
|
-
|
|
521
|
+
// `linked`: the label is written as a link to the page (`[Card](card.md)`, GitBook's form).
|
|
522
|
+
return { url: { default: "" }, label: { default: "" }, linked: { default: true, rendered: false } }
|
|
447
523
|
},
|
|
448
524
|
parseHTML() {
|
|
449
525
|
return [{ tag: "div[data-gb-content-ref]" }]
|
|
@@ -528,20 +604,146 @@ export const GbSourceRef = Node.create({
|
|
|
528
604
|
|
|
529
605
|
// ---------- Demo ----------
|
|
530
606
|
|
|
607
|
+
/** Inline demo files editor: a tab per file (path editable), a code area, add / remove. */
|
|
608
|
+
function DemoFilesEditor({ files, entry, onChange }: {
|
|
609
|
+
files: DemoInlineFile[]
|
|
610
|
+
entry: string
|
|
611
|
+
onChange: (files: DemoInlineFile[], entry?: string | null) => void
|
|
612
|
+
}) {
|
|
613
|
+
const [selected, setSelected] = useState(0)
|
|
614
|
+
const index = Math.min(selected, Math.max(0, files.length - 1))
|
|
615
|
+
const file = files[index]
|
|
616
|
+
const update = (patch: Partial<DemoInlineFile>) => {
|
|
617
|
+
const next = files.map((f, i) => {
|
|
618
|
+
if (i !== index) return f
|
|
619
|
+
const merged = { ...f, ...patch }
|
|
620
|
+
if (!merged.language) delete merged.language
|
|
621
|
+
return merged
|
|
622
|
+
})
|
|
623
|
+
// Renaming the entry file keeps it the entry.
|
|
624
|
+
const renamed = patch.path !== undefined && file && entry === file.path ? patch.path : undefined
|
|
625
|
+
onChange(next, renamed)
|
|
626
|
+
}
|
|
627
|
+
const add = () => {
|
|
628
|
+
const taken = new Set(files.map((f) => f.path))
|
|
629
|
+
let n = files.length + 1
|
|
630
|
+
let path = files.length ? `file${n}.tsx` : "index.tsx"
|
|
631
|
+
while (taken.has(path)) path = `file${++n}.tsx`
|
|
632
|
+
onChange([...files, { path, content: "", language: "tsx" }])
|
|
633
|
+
setSelected(files.length)
|
|
634
|
+
}
|
|
635
|
+
const remove = (i: number) => {
|
|
636
|
+
const removed = files[i]
|
|
637
|
+
onChange(files.filter((_, k) => k !== i), removed && entry === removed.path ? null : undefined)
|
|
638
|
+
setSelected(Math.max(0, i - 1))
|
|
639
|
+
}
|
|
640
|
+
return (
|
|
641
|
+
<div className="gb-demo-files">
|
|
642
|
+
<div className="gb-demo-files-tabs" role="tablist" aria-label="Demo files">
|
|
643
|
+
{files.map((f, i) => (
|
|
644
|
+
<div key={i} className={i === index ? "gb-demo-file-tab gb-demo-file-tab-active" : "gb-demo-file-tab"} onClick={() => setSelected(i)}>
|
|
645
|
+
{i === index ? (
|
|
646
|
+
<input
|
|
647
|
+
className="gb-demo-file-path"
|
|
648
|
+
value={f.path}
|
|
649
|
+
size={Math.max(f.path.length, 6)}
|
|
650
|
+
placeholder="index.tsx"
|
|
651
|
+
aria-label="File path"
|
|
652
|
+
onChange={(e) => update({ path: e.target.value })}
|
|
653
|
+
/>
|
|
654
|
+
) : (
|
|
655
|
+
<span>{f.path || "untitled"}</span>
|
|
656
|
+
)}
|
|
657
|
+
{f.path && entry === f.path ? <span className="gb-demo-file-entry">entry</span> : null}
|
|
658
|
+
<button type="button" className="gb-icon-btn" title={`Remove ${f.path || "file"}`} onClick={(e) => { e.stopPropagation(); remove(i) }}>
|
|
659
|
+
<X className="size-3" />
|
|
660
|
+
</button>
|
|
661
|
+
</div>
|
|
662
|
+
))}
|
|
663
|
+
<button type="button" className="gb-icon-btn" title="Add file" onClick={add}>
|
|
664
|
+
<Plus className="size-3.5" />
|
|
665
|
+
</button>
|
|
666
|
+
</div>
|
|
667
|
+
{file ? (
|
|
668
|
+
<div className="gb-demo-file-body">
|
|
669
|
+
<div className="gb-demo-file-meta">
|
|
670
|
+
<input
|
|
671
|
+
className="gb-inline-input gb-demo-file-lang"
|
|
672
|
+
value={file.language ?? ""}
|
|
673
|
+
placeholder="lang"
|
|
674
|
+
aria-label="Language"
|
|
675
|
+
onChange={(e) => update({ language: e.target.value })}
|
|
676
|
+
/>
|
|
677
|
+
<label className="gb-demo-file-entry-toggle">
|
|
678
|
+
<input
|
|
679
|
+
type="radio"
|
|
680
|
+
checked={entry ? entry === file.path : index === 0}
|
|
681
|
+
onChange={() => onChange(files, index === 0 ? null : file.path)}
|
|
682
|
+
/>
|
|
683
|
+
Entry
|
|
684
|
+
</label>
|
|
685
|
+
</div>
|
|
686
|
+
<textarea
|
|
687
|
+
className="gb-demo-file-code"
|
|
688
|
+
value={file.content}
|
|
689
|
+
spellCheck={false}
|
|
690
|
+
rows={Math.min(24, Math.max(4, file.content.split("\n").length + 1))}
|
|
691
|
+
aria-label={`Edit ${file.path}`}
|
|
692
|
+
onChange={(e) => update({ content: e.target.value })}
|
|
693
|
+
onKeyDown={(e) => {
|
|
694
|
+
if (e.key !== "Tab" || e.shiftKey) return
|
|
695
|
+
e.preventDefault()
|
|
696
|
+
const el = e.currentTarget
|
|
697
|
+
const { selectionStart: a, selectionEnd: b } = el
|
|
698
|
+
update({ content: `${el.value.slice(0, a)} ${el.value.slice(b)}` })
|
|
699
|
+
requestAnimationFrame(() => el.setSelectionRange(a + 2, a + 2))
|
|
700
|
+
}}
|
|
701
|
+
/>
|
|
702
|
+
</div>
|
|
703
|
+
) : (
|
|
704
|
+
<div className="gb-demo-files-empty">No inline files — the demo resolves from its <code>src</code>.</div>
|
|
705
|
+
)}
|
|
706
|
+
</div>
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
|
|
531
710
|
function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
532
711
|
const editable = editor.isEditable
|
|
533
|
-
const { demoResolver } = useEditorRuntime()
|
|
712
|
+
const { demoResolver, demoRuntime, demoDependencies } = useEditorRuntime()
|
|
534
713
|
const demo = useMemo(() => pmToDemo(node.attrs), [node.attrs])
|
|
535
|
-
|
|
714
|
+
const [filesOpen, setFilesOpen] = useState(false)
|
|
715
|
+
// Typing into `src` or a file would otherwise re-resolve / re-run every keystroke.
|
|
536
716
|
const src = useDebouncedValue(demo.src)
|
|
717
|
+
const files = useDebouncedValue(demo.files)
|
|
718
|
+
const entry = useDebouncedValue(demo.entry)
|
|
719
|
+
const hasFiles = !!demo.files?.length
|
|
720
|
+
// The resolver is the authority when it knows `src`; inline files stand in otherwise
|
|
721
|
+
// (DemoViewer falls back to them), so a pasted docs page looks like the page.
|
|
722
|
+
const preview = (!!demoResolver && !!src) || !!files?.length
|
|
723
|
+
const setFiles = (next: DemoInlineFile[], nextEntry?: string | null) => {
|
|
724
|
+
updateAttributes({
|
|
725
|
+
files: next.length ? next : null,
|
|
726
|
+
...(nextEntry !== undefined ? { entry: nextEntry || null } : {}),
|
|
727
|
+
})
|
|
728
|
+
}
|
|
537
729
|
return (
|
|
538
|
-
<NodeViewWrapper className={
|
|
730
|
+
<NodeViewWrapper className={preview || (editable && filesOpen) ? "gb-demo-block gb-demo-block-preview" : "gb-demo-block"} contentEditable={false}>
|
|
539
731
|
<div className="gb-demo">
|
|
540
732
|
<AppWindow className="size-4 shrink-0" />
|
|
541
733
|
{editable ? (
|
|
542
734
|
<>
|
|
543
735
|
<input className="gb-inline-input gb-demo-src" value={node.attrs.src} placeholder="page/example" onChange={(event) => updateAttributes({ src: event.target.value })} />
|
|
544
736
|
<input className="gb-inline-input gb-demo-title" value={node.attrs.title} placeholder="Title" onChange={(event) => updateAttributes({ title: event.target.value })} />
|
|
737
|
+
<button
|
|
738
|
+
type="button"
|
|
739
|
+
className={filesOpen ? "gb-demo-files-toggle gb-demo-files-toggle-active" : "gb-demo-files-toggle"}
|
|
740
|
+
title="Edit the files carried inline by this demo"
|
|
741
|
+
aria-expanded={filesOpen}
|
|
742
|
+
onClick={() => setFilesOpen((open) => !open)}
|
|
743
|
+
>
|
|
744
|
+
<FileCode2 className="size-3.5" />
|
|
745
|
+
{hasFiles ? `${demo.files!.length} file${demo.files!.length === 1 ? "" : "s"}` : "Files"}
|
|
746
|
+
</button>
|
|
545
747
|
</>
|
|
546
748
|
) : (
|
|
547
749
|
<>
|
|
@@ -550,11 +752,14 @@ function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
|
550
752
|
</>
|
|
551
753
|
)}
|
|
552
754
|
</div>
|
|
553
|
-
{
|
|
755
|
+
{editable && filesOpen ? (
|
|
756
|
+
<DemoFilesEditor files={demo.files ?? []} entry={demo.entry ?? ""} onChange={setFiles} />
|
|
757
|
+
) : null}
|
|
758
|
+
{preview ? (
|
|
554
759
|
<div className="gb-demo-preview">
|
|
555
760
|
<DemoViewer
|
|
556
761
|
key={src}
|
|
557
|
-
|
|
762
|
+
{...(demoResolver ? { resolver: demoResolver } : {})}
|
|
558
763
|
src={src}
|
|
559
764
|
title={demo.title}
|
|
560
765
|
description={demo.description}
|
|
@@ -562,6 +767,10 @@ function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
|
562
767
|
layout={demo.layout}
|
|
563
768
|
variants={demo.variants}
|
|
564
769
|
viewport={demo.viewport}
|
|
770
|
+
files={files}
|
|
771
|
+
entry={entry}
|
|
772
|
+
{...(demoRuntime ? { runtime: demoRuntime } : {})}
|
|
773
|
+
{...(demoDependencies ? { dependencies: demoDependencies } : {})}
|
|
565
774
|
/>
|
|
566
775
|
</div>
|
|
567
776
|
) : null}
|
|
@@ -569,7 +778,10 @@ function DemoView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
|
569
778
|
)
|
|
570
779
|
}
|
|
571
780
|
|
|
572
|
-
/**
|
|
781
|
+
/**
|
|
782
|
+
* `{% demo %}` — every tag attribute is kept on the node, and the block form's inline
|
|
783
|
+
* files (`files`, in document order) with it, so it round-trips losslessly.
|
|
784
|
+
*/
|
|
573
785
|
export const GbDemo = Node.create({
|
|
574
786
|
name: "gbDemo",
|
|
575
787
|
group: "block",
|
|
@@ -583,6 +795,22 @@ export const GbDemo = Node.create({
|
|
|
583
795
|
layout: { default: null },
|
|
584
796
|
variants: { default: null, rendered: false },
|
|
585
797
|
viewport: { default: null },
|
|
798
|
+
entry: { default: null },
|
|
799
|
+
// `{ path, content, language? }[]` — the files between `{% demo %}` and `{% enddemo %}`.
|
|
800
|
+
files: {
|
|
801
|
+
default: null,
|
|
802
|
+
rendered: false,
|
|
803
|
+
parseHTML: (element) => {
|
|
804
|
+
const raw = element.getAttribute("data-files")
|
|
805
|
+
if (!raw) return null
|
|
806
|
+
try {
|
|
807
|
+
return JSON.parse(raw)
|
|
808
|
+
} catch {
|
|
809
|
+
return null
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
renderHTML: (attributes) => (attributes.files ? { "data-files": JSON.stringify(attributes.files) } : {}),
|
|
813
|
+
},
|
|
586
814
|
}
|
|
587
815
|
},
|
|
588
816
|
parseHTML() {
|
|
@@ -596,6 +824,86 @@ export const GbDemo = Node.create({
|
|
|
596
824
|
},
|
|
597
825
|
})
|
|
598
826
|
|
|
827
|
+
// ---------- Command (package-manager install box) ----------
|
|
828
|
+
|
|
829
|
+
const COMMAND_MANAGERS = ["pnpm", "yarn", "bun"] as const
|
|
830
|
+
|
|
831
|
+
function CommandView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
832
|
+
const editable = editor.isEditable
|
|
833
|
+
const command = useMemo(() => pmToCommand(node.attrs), [node.attrs])
|
|
834
|
+
const derived = useMemo(() => packageManagerCommands(command.command), [command.command])
|
|
835
|
+
// docstream's own command box, so the editor shows exactly what readers get.
|
|
836
|
+
const doc = useMemo<DocumentNode>(() => ({ type: "doc", children: [command] }), [command])
|
|
837
|
+
const setOverride = (pm: (typeof COMMAND_MANAGERS)[number], value: string) => {
|
|
838
|
+
const next = { ...(command.overrides ?? {}) }
|
|
839
|
+
if (value) next[pm] = value
|
|
840
|
+
else delete next[pm]
|
|
841
|
+
updateAttributes({ overrides: Object.keys(next).length ? next : null })
|
|
842
|
+
}
|
|
843
|
+
return (
|
|
844
|
+
<NodeViewWrapper className="gb-command" contentEditable={false}>
|
|
845
|
+
{command.command ? (
|
|
846
|
+
<div className="gb-command-preview">
|
|
847
|
+
<DocsRenderer doc={doc} />
|
|
848
|
+
</div>
|
|
849
|
+
) : null}
|
|
850
|
+
{editable ? (
|
|
851
|
+
<div className="gb-command-fields">
|
|
852
|
+
<label className="gb-command-field gb-command-field-npm">
|
|
853
|
+
<span>npm</span>
|
|
854
|
+
<textarea
|
|
855
|
+
className="gb-command-input"
|
|
856
|
+
value={command.command}
|
|
857
|
+
placeholder="npm install <package>"
|
|
858
|
+
spellCheck={false}
|
|
859
|
+
rows={Math.max(1, command.command.split("\n").length)}
|
|
860
|
+
onChange={(e) => updateAttributes({ command: e.target.value })}
|
|
861
|
+
/>
|
|
862
|
+
</label>
|
|
863
|
+
{COMMAND_MANAGERS.map((pm) => (
|
|
864
|
+
<label key={pm} className="gb-command-field">
|
|
865
|
+
<span>{pm}</span>
|
|
866
|
+
<input
|
|
867
|
+
className="gb-inline-input gb-command-override"
|
|
868
|
+
value={command.overrides?.[pm] ?? ""}
|
|
869
|
+
placeholder={derived[pm] || `${pm} (derived)`}
|
|
870
|
+
spellCheck={false}
|
|
871
|
+
title={`Override the derived ${pm} command (leave empty to derive it)`}
|
|
872
|
+
onChange={(e) => setOverride(pm, e.target.value)}
|
|
873
|
+
/>
|
|
874
|
+
</label>
|
|
875
|
+
))}
|
|
876
|
+
</div>
|
|
877
|
+
) : null}
|
|
878
|
+
</NodeViewWrapper>
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** `{% command %}npm install x{% endcommand %}` — one npm command shown for the reader's package manager. */
|
|
883
|
+
export const GbCommand = Node.create({
|
|
884
|
+
name: "gbCommand",
|
|
885
|
+
group: "block",
|
|
886
|
+
atom: true,
|
|
887
|
+
addAttributes() {
|
|
888
|
+
return {
|
|
889
|
+
command: { default: "" },
|
|
890
|
+
// `{ pnpm?, yarn?, bun? }` — verbatim commands replacing the derived ones.
|
|
891
|
+
overrides: { default: null, rendered: false },
|
|
892
|
+
// Sync key; null means docstream's default ("pm").
|
|
893
|
+
sync: { default: null },
|
|
894
|
+
}
|
|
895
|
+
},
|
|
896
|
+
parseHTML() {
|
|
897
|
+
return [{ tag: "div[data-gb-command]" }]
|
|
898
|
+
},
|
|
899
|
+
renderHTML({ HTMLAttributes, node }) {
|
|
900
|
+
return ["div", mergeAttributes(HTMLAttributes, { "data-gb-command": "" }), node.attrs.command]
|
|
901
|
+
},
|
|
902
|
+
addNodeView() {
|
|
903
|
+
return ReactNodeViewRenderer(CommandView)
|
|
904
|
+
},
|
|
905
|
+
})
|
|
906
|
+
|
|
599
907
|
// ---------- Columns ----------
|
|
600
908
|
|
|
601
909
|
export const GbColumns = Node.create({
|
|
@@ -809,6 +1117,8 @@ export const GbCodeBlock = CodeBlock.extend({
|
|
|
809
1117
|
lineNumbers: { default: false },
|
|
810
1118
|
live: { default: false },
|
|
811
1119
|
entry: { default: null },
|
|
1120
|
+
collapsedCodeLines: { default: null, rendered: false },
|
|
1121
|
+
expandedCodeLines: { default: null, rendered: false },
|
|
812
1122
|
}
|
|
813
1123
|
},
|
|
814
1124
|
addNodeView() {
|
|
@@ -1057,6 +1367,7 @@ export const gitbookNodes = [
|
|
|
1057
1367
|
GbContentRef,
|
|
1058
1368
|
GbSourceRef,
|
|
1059
1369
|
GbDemo,
|
|
1370
|
+
GbCommand,
|
|
1060
1371
|
GbColumns,
|
|
1061
1372
|
GbColumn,
|
|
1062
1373
|
GbFigure,
|
package/src/editor/runtime.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from "react"
|
|
2
2
|
|
|
3
|
-
import type { DemoResolver } from "@brett_lamy/docstream/demo"
|
|
3
|
+
import type { DemoResolver, InlineDemoRuntime } from "@brett_lamy/docstream/demo"
|
|
4
4
|
import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
|
|
5
5
|
import type { EditorAttachment } from "./attachments"
|
|
6
6
|
|
|
@@ -11,6 +11,8 @@ export interface EditorRuntimeOptions {
|
|
|
11
11
|
onSourceSaved?: (snapshot: SourceFileSnapshot) => void
|
|
12
12
|
onSourceError?: (error: Error) => void
|
|
13
13
|
demoResolver?: DemoResolver
|
|
14
|
+
demoRuntime?: InlineDemoRuntime
|
|
15
|
+
demoDependencies?: Record<string, string>
|
|
14
16
|
attachments?: EditorAttachment[]
|
|
15
17
|
onAttachmentOpen?: (attachment: EditorAttachment) => void
|
|
16
18
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
SquareChevronDown,
|
|
24
24
|
Superscript,
|
|
25
25
|
Table as TableIcon,
|
|
26
|
+
Terminal,
|
|
26
27
|
Webhook,
|
|
27
28
|
Workflow,
|
|
28
29
|
type LucideIcon,
|
|
@@ -72,6 +73,12 @@ export const SLASH_ITEMS: SlashItem[] = [
|
|
|
72
73
|
],
|
|
73
74
|
}),
|
|
74
75
|
},
|
|
76
|
+
{
|
|
77
|
+
title: "Install command",
|
|
78
|
+
keywords: "command npm pnpm yarn bun npx package manager install terminal shell",
|
|
79
|
+
icon: Terminal,
|
|
80
|
+
run: insertBlock({ type: "gbCommand", attrs: { command: "", overrides: null, sync: null } }),
|
|
81
|
+
},
|
|
75
82
|
{
|
|
76
83
|
title: "Expandable",
|
|
77
84
|
keywords: "details accordion collapse",
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
export { GitbookEditor } from "./editor/Editor"
|
|
2
2
|
export type { GitbookEditorProps, EditorAttachment } from "./editor/Editor"
|
|
3
|
-
export { astToTiptap, tiptapToAst, serializeEditorMarkdown } from "./editor/convert"
|
|
3
|
+
export { astToTiptap, tiptapToAst, serializeEditorMarkdown, markdownPasteContent } from "./editor/convert"
|
|
4
4
|
export type { PMNode } from "./editor/convert"
|
|
5
5
|
|
|
6
6
|
// Building blocks for composing a custom editor (e.g. with Yjs collaboration
|
|
7
7
|
// or a bespoke slash menu) instead of the batteries-included GitbookEditor.
|
|
8
|
-
export { createGitbookExtensions, getGitbookSchema, GbTable } from "./editor/extensions"
|
|
8
|
+
export { createGitbookExtensions, getGitbookSchema, GbTable, GbLinkInner } from "./editor/extensions"
|
|
9
9
|
export type { GitbookExtensionOptions, ReferenceSources } from "./editor/extensions"
|
|
10
|
-
export { gitbookNodes, GbCodeBlock, GbReference } from "./editor/nodes"
|
|
10
|
+
export { gitbookNodes, GbCodeBlock, GbCommand, GbDemo, GbReference } from "./editor/nodes"
|
|
11
11
|
export { GbAttachment, formatBytes } from "./editor/attachments"
|
|
12
12
|
export { SlashMenu, createSlashMenu, SLASH_ITEMS } from "./editor/slash-menu"
|
|
13
13
|
export type { SlashItem } from "./editor/slash-menu"
|
package/src/styles.css
CHANGED
|
@@ -125,6 +125,89 @@
|
|
|
125
125
|
margin: 0;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
.gb-demo-files-toggle {
|
|
129
|
+
display: inline-flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
gap: 4px;
|
|
132
|
+
padding: 2px 8px;
|
|
133
|
+
border: 1px solid var(--gb-border);
|
|
134
|
+
border-radius: 999px;
|
|
135
|
+
background: transparent;
|
|
136
|
+
color: var(--gb-muted-foreground);
|
|
137
|
+
font-size: 12px;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
white-space: nowrap;
|
|
140
|
+
}
|
|
141
|
+
.gb-demo-files-toggle-active { background: var(--gb-bg); color: var(--gb-text); }
|
|
142
|
+
|
|
143
|
+
/* Inline demo files: a tab per file and a code area. */
|
|
144
|
+
.gb-demo-files {
|
|
145
|
+
border: 1px solid var(--gb-border);
|
|
146
|
+
border-top: 0;
|
|
147
|
+
background: var(--gb-panel);
|
|
148
|
+
}
|
|
149
|
+
.gb-demo-block:not(.gb-demo-block-preview) .gb-demo-files,
|
|
150
|
+
.gb-demo-files:last-child {
|
|
151
|
+
border-radius: 0 0 var(--gb-radius) var(--gb-radius);
|
|
152
|
+
}
|
|
153
|
+
.gb-demo-files-tabs {
|
|
154
|
+
display: flex;
|
|
155
|
+
flex-wrap: wrap;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: 2px;
|
|
158
|
+
padding: 6px 8px 0;
|
|
159
|
+
border-bottom: 1px solid var(--gb-border);
|
|
160
|
+
background: var(--gb-muted);
|
|
161
|
+
}
|
|
162
|
+
.gb-demo-file-tab {
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
align-items: center;
|
|
165
|
+
gap: 4px;
|
|
166
|
+
padding: 5px 10px;
|
|
167
|
+
border-radius: 8px 8px 0 0;
|
|
168
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
169
|
+
font-size: 12px;
|
|
170
|
+
color: var(--gb-muted-foreground);
|
|
171
|
+
cursor: pointer;
|
|
172
|
+
}
|
|
173
|
+
.gb-demo-file-tab-active { background: var(--gb-panel); color: var(--gb-text); }
|
|
174
|
+
.gb-demo-file-path { font: inherit; color: inherit; background: transparent; border: none; outline: none; }
|
|
175
|
+
.gb-demo-file-entry {
|
|
176
|
+
padding: 0 5px;
|
|
177
|
+
border-radius: 4px;
|
|
178
|
+
background: var(--gb-accent);
|
|
179
|
+
font-size: 10px;
|
|
180
|
+
text-transform: uppercase;
|
|
181
|
+
letter-spacing: 0.04em;
|
|
182
|
+
}
|
|
183
|
+
.gb-demo-file-body { display: flex; flex-direction: column; }
|
|
184
|
+
.gb-demo-file-meta {
|
|
185
|
+
display: flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
gap: 12px;
|
|
188
|
+
padding: 4px 12px;
|
|
189
|
+
font-size: 12px;
|
|
190
|
+
color: var(--gb-muted-foreground);
|
|
191
|
+
}
|
|
192
|
+
.gb-demo-file-lang { width: 6rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
193
|
+
.gb-demo-file-entry-toggle { display: inline-flex; align-items: center; gap: 4px; }
|
|
194
|
+
.gb-demo-file-code {
|
|
195
|
+
display: block;
|
|
196
|
+
width: 100%;
|
|
197
|
+
box-sizing: border-box;
|
|
198
|
+
padding: 8px 12px 12px;
|
|
199
|
+
border: 0;
|
|
200
|
+
outline: none;
|
|
201
|
+
resize: vertical;
|
|
202
|
+
background: transparent;
|
|
203
|
+
color: var(--gb-code-fg);
|
|
204
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
205
|
+
font-size: 12.5px;
|
|
206
|
+
line-height: 1.55;
|
|
207
|
+
tab-size: 2;
|
|
208
|
+
}
|
|
209
|
+
.gb-demo-files-empty { padding: 10px 12px; font-size: 12.5px; color: var(--gb-muted-foreground); }
|
|
210
|
+
|
|
128
211
|
.gb-source-file-editor {
|
|
129
212
|
border: 1px solid var(--gb-border);
|
|
130
213
|
border-radius: var(--gb-radius);
|
|
@@ -406,6 +489,84 @@
|
|
|
406
489
|
.gb-tabs-content .gb-tab > :first-child { margin-top: 0; }
|
|
407
490
|
.gb-tabs-content .gb-tab > :last-child { margin-bottom: 0; }
|
|
408
491
|
|
|
492
|
+
/* ── Titled tabs (mirrors .docs-tabs-section): heading + pill switch on one row ── */
|
|
493
|
+
.gb-tabs-section {
|
|
494
|
+
border: 0;
|
|
495
|
+
border-radius: 0;
|
|
496
|
+
overflow: visible;
|
|
497
|
+
margin: 1.6em 0 0.9em;
|
|
498
|
+
}
|
|
499
|
+
.gb-tabs-section-head {
|
|
500
|
+
display: flex;
|
|
501
|
+
flex-wrap: wrap;
|
|
502
|
+
align-items: center;
|
|
503
|
+
gap: 12px;
|
|
504
|
+
margin-bottom: 12px;
|
|
505
|
+
}
|
|
506
|
+
.gb-tabs-section-title {
|
|
507
|
+
flex: 1;
|
|
508
|
+
min-width: 10rem;
|
|
509
|
+
margin: 0;
|
|
510
|
+
padding: 0;
|
|
511
|
+
border: 0;
|
|
512
|
+
}
|
|
513
|
+
.gb-tabs-section-input { width: 100%; font-weight: inherit; font-size: inherit; letter-spacing: inherit; }
|
|
514
|
+
.gb-tabs-level {
|
|
515
|
+
font-size: 12px;
|
|
516
|
+
color: var(--gb-muted-foreground);
|
|
517
|
+
background: transparent;
|
|
518
|
+
border: 1px solid var(--gb-border);
|
|
519
|
+
border-radius: 6px;
|
|
520
|
+
}
|
|
521
|
+
.gb-tabs-pills {
|
|
522
|
+
display: inline-flex;
|
|
523
|
+
align-items: center;
|
|
524
|
+
gap: 2px;
|
|
525
|
+
padding: 3px;
|
|
526
|
+
border-radius: 999px;
|
|
527
|
+
background: var(--gb-muted);
|
|
528
|
+
}
|
|
529
|
+
.gb-tabs-pills .gb-tabs-tab { padding: 4px 12px; border-radius: 999px; font-size: 13px; }
|
|
530
|
+
.gb-tabs-pills .gb-tabs-tab-active { background: var(--gb-bg); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
|
|
531
|
+
.gb-tabs-section > .gb-tabs-content { padding: 0; }
|
|
532
|
+
|
|
533
|
+
/* ── Command box: docstream's own box, with the editable npm command + overrides below ── */
|
|
534
|
+
.gb-command { margin: 0.85em 0; }
|
|
535
|
+
.gb-command-preview .docs-article { margin: 0; padding: 0; max-width: none; }
|
|
536
|
+
.gb-command-preview .docs-command { margin: 0; }
|
|
537
|
+
.gb-command-fields {
|
|
538
|
+
display: grid;
|
|
539
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
540
|
+
gap: 6px 10px;
|
|
541
|
+
margin-top: 6px;
|
|
542
|
+
padding: 8px 10px;
|
|
543
|
+
border: 1px dashed var(--gb-border);
|
|
544
|
+
border-radius: 10px;
|
|
545
|
+
font-size: 12.5px;
|
|
546
|
+
}
|
|
547
|
+
.gb-command-field { display: flex; align-items: baseline; gap: 6px; min-width: 0; }
|
|
548
|
+
.gb-command-field > span {
|
|
549
|
+
flex-shrink: 0;
|
|
550
|
+
width: 2.8rem;
|
|
551
|
+
color: var(--gb-muted-foreground);
|
|
552
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
553
|
+
}
|
|
554
|
+
.gb-command-field-npm { grid-column: 1 / -1; }
|
|
555
|
+
.gb-command-input,
|
|
556
|
+
.gb-command-override {
|
|
557
|
+
flex: 1;
|
|
558
|
+
min-width: 0;
|
|
559
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
560
|
+
font-size: 12.5px;
|
|
561
|
+
}
|
|
562
|
+
.gb-command-input {
|
|
563
|
+
resize: vertical;
|
|
564
|
+
background: transparent;
|
|
565
|
+
border: none;
|
|
566
|
+
outline: none;
|
|
567
|
+
color: inherit;
|
|
568
|
+
}
|
|
569
|
+
|
|
409
570
|
/* ── Expandable (mirrors .docs-expandable) ──────────────── */
|
|
410
571
|
.gb-expandable {
|
|
411
572
|
border: 1px solid var(--gb-border);
|