@brett_lamy/docstream-editor 0.3.7 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream-editor",
3
- "version": "0.3.7",
3
+ "version": "0.5.1",
4
4
  "description": "TipTap editor for Docstream GitBook-style markdown documents.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -11,6 +11,7 @@
11
11
  "types": "./src/index.ts",
12
12
  "files": [
13
13
  "src",
14
+ "!src/**/*.test.ts",
14
15
  "README.md"
15
16
  ],
16
17
  "sideEffects": [
@@ -32,7 +33,7 @@
32
33
  "./styles.css": "./src/styles.css"
33
34
  },
34
35
  "dependencies": {
35
- "@brett_lamy/docstream": "0.4.0",
36
+ "@brett_lamy/docstream": "0.5.1",
36
37
  "lowlight": "^3.3.0",
37
38
  "lucide-react": "^1.17.0"
38
39
  },
@@ -10,7 +10,7 @@ import {
10
10
  Strikethrough,
11
11
  } from "lucide-react"
12
12
 
13
- import { parseMarkdown, serializeMarkdown } from "@brett_lamy/docstream/gitbook"
13
+ import { parseMarkdown, serializeMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
14
14
  import { astToTiptap, tiptapToAst, type PMNode } from "./convert"
15
15
  import { createGitbookExtensions } from "./extensions"
16
16
  import type { SlashItem } from "./slash-menu"
@@ -24,6 +24,8 @@ export interface GitbookEditorProps {
24
24
  toolbar?: boolean
25
25
  /** Built-in "/" slash menu: true (default), false, or a custom item list. */
26
26
  slashMenu?: boolean | { items?: SlashItem[] }
27
+ /** The "@" / "#" reference chip pickers: true (default), false, or known id lists. */
28
+ references?: boolean | { mentions?: string[]; tags?: string[] }
27
29
  /** Whether the document is editable (default true). */
28
30
  editable?: boolean
29
31
  /** Placeholder shown in an empty document. */
@@ -112,6 +114,7 @@ export function GitbookEditor({
112
114
  onChange,
113
115
  toolbar = true,
114
116
  slashMenu = true,
117
+ references = true,
115
118
  editable = true,
116
119
  placeholder,
117
120
  className,
@@ -125,14 +128,24 @@ export function GitbookEditor({
125
128
  // Tracks the markdown the editor itself produced, so external updates
126
129
  // (file switches) reset content but our own onChange echoes don't.
127
130
  const lastEmitted = useRef<string | null>(null)
131
+ // PM JSON has nowhere to carry doc-level citation definitions; remember the
132
+ // ones from the last applied markdown and reattach them on serialize.
133
+ const citationsRef = useRef<CitationDef[] | undefined>(undefined)
128
134
  const controlled = markdown !== undefined
129
135
 
136
+ const parseAndTrack = (md: string) => {
137
+ const doc = parseMarkdown(md)
138
+ citationsRef.current = doc.citations
139
+ return doc
140
+ }
141
+
130
142
  const editor = useEditor({
131
143
  editable,
132
144
  autofocus,
133
145
  extensions: createGitbookExtensions({
134
146
  ...(placeholder !== undefined ? { placeholder } : {}),
135
147
  slashMenu,
148
+ references,
136
149
  ...(disableHistory !== undefined ? { disableHistory } : {}),
137
150
  ...(extensions ? { extensions } : {}),
138
151
  }),
@@ -140,10 +153,12 @@ export function GitbookEditor({
140
153
  handleKeyDown: (_view, event) => onKeyDown?.(event) ?? false,
141
154
  handlePaste: (_view, event) => onPaste?.(event) ?? false,
142
155
  },
143
- ...(controlled ? { content: astToTiptap(parseMarkdown(markdown as string)) } : {}),
156
+ ...(controlled ? { content: astToTiptap(parseAndTrack(markdown as string)) } : {}),
144
157
  onUpdate({ editor }) {
145
158
  if (!onChange) return
146
- const md = serializeMarkdown(tiptapToAst(editor.getJSON() as PMNode))
159
+ const ast = tiptapToAst(editor.getJSON() as PMNode)
160
+ if (citationsRef.current?.length) ast.citations = citationsRef.current
161
+ const md = serializeMarkdown(ast)
147
162
  lastEmitted.current = md
148
163
  onChange(md)
149
164
  },
@@ -159,7 +174,9 @@ export function GitbookEditor({
159
174
  if (!editor || !controlled) return
160
175
  if (markdown === lastEmitted.current) return
161
176
  lastEmitted.current = markdown as string
162
- editor.commands.setContent(astToTiptap(parseMarkdown(markdown as string)), { emitUpdate: false })
177
+ const doc = parseMarkdown(markdown as string)
178
+ citationsRef.current = doc.citations
179
+ editor.commands.setContent(astToTiptap(doc), { emitUpdate: false })
163
180
  }, [editor, controlled, markdown])
164
181
 
165
182
  if (!editor) return null
@@ -13,8 +13,16 @@ export interface PMNode {
13
13
 
14
14
  function inlineToPM(nodes: Inline[]): PMNode[] {
15
15
  return nodes
16
- .filter((n) => n.type === "image" || n.text.length > 0)
16
+ // Only empty text nodes are dropped — every non-text inline must pass, or
17
+ // it silently vanishes on the first edit round-trip.
18
+ .filter((n) => n.type !== "text" || n.text.length > 0)
17
19
  .map((n) => {
20
+ if (n.type === "reference") {
21
+ return {
22
+ type: "gbReference",
23
+ attrs: { kind: n.kind, id: n.id, url: n.url ?? "", label: n.label ?? "" },
24
+ }
25
+ }
18
26
  if (n.type === "image") {
19
27
  return {
20
28
  type: "gbInlineImage",
@@ -191,8 +199,19 @@ export function astToTiptap(doc: DocumentNode): PMNode {
191
199
  function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
192
200
  if (!nodes) return []
193
201
  return nodes
194
- .filter((n) => (n.type === "text" && n.text) || n.type === "gbInlineImage")
202
+ .filter((n) => (n.type === "text" && n.text) || n.type === "gbInlineImage" || n.type === "gbReference")
195
203
  .map((n): Inline => {
204
+ if (n.type === "gbReference") {
205
+ const a = n.attrs ?? {}
206
+ const kind = a.kind === "tag" ? "tag" : a.kind === "citation" ? "citation" : "mention"
207
+ return {
208
+ type: "reference",
209
+ kind,
210
+ id: String(a.id ?? ""),
211
+ ...(a.url ? { url: String(a.url) } : {}),
212
+ ...(a.label ? { label: String(a.label) } : {}),
213
+ }
214
+ }
196
215
  if (n.type === "gbInlineImage") {
197
216
  const a = n.attrs ?? {}
198
217
  return {
@@ -6,6 +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
10
 
10
11
  // Carries GitBook's data-view (e.g. "cards") through the editor untouched.
11
12
  export const GbTable = Table.extend({
@@ -18,6 +19,11 @@ export interface GitbookExtensionOptions {
18
19
  placeholder?: string
19
20
  /** Built-in "/" slash menu: true (default), false, or a custom item list. */
20
21
  slashMenu?: boolean | { items?: SlashItem[] }
22
+ /**
23
+ * The "@" / "#" reference chip pickers: true (default), false, or lists of
24
+ * known ids to offer. Free-form entry always works.
25
+ */
26
+ references?: boolean | { mentions?: string[]; tags?: string[] }
21
27
  /**
22
28
  * Disable StarterKit's built-in undo/redo. Required when wiring Yjs
23
29
  * Collaboration, which provides its own shared history.
@@ -36,6 +42,7 @@ export function createGitbookExtensions(options: GitbookExtensionOptions = {}):
36
42
  const {
37
43
  placeholder = "Write, or type / to insert a block…",
38
44
  slashMenu = true,
45
+ references = true,
39
46
  disableHistory = false,
40
47
  extensions = [],
41
48
  } = options
@@ -60,6 +67,14 @@ export function createGitbookExtensions(options: GitbookExtensionOptions = {}):
60
67
  list.push(slashMenu === true ? SlashMenu : createSlashMenu(slashMenu.items))
61
68
  }
62
69
 
70
+ if (references) {
71
+ const opts = references === true ? {} : references
72
+ list.push(
73
+ createReferenceSuggestion({ char: "@", ...(opts.mentions ? { items: opts.mentions } : {}) }),
74
+ createReferenceSuggestion({ char: "#", ...(opts.tags ? { items: opts.tags } : {}) })
75
+ )
76
+ }
77
+
63
78
  list.push(...extensions)
64
79
  return list
65
80
  }
@@ -9,8 +9,10 @@ import {
9
9
  } from "@tiptap/react"
10
10
  import {
11
11
  AlertTriangle,
12
+ AtSign,
12
13
  CheckCircle2,
13
14
  ChevronDown,
15
+ Hash,
14
16
  Info,
15
17
  Link2,
16
18
  FileCode2,
@@ -711,6 +713,56 @@ export const GbInlineImage = Node.create({
711
713
  },
712
714
  })
713
715
 
716
+ // ---------- Reference chips (@mention, #tag, [^n] citation) ----------
717
+
718
+ function ReferenceView({ node }: NodeViewProps) {
719
+ const { kind, id, url, label } = node.attrs
720
+ return (
721
+ <NodeViewWrapper as="span" className="gb-ref-wrap" contentEditable={false}>
722
+ {kind === "citation" ? (
723
+ <span className="gb-cite" title={label || url || undefined}>
724
+ {id}
725
+ </span>
726
+ ) : (
727
+ <span className={`gb-ref gb-ref-${kind}`}>
728
+ {kind === "mention" ? <AtSign className="gb-ref-icon" /> : <Hash className="gb-ref-icon" />}
729
+ {id}
730
+ </span>
731
+ )}
732
+ </NodeViewWrapper>
733
+ )
734
+ }
735
+
736
+ export const GbReference = Node.create({
737
+ name: "gbReference",
738
+ group: "inline",
739
+ inline: true,
740
+ atom: true,
741
+ selectable: true,
742
+ addAttributes() {
743
+ return {
744
+ kind: { default: "mention" },
745
+ id: { default: "" },
746
+ url: { default: "" },
747
+ label: { default: "" },
748
+ }
749
+ },
750
+ parseHTML() {
751
+ return [{ tag: "span[data-gb-ref]" }]
752
+ },
753
+ renderHTML({ HTMLAttributes, node }) {
754
+ const sigil = node.attrs.kind === "mention" ? "@" : node.attrs.kind === "tag" ? "#" : "^"
755
+ return [
756
+ "span",
757
+ mergeAttributes(HTMLAttributes, { "data-gb-ref": node.attrs.kind, "data-gb-ref-id": node.attrs.id }),
758
+ `${sigil}${node.attrs.id}`,
759
+ ]
760
+ },
761
+ addNodeView() {
762
+ return ReactNodeViewRenderer(ReferenceView)
763
+ },
764
+ })
765
+
714
766
  // ---------- Updates (changelog) ----------
715
767
 
716
768
  export const GbUpdates = Node.create({
@@ -849,4 +901,5 @@ export const gitbookNodes = [
849
901
  GbUpdate,
850
902
  GbOpenapi,
851
903
  GbInlineImage,
904
+ GbReference,
852
905
  ]
@@ -0,0 +1,68 @@
1
+ import { Extension, type Editor, type Range } from "@tiptap/core"
2
+ import Suggestion from "@tiptap/suggestion"
3
+ import { PluginKey } from "@tiptap/pm/state"
4
+ import { AtSign, Hash } from "lucide-react"
5
+
6
+ import { createSuggestionRender, type SuggestionMenuItem } from "./suggestion-menu"
7
+
8
+ export type ReferenceTrigger = "@" | "#"
9
+
10
+ interface ReferenceItem extends SuggestionMenuItem {
11
+ id: string
12
+ }
13
+
14
+ export interface ReferenceSuggestionOptions {
15
+ char: ReferenceTrigger
16
+ /** Known ids offered in the picker; free-form entry always works. */
17
+ items?: string[]
18
+ }
19
+
20
+ const KIND: Record<ReferenceTrigger, "mention" | "tag"> = { "@": "mention", "#": "tag" }
21
+
22
+ /**
23
+ * An `@mention` / `#tag` picker built on @tiptap/suggestion. Filters the host's
24
+ * known ids and always offers inserting the raw query, so typing `@anything⏎`
25
+ * works with no list configured.
26
+ */
27
+ export function createReferenceSuggestion({ char, items = [] }: ReferenceSuggestionOptions) {
28
+ const kind = KIND[char]
29
+ const icon = char === "@" ? AtSign : Hash
30
+ return Extension.create({
31
+ name: `${kind}Suggestion`,
32
+ addProseMirrorPlugins() {
33
+ return [
34
+ Suggestion<ReferenceItem>({
35
+ editor: this.editor,
36
+ char,
37
+ startOfLine: false,
38
+ allowSpaces: false,
39
+ // Each Suggestion plugin needs its own key — the default is shared
40
+ // with the slash menu and would collide.
41
+ pluginKey: new PluginKey(`${kind}Suggestion`),
42
+ items: ({ query }) => {
43
+ const q = query.toLowerCase()
44
+ const matches = items
45
+ .filter((id) => id.toLowerCase().includes(q))
46
+ .map((id) => ({ id, title: `${char}${id}`, icon }))
47
+ const freeForm = query && !items.some((id) => id.toLowerCase() === q)
48
+ return freeForm ? [...matches, { id: query, title: `${char}${query}`, icon }] : matches
49
+ },
50
+ command: ({ editor, range, props }) => {
51
+ ;(editor as Editor)
52
+ .chain()
53
+ .focus()
54
+ .deleteRange(range as Range)
55
+ .insertContent([
56
+ { type: "gbReference", attrs: { kind, id: props.id } },
57
+ { type: "text", text: " " },
58
+ ])
59
+ .run()
60
+ },
61
+ render: createSuggestionRender<ReferenceItem>(
62
+ char === "@" ? "Type a name to mention" : "Type a tag"
63
+ ),
64
+ }),
65
+ ]
66
+ },
67
+ })
68
+ }
@@ -1,14 +1,5 @@
1
- import {
2
- forwardRef,
3
- useEffect,
4
- useImperativeHandle,
5
- useLayoutEffect,
6
- useRef,
7
- useState,
8
- } from "react"
9
1
  import { Extension, type Editor, type Range } from "@tiptap/core"
10
- import Suggestion, { type SuggestionProps } from "@tiptap/suggestion"
11
- import { ReactRenderer } from "@tiptap/react"
2
+ import Suggestion from "@tiptap/suggestion"
12
3
  import {
13
4
  Columns2,
14
5
  FileCode2,
@@ -29,6 +20,7 @@ import {
29
20
  Quote,
30
21
  Sigma,
31
22
  SquareChevronDown,
23
+ Superscript,
32
24
  Table as TableIcon,
33
25
  Webhook,
34
26
  Workflow,
@@ -36,6 +28,7 @@ import {
36
28
  } from "lucide-react"
37
29
 
38
30
  import type { PMNode } from "./convert"
31
+ import { createSuggestionRender } from "./suggestion-menu"
39
32
 
40
33
  const para = (text = ""): PMNode =>
41
34
  text ? { type: "paragraph", content: [{ type: "text", text }] } : { type: "paragraph" }
@@ -176,106 +169,26 @@ export const SLASH_ITEMS: SlashItem[] = [
176
169
  content: [{ type: "text", text: "graph TD\n A --> B" }],
177
170
  }),
178
171
  },
179
- ]
180
-
181
- interface MenuProps {
182
- items: SlashItem[]
183
- command: (item: SlashItem) => void
184
- clientRect: (() => DOMRect | null) | null
185
- }
186
-
187
- interface MenuHandle {
188
- onKeyDown: (event: KeyboardEvent) => boolean
189
- }
190
-
191
- const SlashMenuView = forwardRef<MenuHandle, MenuProps>(function SlashMenuView(
192
- { items, command, clientRect },
193
- ref
194
- ) {
195
- const [index, setIndex] = useState(0)
196
- const itemRefs = useRef<Array<HTMLButtonElement | null>>([])
197
-
198
- useEffect(() => setIndex(0), [items])
199
-
200
- // Keep the active item visible while arrowing through a long list.
201
- useEffect(() => {
202
- itemRefs.current[index]?.scrollIntoView({ block: "nearest" })
203
- }, [index])
204
-
205
- useImperativeHandle(ref, () => ({
206
- onKeyDown(event) {
207
- if (event.key === "ArrowDown") {
208
- setIndex((i) => (i + 1) % Math.max(items.length, 1))
209
- return true
210
- }
211
- if (event.key === "ArrowUp") {
212
- setIndex((i) => (i - 1 + items.length) % Math.max(items.length, 1))
213
- return true
214
- }
215
- if (event.key === "Enter") {
216
- if (items[index]) command(items[index])
217
- return true
218
- }
219
- return false
172
+ {
173
+ title: "Citation",
174
+ keywords: "cite footnote source reference superscript",
175
+ icon: Superscript,
176
+ run: (e) => {
177
+ // Next free numeric citation id in the document.
178
+ let max = 0
179
+ e.state.doc.descendants((node) => {
180
+ if (node.type.name === "gbReference" && node.attrs.kind === "citation") {
181
+ const n = Number(node.attrs.id)
182
+ if (Number.isInteger(n) && n > max) max = n
183
+ }
184
+ })
185
+ e.chain()
186
+ .focus()
187
+ .insertContent([{ type: "gbReference", attrs: { kind: "citation", id: String(max + 1) } }])
188
+ .run()
220
189
  },
221
- }))
222
-
223
- const menuRef = useRef<HTMLDivElement>(null)
224
- const rect = clientRect?.()
225
- const [pos, setPos] = useState<{ left: number; top: number }>(() => ({
226
- left: rect ? rect.left : 0,
227
- top: rect ? rect.bottom + 6 : 0,
228
- }))
229
-
230
- // Position below the cursor, but flip above when there isn't room below
231
- // (e.g. triggered near the bottom of the viewport). Measured after render.
232
- useLayoutEffect(() => {
233
- const r = clientRect?.()
234
- const el = menuRef.current
235
- if (!r || !el) return
236
- const margin = 6
237
- const menuH = el.offsetHeight
238
- const menuW = el.offsetWidth
239
- const spaceBelow = window.innerHeight - r.bottom
240
- let top = r.bottom + margin
241
- if (spaceBelow < menuH + margin && r.top - margin > menuH) {
242
- top = r.top - menuH - margin
243
- }
244
- top = Math.max(margin, Math.min(top, window.innerHeight - menuH - margin))
245
- let left = Math.min(r.left, window.innerWidth - menuW - margin)
246
- left = Math.max(margin, left)
247
- if (left !== pos.left || top !== pos.top) setPos({ left, top })
248
- }, [clientRect, items, pos.left, pos.top])
249
-
250
- if (!rect) return null
251
-
252
- return (
253
- <div
254
- ref={menuRef}
255
- className="slash-menu"
256
- style={{ position: "fixed", left: pos.left, top: pos.top }}
257
- >
258
- {items.length === 0 && <div className="slash-empty">No matching blocks</div>}
259
- {items.map((item, i) => (
260
- <button
261
- key={item.title}
262
- ref={(el) => {
263
- itemRefs.current[i] = el
264
- }}
265
- className={`slash-item ${i === index ? "slash-item-active" : ""}`}
266
- onMouseEnter={() => setIndex(i)}
267
- onMouseDown={(e) => {
268
- e.preventDefault()
269
- command(item)
270
- }}
271
- >
272
- <item.icon className="size-4" />
273
- <span>{item.title}</span>
274
- </button>
275
- ))}
276
- </div>
277
- )
278
- })
190
+ },
191
+ ]
279
192
 
280
193
  export type { SlashItem }
281
194
 
@@ -287,7 +200,6 @@ export function createSlashMenu(items: SlashItem[] = SLASH_ITEMS) {
287
200
  return Extension.create({
288
201
  name: "slashMenu",
289
202
  addProseMirrorPlugins() {
290
- let renderer: ReactRenderer<MenuHandle, MenuProps> | null = null
291
203
  return [
292
204
  Suggestion<SlashItem>({
293
205
  editor: this.editor,
@@ -297,44 +209,11 @@ export function createSlashMenu(items: SlashItem[] = SLASH_ITEMS) {
297
209
  items.filter((item) =>
298
210
  `${item.title} ${item.keywords}`.toLowerCase().includes(query.toLowerCase())
299
211
  ),
300
- command: ({ editor, range, props }) => {
301
- editor.chain().focus().deleteRange(range as Range).run()
302
- props.run(editor as Editor)
303
- },
304
- render: () => ({
305
- onStart: (props: SuggestionProps<SlashItem>) => {
306
- renderer = new ReactRenderer(SlashMenuView, {
307
- editor: props.editor,
308
- props: {
309
- items: props.items,
310
- command: props.command,
311
- clientRect: props.clientRect ?? null,
312
- },
313
- })
314
- document.body.appendChild(renderer.element)
315
- },
316
- onUpdate: (props: SuggestionProps<SlashItem>) => {
317
- renderer?.updateProps({
318
- items: props.items,
319
- command: props.command,
320
- clientRect: props.clientRect ?? null,
321
- })
322
- },
323
- onKeyDown: ({ event }) => {
324
- if (event.key === "Escape") {
325
- renderer?.destroy()
326
- renderer?.element.remove()
327
- renderer = null
328
- return true
329
- }
330
- return renderer?.ref?.onKeyDown(event) ?? false
331
- },
332
- onExit: () => {
333
- renderer?.element.remove()
334
- renderer?.destroy()
335
- renderer = null
212
+ command: ({ editor, range, props }) => {
213
+ editor.chain().focus().deleteRange(range as Range).run()
214
+ props.run(editor as Editor)
336
215
  },
337
- }),
216
+ render: createSuggestionRender<SlashItem>("No matching blocks"),
338
217
  }),
339
218
  ]
340
219
  },
@@ -0,0 +1,164 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ type ForwardRefExoticComponent,
9
+ type RefAttributes,
10
+ } from "react"
11
+ import type { SuggestionProps } from "@tiptap/suggestion"
12
+ import { ReactRenderer } from "@tiptap/react"
13
+ import type { LucideIcon } from "lucide-react"
14
+
15
+ // Shared popup used by the "/" slash menu and the "@"/"#" reference menus.
16
+
17
+ export interface SuggestionMenuItem {
18
+ title: string
19
+ icon: LucideIcon
20
+ }
21
+
22
+ export interface MenuProps<Item extends SuggestionMenuItem> {
23
+ items: Item[]
24
+ command: (item: Item) => void
25
+ clientRect: (() => DOMRect | null) | null
26
+ emptyLabel?: string
27
+ }
28
+
29
+ export interface MenuHandle {
30
+ onKeyDown: (event: KeyboardEvent) => boolean
31
+ }
32
+
33
+ export const SuggestionMenuView = forwardRef<MenuHandle, MenuProps<SuggestionMenuItem>>(
34
+ function SuggestionMenuView({ items, command, clientRect, emptyLabel = "No matches" }, ref) {
35
+ const [index, setIndex] = useState(0)
36
+ const itemRefs = useRef<Array<HTMLButtonElement | null>>([])
37
+
38
+ useEffect(() => setIndex(0), [items])
39
+
40
+ // Keep the active item visible while arrowing through a long list.
41
+ useEffect(() => {
42
+ itemRefs.current[index]?.scrollIntoView({ block: "nearest" })
43
+ }, [index])
44
+
45
+ useImperativeHandle(ref, () => ({
46
+ onKeyDown(event) {
47
+ if (event.key === "ArrowDown") {
48
+ setIndex((i) => (i + 1) % Math.max(items.length, 1))
49
+ return true
50
+ }
51
+ if (event.key === "ArrowUp") {
52
+ setIndex((i) => (i - 1 + items.length) % Math.max(items.length, 1))
53
+ return true
54
+ }
55
+ if (event.key === "Enter") {
56
+ if (items[index]) command(items[index])
57
+ return true
58
+ }
59
+ return false
60
+ },
61
+ }))
62
+
63
+ const menuRef = useRef<HTMLDivElement>(null)
64
+ const rect = clientRect?.()
65
+ const [pos, setPos] = useState<{ left: number; top: number }>(() => ({
66
+ left: rect ? rect.left : 0,
67
+ top: rect ? rect.bottom + 6 : 0,
68
+ }))
69
+
70
+ // Position below the cursor, but flip above when there isn't room below
71
+ // (e.g. triggered near the bottom of the viewport). Measured after render.
72
+ useLayoutEffect(() => {
73
+ const r = clientRect?.()
74
+ const el = menuRef.current
75
+ if (!r || !el) return
76
+ const margin = 6
77
+ const menuH = el.offsetHeight
78
+ const menuW = el.offsetWidth
79
+ const spaceBelow = window.innerHeight - r.bottom
80
+ let top = r.bottom + margin
81
+ if (spaceBelow < menuH + margin && r.top - margin > menuH) {
82
+ top = r.top - menuH - margin
83
+ }
84
+ top = Math.max(margin, Math.min(top, window.innerHeight - menuH - margin))
85
+ let left = Math.min(r.left, window.innerWidth - menuW - margin)
86
+ left = Math.max(margin, left)
87
+ if (left !== pos.left || top !== pos.top) setPos({ left, top })
88
+ }, [clientRect, items, pos.left, pos.top])
89
+
90
+ if (!rect) return null
91
+
92
+ return (
93
+ <div
94
+ ref={menuRef}
95
+ className="slash-menu"
96
+ style={{ position: "fixed", left: pos.left, top: pos.top }}
97
+ >
98
+ {items.length === 0 && <div className="slash-empty">{emptyLabel}</div>}
99
+ {items.map((item, i) => (
100
+ <button
101
+ key={item.title}
102
+ ref={(el) => {
103
+ itemRefs.current[i] = el
104
+ }}
105
+ className={`slash-item ${i === index ? "slash-item-active" : ""}`}
106
+ onMouseEnter={() => setIndex(i)}
107
+ onMouseDown={(e) => {
108
+ e.preventDefault()
109
+ command(item)
110
+ }}
111
+ >
112
+ <item.icon className="size-4" />
113
+ <span>{item.title}</span>
114
+ </button>
115
+ ))}
116
+ </div>
117
+ )
118
+ }
119
+ )
120
+
121
+ /**
122
+ * The Suggestion render lifecycle wired to SuggestionMenuView — shared by the
123
+ * slash menu and the reference menus.
124
+ */
125
+ export function createSuggestionRender<Item extends SuggestionMenuItem>(emptyLabel?: string) {
126
+ // The view only reads SuggestionMenuItem fields, so retyping it per Item is safe.
127
+ const View = SuggestionMenuView as unknown as ForwardRefExoticComponent<
128
+ MenuProps<Item> & RefAttributes<MenuHandle>
129
+ >
130
+ let renderer: ReactRenderer<MenuHandle, MenuProps<Item>> | null = null
131
+ const menuProps = (props: SuggestionProps<Item>): MenuProps<Item> => ({
132
+ items: props.items,
133
+ command: props.command,
134
+ clientRect: props.clientRect ?? null,
135
+ ...(emptyLabel !== undefined ? { emptyLabel } : {}),
136
+ })
137
+ return () => ({
138
+ onStart: (props: SuggestionProps<Item>) => {
139
+ const r = new ReactRenderer(View, {
140
+ editor: props.editor,
141
+ props: menuProps(props),
142
+ })
143
+ renderer = r
144
+ document.body.appendChild(r.element)
145
+ },
146
+ onUpdate: (props: SuggestionProps<Item>) => {
147
+ renderer?.updateProps(menuProps(props))
148
+ },
149
+ onKeyDown: ({ event }: { event: KeyboardEvent }) => {
150
+ if (event.key === "Escape") {
151
+ renderer?.destroy()
152
+ renderer?.element.remove()
153
+ renderer = null
154
+ return true
155
+ }
156
+ return renderer?.ref?.onKeyDown(event) ?? false
157
+ },
158
+ onExit: () => {
159
+ renderer?.element.remove()
160
+ renderer?.destroy()
161
+ renderer = null
162
+ },
163
+ })
164
+ }
package/src/index.ts CHANGED
@@ -7,9 +7,13 @@ export type { PMNode } from "./editor/convert"
7
7
  // or a bespoke slash menu) instead of the batteries-included GitbookEditor.
8
8
  export { createGitbookExtensions, getGitbookSchema, GbTable } from "./editor/extensions"
9
9
  export type { GitbookExtensionOptions } from "./editor/extensions"
10
- export { gitbookNodes, GbCodeBlock } from "./editor/nodes"
10
+ export { gitbookNodes, GbCodeBlock, GbReference } from "./editor/nodes"
11
11
  export { SlashMenu, createSlashMenu, SLASH_ITEMS } from "./editor/slash-menu"
12
12
  export type { SlashItem } from "./editor/slash-menu"
13
+ export { createReferenceSuggestion } from "./editor/reference-menu"
14
+ export type { ReferenceSuggestionOptions, ReferenceTrigger } from "./editor/reference-menu"
15
+ export { SuggestionMenuView, createSuggestionRender } from "./editor/suggestion-menu"
16
+ export type { SuggestionMenuItem } from "./editor/suggestion-menu"
13
17
  export { ReactDemoEditor } from "./editor/ReactDemoEditor"
14
18
  export type { ReactDemoEditorProps } from "./editor/ReactDemoEditor"
15
19
  export { SourceFileEditor } from "./editor/SourceFileEditor"
package/src/styles.css CHANGED
@@ -739,3 +739,56 @@
739
739
  .gb-react-demo-files-header { flex: 0 0 auto; border-bottom: 0; }
740
740
  .gb-react-demo-file { width: auto; flex: 0 0 auto; }
741
741
  }
742
+
743
+ /* ---- Inline reference chips (@mention, #tag) and citations ---- */
744
+
745
+ .gb-ref-wrap {
746
+ display: inline;
747
+ }
748
+
749
+ .gb-ref {
750
+ display: inline-flex;
751
+ align-items: center;
752
+ gap: 4px;
753
+ padding: 1px 8px;
754
+ border-radius: 999px;
755
+ font-size: 0.85em;
756
+ font-weight: 500;
757
+ line-height: 1.5;
758
+ vertical-align: baseline;
759
+ }
760
+
761
+ .gb-ref-icon {
762
+ width: 12px;
763
+ height: 12px;
764
+ }
765
+
766
+ .gb-ref-mention {
767
+ border: 1px solid rgba(59, 130, 246, 0.45);
768
+ background: rgba(59, 130, 246, 0.1);
769
+ color: var(--gb-info, #3b82f6);
770
+ }
771
+
772
+ .gb-ref-tag {
773
+ border: 1px solid var(--gb-border);
774
+ background: var(--gb-muted);
775
+ color: var(--gb-muted-foreground);
776
+ }
777
+
778
+ .gb-cite {
779
+ display: inline-block;
780
+ padding: 1px 5px;
781
+ border-radius: 5px;
782
+ background: rgba(59, 130, 246, 0.14);
783
+ color: var(--gb-info, #3b82f6);
784
+ font-size: 10px;
785
+ font-weight: 700;
786
+ line-height: 1.4;
787
+ vertical-align: super;
788
+ }
789
+
790
+ .ProseMirror-selectednode .gb-ref,
791
+ .ProseMirror-selectednode .gb-cite {
792
+ outline: 2px solid var(--gb-primary);
793
+ outline-offset: 1px;
794
+ }