@brett_lamy/docstream-editor 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,174 @@
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
+ - Table, task list, heading, quote, and inline formatting support.
15
+ - Preserves Docstream/GitBook block semantics when converting back to markdown.
16
+
17
+ ## Installation
18
+
19
+ ```sh
20
+ npm install @brett_lamy/docstream-editor @brett_lamy/docstream react
21
+ ```
22
+
23
+ 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.
24
+
25
+ ## Basic Setup
26
+
27
+ Import both package styles near your app entrypoint:
28
+
29
+ ```ts
30
+ import "@brett_lamy/docstream/styles.css"
31
+ import "@brett_lamy/docstream-editor/styles.css"
32
+ ```
33
+
34
+ If your TypeScript app checks CSS side-effect imports, include Vite's standard environment declaration or an equivalent CSS module declaration:
35
+
36
+ ```ts
37
+ /// <reference types="vite/client" />
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ `GitbookEditor` is a controlled component. Pass the current markdown and receive updated markdown through `onChange`.
43
+
44
+ ```tsx
45
+ import { useState } from "react"
46
+ import { GitbookEditor } from "@brett_lamy/docstream-editor"
47
+ import "@brett_lamy/docstream/styles.css"
48
+ import "@brett_lamy/docstream-editor/styles.css"
49
+
50
+ const initialMarkdown = `# Getting started
51
+
52
+ {% hint style="info" %}
53
+ Type / to insert supported blocks.
54
+ {% endhint %}
55
+ `
56
+
57
+ export function EditorPage() {
58
+ const [markdown, setMarkdown] = useState(initialMarkdown)
59
+
60
+ return (
61
+ <GitbookEditor
62
+ markdown={markdown}
63
+ onChange={setMarkdown}
64
+ />
65
+ )
66
+ }
67
+ ```
68
+
69
+ ## Props
70
+
71
+ ```ts
72
+ export interface GitbookEditorProps {
73
+ markdown: string
74
+ onChange: (markdown: string) => void
75
+ }
76
+ ```
77
+
78
+ - `markdown`: Source markdown to load into the editor.
79
+ - `onChange`: Called with serialized markdown whenever TipTap content changes.
80
+
81
+ 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.
82
+
83
+ ## Supported Editing Surface
84
+
85
+ The editor supports common ProseMirror/TipTap content plus GitBook-flavored blocks from Docstream.
86
+
87
+ - Headings
88
+ - Paragraphs
89
+ - Bold, italic, strike, inline code, and links
90
+ - Bullet, ordered, and task lists
91
+ - Blockquotes
92
+ - Code blocks
93
+ - Tables
94
+ - Hints
95
+ - Tabs
96
+ - Expandables
97
+ - Steppers
98
+ - Embeds
99
+ - Content references
100
+ - Columns
101
+ - Figures and images
102
+ - OpenAPI operation placeholders
103
+
104
+ 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.
105
+
106
+ ## Slash Menu
107
+
108
+ The editor includes a slash menu extension for inserting supported block structures. Type `/` in an empty paragraph to open block insertion options.
109
+
110
+ ## Conversion Helpers
111
+
112
+ Use the conversion helpers when you need to inspect or transform editor state directly.
113
+
114
+ ```ts
115
+ import { astToTiptap, tiptapToAst } from "@brett_lamy/docstream-editor"
116
+ import { parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream"
117
+
118
+ const ast = parseMarkdown(markdown)
119
+ const tiptapJson = astToTiptap(ast)
120
+ const markdownAgain = serializeMarkdown(tiptapToAst(tiptapJson))
121
+ ```
122
+
123
+ Exports:
124
+
125
+ - `GitbookEditor`
126
+ - `GitbookEditorProps`
127
+ - `astToTiptap`
128
+ - `tiptapToAst`
129
+ - `PMNode`
130
+
131
+ ## Styling and Theming
132
+
133
+ 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.
134
+
135
+ ```css
136
+ :root {
137
+ --background: 0 0% 100%;
138
+ --foreground: 222.2 84% 4.9%;
139
+ --border: 214.3 31.8% 91.4%;
140
+ --muted: 210 40% 96.1%;
141
+ --muted-foreground: 215.4 16.3% 46.9%;
142
+ --primary: 221.2 83.2% 53.3%;
143
+ }
144
+ ```
145
+
146
+ The editor exposes classes such as `gb`, `gb-toolbar`, `gb-tool`, `gb-tool-active`, and `gb-content` for host app overrides.
147
+
148
+ ## Bundler Notes
149
+
150
+ This release ships TypeScript and TSX source through ESM exports:
151
+
152
+ ```json
153
+ {
154
+ "exports": {
155
+ ".": {
156
+ "types": "./src/index.ts",
157
+ "import": "./src/index.ts"
158
+ },
159
+ "./styles.css": "./src/styles.css"
160
+ }
161
+ }
162
+ ```
163
+
164
+ 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.
165
+
166
+ ## Renderer Pairing
167
+
168
+ For read-only previews or AI stream output, pair this package with `@brett_lamy/docstream`:
169
+
170
+ ```tsx
171
+ import { GitbookStreamdown } from "@brett_lamy/docstream"
172
+
173
+ <GitbookStreamdown markdown={markdown} isStreaming={false} />
174
+ ```
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
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,24 @@
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"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/BLamy/docstream-editor.git"
36
42
  }
37
43
  }
@@ -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 StarterKit from "@tiptap/starter-kit"
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 { GbCodeBlock, gitbookNodes } from "./nodes"
19
- import { SlashMenu } from "./slash-menu"
15
+ import { createGitbookExtensions } from "./extensions"
16
+ import type { SlashItem } from "./slash-menu"
20
17
 
21
18
  export interface GitbookEditorProps {
22
- markdown: string
23
- onChange: (markdown: string) => void
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({ markdown, onChange }: GitbookEditorProps) {
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
- extensions: [
100
- StarterKit.configure({ codeBlock: false }),
101
- GbCodeBlock,
102
- TaskList,
103
- TaskItem.configure({ nested: true }),
104
- GbTable.configure({ resizable: false }),
105
- TableRow,
106
- TableHeader,
107
- TableCell,
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
+ }
@@ -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: { language: b.language, title: b.title, lineNumbers: b.lineNumbers },
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
+ }
@@ -18,7 +18,10 @@ import {
18
18
  XCircle,
19
19
  } from "lucide-react"
20
20
 
21
- import { OpenApiOperation, resolveAsset, type HintStyle } from "@brett_lamy/docstream"
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
- {preview ? (
308
- <iframe className="gb-embed-frame" src={preview} title={url} allowFullScreen />
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() {