@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.
@@ -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, diffDocs, docToMarkdown, markdownToDoc, stableJson, listCommentRanges } from '../index'
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
- contextmenu: (view, event) => {
19
- const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
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
@@ -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 / placeholder
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 / placeholder
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(locale: TesseraLocale = 'zh-CN'): TesseraTranslator {
318
- const table = tesseraMessages[locale] ?? tesseraMessages['zh-CN']
319
- return key => (table as Record<string, string | ((n: number) => string)>)[key] as string ?? key
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 {
@@ -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
 
@@ -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 }) => {