@brett_lamy/docstream-editor 0.7.0 → 1.0.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 CHANGED
@@ -75,6 +75,11 @@ export interface GitbookEditorProps {
75
75
  onChange: (markdown: string) => void
76
76
  onKeyDown?: (event: KeyboardEvent) => boolean
77
77
  onPaste?: (event: ClipboardEvent) => boolean
78
+ imagePaste?: "inline" | "chip"
79
+ attachments?: EditorAttachment[]
80
+ onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
81
+ onAttachmentRemove?: (ids: string[]) => void
82
+ onAttachmentOpen?: (attachment: EditorAttachment) => void
78
83
  }
79
84
  ```
80
85
 
@@ -82,6 +87,8 @@ export interface GitbookEditorProps {
82
87
  - `onChange`: Called with serialized markdown whenever TipTap content changes.
83
88
  - `onKeyDown` / `onPaste`: Optional host hooks that run before the built-in editor behavior;
84
89
  return `true` when the application handled the event.
90
+ - `imagePaste`, `attachments`, `onAttachmentAdd`, `onAttachmentRemove`, `onAttachmentOpen`:
91
+ how pasted/dropped images are handled — see [Pasted and dropped images](#pasted-and-dropped-images).
85
92
 
86
93
  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.
87
94
 
@@ -132,6 +139,42 @@ dollar signs in ordinary prose never open a menu. Free-form entry is always offe
132
139
  />
133
140
  ```
134
141
 
142
+ ## Pasted and dropped images
143
+
144
+ `imagePaste` controls how pasted or dropped image files enter the document. It runs after
145
+ your `onPaste` (return `true` there to take over). Clipboard data that also carries plain
146
+ text, such as a Word or Excel selection, still pastes as text.
147
+
148
+ - `"inline"` (default): an image block with the file as a data URL, the way it will render.
149
+ - `"chip"`: a compact attachment chip at the caret. The host stores the file. The chip
150
+ serializes as `![image.png](attachment:att-xyz)`, shows a thumbnail and size, previews the
151
+ full image on hover, and opens it on click.
152
+
153
+ ```tsx
154
+ const [markdown, setMarkdown] = useState("")
155
+ const [attachments, setAttachments] = useState<EditorAttachment[]>([])
156
+
157
+ <GitbookEditor
158
+ markdown={markdown}
159
+ onChange={setMarkdown}
160
+ imagePaste="chip"
161
+ attachments={attachments}
162
+ // once per image: { id, name, src (data URL), size, type, file }
163
+ onAttachmentAdd={({ file, ...attachment }) => setAttachments((all) => [...all, attachment])}
164
+ // chips deleted, cut or cleared by the user
165
+ onAttachmentRemove={(ids) => setAttachments((all) => all.filter((a) => !ids.includes(a.id)))}
166
+ // optional; omit it to use the built-in full-screen viewer
167
+ onAttachmentOpen={(attachment) => openLightbox(attachment)}
168
+ />
169
+ ```
170
+
171
+ `EditorAttachment` is `{ id, name, src?, size?, type? }`. Chips find their image in
172
+ `attachments` by id, so `src` can be a data URL or an uploaded URL. To remove a chip
173
+ from outside the editor, call `editor.commands.removeAttachment(id)` (get the instance
174
+ from `onEditorReady`). Markdown images with an `attachment:` URL always load as chips,
175
+ whatever the mode. `serializeEditorMarkdown(ast)` is the serializer that writes them in
176
+ this form.
177
+
135
178
  ## Edit a referenced source file
136
179
 
137
180
  `GitbookEditor` edits the Markdown composition, including a structured
@@ -182,6 +225,8 @@ Exports:
182
225
  - `astToTiptap`
183
226
  - `tiptapToAst`
184
227
  - `PMNode`
228
+ - `EditorAttachment`
229
+ - `serializeEditorMarkdown`
185
230
  - `SourceFileEditor`
186
231
  - `SourceFileEditorProps`
187
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "0.7.0",
3
+ "version": "1.0.0",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,7 +33,7 @@
33
33
  "./styles.css": "./src/styles.css"
34
34
  },
35
35
  "dependencies": {
36
- "@brett_lamy/docstream": "0.6.0",
36
+ "@brett_lamy/docstream": "1.0.0",
37
37
  "gpu-lexer": "0.0.2",
38
38
  "lucide-react": "^1.17.0"
39
39
  },
@@ -10,13 +10,23 @@ import {
10
10
  Strikethrough,
11
11
  } from "lucide-react"
12
12
 
13
- import { parseMarkdown, serializeMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
13
+ import { parseMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
14
14
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
15
- import { astToTiptap, tiptapToAst, type PMNode } from "./convert"
15
+ import {
16
+ attachmentName,
17
+ collectAttachmentIds,
18
+ imageFilesFrom,
19
+ newAttachmentId,
20
+ readFileAsDataURL,
21
+ type EditorAttachment,
22
+ } from "./attachments"
23
+ import { astToTiptap, serializeEditorMarkdown, tiptapToAst, type PMNode } from "./convert"
16
24
  import { createGitbookExtensions, type ReferenceSources } from "./extensions"
17
25
  import { EditorRuntimeProvider } from "./runtime"
18
26
  import type { SlashItem } from "./slash-menu"
19
27
 
28
+ export type { EditorAttachment } from "./attachments"
29
+
20
30
  export interface GitbookEditorProps {
21
31
  /** Markdown source. When provided, the editor stays in sync with it (controlled). */
22
32
  markdown?: string
@@ -50,6 +60,20 @@ export interface GitbookEditorProps {
50
60
  onKeyDown?: (event: KeyboardEvent) => boolean
51
61
  /** Handle clipboard content before the editor's built-in paste behavior. Return true when handled. */
52
62
  onPaste?: (event: ClipboardEvent) => boolean
63
+ /**
64
+ * How pasted or dropped image files enter the document.
65
+ * "inline" (default) — an image block with the file as a data URL, as it will render.
66
+ * "chip" — a compact attachment chip at the caret; the host owns the file via `attachments`.
67
+ */
68
+ imagePaste?: "inline" | "chip"
69
+ /** Chip mode: the host's attachment store. Chips look up their thumbnail, full image and size here by id. */
70
+ attachments?: EditorAttachment[]
71
+ /** Chip mode: fired once per pasted/dropped image with a fresh id and the file read as a data URL. Add it to `attachments`. */
72
+ onAttachmentAdd?: (attachment: EditorAttachment & { file: File }) => void
73
+ /** Chip mode: fired when chips disappear from the document (deleted, cut, cleared) with their ids. */
74
+ onAttachmentRemove?: (ids: string[]) => void
75
+ /** Chip mode: clicking (or Enter/Space on a focused) chip. Omit to open the built-in image viewer. */
76
+ onAttachmentOpen?: (attachment: EditorAttachment) => void
53
77
  /** Receive the underlying TipTap editor instance (and null on teardown). */
54
78
  onEditorReady?: (editor: TiptapEditor | null) => void
55
79
  /** Client used to edit and preview `{% source-ref %}` files. */
@@ -138,6 +162,11 @@ export function GitbookEditor({
138
162
  extensions,
139
163
  onKeyDown,
140
164
  onPaste,
165
+ imagePaste = "inline",
166
+ attachments,
167
+ onAttachmentAdd,
168
+ onAttachmentRemove,
169
+ onAttachmentOpen,
141
170
  onEditorReady,
142
171
  sourceClient,
143
172
  sourcePreview = true,
@@ -152,6 +181,56 @@ export function GitbookEditor({
152
181
  // ones from the last applied markdown and reattach them on serialize.
153
182
  const citationsRef = useRef<CitationDef[] | undefined>(undefined)
154
183
  const controlled = markdown !== undefined
184
+ // Ids of the attachment chips in the document, to report removals.
185
+ const attachmentIds = useRef<Set<string>>(new Set())
186
+ // Latest callbacks for handlers the editor captured at creation.
187
+ const latest = useRef({ onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove })
188
+ latest.current = { onPaste, imagePaste, onAttachmentAdd, onAttachmentRemove }
189
+ const editorRef = useRef<TiptapEditor | null>(null)
190
+
191
+ // Pasted/dropped image files: image blocks ("inline") or attachment chips ("chip").
192
+ const insertImageFiles = (files: File[], at?: number) => {
193
+ const ed = editorRef.current
194
+ if (!ed) return
195
+ const insert = (content: PMNode[]) => {
196
+ const chain = ed.chain().focus()
197
+ const pos = at === undefined ? undefined : Math.min(at, ed.state.doc.content.size)
198
+ ;(pos === undefined ? chain.insertContent(content) : chain.insertContentAt(pos, content)).run()
199
+ }
200
+ if (latest.current.imagePaste === "chip") {
201
+ const items = files.map((file) => ({ file, id: newAttachmentId(), name: attachmentName(file) }))
202
+ // Keep chips off the preceding word: "done.[chip]" → "done. [chip]".
203
+ const $at = at === undefined ? ed.state.selection.$from : ed.state.doc.resolve(Math.min(at, ed.state.doc.content.size))
204
+ const before = $at.parent.isTextblock ? $at.parent.textBetween(Math.max(0, $at.parentOffset - 1), $at.parentOffset, "", "\uFFFC") : ""
205
+ const lead: PMNode[] = before && !/\s/.test(before) ? [{ type: "text", text: " " }] : []
206
+ insert([
207
+ ...lead,
208
+ ...items.flatMap(({ id, name }): PMNode[] => [
209
+ { type: "gbAttachment", attrs: { id, name } },
210
+ { type: "text", text: " " },
211
+ ]),
212
+ ])
213
+ for (const { file, id, name } of items) {
214
+ const base = { id, name, size: file.size, type: file.type, file }
215
+ readFileAsDataURL(file).then(
216
+ (src) => latest.current.onAttachmentAdd?.({ ...base, src }),
217
+ () => latest.current.onAttachmentAdd?.(base)
218
+ )
219
+ }
220
+ return
221
+ }
222
+ void Promise.all(
223
+ files.map((file) =>
224
+ readFileAsDataURL(file).then(
225
+ (src): PMNode => ({ type: "gbFigure", attrs: { src, alt: attachmentName(file), caption: "" } }),
226
+ () => null
227
+ )
228
+ )
229
+ ).then((figures) => {
230
+ const content = figures.filter((f): f is PMNode => f !== null)
231
+ if (content.length && !ed.isDestroyed) insert(content)
232
+ })
233
+ }
155
234
 
156
235
  const parseAndTrack = (md: string) => {
157
236
  const doc = parseMarkdown(md)
@@ -171,19 +250,44 @@ export function GitbookEditor({
171
250
  }),
172
251
  editorProps: {
173
252
  handleKeyDown: (_view, event) => onKeyDown?.(event) ?? false,
174
- handlePaste: (_view, event) => onPaste?.(event) ?? false,
253
+ handlePaste: (view, event) => {
254
+ if (latest.current.onPaste?.(event)) return true
255
+ if (!view.editable || view.state.selection.$from.parent.type.spec.code) return false
256
+ const files = imageFilesFrom(event.clipboardData, { ignoreWithText: true })
257
+ if (!files.length) return false
258
+ event.preventDefault()
259
+ insertImageFiles(files)
260
+ return true
261
+ },
262
+ handleDrop: (view, event, _slice, moved) => {
263
+ if (moved || !view.editable) return false
264
+ const files = imageFilesFrom(event.dataTransfer)
265
+ if (!files.length) return false
266
+ event.preventDefault()
267
+ insertImageFiles(files, view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos)
268
+ return true
269
+ },
175
270
  },
176
271
  ...(controlled ? { content: astToTiptap(parseAndTrack(markdown as string)) } : {}),
272
+ onCreate({ editor }) {
273
+ attachmentIds.current = collectAttachmentIds(editor.state.doc)
274
+ },
177
275
  onUpdate({ editor }) {
276
+ const ids = collectAttachmentIds(editor.state.doc)
277
+ const removed = [...attachmentIds.current].filter((id) => !ids.has(id))
278
+ attachmentIds.current = ids
279
+ if (removed.length) latest.current.onAttachmentRemove?.(removed)
178
280
  if (!onChange) return
179
281
  const ast = tiptapToAst(editor.getJSON() as PMNode)
180
282
  if (citationsRef.current?.length) ast.citations = citationsRef.current
181
- const md = serializeMarkdown(ast)
283
+ const md = serializeEditorMarkdown(ast)
182
284
  lastEmitted.current = md
183
285
  onChange(md)
184
286
  },
185
287
  })
186
288
 
289
+ editorRef.current = editor ?? null
290
+
187
291
  // Surface the editor instance to the host (for awareness, commands, etc.).
188
292
  useEffect(() => {
189
293
  onEditorReady?.(editor ?? null)
@@ -197,6 +301,8 @@ export function GitbookEditor({
197
301
  const doc = parseMarkdown(markdown as string)
198
302
  citationsRef.current = doc.citations
199
303
  editor.commands.setContent(astToTiptap(doc), { emitUpdate: false })
304
+ // Chips replaced by the host's own markdown aren't "removed" by the user.
305
+ attachmentIds.current = collectAttachmentIds(editor.state.doc)
200
306
  }, [editor, controlled, markdown])
201
307
 
202
308
  const runtime = useMemo(() => ({
@@ -205,7 +311,9 @@ export function GitbookEditor({
205
311
  sourceAutoSave,
206
312
  ...(onSourceSaved ? { onSourceSaved } : {}),
207
313
  ...(onSourceError ? { onSourceError } : {}),
208
- }), [onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
314
+ ...(attachments ? { attachments } : {}),
315
+ ...(onAttachmentOpen ? { onAttachmentOpen } : {}),
316
+ }), [attachments, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
209
317
 
210
318
  if (!editor) return null
211
319
 
@@ -0,0 +1,354 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core"
2
+ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"
3
+ import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from "@tiptap/react"
4
+ import { ImageIcon } from "lucide-react"
5
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"
6
+ import { createPortal } from "react-dom"
7
+
8
+ import { useEditorRuntime } from "./runtime"
9
+
10
+ /** A file the host owns, referenced from the document by an attachment chip. */
11
+ export interface EditorAttachment {
12
+ id: string
13
+ name: string
14
+ /** Full image URL (typically a data URL). Also used for the chip thumbnail. */
15
+ src?: string
16
+ /** Size in bytes. */
17
+ size?: number
18
+ /** MIME type. */
19
+ type?: string
20
+ }
21
+
22
+ /** The URL scheme attachment chips serialize to: `![name](attachment:<id>)`. */
23
+ export const ATTACHMENT_SCHEME = "attachment:"
24
+
25
+ export function newAttachmentId(): string {
26
+ const rand =
27
+ typeof crypto !== "undefined" && "randomUUID" in crypto
28
+ ? crypto.randomUUID().replace(/-/g, "").slice(0, 12)
29
+ : Math.random().toString(36).slice(2, 14)
30
+ return `att-${rand}`
31
+ }
32
+
33
+ /**
34
+ * The name a pasted/dropped file gets in the document. Clipboard screenshots
35
+ * usually arrive as "image.png"; unnamed ones get that too. Characters that
36
+ * would break `![alt](src)` / `alt="…"` are replaced.
37
+ */
38
+ export function attachmentName(file: File): string {
39
+ const name = (file.name || "image.png").replace(/[[\]"<>\r\n]/g, "_").trim()
40
+ return name || "image.png"
41
+ }
42
+
43
+ /** "84 KB", "1.3 MB". */
44
+ export function formatBytes(bytes: number | undefined): string {
45
+ if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return ""
46
+ if (bytes < 1024) return `${bytes} B`
47
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
48
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
49
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
50
+ }
51
+
52
+ export function readFileAsDataURL(file: File): Promise<string> {
53
+ return new Promise((resolve, reject) => {
54
+ const reader = new FileReader()
55
+ reader.onload = () => resolve(String(reader.result ?? ""))
56
+ reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name}`))
57
+ reader.readAsDataURL(file)
58
+ })
59
+ }
60
+
61
+ /**
62
+ * Image files carried by a paste or drop. Clipboard data that also has plain
63
+ * text (e.g. a Word/Excel selection, which ships a rendered PNG alongside the
64
+ * text) is left to the normal text paste.
65
+ */
66
+ export function imageFilesFrom(data: DataTransfer | null, { ignoreWithText = false } = {}): File[] {
67
+ if (!data) return []
68
+ const files = Array.from(data.files ?? []).filter((f) => f.type.startsWith("image/"))
69
+ if (!files.length) return []
70
+ if (ignoreWithText && data.getData("text/plain").trim()) return []
71
+ return files
72
+ }
73
+
74
+ /** Ids of every attachment chip in a document. */
75
+ export function collectAttachmentIds(doc: ProseMirrorNode): Set<string> {
76
+ const ids = new Set<string>()
77
+ doc.descendants((node) => {
78
+ if (node.type.name === "gbAttachment" && node.attrs.id) ids.add(String(node.attrs.id))
79
+ return node.isBlock || node.type.name === "doc"
80
+ })
81
+ return ids
82
+ }
83
+
84
+ // ---------- Hover card ----------
85
+
86
+ const HOVER_DELAY = 150
87
+ const HIDE_GRACE = 120
88
+ const GAP = 8
89
+ const MARGIN = 8
90
+
91
+ function AttachmentHoverCard({
92
+ anchor,
93
+ attachment,
94
+ }: {
95
+ anchor: HTMLElement
96
+ attachment: EditorAttachment
97
+ }) {
98
+ const cardRef = useRef<HTMLDivElement>(null)
99
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
100
+
101
+ const place = useCallback(() => {
102
+ const card = cardRef.current
103
+ if (!card) return
104
+ const a = anchor.getBoundingClientRect()
105
+ const { width, height } = card.getBoundingClientRect()
106
+ const vw = window.innerWidth
107
+ const vh = window.innerHeight
108
+ let top = a.top - GAP - height
109
+ if (top < MARGIN && a.bottom + GAP + height <= vh - MARGIN) top = a.bottom + GAP
110
+ top = Math.max(MARGIN, top)
111
+ const left = Math.min(Math.max(MARGIN, a.left + a.width / 2 - width / 2), Math.max(MARGIN, vw - MARGIN - width))
112
+ setPos({ top, left })
113
+ }, [anchor])
114
+
115
+ useLayoutEffect(place, [place, attachment.src])
116
+
117
+ const size = formatBytes(attachment.size)
118
+ const style: CSSProperties = pos
119
+ ? { top: pos.top, left: pos.left }
120
+ : { top: 0, left: 0, visibility: "hidden" }
121
+ return createPortal(
122
+ <div ref={cardRef} className="gb-attachment-card" style={style} role="tooltip">
123
+ {attachment.src ? (
124
+ <img className="gb-attachment-card-img" src={attachment.src} alt={attachment.name} onLoad={place} />
125
+ ) : (
126
+ <div className="gb-attachment-card-empty">
127
+ <ImageIcon className="size-5" />
128
+ </div>
129
+ )}
130
+ <div className="gb-attachment-card-meta">
131
+ <span className="gb-attachment-card-name">{attachment.name}</span>
132
+ {size && <span className="gb-attachment-card-size"> · {size}</span>}
133
+ </div>
134
+ </div>,
135
+ document.body
136
+ )
137
+ }
138
+
139
+ // ---------- Built-in viewer ----------
140
+
141
+ function AttachmentViewer({ attachment, onClose }: { attachment: EditorAttachment; onClose: () => void }) {
142
+ useEffect(() => {
143
+ const onKey = (e: KeyboardEvent) => {
144
+ if (e.key === "Escape") {
145
+ e.preventDefault()
146
+ e.stopPropagation()
147
+ onClose()
148
+ }
149
+ }
150
+ document.addEventListener("keydown", onKey, true)
151
+ return () => document.removeEventListener("keydown", onKey, true)
152
+ }, [onClose])
153
+
154
+ return createPortal(
155
+ <div
156
+ className="gb-attachment-viewer"
157
+ role="dialog"
158
+ aria-modal="true"
159
+ aria-label={attachment.name}
160
+ onMouseDown={(e) => e.preventDefault()}
161
+ onClick={onClose}
162
+ >
163
+ {attachment.src ? (
164
+ <img className="gb-attachment-viewer-img" src={attachment.src} alt={attachment.name} />
165
+ ) : (
166
+ <div className="gb-attachment-viewer-empty">
167
+ <ImageIcon className="size-6" />
168
+ {attachment.name}
169
+ </div>
170
+ )}
171
+ </div>,
172
+ document.body
173
+ )
174
+ }
175
+
176
+ // ---------- Chip ----------
177
+
178
+ function AttachmentView({ node, selected, editor }: NodeViewProps) {
179
+ const { attachments, onAttachmentOpen } = useEditorRuntime()
180
+ const id = String(node.attrs.id ?? "")
181
+ const stored = attachments?.find((a) => a.id === id)
182
+ const attachment: EditorAttachment = { ...stored, id, name: stored?.name || String(node.attrs.name || "image") }
183
+ const size = formatBytes(attachment.size)
184
+
185
+ const chipRef = useRef<HTMLSpanElement>(null)
186
+ const [hovered, setHovered] = useState(false)
187
+ const [focused, setFocused] = useState(false)
188
+ const [dismissed, setDismissed] = useState(false)
189
+ const [viewing, setViewing] = useState(false)
190
+ const showTimer = useRef<number | undefined>(undefined)
191
+ const hideTimer = useRef<number | undefined>(undefined)
192
+
193
+ const clearTimers = () => {
194
+ window.clearTimeout(showTimer.current)
195
+ window.clearTimeout(hideTimer.current)
196
+ }
197
+ useEffect(() => clearTimers, [])
198
+
199
+ // A fresh selection of the chip re-arms a card dismissed with Escape.
200
+ useEffect(() => {
201
+ if (selected) setDismissed(false)
202
+ }, [selected])
203
+
204
+ const cardOpen =
205
+ !viewing && !dismissed && (hovered || focused || (selected && editor.isFocused))
206
+
207
+ useEffect(() => {
208
+ if (!cardOpen) return
209
+ const hide = () => {
210
+ clearTimers()
211
+ setHovered(false)
212
+ setDismissed(true)
213
+ }
214
+ const onKey = (e: KeyboardEvent) => {
215
+ if (e.key === "Escape") hide()
216
+ }
217
+ window.addEventListener("scroll", hide, true)
218
+ document.addEventListener("keydown", onKey, true)
219
+ return () => {
220
+ window.removeEventListener("scroll", hide, true)
221
+ document.removeEventListener("keydown", onKey, true)
222
+ }
223
+ }, [cardOpen])
224
+
225
+ const open = () => {
226
+ clearTimers()
227
+ setHovered(false)
228
+ if (onAttachmentOpen) onAttachmentOpen(attachment)
229
+ else setViewing(true)
230
+ }
231
+
232
+ const closeViewer = useCallback(() => setViewing(false), [])
233
+
234
+ return (
235
+ <NodeViewWrapper as="span" className="gb-attachment-wrap" contentEditable={false}>
236
+ <span
237
+ ref={chipRef}
238
+ className="gb-attachment"
239
+ role="button"
240
+ tabIndex={0}
241
+ aria-label={size ? `${attachment.name}, ${size}` : attachment.name}
242
+ data-attachment-id={id}
243
+ onMouseEnter={() => {
244
+ window.clearTimeout(hideTimer.current)
245
+ setDismissed(false)
246
+ showTimer.current = window.setTimeout(() => setHovered(true), HOVER_DELAY)
247
+ }}
248
+ onMouseLeave={() => {
249
+ window.clearTimeout(showTimer.current)
250
+ hideTimer.current = window.setTimeout(() => setHovered(false), HIDE_GRACE)
251
+ }}
252
+ onFocus={() => {
253
+ setDismissed(false)
254
+ setFocused(true)
255
+ }}
256
+ onBlur={() => setFocused(false)}
257
+ // Keep the caret where it is — the chip is a button, not a text target.
258
+ onMouseDown={(e) => e.preventDefault()}
259
+ onClick={(e) => {
260
+ e.preventDefault()
261
+ open()
262
+ }}
263
+ onKeyDown={(e) => {
264
+ if (e.key === "Enter" || e.key === " ") {
265
+ e.preventDefault()
266
+ e.stopPropagation()
267
+ open()
268
+ }
269
+ }}
270
+ >
271
+ <span className="gb-attachment-thumb">
272
+ {attachment.src ? (
273
+ <img src={attachment.src} alt="" draggable={false} />
274
+ ) : (
275
+ <ImageIcon className="gb-attachment-thumb-icon" />
276
+ )}
277
+ </span>
278
+ <span className="gb-attachment-name">{attachment.name}</span>
279
+ {size && <span className="gb-attachment-size">{size}</span>}
280
+ </span>
281
+ {cardOpen && chipRef.current && <AttachmentHoverCard anchor={chipRef.current} attachment={attachment} />}
282
+ {viewing && <AttachmentViewer attachment={attachment} onClose={closeViewer} />}
283
+ </NodeViewWrapper>
284
+ )
285
+ }
286
+
287
+ declare module "@tiptap/core" {
288
+ interface Commands<ReturnType> {
289
+ gbAttachment: {
290
+ /** Delete every attachment chip with this id. */
291
+ removeAttachment: (id: string) => ReturnType
292
+ }
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Inline attachment chip (`![name](attachment:<id>)`). The file itself lives in
298
+ * the host's attachment store; the node only carries the id and display name.
299
+ */
300
+ export const GbAttachment = Node.create({
301
+ name: "gbAttachment",
302
+ group: "inline",
303
+ inline: true,
304
+ atom: true,
305
+ selectable: true,
306
+ draggable: false,
307
+ addAttributes() {
308
+ return {
309
+ id: { default: "" },
310
+ name: { default: "" },
311
+ }
312
+ },
313
+ parseHTML() {
314
+ return [
315
+ {
316
+ tag: "span[data-gb-attachment]",
317
+ getAttrs: (el) => ({
318
+ id: (el as HTMLElement).getAttribute("data-gb-attachment") ?? "",
319
+ name: (el as HTMLElement).getAttribute("data-name") ?? "",
320
+ }),
321
+ },
322
+ ]
323
+ },
324
+ renderHTML({ HTMLAttributes, node }) {
325
+ return [
326
+ "span",
327
+ mergeAttributes(HTMLAttributes, { "data-gb-attachment": node.attrs.id, "data-name": node.attrs.name }),
328
+ String(node.attrs.name),
329
+ ]
330
+ },
331
+ renderText({ node }) {
332
+ return String(node.attrs.name)
333
+ },
334
+ addCommands() {
335
+ return {
336
+ removeAttachment:
337
+ (id) =>
338
+ ({ tr, state, dispatch }) => {
339
+ const ranges: Array<[number, number]> = []
340
+ state.doc.descendants((node, pos) => {
341
+ if (node.type.name === this.name && node.attrs.id === id) ranges.push([pos, pos + node.nodeSize])
342
+ })
343
+ if (!ranges.length) return false
344
+ if (dispatch) {
345
+ for (const [from, to] of ranges.reverse()) tr.delete(from, to)
346
+ }
347
+ return true
348
+ },
349
+ }
350
+ },
351
+ addNodeView() {
352
+ return ReactNodeViewRenderer(AttachmentView)
353
+ },
354
+ })
@@ -1,4 +1,19 @@
1
- import { plainText, type Block, type DocumentNode, type Inline, type ListItemNode } from "@brett_lamy/docstream/gitbook"
1
+ import {
2
+ plainText,
3
+ serializeMarkdown,
4
+ type Block,
5
+ type DocumentNode,
6
+ type Inline,
7
+ type ListItemNode,
8
+ } from "@brett_lamy/docstream/gitbook"
9
+
10
+ // Attachment chips live in markdown as images with an `attachment:<id>` URL.
11
+ const ATTACHMENT_SCHEME = "attachment:"
12
+ const isAttachmentSrc = (src: string | undefined) => !!src && src.startsWith(ATTACHMENT_SCHEME)
13
+ const attachmentPM = (src: string, alt: string | undefined): PMNode => ({
14
+ type: "gbAttachment",
15
+ attrs: { id: src.slice(ATTACHMENT_SCHEME.length), name: alt ?? "" },
16
+ })
2
17
 
3
18
  // TipTap/ProseMirror JSON shape (loosely typed on purpose)
4
19
  export interface PMNode {
@@ -23,6 +38,7 @@ function inlineToPM(nodes: Inline[]): PMNode[] {
23
38
  attrs: { kind: n.kind, id: n.id, url: n.url ?? "", label: n.label ?? "" },
24
39
  }
25
40
  }
41
+ if (n.type === "image" && isAttachmentSrc(n.src)) return attachmentPM(n.src, n.alt)
26
42
  if (n.type === "image") {
27
43
  return {
28
44
  type: "gbInlineImage",
@@ -135,6 +151,8 @@ function blockToPM(b: Block): PMNode {
135
151
  content: b.columns.map((c) => ({ type: "gbColumn", content: blocksPM(c.children) })),
136
152
  }
137
153
  case "figure":
154
+ // A chip alone on its line parses as a figure; it is still a chip.
155
+ if (isAttachmentSrc(b.src)) return { type: "paragraph", content: [attachmentPM(b.src, b.alt)] }
138
156
  return { type: "gbFigure", attrs: { src: b.src, alt: b.alt, caption: b.caption } }
139
157
  case "list":
140
158
  return {
@@ -199,8 +217,22 @@ export function astToTiptap(doc: DocumentNode): PMNode {
199
217
  function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
200
218
  if (!nodes) return []
201
219
  return nodes
202
- .filter((n) => (n.type === "text" && n.text) || n.type === "gbInlineImage" || n.type === "gbReference")
220
+ .filter(
221
+ (n) =>
222
+ (n.type === "text" && n.text) ||
223
+ n.type === "gbInlineImage" ||
224
+ n.type === "gbReference" ||
225
+ n.type === "gbAttachment"
226
+ )
203
227
  .map((n): Inline => {
228
+ if (n.type === "gbAttachment") {
229
+ const a = n.attrs ?? {}
230
+ return {
231
+ type: "image",
232
+ src: `${ATTACHMENT_SCHEME}${String(a.id ?? "")}`,
233
+ ...(a.name ? { alt: String(a.name) } : {}),
234
+ }
235
+ }
204
236
  if (n.type === "gbReference") {
205
237
  const a = n.attrs ?? {}
206
238
  const kind =
@@ -398,3 +430,19 @@ function pmToBlocks(nodes: PMNode[] | undefined): Block[] {
398
430
  export function tiptapToAst(doc: PMNode): DocumentNode {
399
431
  return { type: "doc", children: pmToBlocks(doc.content) }
400
432
  }
433
+
434
+ // docstream writes inline images as `<img src alt />`; attachment chips read
435
+ // better (and diff better) as plain markdown images.
436
+ const ATTACHMENT_IMG = /<img src="(attachment:[^"\s]+)"(?: alt="([^"]*)")? \/>/g
437
+
438
+ /**
439
+ * Serialize an editor AST to GitBook markdown, writing attachment chips as
440
+ * `![name](attachment:<id>)`. Use this instead of `serializeMarkdown` for
441
+ * editor output.
442
+ */
443
+ export function serializeEditorMarkdown(doc: DocumentNode): string {
444
+ return serializeMarkdown(doc).replace(ATTACHMENT_IMG, (img: string, src: string, alt = "") =>
445
+ // `]` can't be escaped in `![alt]`; such names keep the <img> form.
446
+ alt.includes("]") ? img : `![${alt}](${src})`
447
+ )
448
+ }
@@ -32,6 +32,7 @@ import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
32
32
  import { VideoEmbed } from "@brett_lamy/docstream/video"
33
33
  import { SourceFileEditor } from "./SourceFileEditor"
34
34
  import { useEditorRuntime } from "./runtime"
35
+ import { GbAttachment } from "./attachments"
35
36
 
36
37
  function useDebouncedValue<T>(value: T, delay = 350): T {
37
38
  const [debounced, setDebounced] = useState(value)
@@ -983,4 +984,5 @@ export const gitbookNodes = [
983
984
  GbOpenapi,
984
985
  GbInlineImage,
985
986
  GbReference,
987
+ GbAttachment,
986
988
  ]
@@ -1,6 +1,7 @@
1
1
  import { createContext, useContext, type ReactNode } from "react"
2
2
 
3
3
  import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
4
+ import type { EditorAttachment } from "./attachments"
4
5
 
5
6
  export interface EditorRuntimeOptions {
6
7
  sourceClient?: SourceReferenceClient
@@ -8,6 +9,8 @@ export interface EditorRuntimeOptions {
8
9
  sourceAutoSave?: boolean | number
9
10
  onSourceSaved?: (snapshot: SourceFileSnapshot) => void
10
11
  onSourceError?: (error: Error) => void
12
+ attachments?: EditorAttachment[]
13
+ onAttachmentOpen?: (attachment: EditorAttachment) => void
11
14
  }
12
15
 
13
16
  const EditorRuntimeContext = createContext<EditorRuntimeOptions>({})
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { GitbookEditor } from "./editor/Editor"
2
- export type { GitbookEditorProps } from "./editor/Editor"
3
- export { astToTiptap, tiptapToAst } from "./editor/convert"
2
+ export type { GitbookEditorProps, EditorAttachment } from "./editor/Editor"
3
+ export { astToTiptap, tiptapToAst, serializeEditorMarkdown } 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
@@ -8,6 +8,7 @@ export type { PMNode } from "./editor/convert"
8
8
  export { createGitbookExtensions, getGitbookSchema, GbTable } from "./editor/extensions"
9
9
  export type { GitbookExtensionOptions, ReferenceSources } from "./editor/extensions"
10
10
  export { gitbookNodes, GbCodeBlock, GbReference } from "./editor/nodes"
11
+ export { GbAttachment, formatBytes } from "./editor/attachments"
11
12
  export { SlashMenu, createSlashMenu, SLASH_ITEMS } from "./editor/slash-menu"
12
13
  export type { SlashItem } from "./editor/slash-menu"
13
14
  export { createReferenceSuggestion, resolveReferenceOptions } from "./editor/reference-menu"
package/src/styles.css CHANGED
@@ -583,14 +583,15 @@
583
583
  }
584
584
  .gb-code > pre code { background: none; padding: 0; font-size: inherit; color: inherit; }
585
585
  /* gpu-lexer span classes (palette shared with docstream's HighlightedCode). */
586
- .gb-code .gb-syntax-comment { color: #6b6b78; font-style: italic; }
587
- .gb-code .gb-syntax-string { color: #a5d6a7; }
588
- .gb-code .gb-syntax-number { color: #f78c6c; }
589
- .gb-code .gb-syntax-keyword { color: #c792ea; }
590
- .gb-code .gb-syntax-type { color: #ffcb6b; }
591
- .gb-code .gb-syntax-function { color: #82aaff; }
592
- .gb-code .gb-syntax-constant { color: #f07178; }
593
- .gb-code .gb-syntax-operator { color: #89ddff; }
586
+ /* Same palette and custom properties as Docstream's .docs-tok-*: follows the host color-scheme. */
587
+ .gb-code .gb-syntax-comment { color: var(--docs-tok-comment, light-dark(#6E6E7A, #8A8A98)); font-style: italic; }
588
+ .gb-code .gb-syntax-string { color: var(--docs-tok-string, light-dark(#23803A, #A5D6A7)); }
589
+ .gb-code .gb-syntax-number { color: var(--docs-tok-number, light-dark(#C2410C, #F78C6C)); }
590
+ .gb-code .gb-syntax-keyword { color: var(--docs-tok-keyword, light-dark(#8839C4, #C792EA)); }
591
+ .gb-code .gb-syntax-type { color: var(--docs-tok-type, light-dark(#A15C00, #FFCB6B)); }
592
+ .gb-code .gb-syntax-function { color: var(--docs-tok-function, light-dark(#2F5BD3, #82AAFF)); }
593
+ .gb-code .gb-syntax-constant { color: var(--docs-tok-constant, light-dark(#C8283F, #F07178)); }
594
+ .gb-code .gb-syntax-operator { color: var(--docs-tok-operator, light-dark(#0B7285, #89DDFF)); }
594
595
 
595
596
  /* ── Updates / changelog (mirrors .docs-updates) ────────── */
596
597
  .gb-updates { margin: 0.85em 0; border-top: 1px solid var(--gb-border); }
@@ -859,3 +860,163 @@
859
860
  outline: 2px solid var(--gb-primary);
860
861
  outline-offset: 1px;
861
862
  }
863
+
864
+ /* ---- Attachment chips (pasted images in imagePaste="chip" mode) ---- */
865
+
866
+ .gb-attachment-wrap {
867
+ display: inline;
868
+ }
869
+
870
+ .gb-attachment {
871
+ display: inline-flex;
872
+ align-items: center;
873
+ gap: 5px;
874
+ max-width: 100%;
875
+ padding: 1px 7px 1px 3px;
876
+ border: 1px solid var(--gb-border);
877
+ border-radius: 7px;
878
+ background: var(--gb-muted);
879
+ color: var(--gb-text);
880
+ font-size: 0.85em;
881
+ line-height: 1.5;
882
+ vertical-align: middle;
883
+ cursor: pointer;
884
+ user-select: none;
885
+ transition: border-color 120ms ease, background-color 120ms ease;
886
+ }
887
+ .gb-attachment:hover {
888
+ border-color: var(--gb-muted-foreground);
889
+ }
890
+ .gb-attachment:focus {
891
+ outline: none;
892
+ }
893
+ .gb-attachment:focus-visible,
894
+ .ProseMirror-selectednode .gb-attachment {
895
+ outline: 2px solid var(--gb-primary);
896
+ outline-offset: 1px;
897
+ }
898
+
899
+ .gb-attachment-thumb {
900
+ display: inline-flex;
901
+ flex-shrink: 0;
902
+ align-items: center;
903
+ justify-content: center;
904
+ width: 20px;
905
+ height: 16px;
906
+ overflow: hidden;
907
+ border-radius: 3px;
908
+ background: var(--gb-border);
909
+ color: var(--gb-muted-foreground);
910
+ }
911
+ .gb-attachment-thumb img {
912
+ display: block;
913
+ width: 100%;
914
+ height: 100%;
915
+ object-fit: cover;
916
+ }
917
+ .gb-attachment-thumb-icon {
918
+ width: 12px;
919
+ height: 12px;
920
+ }
921
+
922
+ .gb-attachment-name {
923
+ overflow: hidden;
924
+ font-weight: 500;
925
+ white-space: nowrap;
926
+ text-overflow: ellipsis;
927
+ max-width: 22ch;
928
+ }
929
+ .gb-attachment-size {
930
+ flex-shrink: 0;
931
+ color: var(--gb-muted-foreground);
932
+ font-variant-numeric: tabular-nums;
933
+ white-space: nowrap;
934
+ }
935
+
936
+ /* Portaled to <body>, outside .gb — tokens resolve from the host directly. */
937
+ .gb-attachment-card {
938
+ position: fixed;
939
+ z-index: 1000;
940
+ box-sizing: border-box;
941
+ max-width: calc(100vw - 16px);
942
+ padding: 6px;
943
+ border: 1px solid var(--border, #e5e7eb);
944
+ border-radius: 12px;
945
+ background: var(--card, #ffffff);
946
+ color: var(--card-foreground, var(--foreground, #111827));
947
+ font-family: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
948
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.08);
949
+ pointer-events: none;
950
+ animation: gb-attachment-in 120ms ease-out;
951
+ }
952
+ .gb-attachment-card-img {
953
+ display: block;
954
+ max-width: 360px;
955
+ max-height: 280px;
956
+ width: auto;
957
+ height: auto;
958
+ object-fit: contain;
959
+ border-radius: 7px;
960
+ background: var(--muted, #f4f4f5);
961
+ }
962
+ .gb-attachment-card-empty {
963
+ display: flex;
964
+ align-items: center;
965
+ justify-content: center;
966
+ width: 160px;
967
+ height: 100px;
968
+ border-radius: 7px;
969
+ background: var(--muted, #f4f4f5);
970
+ color: var(--muted-foreground, #6b7280);
971
+ }
972
+ .gb-attachment-card-meta {
973
+ max-width: 360px;
974
+ padding: 6px 4px 1px;
975
+ overflow: hidden;
976
+ font-size: 12px;
977
+ line-height: 1.4;
978
+ white-space: nowrap;
979
+ text-overflow: ellipsis;
980
+ }
981
+ .gb-attachment-card-name { font-weight: 500; }
982
+ .gb-attachment-card-size { color: var(--muted-foreground, #6b7280); }
983
+
984
+ .gb-attachment-viewer {
985
+ position: fixed;
986
+ inset: 0;
987
+ z-index: 1001;
988
+ display: flex;
989
+ align-items: center;
990
+ justify-content: center;
991
+ background: rgba(0, 0, 0, 0.72);
992
+ cursor: zoom-out;
993
+ animation: gb-attachment-fade 140ms ease-out;
994
+ }
995
+ .gb-attachment-viewer-img {
996
+ max-width: 90vw;
997
+ max-height: 90vh;
998
+ object-fit: contain;
999
+ border-radius: 8px;
1000
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
1001
+ }
1002
+ .gb-attachment-viewer-empty {
1003
+ display: flex;
1004
+ flex-direction: column;
1005
+ align-items: center;
1006
+ gap: 10px;
1007
+ color: #ffffff;
1008
+ font-size: 14px;
1009
+ }
1010
+
1011
+ @keyframes gb-attachment-in {
1012
+ from { opacity: 0; transform: translateY(2px); }
1013
+ to { opacity: 1; transform: none; }
1014
+ }
1015
+ @keyframes gb-attachment-fade {
1016
+ from { opacity: 0; }
1017
+ to { opacity: 1; }
1018
+ }
1019
+ @media (prefers-reduced-motion: reduce) {
1020
+ .gb-attachment-card,
1021
+ .gb-attachment-viewer { animation: none; }
1022
+ }