@brett_lamy/docstream-editor 0.1.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/package.json +37 -0
- package/src/editor/Editor.tsx +135 -0
- package/src/editor/convert.ts +333 -0
- package/src/editor/nodes.tsx +746 -0
- package/src/editor/slash-menu.tsx +309 -0
- package/src/index.ts +4 -0
- package/src/styles.css +1 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useImperativeHandle,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react"
|
|
8
|
+
import { Extension, type Editor, type Range } from "@tiptap/core"
|
|
9
|
+
import Suggestion, { type SuggestionProps } from "@tiptap/suggestion"
|
|
10
|
+
import { ReactRenderer } from "@tiptap/react"
|
|
11
|
+
import {
|
|
12
|
+
Columns2,
|
|
13
|
+
FileCode2,
|
|
14
|
+
Heading1,
|
|
15
|
+
Heading2,
|
|
16
|
+
Heading3,
|
|
17
|
+
Image,
|
|
18
|
+
Lightbulb,
|
|
19
|
+
Link2,
|
|
20
|
+
List,
|
|
21
|
+
ListChecks,
|
|
22
|
+
ListOrdered,
|
|
23
|
+
ListTodo,
|
|
24
|
+
Megaphone,
|
|
25
|
+
Minus,
|
|
26
|
+
MonitorPlay,
|
|
27
|
+
PanelTop,
|
|
28
|
+
Quote,
|
|
29
|
+
Sigma,
|
|
30
|
+
SquareChevronDown,
|
|
31
|
+
Table as TableIcon,
|
|
32
|
+
Webhook,
|
|
33
|
+
Workflow,
|
|
34
|
+
type LucideIcon,
|
|
35
|
+
} from "lucide-react"
|
|
36
|
+
|
|
37
|
+
import type { PMNode } from "./convert"
|
|
38
|
+
|
|
39
|
+
const para = (text = ""): PMNode =>
|
|
40
|
+
text ? { type: "paragraph", content: [{ type: "text", text }] } : { type: "paragraph" }
|
|
41
|
+
|
|
42
|
+
interface SlashItem {
|
|
43
|
+
title: string
|
|
44
|
+
keywords: string
|
|
45
|
+
icon: LucideIcon
|
|
46
|
+
run: (editor: Editor) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const insertBlock = (content: PMNode) => (editor: Editor) =>
|
|
50
|
+
editor.chain().focus().insertContent(content).run()
|
|
51
|
+
|
|
52
|
+
export const SLASH_ITEMS: SlashItem[] = [
|
|
53
|
+
{ title: "Heading 1", keywords: "h1 title", icon: Heading1, run: (e) => e.chain().focus().setHeading({ level: 1 }).run() },
|
|
54
|
+
{ title: "Heading 2", keywords: "h2 section", icon: Heading2, run: (e) => e.chain().focus().setHeading({ level: 2 }).run() },
|
|
55
|
+
{ title: "Heading 3", keywords: "h3 subsection", icon: Heading3, run: (e) => e.chain().focus().setHeading({ level: 3 }).run() },
|
|
56
|
+
{ title: "Bullet list", keywords: "ul unordered", icon: List, run: (e) => e.chain().focus().toggleBulletList().run() },
|
|
57
|
+
{ title: "Numbered list", keywords: "ol ordered", icon: ListOrdered, run: (e) => e.chain().focus().toggleOrderedList().run() },
|
|
58
|
+
{ title: "Task list", keywords: "todo checkbox", icon: ListTodo, run: (e) => e.chain().focus().toggleList("taskList", "taskItem").run() },
|
|
59
|
+
{ title: "Quote", keywords: "blockquote", icon: Quote, run: (e) => e.chain().focus().toggleBlockquote().run() },
|
|
60
|
+
{ title: "Divider", keywords: "hr rule separator", icon: Minus, run: (e) => e.chain().focus().setHorizontalRule().run() },
|
|
61
|
+
{ title: "Code block", keywords: "snippet pre", icon: FileCode2, run: (e) => e.chain().focus().toggleCodeBlock().run() },
|
|
62
|
+
{
|
|
63
|
+
title: "Hint",
|
|
64
|
+
keywords: "callout info warning danger success admonition",
|
|
65
|
+
icon: Lightbulb,
|
|
66
|
+
run: insertBlock({ type: "gbHint", attrs: { style: "info" }, content: [para()] }),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
title: "Tabs",
|
|
70
|
+
keywords: "tabbed switcher",
|
|
71
|
+
icon: PanelTop,
|
|
72
|
+
run: insertBlock({
|
|
73
|
+
type: "gbTabs",
|
|
74
|
+
content: [
|
|
75
|
+
{ type: "gbTab", attrs: { title: "First tab" }, content: [para()] },
|
|
76
|
+
{ type: "gbTab", attrs: { title: "Second tab" }, content: [para()] },
|
|
77
|
+
],
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
title: "Expandable",
|
|
82
|
+
keywords: "details accordion collapse",
|
|
83
|
+
icon: SquareChevronDown,
|
|
84
|
+
run: insertBlock({ type: "gbExpandable", attrs: { summary: "Click to expand" }, content: [para()] }),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
title: "Stepper",
|
|
88
|
+
keywords: "steps guide step-by-step",
|
|
89
|
+
icon: ListChecks,
|
|
90
|
+
run: insertBlock({
|
|
91
|
+
type: "gbStepper",
|
|
92
|
+
content: [
|
|
93
|
+
{ type: "gbStep", attrs: { title: "First step" }, content: [para()] },
|
|
94
|
+
{ type: "gbStep", attrs: { title: "Second step" }, content: [para()] },
|
|
95
|
+
],
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
title: "Embed",
|
|
100
|
+
keywords: "youtube video iframe url",
|
|
101
|
+
icon: MonitorPlay,
|
|
102
|
+
run: insertBlock({ type: "gbEmbed", attrs: { url: "" } }),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
title: "Page link",
|
|
106
|
+
keywords: "content-ref reference",
|
|
107
|
+
icon: Link2,
|
|
108
|
+
run: insertBlock({ type: "gbContentRef", attrs: { url: "", label: "Page link" } }),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
title: "Columns",
|
|
112
|
+
keywords: "two column layout",
|
|
113
|
+
icon: Columns2,
|
|
114
|
+
run: insertBlock({
|
|
115
|
+
type: "gbColumns",
|
|
116
|
+
content: [
|
|
117
|
+
{ type: "gbColumn", content: [para()] },
|
|
118
|
+
{ type: "gbColumn", content: [para()] },
|
|
119
|
+
],
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
title: "Image",
|
|
124
|
+
keywords: "figure picture photo",
|
|
125
|
+
icon: Image,
|
|
126
|
+
run: insertBlock({ type: "gbFigure", attrs: { src: "", alt: "", caption: "" } }),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
title: "Math",
|
|
130
|
+
keywords: "tex latex formula equation",
|
|
131
|
+
icon: Sigma,
|
|
132
|
+
run: insertBlock({ type: "gbMath", attrs: { formula: "e = mc^2" } }),
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
title: "Table",
|
|
136
|
+
keywords: "grid cells",
|
|
137
|
+
icon: TableIcon,
|
|
138
|
+
run: (e) => e.chain().focus().insertTable({ rows: 2, cols: 2, withHeaderRow: true }).run(),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
title: "Updates",
|
|
142
|
+
keywords: "changelog release notes",
|
|
143
|
+
icon: Megaphone,
|
|
144
|
+
run: insertBlock({
|
|
145
|
+
type: "gbUpdates",
|
|
146
|
+
attrs: { format: "full" },
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: "gbUpdate",
|
|
150
|
+
attrs: { date: new Date().toISOString().slice(0, 10) },
|
|
151
|
+
content: [
|
|
152
|
+
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "What changed" }] },
|
|
153
|
+
para(),
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
title: "OpenAPI operation",
|
|
161
|
+
keywords: "api swagger endpoint rest",
|
|
162
|
+
icon: Webhook,
|
|
163
|
+
run: insertBlock({
|
|
164
|
+
type: "gbOpenapi",
|
|
165
|
+
attrs: { spec: "", path: "/", method: "get", specUrl: "", label: "" },
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
title: "Mermaid diagram",
|
|
170
|
+
keywords: "chart graph flowchart",
|
|
171
|
+
icon: Workflow,
|
|
172
|
+
run: insertBlock({
|
|
173
|
+
type: "codeBlock",
|
|
174
|
+
attrs: { language: "mermaid", title: null, lineNumbers: false },
|
|
175
|
+
content: [{ type: "text", text: "graph TD\n A --> B" }],
|
|
176
|
+
}),
|
|
177
|
+
},
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
interface MenuProps {
|
|
181
|
+
items: SlashItem[]
|
|
182
|
+
command: (item: SlashItem) => void
|
|
183
|
+
clientRect: (() => DOMRect | null) | null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface MenuHandle {
|
|
187
|
+
onKeyDown: (event: KeyboardEvent) => boolean
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const SlashMenuView = forwardRef<MenuHandle, MenuProps>(function SlashMenuView(
|
|
191
|
+
{ items, command, clientRect },
|
|
192
|
+
ref
|
|
193
|
+
) {
|
|
194
|
+
const [index, setIndex] = useState(0)
|
|
195
|
+
const itemRefs = useRef<Array<HTMLButtonElement | null>>([])
|
|
196
|
+
|
|
197
|
+
useEffect(() => setIndex(0), [items])
|
|
198
|
+
|
|
199
|
+
// Keep the active item visible while arrowing through a long list.
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
itemRefs.current[index]?.scrollIntoView({ block: "nearest" })
|
|
202
|
+
}, [index])
|
|
203
|
+
|
|
204
|
+
useImperativeHandle(ref, () => ({
|
|
205
|
+
onKeyDown(event) {
|
|
206
|
+
if (event.key === "ArrowDown") {
|
|
207
|
+
setIndex((i) => (i + 1) % Math.max(items.length, 1))
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
if (event.key === "ArrowUp") {
|
|
211
|
+
setIndex((i) => (i - 1 + items.length) % Math.max(items.length, 1))
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
if (event.key === "Enter") {
|
|
215
|
+
if (items[index]) command(items[index])
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
return false
|
|
219
|
+
},
|
|
220
|
+
}))
|
|
221
|
+
|
|
222
|
+
const rect = clientRect?.()
|
|
223
|
+
if (!rect) return null
|
|
224
|
+
|
|
225
|
+
const style: React.CSSProperties = {
|
|
226
|
+
position: "fixed",
|
|
227
|
+
left: rect.left,
|
|
228
|
+
top: rect.bottom + 6,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div className="slash-menu" style={style}>
|
|
233
|
+
{items.length === 0 && <div className="slash-empty">No matching blocks</div>}
|
|
234
|
+
{items.map((item, i) => (
|
|
235
|
+
<button
|
|
236
|
+
key={item.title}
|
|
237
|
+
ref={(el) => {
|
|
238
|
+
itemRefs.current[i] = el
|
|
239
|
+
}}
|
|
240
|
+
className={`slash-item ${i === index ? "slash-item-active" : ""}`}
|
|
241
|
+
onMouseEnter={() => setIndex(i)}
|
|
242
|
+
onMouseDown={(e) => {
|
|
243
|
+
e.preventDefault()
|
|
244
|
+
command(item)
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
<item.icon className="size-4" />
|
|
248
|
+
<span>{item.title}</span>
|
|
249
|
+
</button>
|
|
250
|
+
))}
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
export const SlashMenu = Extension.create({
|
|
256
|
+
name: "slashMenu",
|
|
257
|
+
addProseMirrorPlugins() {
|
|
258
|
+
let renderer: ReactRenderer<MenuHandle, MenuProps> | null = null
|
|
259
|
+
return [
|
|
260
|
+
Suggestion<SlashItem>({
|
|
261
|
+
editor: this.editor,
|
|
262
|
+
char: "/",
|
|
263
|
+
startOfLine: false,
|
|
264
|
+
items: ({ query }) =>
|
|
265
|
+
SLASH_ITEMS.filter((item) =>
|
|
266
|
+
`${item.title} ${item.keywords}`.toLowerCase().includes(query.toLowerCase())
|
|
267
|
+
),
|
|
268
|
+
command: ({ editor, range, props }) => {
|
|
269
|
+
editor.chain().focus().deleteRange(range as Range).run()
|
|
270
|
+
props.run(editor as Editor)
|
|
271
|
+
},
|
|
272
|
+
render: () => ({
|
|
273
|
+
onStart: (props: SuggestionProps<SlashItem>) => {
|
|
274
|
+
renderer = new ReactRenderer(SlashMenuView, {
|
|
275
|
+
editor: props.editor,
|
|
276
|
+
props: {
|
|
277
|
+
items: props.items,
|
|
278
|
+
command: props.command,
|
|
279
|
+
clientRect: props.clientRect ?? null,
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
document.body.appendChild(renderer.element)
|
|
283
|
+
},
|
|
284
|
+
onUpdate: (props: SuggestionProps<SlashItem>) => {
|
|
285
|
+
renderer?.updateProps({
|
|
286
|
+
items: props.items,
|
|
287
|
+
command: props.command,
|
|
288
|
+
clientRect: props.clientRect ?? null,
|
|
289
|
+
})
|
|
290
|
+
},
|
|
291
|
+
onKeyDown: ({ event }) => {
|
|
292
|
+
if (event.key === "Escape") {
|
|
293
|
+
renderer?.destroy()
|
|
294
|
+
renderer?.element.remove()
|
|
295
|
+
renderer = null
|
|
296
|
+
return true
|
|
297
|
+
}
|
|
298
|
+
return renderer?.ref?.onKeyDown(event) ?? false
|
|
299
|
+
},
|
|
300
|
+
onExit: () => {
|
|
301
|
+
renderer?.element.remove()
|
|
302
|
+
renderer?.destroy()
|
|
303
|
+
renderer = null
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
}),
|
|
307
|
+
]
|
|
308
|
+
},
|
|
309
|
+
})
|
package/src/index.ts
ADDED
package/src/styles.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/* Editor styles are currently provided by the host app's GitBook theme CSS. */
|