@brett_lamy/docstream-editor 0.6.0 → 0.8.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 +68 -0
- package/package.json +2 -2
- package/src/editor/Editor.tsx +119 -8
- package/src/editor/attachments.tsx +354 -0
- package/src/editor/convert.ts +52 -3
- package/src/editor/extensions.ts +16 -4
- package/src/editor/nodes.tsx +18 -2
- package/src/editor/reference-menu.tsx +88 -21
- package/src/editor/runtime.tsx +3 -0
- package/src/editor/suggestion-menu.tsx +25 -16
- package/src/index.ts +11 -5
- package/src/styles.css +192 -8
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
|
|
|
@@ -109,6 +116,65 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
|
|
|
109
116
|
|
|
110
117
|
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.
|
|
111
118
|
|
|
119
|
+
## Mentions, channels, and codebases
|
|
120
|
+
|
|
121
|
+
Typing `@`, `#`, or `$` opens a picker and inserts a reference chip that serializes as
|
|
122
|
+
`@id`, `#id`, or `$id` (`$org/repo` works too). Feed each trigger a static list or an
|
|
123
|
+
async `(query) => options` function; options can carry a `label`, `description`, and
|
|
124
|
+
`group` (rendered as section headers). `$` is only enabled when `codebases` is set, so
|
|
125
|
+
dollar signs in ordinary prose never open a menu. Free-form entry is always offered.
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
<GitbookEditor
|
|
129
|
+
markdown={md}
|
|
130
|
+
onChange={setMd}
|
|
131
|
+
references={{
|
|
132
|
+
mentions: [
|
|
133
|
+
...people.map((p) => ({ id: p.handle, label: p.name, description: p.email, group: "People" })),
|
|
134
|
+
...agents.map((a) => ({ id: a.handle, label: a.name, description: a.harness, group: "Agents" })),
|
|
135
|
+
],
|
|
136
|
+
tags: async (query) => searchChannels(query), // [{ id: "general", description: "channel" }]
|
|
137
|
+
codebases: async (query) => searchRepos(query), // [{ id: "reading-room" }]
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
```
|
|
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 ``, 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
|
+
|
|
112
178
|
## Edit a referenced source file
|
|
113
179
|
|
|
114
180
|
`GitbookEditor` edits the Markdown composition, including a structured
|
|
@@ -159,6 +225,8 @@ Exports:
|
|
|
159
225
|
- `astToTiptap`
|
|
160
226
|
- `tiptapToAst`
|
|
161
227
|
- `PMNode`
|
|
228
|
+
- `EditorAttachment`
|
|
229
|
+
- `serializeEditorMarkdown`
|
|
162
230
|
- `SourceFileEditor`
|
|
163
231
|
- `SourceFileEditorProps`
|
|
164
232
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.
|
|
36
|
+
"@brett_lamy/docstream": "0.7.0",
|
|
37
37
|
"gpu-lexer": "0.0.2",
|
|
38
38
|
"lucide-react": "^1.17.0"
|
|
39
39
|
},
|
package/src/editor/Editor.tsx
CHANGED
|
@@ -10,13 +10,23 @@ import {
|
|
|
10
10
|
Strikethrough,
|
|
11
11
|
} from "lucide-react"
|
|
12
12
|
|
|
13
|
-
import { parseMarkdown,
|
|
13
|
+
import { parseMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
|
|
14
14
|
import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
|
|
15
|
-
import {
|
|
16
|
-
|
|
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"
|
|
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
|
|
@@ -26,8 +36,11 @@ export interface GitbookEditorProps {
|
|
|
26
36
|
toolbar?: boolean
|
|
27
37
|
/** Built-in "/" slash menu: true (default), false, or a custom item list. */
|
|
28
38
|
slashMenu?: boolean | { items?: SlashItem[] }
|
|
29
|
-
/**
|
|
30
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Reference chip pickers: true (default "@"/"#"), false, or per-trigger sources —
|
|
41
|
+
* static lists or async `(query) => options`. A `codebases` source enables "$".
|
|
42
|
+
*/
|
|
43
|
+
references?: boolean | ReferenceSources
|
|
31
44
|
/** Whether the document is editable (default true). */
|
|
32
45
|
editable?: boolean
|
|
33
46
|
/** Placeholder shown in an empty document. */
|
|
@@ -47,6 +60,20 @@ export interface GitbookEditorProps {
|
|
|
47
60
|
onKeyDown?: (event: KeyboardEvent) => boolean
|
|
48
61
|
/** Handle clipboard content before the editor's built-in paste behavior. Return true when handled. */
|
|
49
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
|
|
50
77
|
/** Receive the underlying TipTap editor instance (and null on teardown). */
|
|
51
78
|
onEditorReady?: (editor: TiptapEditor | null) => void
|
|
52
79
|
/** Client used to edit and preview `{% source-ref %}` files. */
|
|
@@ -135,6 +162,11 @@ export function GitbookEditor({
|
|
|
135
162
|
extensions,
|
|
136
163
|
onKeyDown,
|
|
137
164
|
onPaste,
|
|
165
|
+
imagePaste = "inline",
|
|
166
|
+
attachments,
|
|
167
|
+
onAttachmentAdd,
|
|
168
|
+
onAttachmentRemove,
|
|
169
|
+
onAttachmentOpen,
|
|
138
170
|
onEditorReady,
|
|
139
171
|
sourceClient,
|
|
140
172
|
sourcePreview = true,
|
|
@@ -149,6 +181,56 @@ export function GitbookEditor({
|
|
|
149
181
|
// ones from the last applied markdown and reattach them on serialize.
|
|
150
182
|
const citationsRef = useRef<CitationDef[] | undefined>(undefined)
|
|
151
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
|
+
}
|
|
152
234
|
|
|
153
235
|
const parseAndTrack = (md: string) => {
|
|
154
236
|
const doc = parseMarkdown(md)
|
|
@@ -168,19 +250,44 @@ export function GitbookEditor({
|
|
|
168
250
|
}),
|
|
169
251
|
editorProps: {
|
|
170
252
|
handleKeyDown: (_view, event) => onKeyDown?.(event) ?? false,
|
|
171
|
-
handlePaste: (
|
|
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
|
+
},
|
|
172
270
|
},
|
|
173
271
|
...(controlled ? { content: astToTiptap(parseAndTrack(markdown as string)) } : {}),
|
|
272
|
+
onCreate({ editor }) {
|
|
273
|
+
attachmentIds.current = collectAttachmentIds(editor.state.doc)
|
|
274
|
+
},
|
|
174
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)
|
|
175
280
|
if (!onChange) return
|
|
176
281
|
const ast = tiptapToAst(editor.getJSON() as PMNode)
|
|
177
282
|
if (citationsRef.current?.length) ast.citations = citationsRef.current
|
|
178
|
-
const md =
|
|
283
|
+
const md = serializeEditorMarkdown(ast)
|
|
179
284
|
lastEmitted.current = md
|
|
180
285
|
onChange(md)
|
|
181
286
|
},
|
|
182
287
|
})
|
|
183
288
|
|
|
289
|
+
editorRef.current = editor ?? null
|
|
290
|
+
|
|
184
291
|
// Surface the editor instance to the host (for awareness, commands, etc.).
|
|
185
292
|
useEffect(() => {
|
|
186
293
|
onEditorReady?.(editor ?? null)
|
|
@@ -194,6 +301,8 @@ export function GitbookEditor({
|
|
|
194
301
|
const doc = parseMarkdown(markdown as string)
|
|
195
302
|
citationsRef.current = doc.citations
|
|
196
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)
|
|
197
306
|
}, [editor, controlled, markdown])
|
|
198
307
|
|
|
199
308
|
const runtime = useMemo(() => ({
|
|
@@ -202,7 +311,9 @@ export function GitbookEditor({
|
|
|
202
311
|
sourceAutoSave,
|
|
203
312
|
...(onSourceSaved ? { onSourceSaved } : {}),
|
|
204
313
|
...(onSourceError ? { onSourceError } : {}),
|
|
205
|
-
|
|
314
|
+
...(attachments ? { attachments } : {}),
|
|
315
|
+
...(onAttachmentOpen ? { onAttachmentOpen } : {}),
|
|
316
|
+
}), [attachments, onAttachmentOpen, onSourceError, onSourceSaved, sourceAutoSave, sourceClient, sourcePreview])
|
|
206
317
|
|
|
207
318
|
if (!editor) return null
|
|
208
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: ``. */
|
|
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="…"` 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 (``). 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
|
+
})
|
package/src/editor/convert.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
import {
|
|
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,11 +217,26 @@ 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(
|
|
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
|
-
const kind =
|
|
238
|
+
const kind =
|
|
239
|
+
a.kind === "tag" || a.kind === "codebase" || a.kind === "citation" ? a.kind : "mention"
|
|
207
240
|
return {
|
|
208
241
|
type: "reference",
|
|
209
242
|
kind,
|
|
@@ -397,3 +430,19 @@ function pmToBlocks(nodes: PMNode[] | undefined): Block[] {
|
|
|
397
430
|
export function tiptapToAst(doc: PMNode): DocumentNode {
|
|
398
431
|
return { type: "doc", children: pmToBlocks(doc.content) }
|
|
399
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
|
+
* ``. 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 : ``
|
|
447
|
+
)
|
|
448
|
+
}
|
package/src/editor/extensions.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { 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"
|
|
9
|
-
import { createReferenceSuggestion } from "./reference-menu"
|
|
9
|
+
import { createReferenceSuggestion, type ReferenceSource } from "./reference-menu"
|
|
10
10
|
|
|
11
11
|
// Carries GitBook's data-view (e.g. "cards") through the editor untouched.
|
|
12
12
|
export const GbTable = Table.extend({
|
|
@@ -15,15 +15,26 @@ export const GbTable = Table.extend({
|
|
|
15
15
|
},
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
+
export interface ReferenceSources {
|
|
19
|
+
/** "@" — people, agents, … */
|
|
20
|
+
mentions?: ReferenceSource
|
|
21
|
+
/** "#" — channels, tags, … */
|
|
22
|
+
tags?: ReferenceSource
|
|
23
|
+
/** "$" — codebases (`$repo` / `$org/repo`). Enables the "$" trigger. */
|
|
24
|
+
codebases?: ReferenceSource
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
export interface GitbookExtensionOptions {
|
|
19
28
|
placeholder?: string
|
|
20
29
|
/** Built-in "/" slash menu: true (default), false, or a custom item list. */
|
|
21
30
|
slashMenu?: boolean | { items?: SlashItem[] }
|
|
22
31
|
/**
|
|
23
|
-
* The
|
|
24
|
-
*
|
|
32
|
+
* The reference chip pickers: true (default: "@" and "#" with free-form entry),
|
|
33
|
+
* false, or per-trigger sources — static lists or async `(query) => options`.
|
|
34
|
+
* "$" (codebases) is only enabled when a `codebases` source is given, so a
|
|
35
|
+
* dollar sign in ordinary prose never opens a menu.
|
|
25
36
|
*/
|
|
26
|
-
references?: boolean |
|
|
37
|
+
references?: boolean | ReferenceSources
|
|
27
38
|
/**
|
|
28
39
|
* Disable StarterKit's built-in undo/redo. Required when wiring Yjs
|
|
29
40
|
* Collaboration, which provides its own shared history.
|
|
@@ -73,6 +84,7 @@ export function createGitbookExtensions(options: GitbookExtensionOptions = {}):
|
|
|
73
84
|
createReferenceSuggestion({ char: "@", ...(opts.mentions ? { items: opts.mentions } : {}) }),
|
|
74
85
|
createReferenceSuggestion({ char: "#", ...(opts.tags ? { items: opts.tags } : {}) })
|
|
75
86
|
)
|
|
87
|
+
if (opts.codebases) list.push(createReferenceSuggestion({ char: "$", items: opts.codebases }))
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
list.push(...extensions)
|
package/src/editor/nodes.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
AtSign,
|
|
14
14
|
CheckCircle2,
|
|
15
15
|
ChevronDown,
|
|
16
|
+
FolderGit2,
|
|
16
17
|
Hash,
|
|
17
18
|
Info,
|
|
18
19
|
Link2,
|
|
@@ -31,6 +32,7 @@ import { ReplayPreview, isReplayQaUrl } from "@brett_lamy/docstream/replay"
|
|
|
31
32
|
import { VideoEmbed } from "@brett_lamy/docstream/video"
|
|
32
33
|
import { SourceFileEditor } from "./SourceFileEditor"
|
|
33
34
|
import { useEditorRuntime } from "./runtime"
|
|
35
|
+
import { GbAttachment } from "./attachments"
|
|
34
36
|
|
|
35
37
|
function useDebouncedValue<T>(value: T, delay = 350): T {
|
|
36
38
|
const [debounced, setDebounced] = useState(value)
|
|
@@ -792,7 +794,13 @@ function ReferenceView({ node }: NodeViewProps) {
|
|
|
792
794
|
</span>
|
|
793
795
|
) : (
|
|
794
796
|
<span className={`gb-ref gb-ref-${kind}`}>
|
|
795
|
-
{kind === "mention" ?
|
|
797
|
+
{kind === "mention" ? (
|
|
798
|
+
<AtSign className="gb-ref-icon" />
|
|
799
|
+
) : kind === "codebase" ? (
|
|
800
|
+
<FolderGit2 className="gb-ref-icon" />
|
|
801
|
+
) : (
|
|
802
|
+
<Hash className="gb-ref-icon" />
|
|
803
|
+
)}
|
|
796
804
|
{id}
|
|
797
805
|
</span>
|
|
798
806
|
)}
|
|
@@ -818,7 +826,14 @@ export const GbReference = Node.create({
|
|
|
818
826
|
return [{ tag: "span[data-gb-ref]" }]
|
|
819
827
|
},
|
|
820
828
|
renderHTML({ HTMLAttributes, node }) {
|
|
821
|
-
const sigil =
|
|
829
|
+
const sigil =
|
|
830
|
+
node.attrs.kind === "mention"
|
|
831
|
+
? "@"
|
|
832
|
+
: node.attrs.kind === "tag"
|
|
833
|
+
? "#"
|
|
834
|
+
: node.attrs.kind === "codebase"
|
|
835
|
+
? "$"
|
|
836
|
+
: "^"
|
|
822
837
|
return [
|
|
823
838
|
"span",
|
|
824
839
|
mergeAttributes(HTMLAttributes, { "data-gb-ref": node.attrs.kind, "data-gb-ref-id": node.attrs.id }),
|
|
@@ -969,4 +984,5 @@ export const gitbookNodes = [
|
|
|
969
984
|
GbOpenapi,
|
|
970
985
|
GbInlineImage,
|
|
971
986
|
GbReference,
|
|
987
|
+
GbAttachment,
|
|
972
988
|
]
|
|
@@ -1,32 +1,88 @@
|
|
|
1
1
|
import { Extension, type Editor, type Range } from "@tiptap/core"
|
|
2
2
|
import Suggestion from "@tiptap/suggestion"
|
|
3
3
|
import { PluginKey } from "@tiptap/pm/state"
|
|
4
|
-
import { AtSign, Hash } from "lucide-react"
|
|
4
|
+
import { AtSign, FolderGit2, Hash, type LucideIcon } from "lucide-react"
|
|
5
5
|
|
|
6
6
|
import { createSuggestionRender, type SuggestionMenuItem } from "./suggestion-menu"
|
|
7
7
|
|
|
8
|
-
export type ReferenceTrigger = "@" | "#"
|
|
8
|
+
export type ReferenceTrigger = "@" | "#" | "$"
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
/** One pickable reference: the chip stores `id`; the rest only decorates the menu. */
|
|
11
|
+
export interface ReferenceOption {
|
|
11
12
|
id: string
|
|
13
|
+
/** Display name shown next to the id (e.g. "Luna" for `@luna`). */
|
|
14
|
+
label?: string
|
|
15
|
+
/** Secondary line (e.g. an email, "agent · claude-code", a topic). */
|
|
16
|
+
description?: string
|
|
17
|
+
/** Section header; consecutive options with the same group are listed together. */
|
|
18
|
+
group?: string
|
|
12
19
|
}
|
|
13
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Where a trigger's options come from: a static list (filtered by the editor), or a
|
|
23
|
+
* function the host answers per query — sync or async, already filtered.
|
|
24
|
+
*/
|
|
25
|
+
export type ReferenceSource =
|
|
26
|
+
| readonly (string | ReferenceOption)[]
|
|
27
|
+
| ((query: string) => readonly ReferenceOption[] | Promise<readonly ReferenceOption[]>)
|
|
28
|
+
|
|
14
29
|
export interface ReferenceSuggestionOptions {
|
|
15
30
|
char: ReferenceTrigger
|
|
16
|
-
/**
|
|
17
|
-
items?:
|
|
31
|
+
/** Options offered in the picker; free-form entry works unless disabled. */
|
|
32
|
+
items?: ReferenceSource
|
|
33
|
+
/** Offer inserting the raw query when nothing matches exactly (default true). */
|
|
34
|
+
allowFreeForm?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ReferenceItem extends SuggestionMenuItem {
|
|
38
|
+
id: string
|
|
18
39
|
}
|
|
19
40
|
|
|
20
|
-
const KIND: Record<ReferenceTrigger, "mention" | "tag"
|
|
41
|
+
const KIND: Record<ReferenceTrigger, "mention" | "tag" | "codebase"> = {
|
|
42
|
+
"@": "mention",
|
|
43
|
+
"#": "tag",
|
|
44
|
+
$: "codebase",
|
|
45
|
+
}
|
|
46
|
+
const ICON: Record<ReferenceTrigger, LucideIcon> = { "@": AtSign, "#": Hash, $: FolderGit2 }
|
|
47
|
+
const EMPTY: Record<ReferenceTrigger, string> = {
|
|
48
|
+
"@": "Type a name to mention",
|
|
49
|
+
"#": "Type a channel or tag",
|
|
50
|
+
$: "Type a codebase",
|
|
51
|
+
}
|
|
52
|
+
const MAX_OPTIONS = 50
|
|
53
|
+
|
|
54
|
+
function normalize(option: string | ReferenceOption): ReferenceOption {
|
|
55
|
+
return typeof option === "string" ? { id: option } : option
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matches(option: ReferenceOption, query: string): boolean {
|
|
59
|
+
if (!query) return true
|
|
60
|
+
return [option.id, option.label ?? ""].some((value) => value.toLowerCase().includes(query))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolve a source for a query into options (exported for hosts' tests). */
|
|
64
|
+
export async function resolveReferenceOptions(
|
|
65
|
+
source: ReferenceSource | undefined,
|
|
66
|
+
query: string,
|
|
67
|
+
): Promise<ReferenceOption[]> {
|
|
68
|
+
if (source === undefined) return []
|
|
69
|
+
if (typeof source === "function") return [...(await source(query))].slice(0, MAX_OPTIONS)
|
|
70
|
+
const q = query.toLowerCase()
|
|
71
|
+
return source.map(normalize).filter((option) => matches(option, q)).slice(0, MAX_OPTIONS)
|
|
72
|
+
}
|
|
21
73
|
|
|
22
74
|
/**
|
|
23
|
-
* An `@mention` / `#tag` picker built on @tiptap/suggestion.
|
|
24
|
-
*
|
|
25
|
-
* works with no
|
|
75
|
+
* An `@mention` / `#tag` / `$codebase` picker built on @tiptap/suggestion. Options come
|
|
76
|
+
* from the host (static or async) and the raw query is offered as a free-form entry,
|
|
77
|
+
* so `@anything⏎` works with no source configured.
|
|
26
78
|
*/
|
|
27
|
-
export function createReferenceSuggestion({
|
|
79
|
+
export function createReferenceSuggestion({
|
|
80
|
+
char,
|
|
81
|
+
items,
|
|
82
|
+
allowFreeForm = true,
|
|
83
|
+
}: ReferenceSuggestionOptions) {
|
|
28
84
|
const kind = KIND[char]
|
|
29
|
-
const icon = char
|
|
85
|
+
const icon = ICON[char]
|
|
30
86
|
return Extension.create({
|
|
31
87
|
name: `${kind}Suggestion`,
|
|
32
88
|
addProseMirrorPlugins() {
|
|
@@ -39,13 +95,26 @@ export function createReferenceSuggestion({ char, items = [] }: ReferenceSuggest
|
|
|
39
95
|
// Each Suggestion plugin needs its own key — the default is shared
|
|
40
96
|
// with the slash menu and would collide.
|
|
41
97
|
pluginKey: new PluginKey(`${kind}Suggestion`),
|
|
42
|
-
items: ({ query }) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
98
|
+
items: async ({ query }) => {
|
|
99
|
+
let options: ReferenceOption[]
|
|
100
|
+
try {
|
|
101
|
+
options = await resolveReferenceOptions(items, query)
|
|
102
|
+
} catch {
|
|
103
|
+
options = []
|
|
104
|
+
}
|
|
105
|
+
const menu: ReferenceItem[] = options.map((option) => ({
|
|
106
|
+
id: option.id,
|
|
107
|
+
title: `${char}${option.id}`,
|
|
108
|
+
icon,
|
|
109
|
+
...(option.label || option.description
|
|
110
|
+
? { description: [option.label, option.description].filter(Boolean).join(" · ") }
|
|
111
|
+
: {}),
|
|
112
|
+
...(option.group ? { group: option.group } : {}),
|
|
113
|
+
}))
|
|
114
|
+
const exact = options.some((option) => option.id.toLowerCase() === query.toLowerCase())
|
|
115
|
+
return allowFreeForm && query && !exact
|
|
116
|
+
? [...menu, { id: query, title: `${char}${query}`, icon }]
|
|
117
|
+
: menu
|
|
49
118
|
},
|
|
50
119
|
command: ({ editor, range, props }) => {
|
|
51
120
|
;(editor as Editor)
|
|
@@ -58,9 +127,7 @@ export function createReferenceSuggestion({ char, items = [] }: ReferenceSuggest
|
|
|
58
127
|
])
|
|
59
128
|
.run()
|
|
60
129
|
},
|
|
61
|
-
render: createSuggestionRender<ReferenceItem>(
|
|
62
|
-
char === "@" ? "Type a name to mention" : "Type a tag"
|
|
63
|
-
),
|
|
130
|
+
render: createSuggestionRender<ReferenceItem>(EMPTY[char]),
|
|
64
131
|
}),
|
|
65
132
|
]
|
|
66
133
|
},
|
package/src/editor/runtime.tsx
CHANGED
|
@@ -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>({})
|
|
@@ -12,11 +12,15 @@ import type { SuggestionProps } from "@tiptap/suggestion"
|
|
|
12
12
|
import { ReactRenderer } from "@tiptap/react"
|
|
13
13
|
import type { LucideIcon } from "lucide-react"
|
|
14
14
|
|
|
15
|
-
// Shared popup used by the "/" slash menu and the "@"/"#" reference menus.
|
|
15
|
+
// Shared popup used by the "/" slash menu and the "@"/"#"/"$" reference menus.
|
|
16
16
|
|
|
17
17
|
export interface SuggestionMenuItem {
|
|
18
18
|
title: string
|
|
19
19
|
icon: LucideIcon
|
|
20
|
+
/** Secondary text shown after the title (e.g. a display name or email). */
|
|
21
|
+
description?: string
|
|
22
|
+
/** Section header; shown when it differs from the previous item's group. */
|
|
23
|
+
group?: string
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
export interface MenuProps<Item extends SuggestionMenuItem> {
|
|
@@ -97,21 +101,26 @@ export const SuggestionMenuView = forwardRef<MenuHandle, MenuProps<SuggestionMen
|
|
|
97
101
|
>
|
|
98
102
|
{items.length === 0 && <div className="slash-empty">{emptyLabel}</div>}
|
|
99
103
|
{items.map((item, i) => (
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
<div key={`${item.group ?? ""}:${item.title}:${i}`} className="slash-row">
|
|
105
|
+
{item.group && item.group !== items[i - 1]?.group && (
|
|
106
|
+
<div className="slash-group">{item.group}</div>
|
|
107
|
+
)}
|
|
108
|
+
<button
|
|
109
|
+
ref={(el) => {
|
|
110
|
+
itemRefs.current[i] = el
|
|
111
|
+
}}
|
|
112
|
+
className={`slash-item ${i === index ? "slash-item-active" : ""}`}
|
|
113
|
+
onMouseEnter={() => setIndex(i)}
|
|
114
|
+
onMouseDown={(e) => {
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
command(item)
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<item.icon className="size-4" />
|
|
120
|
+
<span>{item.title}</span>
|
|
121
|
+
{item.description && <span className="slash-item-description">{item.description}</span>}
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
115
124
|
))}
|
|
116
125
|
</div>
|
|
117
126
|
)
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
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
|
|
7
7
|
// or a bespoke slash menu) instead of the batteries-included GitbookEditor.
|
|
8
8
|
export { createGitbookExtensions, getGitbookSchema, GbTable } from "./editor/extensions"
|
|
9
|
-
export type { GitbookExtensionOptions } from "./editor/extensions"
|
|
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
|
-
export { createReferenceSuggestion } from "./editor/reference-menu"
|
|
14
|
-
export type {
|
|
14
|
+
export { createReferenceSuggestion, resolveReferenceOptions } from "./editor/reference-menu"
|
|
15
|
+
export type {
|
|
16
|
+
ReferenceOption,
|
|
17
|
+
ReferenceSource,
|
|
18
|
+
ReferenceSuggestionOptions,
|
|
19
|
+
ReferenceTrigger,
|
|
20
|
+
} from "./editor/reference-menu"
|
|
15
21
|
export { SuggestionMenuView, createSuggestionRender } from "./editor/suggestion-menu"
|
|
16
22
|
export type { SuggestionMenuItem } from "./editor/suggestion-menu"
|
|
17
23
|
export { ReactDemoEditor } from "./editor/ReactDemoEditor"
|
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
|
-
|
|
587
|
-
.gb-code .gb-syntax-
|
|
588
|
-
.gb-code .gb-syntax-
|
|
589
|
-
.gb-code .gb-syntax-
|
|
590
|
-
.gb-code .gb-syntax-
|
|
591
|
-
.gb-code .gb-syntax-
|
|
592
|
-
.gb-code .gb-syntax-
|
|
593
|
-
.gb-code .gb-syntax-
|
|
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); }
|
|
@@ -650,6 +651,23 @@
|
|
|
650
651
|
.slash-item-active { background: var(--accent, #f4f4f5); }
|
|
651
652
|
.slash-item svg { color: var(--muted-foreground, #6b7280); }
|
|
652
653
|
.slash-empty { padding: 8px 10px; font-size: 13px; color: var(--muted-foreground, #6b7280); }
|
|
654
|
+
.slash-group {
|
|
655
|
+
padding: 8px 10px 3px;
|
|
656
|
+
font-size: 11px;
|
|
657
|
+
font-weight: 600;
|
|
658
|
+
letter-spacing: 0.04em;
|
|
659
|
+
text-transform: uppercase;
|
|
660
|
+
color: var(--muted-foreground, #6b7280);
|
|
661
|
+
}
|
|
662
|
+
.slash-item-description {
|
|
663
|
+
margin-left: auto;
|
|
664
|
+
padding-left: 12px;
|
|
665
|
+
overflow: hidden;
|
|
666
|
+
font-size: 12px;
|
|
667
|
+
white-space: nowrap;
|
|
668
|
+
text-overflow: ellipsis;
|
|
669
|
+
color: var(--muted-foreground, #6b7280);
|
|
670
|
+
}
|
|
653
671
|
|
|
654
672
|
.gb-code-live {
|
|
655
673
|
display: flex;
|
|
@@ -819,6 +837,12 @@
|
|
|
819
837
|
color: var(--gb-muted-foreground);
|
|
820
838
|
}
|
|
821
839
|
|
|
840
|
+
.gb-ref-codebase {
|
|
841
|
+
border: 1px solid rgba(34, 197, 94, 0.45);
|
|
842
|
+
background: rgba(34, 197, 94, 0.1);
|
|
843
|
+
color: rgb(22, 163, 74);
|
|
844
|
+
}
|
|
845
|
+
|
|
822
846
|
.gb-cite {
|
|
823
847
|
display: inline-block;
|
|
824
848
|
padding: 1px 5px;
|
|
@@ -836,3 +860,163 @@
|
|
|
836
860
|
outline: 2px solid var(--gb-primary);
|
|
837
861
|
outline-offset: 1px;
|
|
838
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
|
+
}
|