@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 +3 -2
- package/src/editor/Editor.tsx +21 -4
- package/src/editor/convert.ts +21 -2
- package/src/editor/extensions.ts +15 -0
- package/src/editor/nodes.tsx +53 -0
- package/src/editor/reference-menu.tsx +68 -0
- package/src/editor/slash-menu.tsx +26 -147
- package/src/editor/suggestion-menu.tsx +164 -0
- package/src/index.ts +5 -1
- package/src/styles.css +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream-editor",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
36
|
+
"@brett_lamy/docstream": "0.5.1",
|
|
36
37
|
"lowlight": "^3.3.0",
|
|
37
38
|
"lucide-react": "^1.17.0"
|
|
38
39
|
},
|
package/src/editor/Editor.tsx
CHANGED
|
@@ -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(
|
|
156
|
+
...(controlled ? { content: astToTiptap(parseAndTrack(markdown as string)) } : {}),
|
|
144
157
|
onUpdate({ editor }) {
|
|
145
158
|
if (!onChange) return
|
|
146
|
-
const
|
|
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
|
-
|
|
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
|
package/src/editor/convert.ts
CHANGED
|
@@ -13,8 +13,16 @@ export interface PMNode {
|
|
|
13
13
|
|
|
14
14
|
function inlineToPM(nodes: Inline[]): PMNode[] {
|
|
15
15
|
return nodes
|
|
16
|
-
|
|
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 {
|
package/src/editor/extensions.ts
CHANGED
|
@@ -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
|
}
|
package/src/editor/nodes.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
)
|
|
195
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
}
|