@tessera-editor/core 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/LICENSE +21 -0
- package/dist/index.d.ts +903 -0
- package/dist/index.js +2538 -0
- package/dist/index.js.map +1 -0
- package/dist/tessera.css +1491 -0
- package/package.json +72 -0
- package/src/__tests__/markdown.test.ts +138 -0
- package/src/__tests__/v11.test.ts +184 -0
- package/src/__tests__/v112.test.ts +184 -0
- package/src/__tests__/writeback.test.ts +123 -0
- package/src/blockmenu.ts +38 -0
- package/src/diff.ts +150 -0
- package/src/extensions/context-menu.ts +55 -0
- package/src/extensions/emoji.ts +148 -0
- package/src/extensions/find-replace.ts +229 -0
- package/src/extensions/gallery.ts +111 -0
- package/src/extensions/history.ts +133 -0
- package/src/extensions/input-rules.ts +34 -0
- package/src/extensions/metrics.ts +61 -0
- package/src/extensions/shortcuts.ts +118 -0
- package/src/extensions/slash.ts +272 -0
- package/src/extensions/word-paste.ts +25 -0
- package/src/i18n.ts +320 -0
- package/src/index.ts +80 -0
- package/src/markdown.ts +260 -0
- package/src/marks/ai.ts +55 -0
- package/src/marks/comment.ts +137 -0
- package/src/marks/placeholder.ts +63 -0
- package/src/nodes/collapsible.ts +171 -0
- package/src/nodes/embed.ts +55 -0
- package/src/nodes/hint.ts +70 -0
- package/src/nodes/image.ts +77 -0
- package/src/nodes/table.ts +286 -0
- package/src/nodes/toc.ts +38 -0
- package/src/preset.ts +117 -0
- package/src/services.ts +103 -0
- package/src/styles/tessera.css +1491 -0
- package/src/wordpaste.ts +56 -0
- package/src/writeback.ts +156 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core'
|
|
2
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
3
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Find & replace (acceptance §3: ⌘F). The core plugin owns match computation
|
|
7
|
+
* and decorations; the binding renders the panel and calls these commands.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface FindMatch {
|
|
11
|
+
from: number
|
|
12
|
+
to: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FindReplaceState {
|
|
16
|
+
query: string
|
|
17
|
+
matches: FindMatch[]
|
|
18
|
+
active: number
|
|
19
|
+
visible: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FindReplaceStorage extends FindReplaceState {}
|
|
23
|
+
|
|
24
|
+
export const findReplaceKey = new PluginKey<FindReplaceState>('tesseraFindReplace')
|
|
25
|
+
|
|
26
|
+
function computeMatches(doc: import('@tiptap/pm/model').Node, query: string): FindMatch[] {
|
|
27
|
+
if (!query) {
|
|
28
|
+
return []
|
|
29
|
+
}
|
|
30
|
+
const matches: FindMatch[] = []
|
|
31
|
+
const needle = query.toLowerCase()
|
|
32
|
+
doc.descendants((node, pos) => {
|
|
33
|
+
if (node.isText && node.text) {
|
|
34
|
+
const haystack = node.text.toLowerCase()
|
|
35
|
+
let idx = haystack.indexOf(needle)
|
|
36
|
+
while (idx !== -1) {
|
|
37
|
+
matches.push({ from: pos + idx, to: pos + idx + needle.length })
|
|
38
|
+
idx = haystack.indexOf(needle, idx + needle.length)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return true
|
|
42
|
+
})
|
|
43
|
+
return matches
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
declare module '@tiptap/core' {
|
|
47
|
+
interface Commands<ReturnType> {
|
|
48
|
+
findReplace: {
|
|
49
|
+
openFindPanel: () => ReturnType
|
|
50
|
+
closeFindPanel: () => ReturnType
|
|
51
|
+
setFindQuery: (query: string) => ReturnType
|
|
52
|
+
findNext: () => ReturnType
|
|
53
|
+
findPrev: () => ReturnType
|
|
54
|
+
replaceCurrent: (replacement: string) => ReturnType
|
|
55
|
+
replaceAll: (replacement: string) => ReturnType
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const TesseraFindReplace = Extension.create<Record<string, never>, FindReplaceStorage>({
|
|
61
|
+
name: 'tesseraFindReplace',
|
|
62
|
+
|
|
63
|
+
addStorage() {
|
|
64
|
+
return {
|
|
65
|
+
query: '',
|
|
66
|
+
matches: [],
|
|
67
|
+
active: 0,
|
|
68
|
+
visible: false,
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
addCommands() {
|
|
73
|
+
return {
|
|
74
|
+
openFindPanel:
|
|
75
|
+
() =>
|
|
76
|
+
({ tr, dispatch }) => {
|
|
77
|
+
if (dispatch) {
|
|
78
|
+
tr.setMeta(findReplaceKey, { type: 'open' })
|
|
79
|
+
dispatch(tr)
|
|
80
|
+
}
|
|
81
|
+
return true
|
|
82
|
+
},
|
|
83
|
+
closeFindPanel:
|
|
84
|
+
() =>
|
|
85
|
+
({ tr, dispatch }) => {
|
|
86
|
+
if (dispatch) {
|
|
87
|
+
tr.setMeta(findReplaceKey, { type: 'close' })
|
|
88
|
+
dispatch(tr)
|
|
89
|
+
}
|
|
90
|
+
return true
|
|
91
|
+
},
|
|
92
|
+
setFindQuery:
|
|
93
|
+
(query: string) =>
|
|
94
|
+
({ tr, dispatch }) => {
|
|
95
|
+
if (dispatch) {
|
|
96
|
+
tr.setMeta(findReplaceKey, { type: 'query', query })
|
|
97
|
+
dispatch(tr)
|
|
98
|
+
}
|
|
99
|
+
return true
|
|
100
|
+
},
|
|
101
|
+
findNext:
|
|
102
|
+
() =>
|
|
103
|
+
({ tr, state, dispatch }) => {
|
|
104
|
+
const s = findReplaceKey.getState(state)
|
|
105
|
+
if (!s || s.matches.length === 0) {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
if (dispatch) {
|
|
109
|
+
tr.setMeta(findReplaceKey, { type: 'active', active: (s.active + 1) % s.matches.length })
|
|
110
|
+
dispatch(tr)
|
|
111
|
+
}
|
|
112
|
+
return true
|
|
113
|
+
},
|
|
114
|
+
findPrev:
|
|
115
|
+
() =>
|
|
116
|
+
({ tr, state, dispatch }) => {
|
|
117
|
+
const s = findReplaceKey.getState(state)
|
|
118
|
+
if (!s || s.matches.length === 0) {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
if (dispatch) {
|
|
122
|
+
tr.setMeta(findReplaceKey, {
|
|
123
|
+
type: 'active',
|
|
124
|
+
active: (s.active - 1 + s.matches.length) % s.matches.length,
|
|
125
|
+
})
|
|
126
|
+
dispatch(tr)
|
|
127
|
+
}
|
|
128
|
+
return true
|
|
129
|
+
},
|
|
130
|
+
replaceCurrent:
|
|
131
|
+
(replacement: string) =>
|
|
132
|
+
({ state, tr, dispatch }) => {
|
|
133
|
+
const s = findReplaceKey.getState(state)
|
|
134
|
+
if (!s || s.matches.length === 0) {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
const m = s.matches[s.active]
|
|
138
|
+
if (!m) {
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
141
|
+
if (dispatch) {
|
|
142
|
+
tr.insertText(replacement, m.from, m.to)
|
|
143
|
+
tr.setMeta(findReplaceKey, { type: 'requery' })
|
|
144
|
+
dispatch(tr)
|
|
145
|
+
}
|
|
146
|
+
return true
|
|
147
|
+
},
|
|
148
|
+
replaceAll:
|
|
149
|
+
(replacement: string) =>
|
|
150
|
+
({ state, tr, dispatch }) => {
|
|
151
|
+
const s = findReplaceKey.getState(state)
|
|
152
|
+
if (!s || s.matches.length === 0) {
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
if (dispatch) {
|
|
156
|
+
for (let i = s.matches.length - 1; i >= 0; i--) {
|
|
157
|
+
const m = s.matches[i]
|
|
158
|
+
tr.insertText(replacement, m.from, m.to)
|
|
159
|
+
}
|
|
160
|
+
tr.setMeta(findReplaceKey, { type: 'requery' })
|
|
161
|
+
dispatch(tr)
|
|
162
|
+
}
|
|
163
|
+
return true
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
addProseMirrorPlugins() {
|
|
169
|
+
return [
|
|
170
|
+
new Plugin<FindReplaceState>({
|
|
171
|
+
key: findReplaceKey,
|
|
172
|
+
state: {
|
|
173
|
+
init: (): FindReplaceState => ({ query: '', matches: [], active: 0, visible: false }),
|
|
174
|
+
apply: (tr, prev, _oldState, newState) => {
|
|
175
|
+
const meta = tr.getMeta(findReplaceKey) as
|
|
176
|
+
| { type: 'open' | 'close' | 'requery' }
|
|
177
|
+
| { type: 'query'; query: string }
|
|
178
|
+
| { type: 'active'; active: number }
|
|
179
|
+
| undefined
|
|
180
|
+
let next: FindReplaceState | null = null
|
|
181
|
+
if (meta?.type === 'open') {
|
|
182
|
+
next = { ...prev, visible: true }
|
|
183
|
+
} else if (meta?.type === 'close') {
|
|
184
|
+
next = { ...prev, visible: false, matches: [], query: '', active: 0 }
|
|
185
|
+
} else if (meta?.type === 'query') {
|
|
186
|
+
const matches = computeMatches(newState.doc, meta.query)
|
|
187
|
+
next = { query: meta.query, matches, active: 0, visible: true }
|
|
188
|
+
} else if (meta?.type === 'active') {
|
|
189
|
+
next = { ...prev, active: meta.active }
|
|
190
|
+
} else if (meta?.type === 'requery') {
|
|
191
|
+
next = { ...prev, matches: [], active: 0 }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!next) {
|
|
195
|
+
// doc changed outside our commands: recompute if a query is live
|
|
196
|
+
if (prev.query && prev.visible && tr.docChanged) {
|
|
197
|
+
const matches = computeMatches(newState.doc, prev.query)
|
|
198
|
+
return { ...prev, matches, active: 0 }
|
|
199
|
+
}
|
|
200
|
+
return prev
|
|
201
|
+
}
|
|
202
|
+
// mirror state into editor storage for reactive UI
|
|
203
|
+
this.storage.query = next.query
|
|
204
|
+
this.storage.matches = next.matches
|
|
205
|
+
this.storage.active = next.active
|
|
206
|
+
this.storage.visible = next.visible
|
|
207
|
+
return next
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
props: {
|
|
211
|
+
decorations(state) {
|
|
212
|
+
const s = findReplaceKey.getState(state)
|
|
213
|
+
if (!s || !s.visible || s.matches.length === 0) {
|
|
214
|
+
return DecorationSet.empty
|
|
215
|
+
}
|
|
216
|
+
const decorations = s.matches.map((m, i) =>
|
|
217
|
+
Decoration.inline(m.from, m.to, {
|
|
218
|
+
class: i === s.active ? 'tessera-find-match tessera-find-match--active' : 'tessera-find-match',
|
|
219
|
+
}),
|
|
220
|
+
)
|
|
221
|
+
return DecorationSet.create(state.doc, decorations)
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
]
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
export type { FindReplaceStorage }
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core'
|
|
2
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
3
|
+
|
|
4
|
+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
5
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
6
|
+
import type { DecorationAttrs } from '@tiptap/pm/view'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Image gallery (acceptance §6, v1.1): runs of two or more consecutive
|
|
10
|
+
* top-level image blocks are flagged with data-gallery attributes so the
|
|
11
|
+
* theme can lay them out three per row (CSS: inline-block thirds).
|
|
12
|
+
* Purely presentational — the document format is unchanged (still
|
|
13
|
+
* sibling imageBlock nodes), so Markdown/JSON round-trips are unaffected.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const galleryKey = new PluginKey('tesseraGallery')
|
|
17
|
+
|
|
18
|
+
interface GalleryRun {
|
|
19
|
+
from: number
|
|
20
|
+
to: number
|
|
21
|
+
size: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Shared run detection so tests and the plugin agree on the definition. */
|
|
25
|
+
export function findGalleryRuns(doc: ProseMirrorNode): GalleryRun[] {
|
|
26
|
+
const runs: GalleryRun[] = []
|
|
27
|
+
let start = -1
|
|
28
|
+
let size = 0
|
|
29
|
+
doc.forEach((node, offset) => {
|
|
30
|
+
if (node.type.name === 'imageBlock') {
|
|
31
|
+
if (start < 0) {
|
|
32
|
+
start = offset
|
|
33
|
+
size = 0
|
|
34
|
+
}
|
|
35
|
+
size += 1
|
|
36
|
+
} else if (start >= 0) {
|
|
37
|
+
if (size >= 2) {
|
|
38
|
+
runs.push({ from: start, to: offset, size })
|
|
39
|
+
}
|
|
40
|
+
start = -1
|
|
41
|
+
size = 0
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
if (start >= 0 && size >= 2) {
|
|
45
|
+
runs.push({ from: start, to: doc.content.size, size })
|
|
46
|
+
}
|
|
47
|
+
return runs
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function galleryDecorations(doc: ProseMirrorNode): DecorationSet {
|
|
51
|
+
const decorations: Decoration[] = []
|
|
52
|
+
for (const run of findGalleryRuns(doc)) {
|
|
53
|
+
let index = 0
|
|
54
|
+
doc.nodesBetween(run.from, run.to, (node: ProseMirrorNode, pos: number) => {
|
|
55
|
+
if (node.type.name !== 'imageBlock') {
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
const attrs: DecorationAttrs = {
|
|
59
|
+
'data-gallery': 'true',
|
|
60
|
+
'data-gallery-index': String(index),
|
|
61
|
+
'data-gallery-size': String(run.size),
|
|
62
|
+
}
|
|
63
|
+
decorations.push(Decoration.node(pos, pos + node.nodeSize, attrs))
|
|
64
|
+
index += 1
|
|
65
|
+
return false
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
// props.decorations must yield a DecorationSet — a plain array breaks
|
|
69
|
+
// DecorationGroup.from (it flattens only set members)
|
|
70
|
+
return DecorationSet.create(doc, decorations)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const TesseraGallery = Extension.create({
|
|
74
|
+
name: 'tesseraGallery',
|
|
75
|
+
|
|
76
|
+
addProseMirrorPlugins() {
|
|
77
|
+
return [
|
|
78
|
+
new Plugin({
|
|
79
|
+
key: galleryKey,
|
|
80
|
+
state: {
|
|
81
|
+
init: (_config, state) => galleryDecorations(state.doc),
|
|
82
|
+
apply: (tr, old) => (tr.docChanged ? galleryDecorations(tr.doc) : old),
|
|
83
|
+
},
|
|
84
|
+
props: {
|
|
85
|
+
decorations(state) {
|
|
86
|
+
return galleryKey.getState(state)
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
]
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the gallery attributes (if any) a NodeView should apply for `node`:
|
|
96
|
+
* finds the node decoration covering `pos` that carries data-gallery attrs.
|
|
97
|
+
* NodeViews must apply these themselves — custom NodeViews bypass PM's
|
|
98
|
+
* automatic decoration-attr application.
|
|
99
|
+
*/
|
|
100
|
+
export function galleryAttrsFor(
|
|
101
|
+
decorations: readonly Decoration[],
|
|
102
|
+
pos: number,
|
|
103
|
+
): DecorationAttrs | null {
|
|
104
|
+
for (const deco of decorations) {
|
|
105
|
+
const attrs = (deco as unknown as { type?: { attrs?: DecorationAttrs } }).type?.attrs
|
|
106
|
+
if (attrs && 'data-gallery' in attrs && deco.from <= pos && pos <= deco.to) {
|
|
107
|
+
return attrs
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core'
|
|
2
|
+
import type { JSONContent } from '@tiptap/core'
|
|
3
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
4
|
+
import { getStorageService, type DocSnapshot } from '../services'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Version history capture (v1.1): snapshots the canonical JSON into the
|
|
8
|
+
* injected StorageService. Auto-capture fires after `idleMs` of inactivity
|
|
9
|
+
* (Slite: 5 minutes); manual capture is always available as a command.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface HistorySnapshotOptions {
|
|
13
|
+
/** idle window before auto-capture (default 5 minutes) */
|
|
14
|
+
idleMs?: number
|
|
15
|
+
/** minimum ms between two auto-captures (default 60s) */
|
|
16
|
+
minIntervalMs?: number
|
|
17
|
+
/** snapshot label source */
|
|
18
|
+
label?: () => string | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare module '@tiptap/core' {
|
|
22
|
+
interface Commands<ReturnType> {
|
|
23
|
+
tesseraHistory: {
|
|
24
|
+
captureSnapshot: (label?: string) => ReturnType
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
interface EditorEvents {
|
|
28
|
+
'tessera:snapshotSaved': { snapshot: DocSnapshot }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const historyKey = new PluginKey('tesseraHistory')
|
|
33
|
+
|
|
34
|
+
export const TesseraHistory = Extension.create<HistorySnapshotOptions>({
|
|
35
|
+
name: 'tesseraHistory',
|
|
36
|
+
|
|
37
|
+
addOptions() {
|
|
38
|
+
return {
|
|
39
|
+
idleMs: 5 * 60 * 1000,
|
|
40
|
+
minIntervalMs: 60 * 1000,
|
|
41
|
+
label: undefined,
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
addCommands() {
|
|
46
|
+
return {
|
|
47
|
+
captureSnapshot:
|
|
48
|
+
(label?: string) =>
|
|
49
|
+
({ editor, state }) => {
|
|
50
|
+
const storage = getStorageService(editor)
|
|
51
|
+
if (!storage) {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
const doc = editor.getJSON() as JSONContent
|
|
55
|
+
const snapshot: DocSnapshot = {
|
|
56
|
+
id: `snap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
57
|
+
ts: Date.now(),
|
|
58
|
+
doc,
|
|
59
|
+
label: label ?? this.options.label?.(),
|
|
60
|
+
}
|
|
61
|
+
void storage.saveSnapshot(snapshot).then(() => {
|
|
62
|
+
editor.emit('tessera:snapshotSaved', { snapshot })
|
|
63
|
+
})
|
|
64
|
+
const tr = state.tr.setMeta(historyKey, { type: 'captured', ts: snapshot.ts })
|
|
65
|
+
editor.view.dispatch(tr)
|
|
66
|
+
return true
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
addProseMirrorPlugins() {
|
|
72
|
+
const editor = this.editor
|
|
73
|
+
const options = this.options
|
|
74
|
+
return [
|
|
75
|
+
new Plugin({
|
|
76
|
+
key: historyKey,
|
|
77
|
+
state: {
|
|
78
|
+
init: () => ({ lastChange: 0, lastCapture: 0, timer: null as ReturnType<typeof setTimeout> | null }),
|
|
79
|
+
apply: (tr, prev) => {
|
|
80
|
+
const meta = tr.getMeta(historyKey) as { type: string; ts?: number } | undefined
|
|
81
|
+
if (meta?.type === 'captured') {
|
|
82
|
+
return { ...prev, lastCapture: meta.ts ?? Date.now() }
|
|
83
|
+
}
|
|
84
|
+
if (!tr.docChanged) {
|
|
85
|
+
return prev
|
|
86
|
+
}
|
|
87
|
+
return { ...prev, lastChange: Date.now() }
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
view() {
|
|
91
|
+
return {
|
|
92
|
+
update: (_view, prevState) => {
|
|
93
|
+
const before = historyKey.getState(prevState)
|
|
94
|
+
const after = historyKey.getState(editor.state)
|
|
95
|
+
if (!before || !after || after.lastChange === before.lastChange) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const storage = getStorageService(editor)
|
|
99
|
+
if (!storage) {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const s = historyKey.getState(editor.state)
|
|
103
|
+
if (s?.timer) {
|
|
104
|
+
clearTimeout(s.timer)
|
|
105
|
+
}
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
const now = Date.now()
|
|
108
|
+
const cur = historyKey.getState(editor.state)
|
|
109
|
+
if (!cur || now - cur.lastChange < options.idleMs! - 50) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (now - cur.lastCapture < options.minIntervalMs!) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
editor.commands.captureSnapshot()
|
|
116
|
+
}, options.idleMs!)
|
|
117
|
+
const st = historyKey.getState(editor.state)
|
|
118
|
+
if (st) {
|
|
119
|
+
st.timer = timer
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
destroy() {
|
|
123
|
+
const s = historyKey.getState(editor.state)
|
|
124
|
+
if (s?.timer) {
|
|
125
|
+
clearTimeout(s.timer)
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
]
|
|
132
|
+
},
|
|
133
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Extension, wrappingInputRule, markInputRule } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tessera-specific input rules beyond the StarterKit defaults:
|
|
5
|
+
* `[] ` + typing → task list (mirrors Slite's trigger)
|
|
6
|
+
* `::text::` → highlight mark
|
|
7
|
+
*/
|
|
8
|
+
export const TesseraInputRules = Extension.create({
|
|
9
|
+
name: 'tesseraInputRules',
|
|
10
|
+
|
|
11
|
+
addInputRules() {
|
|
12
|
+
const taskList = this.editor.schema.nodes.taskList
|
|
13
|
+
const highlight = this.editor.schema.marks.highlight
|
|
14
|
+
const rules = []
|
|
15
|
+
|
|
16
|
+
if (taskList) {
|
|
17
|
+
rules.push(
|
|
18
|
+
wrappingInputRule({
|
|
19
|
+
find: /^\[\] $/,
|
|
20
|
+
type: taskList,
|
|
21
|
+
}),
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
if (highlight) {
|
|
25
|
+
rules.push(
|
|
26
|
+
markInputRule({
|
|
27
|
+
find: /::([^:]+)::$/,
|
|
28
|
+
type: highlight,
|
|
29
|
+
}),
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return rules
|
|
33
|
+
},
|
|
34
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core'
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
import { docToMarkdown } from '../markdown'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Performance metrics (acceptance §8, v1.1): long-document observability.
|
|
7
|
+
* The architecture keeps per-block NodeViews and lazy decoration passes in
|
|
8
|
+
* place; this exposes the numbers a host needs to watch (block/word counts,
|
|
9
|
+
* heavy-node counts, markdown serialize cost) so virtualization work can be
|
|
10
|
+
* measured rather than guessed. Usage: `getTesseraMetrics(editor)`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface TesseraMetricsSnapshot {
|
|
14
|
+
/** Top-level blocks in the document. */
|
|
15
|
+
blocks: number
|
|
16
|
+
/** Whitespace-split word count of the document text. */
|
|
17
|
+
words: number
|
|
18
|
+
/** imageBlock nodes. */
|
|
19
|
+
images: number
|
|
20
|
+
/** aiTable nodes. */
|
|
21
|
+
tables: number
|
|
22
|
+
/** NodeView wrapper elements currently mounted. */
|
|
23
|
+
mountedNodeViews: number
|
|
24
|
+
/** Milliseconds for a full Markdown serialization of the current doc. */
|
|
25
|
+
serializeMs: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function measureTesseraMetrics(editor: Editor): TesseraMetricsSnapshot {
|
|
29
|
+
let images = 0
|
|
30
|
+
let tables = 0
|
|
31
|
+
editor.state.doc.descendants(node => {
|
|
32
|
+
if (node.type.name === 'imageBlock') images += 1
|
|
33
|
+
if (node.type.name === 'table') tables += 1
|
|
34
|
+
return true
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const started = performance.now()
|
|
38
|
+
docToMarkdown(editor.state.doc, editor.state.schema)
|
|
39
|
+
const serializeMs = Math.round((performance.now() - started) * 100) / 100
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
blocks: editor.state.doc.childCount,
|
|
43
|
+
words: editor.state.doc.textBetween(0, editor.state.doc.content.size, ' ', ' ')
|
|
44
|
+
.split(/\s+/)
|
|
45
|
+
.filter(Boolean).length,
|
|
46
|
+
images,
|
|
47
|
+
tables,
|
|
48
|
+
mountedNodeViews: editor.view
|
|
49
|
+
? editor.view.dom.querySelectorAll('[data-node-view-wrapper]').length
|
|
50
|
+
: 0,
|
|
51
|
+
serializeMs,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const TesseraMetrics = Extension.create({
|
|
56
|
+
name: 'tesseraMetrics',
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export function getTesseraMetrics(editor: Editor): TesseraMetricsSnapshot {
|
|
60
|
+
return measureTesseraMetrics(editor)
|
|
61
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core'
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
import { TextSelection } from '@tiptap/pm/state'
|
|
4
|
+
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Tessera keyboard map (acceptance checklist §3). Panel-opening shortcuts
|
|
8
|
+
* emit `tessera:*` events that the binding's UI layer listens to, keeping this
|
|
9
|
+
* extension UI-free.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
declare module '@tiptap/core' {
|
|
13
|
+
interface Commands<ReturnType> {
|
|
14
|
+
tesseraShortcuts: {
|
|
15
|
+
/** Move the top-level block containing the caret up one position. */
|
|
16
|
+
moveBlockUp: () => ReturnType
|
|
17
|
+
/** Move the top-level block containing the caret down one position. */
|
|
18
|
+
moveBlockDown: () => ReturnType
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
interface EditorEvents {
|
|
22
|
+
'tessera:formatPanel': { kind: 'color' | 'highlight' }
|
|
23
|
+
'tessera:linkPanel': Record<string, never>
|
|
24
|
+
'tessera:findPanel': Record<string, never>
|
|
25
|
+
'tessera:insertImage': Record<string, never>
|
|
26
|
+
'tessera:askPanel': Record<string, never>
|
|
27
|
+
'tessera:historyPanel': Record<string, never>
|
|
28
|
+
'tessera:commentPanel': Record<string, never>
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const TesseraShortcuts = Extension.create({
|
|
33
|
+
name: 'tesseraShortcuts',
|
|
34
|
+
priority: 500,
|
|
35
|
+
|
|
36
|
+
addCommands() {
|
|
37
|
+
return {
|
|
38
|
+
moveBlockUp:
|
|
39
|
+
() =>
|
|
40
|
+
({ state, tr, dispatch }) =>
|
|
41
|
+
swapBlock(state, tr, dispatch, -1),
|
|
42
|
+
moveBlockDown:
|
|
43
|
+
() =>
|
|
44
|
+
({ state, tr, dispatch }) =>
|
|
45
|
+
swapBlock(state, tr, dispatch, 1),
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
addKeyboardShortcuts() {
|
|
50
|
+
return {
|
|
51
|
+
'Mod-Shift-1': () => this.editor.commands.toggleHeading({ level: 1 }),
|
|
52
|
+
'Mod-Shift-2': () => this.editor.commands.toggleHeading({ level: 2 }),
|
|
53
|
+
'Mod-Shift-3': () => this.editor.commands.toggleHeading({ level: 3 }),
|
|
54
|
+
'Mod-Shift-4': () => this.editor.commands.toggleHeading({ level: 4 }),
|
|
55
|
+
'Mod-Shift-7': () => this.editor.commands.toggleOrderedList(),
|
|
56
|
+
'Mod-Shift-8': () => this.editor.commands.toggleBulletList(),
|
|
57
|
+
'Mod-Shift-c': () => this.editor.commands.toggleTaskList(),
|
|
58
|
+
'Mod-Alt-h': () => this.editor.commands.toggleHint(),
|
|
59
|
+
'Mod-j': () => this.editor.commands.toggleCode(),
|
|
60
|
+
'Mod-Shift-9': () => this.editor.commands.toggleCodeBlock(),
|
|
61
|
+
'Mod-Shift-.': () => this.editor.commands.toggleBlockquote(),
|
|
62
|
+
'Mod-e': () => {
|
|
63
|
+
this.editor.emit('tessera:formatPanel', { kind: 'color' })
|
|
64
|
+
return true
|
|
65
|
+
},
|
|
66
|
+
'Mod-k': () => {
|
|
67
|
+
this.editor.emit('tessera:linkPanel', {})
|
|
68
|
+
return true
|
|
69
|
+
},
|
|
70
|
+
'Mod-f': () => {
|
|
71
|
+
this.editor.emit('tessera:findPanel', {})
|
|
72
|
+
return true
|
|
73
|
+
},
|
|
74
|
+
'Mod-Shift-k': () => {
|
|
75
|
+
this.editor.emit('tessera:askPanel', {})
|
|
76
|
+
return true
|
|
77
|
+
},
|
|
78
|
+
'Mod-Alt-s': () => this.editor.commands.insertTableTyped({ withHeaderRow: true }),
|
|
79
|
+
'Mod-Alt-t': () => this.editor.commands.insertTableTyped({ withHeaderRow: false }),
|
|
80
|
+
'Mod-Alt-p': () => this.editor.commands.togglePlaceholderMark('text'),
|
|
81
|
+
'Mod-Alt-m': () => {
|
|
82
|
+
this.editor.emit('tessera:commentPanel', {})
|
|
83
|
+
return true
|
|
84
|
+
},
|
|
85
|
+
'Alt-ArrowUp': () => this.editor.commands.moveBlockUp(),
|
|
86
|
+
'Alt-ArrowDown': () => this.editor.commands.moveBlockDown(),
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
type Dispatch = ((args?: unknown) => void) | undefined
|
|
92
|
+
|
|
93
|
+
function swapBlock(state: EditorState, tr: Transaction, dispatch: Dispatch, direction: -1 | 1): boolean {
|
|
94
|
+
const { $from } = state.selection
|
|
95
|
+
if ($from.depth < 1) {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
const index = $from.index(0)
|
|
99
|
+
const other = index + direction
|
|
100
|
+
if (other < 0 || other >= state.doc.childCount) {
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
const node = state.doc.child(index)
|
|
104
|
+
const otherNode = state.doc.child(other)
|
|
105
|
+
const pos = $from.before(1)
|
|
106
|
+
const nodeStart = direction === -1 ? pos - otherNode.nodeSize : pos
|
|
107
|
+
const rangeEnd = nodeStart + otherNode.nodeSize + node.nodeSize
|
|
108
|
+
if (dispatch) {
|
|
109
|
+
const content = direction === -1 ? [node, otherNode] : [otherNode, node]
|
|
110
|
+
tr.replaceWith(nodeStart, rangeEnd, content)
|
|
111
|
+
const anchor = tr.mapping.map(state.selection.anchor)
|
|
112
|
+
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(anchor, tr.doc.content.size))))
|
|
113
|
+
dispatch(tr)
|
|
114
|
+
}
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type { Editor }
|