@brett_lamy/docstream-editor 0.5.8 → 0.6.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 +1 -1
- package/package.json +4 -4
- package/src/editor/gpu-highlight.ts +139 -0
- package/src/editor/nodes.tsx +7 -4
- package/src/styles.css +9 -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "TipTap editor for Docstream GitBook-style markdown documents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -34,12 +34,12 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@brett_lamy/docstream": "0.5.7",
|
|
37
|
-
"
|
|
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",
|
|
@@ -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,
|
|
@@ -718,7 +718,7 @@ function CodeView({ node, updateAttributes, editor }: NodeViewProps) {
|
|
|
718
718
|
)
|
|
719
719
|
}
|
|
720
720
|
|
|
721
|
-
export const GbCodeBlock =
|
|
721
|
+
export const GbCodeBlock = CodeBlock.extend({
|
|
722
722
|
addAttributes() {
|
|
723
723
|
return {
|
|
724
724
|
...this.parent?.(),
|
|
@@ -731,7 +731,10 @@ export const GbCodeBlock = CodeBlockLowlight.extend({
|
|
|
731
731
|
addNodeView() {
|
|
732
732
|
return ReactNodeViewRenderer(CodeView)
|
|
733
733
|
},
|
|
734
|
-
|
|
734
|
+
addProseMirrorPlugins() {
|
|
735
|
+
return [...(this.parent?.() ?? []), gpuHighlightPlugin(this.name)]
|
|
736
|
+
},
|
|
737
|
+
})
|
|
735
738
|
|
|
736
739
|
// ---------- Inline image (GitHub badge style) ----------
|
|
737
740
|
|
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); }
|