@tessera-editor/core 0.1.0 → 0.2.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/dist/index.d.ts +137 -173
- package/dist/index.js +307 -405
- package/dist/index.js.map +1 -1
- package/dist/tessera.css +132 -120
- package/package.json +1 -1
- package/src/__tests__/host-config.test.ts +115 -0
- package/src/__tests__/linkedit.test.ts +98 -0
- package/src/__tests__/table-guard.test.ts +155 -0
- package/src/__tests__/v11.test.ts +1 -50
- package/src/extensions/context-menu.ts +7 -2
- package/src/extensions/shortcuts.ts +8 -6
- package/src/extensions/slash.ts +43 -25
- package/src/i18n.ts +26 -33
- package/src/index.ts +10 -13
- package/src/linkedit.ts +66 -0
- package/src/markdown.ts +0 -1
- package/src/nodes/table.ts +130 -0
- package/src/preset.ts +65 -26
- package/src/services.ts +1 -22
- package/src/styles/tessera.css +132 -120
- package/src/diff.ts +0 -150
- package/src/extensions/history.ts +0 -133
- package/src/marks/placeholder.ts +0 -63
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, it, expect } from 'vitest'
|
|
3
3
|
import { Editor } from '@tiptap/core'
|
|
4
|
-
import { createTesseraExtensions, tableToCsvAt,
|
|
5
|
-
import type { StorageService, DocSnapshot } from '../index'
|
|
4
|
+
import { createTesseraExtensions, tableToCsvAt, docToMarkdown, markdownToDoc, stableJson, listCommentRanges } from '../index'
|
|
6
5
|
|
|
7
6
|
function makeEditor() {
|
|
8
7
|
return new Editor({ extensions: createTesseraExtensions({ locale: 'zh-CN' }) })
|
|
@@ -108,54 +107,6 @@ describe('typed tables', () => {
|
|
|
108
107
|
})
|
|
109
108
|
})
|
|
110
109
|
|
|
111
|
-
describe('diff', () => {
|
|
112
|
-
it('classifies added / removed / changed blocks by id', () => {
|
|
113
|
-
const before = {
|
|
114
|
-
type: 'doc',
|
|
115
|
-
content: [
|
|
116
|
-
{ type: 'paragraph', attrs: { id: 'p1' }, content: [{ type: 'text', text: 'hello world' }] },
|
|
117
|
-
{ type: 'paragraph', attrs: { id: 'p2' }, content: [{ type: 'text', text: 'gone' }] },
|
|
118
|
-
],
|
|
119
|
-
}
|
|
120
|
-
const after = {
|
|
121
|
-
type: 'doc',
|
|
122
|
-
content: [
|
|
123
|
-
{ type: 'paragraph', attrs: { id: 'p1' }, content: [{ type: 'text', text: 'hello there' }] },
|
|
124
|
-
{ type: 'paragraph', attrs: { id: 'p3' }, content: [{ type: 'text', text: 'new block' }] },
|
|
125
|
-
],
|
|
126
|
-
}
|
|
127
|
-
const entries = diffDocs(before, after)
|
|
128
|
-
const byId = Object.fromEntries(entries.filter(e => e.id).map(e => [e.id, e.kind]))
|
|
129
|
-
expect(byId.p1).toBe('changed')
|
|
130
|
-
expect(byId.p2).toBe('removed')
|
|
131
|
-
expect(byId.p3).toBe('added')
|
|
132
|
-
const changed = entries.find(e => e.id === 'p1')!
|
|
133
|
-
expect(changed.wordDiff?.some(p => p.type === 'del' && p.text.includes('world'))).toBe(true)
|
|
134
|
-
expect(changed.wordDiff?.some(p => p.type === 'add' && p.text.includes('there'))).toBe(true)
|
|
135
|
-
})
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
describe('history snapshots', () => {
|
|
139
|
-
it('captures into the injected storage on command', async () => {
|
|
140
|
-
const saved: DocSnapshot[] = []
|
|
141
|
-
const storage: StorageService = {
|
|
142
|
-
saveSnapshot: async snap => {
|
|
143
|
-
saved.push(snap)
|
|
144
|
-
},
|
|
145
|
-
listSnapshots: async () => saved,
|
|
146
|
-
}
|
|
147
|
-
const editor = makeEditor()
|
|
148
|
-
;(editor.storage as unknown as Record<string, unknown>).tesseraServices = { storage }
|
|
149
|
-
editor.commands.setContent({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'v1' }] }] })
|
|
150
|
-
expect(editor.commands.captureSnapshot('manual')).toBe(true)
|
|
151
|
-
await new Promise(r => setTimeout(r, 20))
|
|
152
|
-
expect(saved).toHaveLength(1)
|
|
153
|
-
expect(saved[0]!.label).toBe('manual')
|
|
154
|
-
expect(JSON.stringify(saved[0]!.doc)).toContain('v1')
|
|
155
|
-
editor.destroy()
|
|
156
|
-
})
|
|
157
|
-
})
|
|
158
|
-
|
|
159
110
|
describe('comments', () => {
|
|
160
111
|
it('adds, resolves and lists comment threads in doc order', () => {
|
|
161
112
|
const editor = makeEditor()
|
|
@@ -15,8 +15,13 @@ export const BlockContextMenu = Extension.create({
|
|
|
15
15
|
new Plugin({
|
|
16
16
|
props: {
|
|
17
17
|
handleDOMEvents: {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
contextmenu: (view, event) => {
|
|
19
|
+
// read-only docs keep the native context menu (copy link, …) —
|
|
20
|
+
// the Tessera menu's actions would mutate a frozen document
|
|
21
|
+
if (!editor.isEditable) {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
|
20
25
|
if (!coords) {
|
|
21
26
|
return false
|
|
22
27
|
}
|
|
@@ -24,7 +24,6 @@ declare module '@tiptap/core' {
|
|
|
24
24
|
'tessera:findPanel': Record<string, never>
|
|
25
25
|
'tessera:insertImage': Record<string, never>
|
|
26
26
|
'tessera:askPanel': Record<string, never>
|
|
27
|
-
'tessera:historyPanel': Record<string, never>
|
|
28
27
|
'tessera:commentPanel': Record<string, never>
|
|
29
28
|
}
|
|
30
29
|
}
|
|
@@ -47,6 +46,10 @@ export const TesseraShortcuts = Extension.create({
|
|
|
47
46
|
},
|
|
48
47
|
|
|
49
48
|
addKeyboardShortcuts() {
|
|
49
|
+
// Hosts may exclude block types (preset `excludeBlocks`); the commands an
|
|
50
|
+
// excluded node registers don't exist, so guard before calling.
|
|
51
|
+
const has = (name: string): boolean =>
|
|
52
|
+
this.editor.extensionManager.extensions.some(ext => ext.name === name)
|
|
50
53
|
return {
|
|
51
54
|
'Mod-Shift-1': () => this.editor.commands.toggleHeading({ level: 1 }),
|
|
52
55
|
'Mod-Shift-2': () => this.editor.commands.toggleHeading({ level: 2 }),
|
|
@@ -54,8 +57,8 @@ export const TesseraShortcuts = Extension.create({
|
|
|
54
57
|
'Mod-Shift-4': () => this.editor.commands.toggleHeading({ level: 4 }),
|
|
55
58
|
'Mod-Shift-7': () => this.editor.commands.toggleOrderedList(),
|
|
56
59
|
'Mod-Shift-8': () => this.editor.commands.toggleBulletList(),
|
|
57
|
-
'Mod-Shift-c': () => this.editor.commands.toggleTaskList(),
|
|
58
|
-
'Mod-Alt-h': () => this.editor.commands.toggleHint(),
|
|
60
|
+
'Mod-Shift-c': () => has('taskList') && this.editor.commands.toggleTaskList(),
|
|
61
|
+
'Mod-Alt-h': () => has('hint') && this.editor.commands.toggleHint(),
|
|
59
62
|
'Mod-j': () => this.editor.commands.toggleCode(),
|
|
60
63
|
'Mod-Shift-9': () => this.editor.commands.toggleCodeBlock(),
|
|
61
64
|
'Mod-Shift-.': () => this.editor.commands.toggleBlockquote(),
|
|
@@ -75,9 +78,8 @@ export const TesseraShortcuts = Extension.create({
|
|
|
75
78
|
this.editor.emit('tessera:askPanel', {})
|
|
76
79
|
return true
|
|
77
80
|
},
|
|
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-s': () => has('table') && this.editor.commands.insertTableTyped({ withHeaderRow: true }),
|
|
82
|
+
'Mod-Alt-t': () => has('table') && this.editor.commands.insertTableTyped({ withHeaderRow: false }),
|
|
81
83
|
'Mod-Alt-m': () => {
|
|
82
84
|
this.editor.emit('tessera:commentPanel', {})
|
|
83
85
|
return true
|
package/src/extensions/slash.ts
CHANGED
|
@@ -30,12 +30,53 @@ export interface SlashMenuOptions {
|
|
|
30
30
|
extraItems?: (ctx: { editor: Editor; t: TesseraTranslator }) => SlashMenuItem[]
|
|
31
31
|
includeAiItems?: boolean
|
|
32
32
|
render?: SlashRenderFactory
|
|
33
|
+
/**
|
|
34
|
+
* Host block policy (mirrors `TesseraPresetOptions.excludeBlocks`): drop
|
|
35
|
+
* default items whose node type is excluded, so the menu never offers a
|
|
36
|
+
* block the editor cannot represent.
|
|
37
|
+
*/
|
|
38
|
+
excludeItems?: string[]
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
function chainDelete(editor: Editor, range: Range) {
|
|
36
42
|
return editor.chain().focus().deleteRange(range)
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/** Slash item id → the node type it produces. */
|
|
46
|
+
export function slashItemNodeName(item: SlashMenuItem): string {
|
|
47
|
+
switch (item.id) {
|
|
48
|
+
case 'divider':
|
|
49
|
+
return 'horizontalRule'
|
|
50
|
+
case 'embed':
|
|
51
|
+
return 'embedBlock'
|
|
52
|
+
case 'toc':
|
|
53
|
+
return 'tocBlock'
|
|
54
|
+
case 'image':
|
|
55
|
+
return 'imageBlock'
|
|
56
|
+
case 'table':
|
|
57
|
+
case 'table-simple':
|
|
58
|
+
return 'table'
|
|
59
|
+
case 'h1':
|
|
60
|
+
case 'h2':
|
|
61
|
+
case 'h3':
|
|
62
|
+
case 'h4':
|
|
63
|
+
return 'heading'
|
|
64
|
+
case 'text':
|
|
65
|
+
return 'paragraph'
|
|
66
|
+
default:
|
|
67
|
+
return item.id
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drop default items whose node type is excluded by the host. */
|
|
72
|
+
export function filterSlashItems(items: SlashMenuItem[], excludeBlocks?: string[]): SlashMenuItem[] {
|
|
73
|
+
if (!excludeBlocks || excludeBlocks.length === 0) {
|
|
74
|
+
return items
|
|
75
|
+
}
|
|
76
|
+
const excluded = new Set(excludeBlocks)
|
|
77
|
+
return items.filter(item => !excluded.has(slashItemNodeName(item)))
|
|
78
|
+
}
|
|
79
|
+
|
|
39
80
|
/** Default block items — mirrors the Slite slash palette for the M1 scope. */
|
|
40
81
|
export function defaultSlashItems(t: TesseraTranslator): SlashMenuItem[] {
|
|
41
82
|
return [
|
|
@@ -195,30 +236,6 @@ export function defaultSlashItems(t: TesseraTranslator): SlashMenuItem[] {
|
|
|
195
236
|
keywords: ['toc', 'outline', '目录', '大纲'],
|
|
196
237
|
command: ({ editor, range }) => chainDelete(editor, range).insertToc().run(),
|
|
197
238
|
},
|
|
198
|
-
{
|
|
199
|
-
id: 'placeholder-person',
|
|
200
|
-
group: 'advanced',
|
|
201
|
-
title: t('placeholderPerson'),
|
|
202
|
-
keywords: ['somebody', 'person', 'owner', '待填人'],
|
|
203
|
-
command: ({ editor, range }) => chainDelete(editor, range).insertPlaceholderToken('person', t('placeholderPerson')).run(),
|
|
204
|
-
},
|
|
205
|
-
{
|
|
206
|
-
id: 'placeholder-date',
|
|
207
|
-
group: 'advanced',
|
|
208
|
-
title: t('placeholderDate'),
|
|
209
|
-
keywords: ['date', 'due', '待填日期'],
|
|
210
|
-
command: ({ editor, range }) => chainDelete(editor, range).insertPlaceholderToken('date', t('placeholderDate')).run(),
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
id: 'history',
|
|
214
|
-
group: 'advanced',
|
|
215
|
-
title: t('itemHistory'),
|
|
216
|
-
keywords: ['history', 'version', 'snapshot', '历史', '版本'],
|
|
217
|
-
command: ({ editor, range }) => {
|
|
218
|
-
chainDelete(editor, range).run()
|
|
219
|
-
editor.emit('tessera:historyPanel', {})
|
|
220
|
-
},
|
|
221
|
-
},
|
|
222
239
|
]
|
|
223
240
|
}
|
|
224
241
|
|
|
@@ -231,6 +248,7 @@ export const SlashMenu = Extension.create<SlashMenuOptions>({
|
|
|
231
248
|
extraItems: undefined,
|
|
232
249
|
includeAiItems: false,
|
|
233
250
|
render: undefined,
|
|
251
|
+
excludeItems: undefined,
|
|
234
252
|
}
|
|
235
253
|
},
|
|
236
254
|
|
|
@@ -240,7 +258,7 @@ export const SlashMenu = Extension.create<SlashMenuOptions>({
|
|
|
240
258
|
const t = createTesseraT(options.locale)
|
|
241
259
|
|
|
242
260
|
const items = [
|
|
243
|
-
...defaultSlashItems(t),
|
|
261
|
+
...filterSlashItems(defaultSlashItems(t), options.excludeItems),
|
|
244
262
|
...(options.extraItems?.({ editor, t }) ?? []),
|
|
245
263
|
]
|
|
246
264
|
|
package/src/i18n.ts
CHANGED
|
@@ -67,6 +67,8 @@ export const tesseraMessages = {
|
|
|
67
67
|
// link panel
|
|
68
68
|
linkPlaceholder: '链接地址…',
|
|
69
69
|
linkApply: '应用',
|
|
70
|
+
linkSave: '保存',
|
|
71
|
+
linkTextPlaceholder: '显示文字…',
|
|
70
72
|
linkRemove: '移除链接',
|
|
71
73
|
linkOpen: '打开',
|
|
72
74
|
// empty-line toolbar
|
|
@@ -85,6 +87,10 @@ export const tesseraMessages = {
|
|
|
85
87
|
imageAlignLeft: '左对齐',
|
|
86
88
|
imageAlignCenter: '居中',
|
|
87
89
|
imageAlignFull: '全宽',
|
|
90
|
+
imageZoomIn: '放大',
|
|
91
|
+
imageZoomOut: '缩小',
|
|
92
|
+
imageZoomReset: '重置',
|
|
93
|
+
imageZoomHint: 'Ctrl+滚轮缩放 · 双击图片复位',
|
|
88
94
|
// AI panel
|
|
89
95
|
aiTitle: 'AI',
|
|
90
96
|
aiAskPlaceholder: '针对本文档提问…',
|
|
@@ -123,17 +129,6 @@ export const tesseraMessages = {
|
|
|
123
129
|
tableCopyCsv: '复制为 CSV',
|
|
124
130
|
tableToggleHeader: '切换表头',
|
|
125
131
|
cellEmpty: '空',
|
|
126
|
-
// v1.1: history
|
|
127
|
-
itemHistory: '版本历史',
|
|
128
|
-
historyTitle: '版本历史',
|
|
129
|
-
historyEmpty: '暂无快照(编辑后空闲自动保存,或手动捕获)',
|
|
130
|
-
historyCapture: '捕获快照',
|
|
131
|
-
historyRestore: '恢复此版本',
|
|
132
|
-
historyCurrent: '当前',
|
|
133
|
-
historyDiffAdded: '新增',
|
|
134
|
-
historyDiffRemoved: '删除',
|
|
135
|
-
historyDiffChanged: '修改',
|
|
136
|
-
historyConfirmRestore: '恢复到该版本?当前内容将被替换(可撤销)',
|
|
137
132
|
// v1.1: comments
|
|
138
133
|
tooltipCommentV11: '评论',
|
|
139
134
|
commentTitle: '评论',
|
|
@@ -145,7 +140,7 @@ export const tesseraMessages = {
|
|
|
145
140
|
commentDelete: '删除',
|
|
146
141
|
commentResolvedBadge: '已解决',
|
|
147
142
|
commentCount: (n: number) => `${n} 条评论`,
|
|
148
|
-
// v1.1: embed / toc
|
|
143
|
+
// v1.1: embed / toc
|
|
149
144
|
itemEmbed: '嵌入',
|
|
150
145
|
itemEmbedDesc: '嵌入外部网页(iframe 沙箱)',
|
|
151
146
|
itemToc: '目录',
|
|
@@ -155,9 +150,6 @@ export const tesseraMessages = {
|
|
|
155
150
|
embedInvalid: '无效链接',
|
|
156
151
|
embedOpen: '打开原链接',
|
|
157
152
|
tocEmpty: '暂无标题——添加 H1–H4 后自动出现',
|
|
158
|
-
placeholderText: '待补充',
|
|
159
|
-
placeholderPerson: '待填人',
|
|
160
|
-
placeholderDate: '待填日期',
|
|
161
153
|
// v1.1: block context menu
|
|
162
154
|
menuCopyAnchor: '复制锚链接',
|
|
163
155
|
menuCopyBlockId: '复制块 ID',
|
|
@@ -216,6 +208,8 @@ export const tesseraMessages = {
|
|
|
216
208
|
highlightNone: 'No highlight',
|
|
217
209
|
linkPlaceholder: 'Link URL…',
|
|
218
210
|
linkApply: 'Apply',
|
|
211
|
+
linkSave: 'Save',
|
|
212
|
+
linkTextPlaceholder: 'Link text…',
|
|
219
213
|
linkRemove: 'Remove link',
|
|
220
214
|
linkOpen: 'Open',
|
|
221
215
|
emptyLineExpand: 'Show all blocks',
|
|
@@ -231,6 +225,10 @@ export const tesseraMessages = {
|
|
|
231
225
|
imageAlignLeft: 'Align left',
|
|
232
226
|
imageAlignCenter: 'Center',
|
|
233
227
|
imageAlignFull: 'Full width',
|
|
228
|
+
imageZoomIn: 'Zoom in',
|
|
229
|
+
imageZoomOut: 'Zoom out',
|
|
230
|
+
imageZoomReset: 'Reset zoom',
|
|
231
|
+
imageZoomHint: 'Ctrl+scroll to zoom · double-click to reset',
|
|
234
232
|
aiTitle: 'AI',
|
|
235
233
|
aiAskPlaceholder: 'Ask about this doc…',
|
|
236
234
|
aiSend: 'Send',
|
|
@@ -268,17 +266,6 @@ export const tesseraMessages = {
|
|
|
268
266
|
tableCopyCsv: 'Copy as CSV',
|
|
269
267
|
tableToggleHeader: 'Toggle header row',
|
|
270
268
|
cellEmpty: 'Empty',
|
|
271
|
-
// v1.1: history
|
|
272
|
-
itemHistory: 'Version history',
|
|
273
|
-
historyTitle: 'Version history',
|
|
274
|
-
historyEmpty: 'No snapshots yet (auto-captured when idle, or capture manually)',
|
|
275
|
-
historyCapture: 'Capture snapshot',
|
|
276
|
-
historyRestore: 'Restore this version',
|
|
277
|
-
historyCurrent: 'Current',
|
|
278
|
-
historyDiffAdded: 'Added',
|
|
279
|
-
historyDiffRemoved: 'Removed',
|
|
280
|
-
historyDiffChanged: 'Changed',
|
|
281
|
-
historyConfirmRestore: 'Restore this version? Current content will be replaced (undoable)',
|
|
282
269
|
// v1.1: comments
|
|
283
270
|
tooltipCommentV11: 'Comment',
|
|
284
271
|
commentTitle: 'Comments',
|
|
@@ -290,7 +277,7 @@ export const tesseraMessages = {
|
|
|
290
277
|
commentDelete: 'Delete',
|
|
291
278
|
commentResolvedBadge: 'Resolved',
|
|
292
279
|
commentCount: (n: number) => `${n} comment${n === 1 ? '' : 's'}`,
|
|
293
|
-
// v1.1: embed / toc
|
|
280
|
+
// v1.1: embed / toc
|
|
294
281
|
itemEmbed: 'Embed',
|
|
295
282
|
itemEmbedDesc: 'Embed an external page (sandboxed iframe)',
|
|
296
283
|
itemToc: 'Table of contents',
|
|
@@ -300,9 +287,6 @@ export const tesseraMessages = {
|
|
|
300
287
|
embedInvalid: 'Invalid URL',
|
|
301
288
|
embedOpen: 'Open original',
|
|
302
289
|
tocEmpty: 'No headings yet — add H1–H4 and they appear here',
|
|
303
|
-
placeholderText: 'to fill in',
|
|
304
|
-
placeholderPerson: 'assignee',
|
|
305
|
-
placeholderDate: 'due date',
|
|
306
290
|
// v1.1: block context menu
|
|
307
291
|
menuCopyAnchor: 'Copy anchor link',
|
|
308
292
|
menuCopyBlockId: 'Copy block ID',
|
|
@@ -312,9 +296,18 @@ export const tesseraMessages = {
|
|
|
312
296
|
|
|
313
297
|
export type TesseraMessageKey = keyof typeof tesseraMessages['zh-CN']
|
|
314
298
|
|
|
299
|
+
/** Host-side overrides of individual UI strings (merged over the dictionary). */
|
|
300
|
+
export type TesseraMessageOverrides = Partial<Record<TesseraMessageKey, string>>
|
|
301
|
+
|
|
315
302
|
export type TesseraTranslator = (key: TesseraMessageKey) => string
|
|
316
303
|
|
|
317
|
-
export function createTesseraT(
|
|
318
|
-
|
|
319
|
-
|
|
304
|
+
export function createTesseraT(
|
|
305
|
+
locale: TesseraLocale = 'zh-CN',
|
|
306
|
+
overrides?: TesseraMessageOverrides,
|
|
307
|
+
): TesseraTranslator {
|
|
308
|
+
const table: Record<string, string | ((n: number) => string)> = {
|
|
309
|
+
...((tesseraMessages[locale] ?? tesseraMessages['zh-CN']) as Record<string, string | ((n: number) => string)>),
|
|
310
|
+
...(overrides as Record<string, string> | undefined),
|
|
311
|
+
}
|
|
312
|
+
return key => (table[key] as string) ?? key
|
|
320
313
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,20 +12,16 @@ export { TocBlock } from './nodes/toc'
|
|
|
12
12
|
export { AiAttribution } from './marks/ai'
|
|
13
13
|
export type { AiAttributionOptions } from './marks/ai'
|
|
14
14
|
export { CommentMark, CommentCommands, listCommentRanges } from './marks/comment'
|
|
15
|
-
export { PlaceholderMark, PlaceholderCommands } from './marks/placeholder'
|
|
16
|
-
export type { PlaceholderKind } from './marks/placeholder'
|
|
17
15
|
|
|
18
16
|
// extensions
|
|
19
17
|
export { TesseraInputRules } from './extensions/input-rules'
|
|
20
18
|
export { TesseraShortcuts } from './extensions/shortcuts'
|
|
21
19
|
export { TesseraFindReplace, findReplaceKey } from './extensions/find-replace'
|
|
22
20
|
export type { FindMatch, FindReplaceState } from './extensions/find-replace'
|
|
23
|
-
export { SlashMenu, defaultSlashItems } from './extensions/slash'
|
|
21
|
+
export { SlashMenu, defaultSlashItems, filterSlashItems, slashItemNodeName } from './extensions/slash'
|
|
24
22
|
export type { SlashMenuItem, SlashMenuOptions, SlashRenderFactory } from './extensions/slash'
|
|
25
23
|
export { EmojiMenu, EMOJI_ITEMS, filterEmojiItems } from './extensions/emoji'
|
|
26
24
|
export type { EmojiItem, EmojiMenuOptions } from './extensions/emoji'
|
|
27
|
-
export { TesseraHistory, historyKey } from './extensions/history'
|
|
28
|
-
export type { HistorySnapshotOptions } from './extensions/history'
|
|
29
25
|
export { BlockContextMenu } from './extensions/context-menu'
|
|
30
26
|
export { TesseraGallery, galleryKey, findGalleryRuns } from './extensions/gallery'
|
|
31
27
|
export { TesseraMetrics, getTesseraMetrics, measureTesseraMetrics } from './extensions/metrics'
|
|
@@ -33,20 +29,26 @@ export type { TesseraMetricsSnapshot } from './extensions/metrics'
|
|
|
33
29
|
export { TesseraWordPaste } from './extensions/word-paste'
|
|
34
30
|
export { isWordHtml, cleanWordHtml } from './wordpaste'
|
|
35
31
|
export { findBlockPosById, deleteBlockById, blockAnchorUrl } from './blockmenu'
|
|
32
|
+
export { findLinkRange, saveLinkRange, removeLinkRange } from './linkedit'
|
|
33
|
+
export type { TesseraLinkRange } from './linkedit'
|
|
36
34
|
|
|
37
35
|
// preset
|
|
38
|
-
export { createTesseraExtensions, ID_BLOCK_TYPES } from './preset'
|
|
36
|
+
export { createTesseraExtensions, createTesseraSchema, ID_BLOCK_TYPES, SUPPORTED_BLOCK_TYPES } from './preset'
|
|
39
37
|
export type { TesseraPresetOptions } from './preset'
|
|
40
38
|
|
|
41
39
|
// i18n
|
|
42
40
|
export { createTesseraT, tesseraMessages } from './i18n'
|
|
43
|
-
export type { TesseraLocale, TesseraMessageKey, TesseraTranslator } from './i18n'
|
|
41
|
+
export type { TesseraLocale, TesseraMessageKey, TesseraMessageOverrides, TesseraTranslator } from './i18n'
|
|
42
|
+
|
|
43
|
+
// re-exported host-facing types (hosts build on these without a direct
|
|
44
|
+
// @tiptap/core dependency)
|
|
45
|
+
export type { Editor } from '@tiptap/core'
|
|
46
|
+
export type { JSONContent } from '@tiptap/core'
|
|
44
47
|
|
|
45
48
|
// services
|
|
46
49
|
export {
|
|
47
50
|
TesseraServices,
|
|
48
51
|
getUploadService,
|
|
49
|
-
getStorageService,
|
|
50
52
|
getCommentStore,
|
|
51
53
|
getIdentityService,
|
|
52
54
|
} from './services'
|
|
@@ -54,8 +56,6 @@ export type {
|
|
|
54
56
|
UploadService,
|
|
55
57
|
UploadedAsset,
|
|
56
58
|
TesseraServicesStorage,
|
|
57
|
-
StorageService,
|
|
58
|
-
DocSnapshot,
|
|
59
59
|
CommentStore,
|
|
60
60
|
CommentThread,
|
|
61
61
|
CommentEntry,
|
|
@@ -65,9 +65,6 @@ export type {
|
|
|
65
65
|
// format layer
|
|
66
66
|
export { docToMarkdown, markdownToDoc, createMarkdownSerializer, stableJson } from './markdown'
|
|
67
67
|
|
|
68
|
-
// diff (history panel)
|
|
69
|
-
export { diffDocs, wordDiff, diffSummary } from './diff'
|
|
70
|
-
export type { WordDiffPart, BlockDiffEntry } from './diff'
|
|
71
68
|
|
|
72
69
|
// write-back protocol
|
|
73
70
|
export {
|
package/src/linkedit.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getMarkRange } from '@tiptap/core'
|
|
2
|
+
import type { Editor } from '@tiptap/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Link click-to-edit: clicking a link opens a panel with its display text
|
|
6
|
+
* and href, and offers unlink (degrade to plain text). The doc semantics
|
|
7
|
+
* live here so React and Vue bindings behave identically.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface TesseraLinkRange {
|
|
11
|
+
from: number
|
|
12
|
+
to: number
|
|
13
|
+
text: string
|
|
14
|
+
href: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Resolve the link mark range under/ending at `pos` (defaults to the caret). */
|
|
18
|
+
export function findLinkRange(editor: Editor, pos?: number): TesseraLinkRange | null {
|
|
19
|
+
const { doc, schema } = editor.state
|
|
20
|
+
const at = pos ?? editor.state.selection.from
|
|
21
|
+
const linkType = schema.marks.link
|
|
22
|
+
// a caret sitting exactly at the link's last position only sees the mark
|
|
23
|
+
// one position earlier, hence the at-1 fallback
|
|
24
|
+
for (const p of [at, Math.max(0, at - 1)]) {
|
|
25
|
+
const range = getMarkRange(doc.resolve(p), linkType)
|
|
26
|
+
if (!range) continue
|
|
27
|
+
const node = doc.nodeAt(range.from)
|
|
28
|
+
const href = (node?.marks.find(m => m.type === linkType)?.attrs.href as string | undefined) ?? ''
|
|
29
|
+
return {
|
|
30
|
+
from: range.from,
|
|
31
|
+
to: range.to,
|
|
32
|
+
text: doc.textBetween(range.from, range.to, '\n'),
|
|
33
|
+
href,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Save an edit in ONE transaction (single undo step): update href, and when
|
|
41
|
+
* the display text changed replace the range content while keeping the
|
|
42
|
+
* text's other marks. An empty href degrades to unlink.
|
|
43
|
+
*/
|
|
44
|
+
export function saveLinkRange(editor: Editor, range: TesseraLinkRange, next: { text: string; href: string }): void {
|
|
45
|
+
const { schema, tr, doc } = editor.state
|
|
46
|
+
const linkType = schema.marks.link
|
|
47
|
+
if (!next.href.trim()) {
|
|
48
|
+
removeLinkRange(editor, range)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
const linkMark = linkType.create({ href: next.href.trim() })
|
|
52
|
+
if (next.text !== range.text) {
|
|
53
|
+
const first = doc.nodeAt(range.from)
|
|
54
|
+
const otherMarks = (first?.marks ?? []).filter(m => m.type !== linkType)
|
|
55
|
+
tr.replaceWith(range.from, range.to, schema.text(next.text, [...otherMarks, linkMark]))
|
|
56
|
+
} else {
|
|
57
|
+
tr.removeMark(range.from, range.to, linkType).addMark(range.from, range.to, linkMark)
|
|
58
|
+
}
|
|
59
|
+
editor.view.dispatch(tr)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Strip the link mark, keeping the display text as plain text. */
|
|
63
|
+
export function removeLinkRange(editor: Editor, range: TesseraLinkRange): void {
|
|
64
|
+
const tr = editor.state.tr.removeMark(range.from, range.to, editor.state.schema.marks.link)
|
|
65
|
+
editor.view.dispatch(tr)
|
|
66
|
+
}
|
package/src/markdown.ts
CHANGED
|
@@ -201,7 +201,6 @@ export function createMarkdownSerializer(schema: Schema): MarkdownSerializer {
|
|
|
201
201
|
textStyle: { open: '', close: '', mixable: true },
|
|
202
202
|
color: { open: '', close: '', mixable: true },
|
|
203
203
|
aiAttribution: { open: '', close: '', mixable: true },
|
|
204
|
-
tesseraPlaceholder: { open: '', close: '', mixable: true },
|
|
205
204
|
comment: { open: '', close: '', mixable: true },
|
|
206
205
|
}
|
|
207
206
|
|
package/src/nodes/table.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
|
2
|
+
import { Plugin } from '@tiptap/pm/state'
|
|
2
3
|
import type { EditorState } from '@tiptap/pm/state'
|
|
4
|
+
import type { EditorProps as PMEditorProps } from '@tiptap/pm/view'
|
|
3
5
|
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -40,6 +42,8 @@ declare module '@tiptap/core' {
|
|
|
40
42
|
setColumnType: (index: number, kind: TableColumnKind) => ReturnType
|
|
41
43
|
/** Stable-sort body rows by column `index`. */
|
|
42
44
|
sortTableByColumn: (index: number, direction: 'asc' | 'desc') => ReturnType
|
|
45
|
+
/** Strip stray hidden text from every typed (non-text) cell. */
|
|
46
|
+
normalizeTypedCells: () => ReturnType
|
|
43
47
|
}
|
|
44
48
|
}
|
|
45
49
|
}
|
|
@@ -74,6 +78,73 @@ export function normalizeTypes(types: unknown, cols: number): TableColumnKind[]
|
|
|
74
78
|
return list.slice(0, cols)
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
/** Kind of the column containing doc position `pos`; null outside table
|
|
82
|
+
* cells and for header cells (headers stay plain text by design). */
|
|
83
|
+
export function cellKindAt(doc: PMNode, pos: number): TableColumnKind | null {
|
|
84
|
+
const $pos = doc.resolve(pos)
|
|
85
|
+
let tableDepth = -1
|
|
86
|
+
for (let depth = $pos.depth; depth > 0; depth--) {
|
|
87
|
+
if ($pos.node(depth).type.name === 'table') {
|
|
88
|
+
tableDepth = depth
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (tableDepth < 1 || $pos.depth < tableDepth + 2) {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
const cell = $pos.node(tableDepth + 2)
|
|
96
|
+
if (cell.type.name !== 'tableCell') {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
const table = $pos.node(tableDepth)
|
|
100
|
+
const col = $pos.index(tableDepth + 1)
|
|
101
|
+
return normalizeTypes(table.attrs.types, columnCount(table))[col] ?? 'text'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Content ranges of every typed (non-text) body cell — the text caret must
|
|
105
|
+
* not enter them; the type widget owns that content. */
|
|
106
|
+
function typedCellRanges(doc: PMNode): { from: number; to: number }[] {
|
|
107
|
+
const out: { from: number; to: number }[] = []
|
|
108
|
+
doc.descendants((node, pos) => {
|
|
109
|
+
if (node.type.name !== 'table') {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
const types = normalizeTypes(node.attrs.types, columnCount(node))
|
|
113
|
+
node.forEach((row, rowOff) => {
|
|
114
|
+
let col = 0
|
|
115
|
+
row.forEach((cell, cellOff) => {
|
|
116
|
+
const kind = types[col]
|
|
117
|
+
if (cell.type.name === 'tableCell' && kind && kind !== 'text') {
|
|
118
|
+
const base = pos + 1 + rowOff + 1 + cellOff
|
|
119
|
+
out.push({ from: base + 1, to: base + cell.nodeSize - 1 })
|
|
120
|
+
}
|
|
121
|
+
col += 1
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Wipe the hidden paragraph text of body cells in column `index` (same
|
|
129
|
+
* transaction) — used when a column becomes a typed column. */
|
|
130
|
+
function clearColumnCellText(
|
|
131
|
+
tr: import('@tiptap/pm/state').Transaction,
|
|
132
|
+
schema: import('@tiptap/pm/model').Schema,
|
|
133
|
+
table: { pos: number; node: PMNode },
|
|
134
|
+
index: number,
|
|
135
|
+
): void {
|
|
136
|
+
table.node.forEach((row, rowOff) => {
|
|
137
|
+
let col = 0
|
|
138
|
+
row.forEach((cell, cellOff) => {
|
|
139
|
+
if (col === index && cell.type.name === 'tableCell' && cell.textContent.trim()) {
|
|
140
|
+
const base = table.pos + 1 + rowOff + 1 + cellOff
|
|
141
|
+
tr.replaceWith(base + 1, base + cell.nodeSize - 1, schema.nodes.paragraph!.create(null))
|
|
142
|
+
}
|
|
143
|
+
col += 1
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
77
148
|
function cellSortValue(row: PMNode, index: number, kind: TableColumnKind): string | number | boolean | null {
|
|
78
149
|
const cell = row.maybeChild(index)
|
|
79
150
|
if (!cell) {
|
|
@@ -148,6 +219,31 @@ export function tableNodeToCsv(table: PMNode): string {
|
|
|
148
219
|
export const AiTable = Table.extend({
|
|
149
220
|
name: 'table',
|
|
150
221
|
|
|
222
|
+
addProseMirrorPlugins() {
|
|
223
|
+
return [
|
|
224
|
+
new Plugin({
|
|
225
|
+
props: {
|
|
226
|
+
// typed cells are owned by their widget: never let the text caret
|
|
227
|
+
// enter the hidden paragraph, type or paste into it, and make
|
|
228
|
+
// cursor movement skip the cell as an atomic unit
|
|
229
|
+
handleTextInput: (view, from) => {
|
|
230
|
+
const kind = cellKindAt(view.state.doc, from)
|
|
231
|
+
return kind !== null && kind !== 'text'
|
|
232
|
+
},
|
|
233
|
+
handlePaste: view => {
|
|
234
|
+
const kind = cellKindAt(view.state.doc, view.state.selection.from)
|
|
235
|
+
return kind !== null && kind !== 'text'
|
|
236
|
+
},
|
|
237
|
+
handleClick: (view, pos) => {
|
|
238
|
+
const kind = cellKindAt(view.state.doc, pos)
|
|
239
|
+
return kind !== null && kind !== 'text'
|
|
240
|
+
},
|
|
241
|
+
atomicRanges: (state: EditorState) => typedCellRanges(state.doc),
|
|
242
|
+
} as PMEditorProps,
|
|
243
|
+
}),
|
|
244
|
+
]
|
|
245
|
+
},
|
|
246
|
+
|
|
151
247
|
addAttributes() {
|
|
152
248
|
return {
|
|
153
249
|
...this.parent?.(),
|
|
@@ -196,10 +292,44 @@ export const AiTable = Table.extend({
|
|
|
196
292
|
types[index] = kind
|
|
197
293
|
if (dispatch) {
|
|
198
294
|
tr.setNodeMarkup(table.pos, undefined, { ...table.node.attrs, types })
|
|
295
|
+
// the widget owns the content of typed cells from now on — drop
|
|
296
|
+
// any stray hidden text the column may still carry
|
|
297
|
+
if (kind !== 'text') {
|
|
298
|
+
clearColumnCellText(tr, state.schema, table, index)
|
|
299
|
+
}
|
|
199
300
|
dispatch(tr)
|
|
200
301
|
}
|
|
201
302
|
return true
|
|
202
303
|
},
|
|
304
|
+
normalizeTypedCells:
|
|
305
|
+
() =>
|
|
306
|
+
({ state, dispatch, tr }) => {
|
|
307
|
+
const edits: { from: number; to: number }[] = []
|
|
308
|
+
state.doc.descendants((node, pos) => {
|
|
309
|
+
if (node.type.name !== 'table') {
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
const types = normalizeTypes(node.attrs.types, columnCount(node))
|
|
313
|
+
node.forEach((row, rowOff) => {
|
|
314
|
+
let col = 0
|
|
315
|
+
row.forEach((cell, cellOff) => {
|
|
316
|
+
const kind = types[col]
|
|
317
|
+
if (cell.type.name === 'tableCell' && kind && kind !== 'text' && cell.textContent.trim()) {
|
|
318
|
+
const base = pos + 1 + rowOff + 1 + cellOff
|
|
319
|
+
edits.push({ from: base + 1, to: base + cell.nodeSize - 1 })
|
|
320
|
+
}
|
|
321
|
+
col += 1
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
if (dispatch && edits.length) {
|
|
326
|
+
for (const e of [...edits].reverse()) {
|
|
327
|
+
tr.replaceWith(e.from, e.to, state.schema.nodes.paragraph!.create(null))
|
|
328
|
+
}
|
|
329
|
+
dispatch(tr)
|
|
330
|
+
}
|
|
331
|
+
return edits.length > 0
|
|
332
|
+
},
|
|
203
333
|
sortTableByColumn:
|
|
204
334
|
(index: number, direction: 'asc' | 'desc') =>
|
|
205
335
|
({ state, dispatch, tr }) => {
|