@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.
@@ -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 }