@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.
@@ -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
@@ -0,0 +1,4 @@
1
+ export { GitbookEditor } from "./editor/Editor"
2
+ export type { GitbookEditorProps } from "./editor/Editor"
3
+ export { astToTiptap, tiptapToAst } from "./editor/convert"
4
+ export type { PMNode } from "./editor/convert"
package/src/styles.css ADDED
@@ -0,0 +1 @@
1
+ /* Editor styles are currently provided by the host app's GitBook theme CSS. */