@brett_lamy/docstream-editor 0.5.8 → 0.7.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 +24 -1
- package/package.json +5 -5
- package/src/editor/Editor.tsx +6 -3
- package/src/editor/convert.ts +2 -1
- package/src/editor/extensions.ts +16 -4
- package/src/editor/gpu-highlight.ts +139 -0
- package/src/editor/nodes.tsx +23 -6
- package/src/editor/reference-menu.tsx +88 -21
- package/src/editor/suggestion-menu.tsx +25 -16
- package/src/index.ts +8 -3
- package/src/styles.css +32 -25
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ TipTap editor components for Docstream GitBook-style markdown documents.
|
|
|
10
10
|
- TipTap extensions for common writing flows.
|
|
11
11
|
- Conversion helpers between the Docstream AST and TipTap JSON.
|
|
12
12
|
- Slash menu for inserting supported blocks.
|
|
13
|
-
- Code block
|
|
13
|
+
- Code block syntax highlighting through [`gpu-lexer`](https://gpu-lexer.vercel.app/) (language-agnostic, WebGPU; plain text where WebGPU is unavailable).
|
|
14
14
|
- Table, task list, heading, quote, and inline formatting support.
|
|
15
15
|
- Preserves Docstream/GitBook block semantics when converting back to markdown.
|
|
16
16
|
- Preserves mounted source-file/export provenance and can write edits to the real file through Vite.
|
|
@@ -109,6 +109,29 @@ The editor supports common ProseMirror/TipTap content plus GitBook-flavored bloc
|
|
|
109
109
|
|
|
110
110
|
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
111
|
|
|
112
|
+
## Mentions, channels, and codebases
|
|
113
|
+
|
|
114
|
+
Typing `@`, `#`, or `$` opens a picker and inserts a reference chip that serializes as
|
|
115
|
+
`@id`, `#id`, or `$id` (`$org/repo` works too). Feed each trigger a static list or an
|
|
116
|
+
async `(query) => options` function; options can carry a `label`, `description`, and
|
|
117
|
+
`group` (rendered as section headers). `$` is only enabled when `codebases` is set, so
|
|
118
|
+
dollar signs in ordinary prose never open a menu. Free-form entry is always offered.
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
<GitbookEditor
|
|
122
|
+
markdown={md}
|
|
123
|
+
onChange={setMd}
|
|
124
|
+
references={{
|
|
125
|
+
mentions: [
|
|
126
|
+
...people.map((p) => ({ id: p.handle, label: p.name, description: p.email, group: "People" })),
|
|
127
|
+
...agents.map((a) => ({ id: a.handle, label: a.name, description: a.harness, group: "Agents" })),
|
|
128
|
+
],
|
|
129
|
+
tags: async (query) => searchChannels(query), // [{ id: "general", description: "channel" }]
|
|
130
|
+
codebases: async (query) => searchRepos(query), // [{ id: "reading-room" }]
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
```
|
|
134
|
+
|
|
112
135
|
## Edit a referenced source file
|
|
113
136
|
|
|
114
137
|
`GitbookEditor` edits the Markdown composition, including a structured
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "TipTap editor for Docstream GitBook-style markdown documents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -33,13 +33,13 @@
|
|
|
33
33
|
"./styles.css": "./src/styles.css"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@brett_lamy/docstream": "0.
|
|
37
|
-
"
|
|
36
|
+
"@brett_lamy/docstream": "0.6.0",
|
|
37
|
+
"gpu-lexer": "0.0.2",
|
|
38
38
|
"lucide-react": "^1.17.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@tiptap/core": "^3.26.1",
|
|
42
|
-
"@tiptap/extension-code-block
|
|
42
|
+
"@tiptap/extension-code-block": "^3.26.1",
|
|
43
43
|
"@tiptap/extension-list": "^3.26.1",
|
|
44
44
|
"@tiptap/extension-placeholder": "^3.26.1",
|
|
45
45
|
"@tiptap/extension-table": "^3.26.1",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@agent-wasm/core": "^0.4.0",
|
|
58
58
|
"@tiptap/core": "^3.26.1",
|
|
59
|
-
"@tiptap/extension-code-block
|
|
59
|
+
"@tiptap/extension-code-block": "^3.26.1",
|
|
60
60
|
"@tiptap/extension-list": "^3.26.1",
|
|
61
61
|
"@tiptap/extension-placeholder": "^3.26.1",
|
|
62
62
|
"@tiptap/extension-table": "^3.26.1",
|
package/src/editor/Editor.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { parseMarkdown, serializeMarkdown, type CitationDef } from "@brett_lamy/docstream/gitbook"
|
|
14
14
|
import type { SourceFileSnapshot, SourceReferenceClient } from "@brett_lamy/docstream/source"
|
|
15
15
|
import { astToTiptap, tiptapToAst, type PMNode } from "./convert"
|
|
16
|
-
import { createGitbookExtensions } from "./extensions"
|
|
16
|
+
import { createGitbookExtensions, type ReferenceSources } from "./extensions"
|
|
17
17
|
import { EditorRuntimeProvider } from "./runtime"
|
|
18
18
|
import type { SlashItem } from "./slash-menu"
|
|
19
19
|
|
|
@@ -26,8 +26,11 @@ export interface GitbookEditorProps {
|
|
|
26
26
|
toolbar?: boolean
|
|
27
27
|
/** Built-in "/" slash menu: true (default), false, or a custom item list. */
|
|
28
28
|
slashMenu?: boolean | { items?: SlashItem[] }
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Reference chip pickers: true (default "@"/"#"), false, or per-trigger sources —
|
|
31
|
+
* static lists or async `(query) => options`. A `codebases` source enables "$".
|
|
32
|
+
*/
|
|
33
|
+
references?: boolean | ReferenceSources
|
|
31
34
|
/** Whether the document is editable (default true). */
|
|
32
35
|
editable?: boolean
|
|
33
36
|
/** Placeholder shown in an empty document. */
|
package/src/editor/convert.ts
CHANGED
|
@@ -203,7 +203,8 @@ function pmTextToInline(nodes: PMNode[] | undefined): Inline[] {
|
|
|
203
203
|
.map((n): Inline => {
|
|
204
204
|
if (n.type === "gbReference") {
|
|
205
205
|
const a = n.attrs ?? {}
|
|
206
|
-
const kind =
|
|
206
|
+
const kind =
|
|
207
|
+
a.kind === "tag" || a.kind === "codebase" || a.kind === "citation" ? a.kind : "mention"
|
|
207
208
|
return {
|
|
208
209
|
type: "reference",
|
|
209
210
|
kind,
|
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)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Node as PmNode } from "@tiptap/pm/model"
|
|
2
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
|
3
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Code-block syntax highlighting through gpu-lexer — the same language-agnostic WebGPU
|
|
7
|
+
* lexer docstream's renderer uses, so edit and preview agree. gpu-lexer is async, so
|
|
8
|
+
* spans are cached by block text: decorations are built synchronously from the cache,
|
|
9
|
+
* missing blocks are tokenized in the background, and a meta transaction re-decorates
|
|
10
|
+
* when results land. Without WebGPU `parse` rejects and blocks stay plain.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type SpanType =
|
|
14
|
+
| "plain"
|
|
15
|
+
| "comment"
|
|
16
|
+
| "string"
|
|
17
|
+
| "number"
|
|
18
|
+
| "keyword"
|
|
19
|
+
| "type"
|
|
20
|
+
| "function"
|
|
21
|
+
| "constant"
|
|
22
|
+
| "operator"
|
|
23
|
+
|
|
24
|
+
interface Span {
|
|
25
|
+
type: SpanType
|
|
26
|
+
start: number
|
|
27
|
+
end: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_CODE_UNITS = 128 * 1024
|
|
31
|
+
const CACHE_LIMIT = 256
|
|
32
|
+
|
|
33
|
+
let lexer: Promise<(code: string) => Promise<Span[]>> | undefined
|
|
34
|
+
const cache = new Map<string, Span[] | null>()
|
|
35
|
+
const pending = new Map<string, Promise<void>>()
|
|
36
|
+
|
|
37
|
+
function remember(code: string, spans: Span[] | null) {
|
|
38
|
+
cache.set(code, spans)
|
|
39
|
+
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value as string)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tokenize(code: string): Promise<void> {
|
|
43
|
+
const inflight = pending.get(code)
|
|
44
|
+
if (inflight) return inflight
|
|
45
|
+
// Loaded on first code block so the model stays out of the editor's initial bundle.
|
|
46
|
+
lexer ??= import("gpu-lexer").then((module) => module.parse as (code: string) => Promise<Span[]>)
|
|
47
|
+
const run = lexer
|
|
48
|
+
.then((parse) => parse(code))
|
|
49
|
+
.then(
|
|
50
|
+
(spans) => remember(code, spans),
|
|
51
|
+
() => remember(code, null),
|
|
52
|
+
)
|
|
53
|
+
.finally(() => pending.delete(code))
|
|
54
|
+
pending.set(code, run)
|
|
55
|
+
return run
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function highlightable(code: string) {
|
|
59
|
+
return code.length > 0 && code.length <= MAX_CODE_UNITS
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decorations from the span cache. A block whose new text is not tokenized yet keeps its
|
|
64
|
+
* previous decorations mapped through the edit, so typing never flickers to plain.
|
|
65
|
+
*/
|
|
66
|
+
function decorationsFor(doc: PmNode, nodeName: string, previous?: DecorationSet): DecorationSet {
|
|
67
|
+
const decorations: Decoration[] = []
|
|
68
|
+
doc.descendants((node, pos) => {
|
|
69
|
+
if (node.type.name !== nodeName) return true
|
|
70
|
+
const from = pos + 1
|
|
71
|
+
const spans = cache.get(node.textContent)
|
|
72
|
+
if (!spans) {
|
|
73
|
+
if (previous) decorations.push(...previous.find(from, from + node.content.size))
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
for (const span of spans) {
|
|
77
|
+
if (span.type === "plain" || span.end <= span.start) continue
|
|
78
|
+
decorations.push(
|
|
79
|
+
Decoration.inline(from + span.start, from + span.end, { class: `gb-syntax-${span.type}` }),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
return false
|
|
83
|
+
})
|
|
84
|
+
return DecorationSet.create(doc, decorations)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function missingBlocks(doc: PmNode, nodeName: string): string[] {
|
|
88
|
+
const missing = new Set<string>()
|
|
89
|
+
doc.descendants((node) => {
|
|
90
|
+
if (node.type.name !== nodeName) return true
|
|
91
|
+
const code = node.textContent
|
|
92
|
+
if (highlightable(code) && !cache.has(code)) missing.add(code)
|
|
93
|
+
return false
|
|
94
|
+
})
|
|
95
|
+
return [...missing]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function gpuHighlightPlugin(nodeName: string): Plugin {
|
|
99
|
+
const key = new PluginKey<DecorationSet>("gpuHighlight")
|
|
100
|
+
return new Plugin<DecorationSet>({
|
|
101
|
+
key,
|
|
102
|
+
state: {
|
|
103
|
+
init: (_, state) => decorationsFor(state.doc, nodeName),
|
|
104
|
+
apply: (tr, previous) =>
|
|
105
|
+
tr.docChanged || tr.getMeta(key)
|
|
106
|
+
? decorationsFor(tr.doc, nodeName, previous.map(tr.mapping, tr.doc))
|
|
107
|
+
: previous,
|
|
108
|
+
},
|
|
109
|
+
props: {
|
|
110
|
+
decorations(state) {
|
|
111
|
+
return key.getState(state)
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
view(view) {
|
|
115
|
+
let destroyed = false
|
|
116
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
117
|
+
const request = () => {
|
|
118
|
+
for (const code of missingBlocks(view.state.doc, nodeName)) {
|
|
119
|
+
void tokenize(code).then(() => {
|
|
120
|
+
if (!destroyed) view.dispatch(view.state.tr.setMeta(key, true))
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
request()
|
|
125
|
+
return {
|
|
126
|
+
update: (_view, previous) => {
|
|
127
|
+
if (previous.doc.eq(view.state.doc)) return
|
|
128
|
+
// One tokenize per typing pause, not per keystroke.
|
|
129
|
+
clearTimeout(timer)
|
|
130
|
+
timer = setTimeout(request, 150)
|
|
131
|
+
},
|
|
132
|
+
destroy: () => {
|
|
133
|
+
destroyed = true
|
|
134
|
+
clearTimeout(timer)
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
}
|
package/src/editor/nodes.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Node, mergeAttributes } from "@tiptap/core"
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { CodeBlock } from "@tiptap/extension-code-block"
|
|
3
|
+
import { gpuHighlightPlugin } from "./gpu-highlight"
|
|
4
4
|
import { useEffect, useMemo, useState } from "react"
|
|
5
5
|
import {
|
|
6
6
|
NodeViewContent,
|
|
@@ -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,
|
|
@@ -718,7 +719,7 @@ function CodeView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
|
718
719
|
)
|
|
719
720
|
}
|
|
720
721
|
|
|
721
|
-
export const GbCodeBlock =
|
|
722
|
+
export const GbCodeBlock = CodeBlock.extend({
|
|
722
723
|
addAttributes() {
|
|
723
724
|
return {
|
|
724
725
|
...this.parent?.(),
|
|
@@ -731,7 +732,10 @@ export const GbCodeBlock = CodeBlockLowlight.extend({
|
|
|
731
732
|
addNodeView() {
|
|
732
733
|
return ReactNodeViewRenderer(CodeView)
|
|
733
734
|
},
|
|
734
|
-
|
|
735
|
+
addProseMirrorPlugins() {
|
|
736
|
+
return [...(this.parent?.() ?? []), gpuHighlightPlugin(this.name)]
|
|
737
|
+
},
|
|
738
|
+
})
|
|
735
739
|
|
|
736
740
|
// ---------- Inline image (GitHub badge style) ----------
|
|
737
741
|
|
|
@@ -789,7 +793,13 @@ function ReferenceView({ node }: NodeViewProps) {
|
|
|
789
793
|
</span>
|
|
790
794
|
) : (
|
|
791
795
|
<span className={`gb-ref gb-ref-${kind}`}>
|
|
792
|
-
{kind === "mention" ?
|
|
796
|
+
{kind === "mention" ? (
|
|
797
|
+
<AtSign className="gb-ref-icon" />
|
|
798
|
+
) : kind === "codebase" ? (
|
|
799
|
+
<FolderGit2 className="gb-ref-icon" />
|
|
800
|
+
) : (
|
|
801
|
+
<Hash className="gb-ref-icon" />
|
|
802
|
+
)}
|
|
793
803
|
{id}
|
|
794
804
|
</span>
|
|
795
805
|
)}
|
|
@@ -815,7 +825,14 @@ export const GbReference = Node.create({
|
|
|
815
825
|
return [{ tag: "span[data-gb-ref]" }]
|
|
816
826
|
},
|
|
817
827
|
renderHTML({ HTMLAttributes, node }) {
|
|
818
|
-
const sigil =
|
|
828
|
+
const sigil =
|
|
829
|
+
node.attrs.kind === "mention"
|
|
830
|
+
? "@"
|
|
831
|
+
: node.attrs.kind === "tag"
|
|
832
|
+
? "#"
|
|
833
|
+
: node.attrs.kind === "codebase"
|
|
834
|
+
? "$"
|
|
835
|
+
: "^"
|
|
819
836
|
return [
|
|
820
837
|
"span",
|
|
821
838
|
mergeAttributes(HTMLAttributes, { "data-gb-ref": node.attrs.kind, "data-gb-ref-id": node.attrs.id }),
|
|
@@ -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
|
},
|
|
@@ -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
|
@@ -6,12 +6,17 @@ export type { PMNode } from "./editor/convert"
|
|
|
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
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 {
|
|
13
|
+
export { createReferenceSuggestion, resolveReferenceOptions } from "./editor/reference-menu"
|
|
14
|
+
export type {
|
|
15
|
+
ReferenceOption,
|
|
16
|
+
ReferenceSource,
|
|
17
|
+
ReferenceSuggestionOptions,
|
|
18
|
+
ReferenceTrigger,
|
|
19
|
+
} from "./editor/reference-menu"
|
|
15
20
|
export { SuggestionMenuView, createSuggestionRender } from "./editor/suggestion-menu"
|
|
16
21
|
export type { SuggestionMenuItem } from "./editor/suggestion-menu"
|
|
17
22
|
export { ReactDemoEditor } from "./editor/ReactDemoEditor"
|
package/src/styles.css
CHANGED
|
@@ -582,31 +582,15 @@
|
|
|
582
582
|
color: var(--gb-code-fg);
|
|
583
583
|
}
|
|
584
584
|
.gb-code > pre code { background: none; padding: 0; font-size: inherit; color: inherit; }
|
|
585
|
-
|
|
586
|
-
.gb-code .
|
|
587
|
-
.gb-code .
|
|
588
|
-
.gb-code .
|
|
589
|
-
.gb-code .
|
|
590
|
-
.gb-code .
|
|
591
|
-
.gb-code .
|
|
592
|
-
.gb-code .
|
|
593
|
-
.gb-code .
|
|
594
|
-
.gb-code .hljs-symbol,
|
|
595
|
-
.gb-code .hljs-bullet { color: #a5d6a7; }
|
|
596
|
-
.gb-code .hljs-title,
|
|
597
|
-
.gb-code .hljs-section,
|
|
598
|
-
.gb-code .hljs-function .hljs-title { color: #82aaff; }
|
|
599
|
-
.gb-code .hljs-number,
|
|
600
|
-
.gb-code .hljs-literal,
|
|
601
|
-
.gb-code .hljs-variable,
|
|
602
|
-
.gb-code .hljs-template-variable { color: #f78c6c; }
|
|
603
|
-
.gb-code .hljs-comment,
|
|
604
|
-
.gb-code .hljs-quote,
|
|
605
|
-
.gb-code .hljs-meta { color: #777784; }
|
|
606
|
-
.gb-code .hljs-operator,
|
|
607
|
-
.gb-code .hljs-punctuation { color: #89ddff; }
|
|
608
|
-
.gb-code .hljs-deletion { color: #ff6b6b; }
|
|
609
|
-
.gb-code .hljs-addition { color: #69db7c; }
|
|
585
|
+
/* gpu-lexer span classes (palette shared with docstream's HighlightedCode). */
|
|
586
|
+
.gb-code .gb-syntax-comment { color: #6b6b78; font-style: italic; }
|
|
587
|
+
.gb-code .gb-syntax-string { color: #a5d6a7; }
|
|
588
|
+
.gb-code .gb-syntax-number { color: #f78c6c; }
|
|
589
|
+
.gb-code .gb-syntax-keyword { color: #c792ea; }
|
|
590
|
+
.gb-code .gb-syntax-type { color: #ffcb6b; }
|
|
591
|
+
.gb-code .gb-syntax-function { color: #82aaff; }
|
|
592
|
+
.gb-code .gb-syntax-constant { color: #f07178; }
|
|
593
|
+
.gb-code .gb-syntax-operator { color: #89ddff; }
|
|
610
594
|
|
|
611
595
|
/* ── Updates / changelog (mirrors .docs-updates) ────────── */
|
|
612
596
|
.gb-updates { margin: 0.85em 0; border-top: 1px solid var(--gb-border); }
|
|
@@ -666,6 +650,23 @@
|
|
|
666
650
|
.slash-item-active { background: var(--accent, #f4f4f5); }
|
|
667
651
|
.slash-item svg { color: var(--muted-foreground, #6b7280); }
|
|
668
652
|
.slash-empty { padding: 8px 10px; font-size: 13px; color: var(--muted-foreground, #6b7280); }
|
|
653
|
+
.slash-group {
|
|
654
|
+
padding: 8px 10px 3px;
|
|
655
|
+
font-size: 11px;
|
|
656
|
+
font-weight: 600;
|
|
657
|
+
letter-spacing: 0.04em;
|
|
658
|
+
text-transform: uppercase;
|
|
659
|
+
color: var(--muted-foreground, #6b7280);
|
|
660
|
+
}
|
|
661
|
+
.slash-item-description {
|
|
662
|
+
margin-left: auto;
|
|
663
|
+
padding-left: 12px;
|
|
664
|
+
overflow: hidden;
|
|
665
|
+
font-size: 12px;
|
|
666
|
+
white-space: nowrap;
|
|
667
|
+
text-overflow: ellipsis;
|
|
668
|
+
color: var(--muted-foreground, #6b7280);
|
|
669
|
+
}
|
|
669
670
|
|
|
670
671
|
.gb-code-live {
|
|
671
672
|
display: flex;
|
|
@@ -835,6 +836,12 @@
|
|
|
835
836
|
color: var(--gb-muted-foreground);
|
|
836
837
|
}
|
|
837
838
|
|
|
839
|
+
.gb-ref-codebase {
|
|
840
|
+
border: 1px solid rgba(34, 197, 94, 0.45);
|
|
841
|
+
background: rgba(34, 197, 94, 0.1);
|
|
842
|
+
color: rgb(22, 163, 74);
|
|
843
|
+
}
|
|
844
|
+
|
|
838
845
|
.gb-cite {
|
|
839
846
|
display: inline-block;
|
|
840
847
|
padding: 1px 5px;
|