@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// nodes & marks
|
|
2
|
+
export { Hint } from './nodes/hint'
|
|
3
|
+
export type { HintVariant, HintOptions } from './nodes/hint'
|
|
4
|
+
export { Collapsible, CollapsibleSummary, CollapsibleContent } from './nodes/collapsible'
|
|
5
|
+
export type { CollapsibleOptions } from './nodes/collapsible'
|
|
6
|
+
export { ImageBlock } from './nodes/image'
|
|
7
|
+
export type { ImageAlign, ImageBlockOptions } from './nodes/image'
|
|
8
|
+
export { AiTable, AiTableRow, AiTableCell, AiTableHeader, TABLE_COLUMN_KINDS, normalizeTypes, tableNodeToCsv, tableToCsvAt } from './nodes/table'
|
|
9
|
+
export type { TableColumnKind } from './nodes/table'
|
|
10
|
+
export { EmbedBlock } from './nodes/embed'
|
|
11
|
+
export { TocBlock } from './nodes/toc'
|
|
12
|
+
export { AiAttribution } from './marks/ai'
|
|
13
|
+
export type { AiAttributionOptions } from './marks/ai'
|
|
14
|
+
export { CommentMark, CommentCommands, listCommentRanges } from './marks/comment'
|
|
15
|
+
export { PlaceholderMark, PlaceholderCommands } from './marks/placeholder'
|
|
16
|
+
export type { PlaceholderKind } from './marks/placeholder'
|
|
17
|
+
|
|
18
|
+
// extensions
|
|
19
|
+
export { TesseraInputRules } from './extensions/input-rules'
|
|
20
|
+
export { TesseraShortcuts } from './extensions/shortcuts'
|
|
21
|
+
export { TesseraFindReplace, findReplaceKey } from './extensions/find-replace'
|
|
22
|
+
export type { FindMatch, FindReplaceState } from './extensions/find-replace'
|
|
23
|
+
export { SlashMenu, defaultSlashItems } from './extensions/slash'
|
|
24
|
+
export type { SlashMenuItem, SlashMenuOptions, SlashRenderFactory } from './extensions/slash'
|
|
25
|
+
export { EmojiMenu, EMOJI_ITEMS, filterEmojiItems } from './extensions/emoji'
|
|
26
|
+
export type { EmojiItem, EmojiMenuOptions } from './extensions/emoji'
|
|
27
|
+
export { TesseraHistory, historyKey } from './extensions/history'
|
|
28
|
+
export type { HistorySnapshotOptions } from './extensions/history'
|
|
29
|
+
export { BlockContextMenu } from './extensions/context-menu'
|
|
30
|
+
export { TesseraGallery, galleryKey, findGalleryRuns } from './extensions/gallery'
|
|
31
|
+
export { TesseraMetrics, getTesseraMetrics, measureTesseraMetrics } from './extensions/metrics'
|
|
32
|
+
export type { TesseraMetricsSnapshot } from './extensions/metrics'
|
|
33
|
+
export { TesseraWordPaste } from './extensions/word-paste'
|
|
34
|
+
export { isWordHtml, cleanWordHtml } from './wordpaste'
|
|
35
|
+
export { findBlockPosById, deleteBlockById, blockAnchorUrl } from './blockmenu'
|
|
36
|
+
|
|
37
|
+
// preset
|
|
38
|
+
export { createTesseraExtensions, ID_BLOCK_TYPES } from './preset'
|
|
39
|
+
export type { TesseraPresetOptions } from './preset'
|
|
40
|
+
|
|
41
|
+
// i18n
|
|
42
|
+
export { createTesseraT, tesseraMessages } from './i18n'
|
|
43
|
+
export type { TesseraLocale, TesseraMessageKey, TesseraTranslator } from './i18n'
|
|
44
|
+
|
|
45
|
+
// services
|
|
46
|
+
export {
|
|
47
|
+
TesseraServices,
|
|
48
|
+
getUploadService,
|
|
49
|
+
getStorageService,
|
|
50
|
+
getCommentStore,
|
|
51
|
+
getIdentityService,
|
|
52
|
+
} from './services'
|
|
53
|
+
export type {
|
|
54
|
+
UploadService,
|
|
55
|
+
UploadedAsset,
|
|
56
|
+
TesseraServicesStorage,
|
|
57
|
+
StorageService,
|
|
58
|
+
DocSnapshot,
|
|
59
|
+
CommentStore,
|
|
60
|
+
CommentThread,
|
|
61
|
+
CommentEntry,
|
|
62
|
+
IdentityService,
|
|
63
|
+
} from './services'
|
|
64
|
+
|
|
65
|
+
// format layer
|
|
66
|
+
export { docToMarkdown, markdownToDoc, createMarkdownSerializer, stableJson } from './markdown'
|
|
67
|
+
|
|
68
|
+
// diff (history panel)
|
|
69
|
+
export { diffDocs, wordDiff, diffSummary } from './diff'
|
|
70
|
+
export type { WordDiffPart, BlockDiffEntry } from './diff'
|
|
71
|
+
|
|
72
|
+
// write-back protocol
|
|
73
|
+
export {
|
|
74
|
+
getTopLevelBlocks,
|
|
75
|
+
getBlockJson,
|
|
76
|
+
modifyRange,
|
|
77
|
+
appendBlocks,
|
|
78
|
+
removeBlocks,
|
|
79
|
+
} from './writeback'
|
|
80
|
+
export type { BlockHandle, ModifyRangeOptions, AppendBlocksOptions } from './writeback'
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it'
|
|
2
|
+
import { DOMParser as PMDOMParser, DOMSerializer } from '@tiptap/pm/model'
|
|
3
|
+
import { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown'
|
|
4
|
+
import type { Node as PMNode, Schema } from '@tiptap/pm/model'
|
|
5
|
+
import type { JSONContent } from '@tiptap/core'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Interchange format layer (ADR-0004): the canonical format is JSON; Markdown
|
|
9
|
+
* is the interchange format with guaranteed two-way conversion.
|
|
10
|
+
*
|
|
11
|
+
* Standard blocks → plain Markdown. Task lists → GFM `- [x]`.
|
|
12
|
+
* Tessera structural blocks (hint / collapsible / imageBlock) → semantic HTML,
|
|
13
|
+
* which round-trips through our parseHTML rules and stays portable elsewhere.
|
|
14
|
+
*
|
|
15
|
+
* Export limitations (documented): text color and AI attribution marks do not
|
|
16
|
+
* carry into Markdown (no static syntax); they survive JSON only.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** markdown-it plugin: GFM task lists (`- [x] text`) → ul/li[data-*]. */
|
|
20
|
+
interface MdToken {
|
|
21
|
+
type: string
|
|
22
|
+
content?: string
|
|
23
|
+
children?: MdToken[]
|
|
24
|
+
attrSet?: (name: string, value: string) => void
|
|
25
|
+
}
|
|
26
|
+
interface MdState {
|
|
27
|
+
tokens: MdToken[]
|
|
28
|
+
}
|
|
29
|
+
interface MarkdownItLike {
|
|
30
|
+
core: {
|
|
31
|
+
ruler: {
|
|
32
|
+
after: (afterName: string, ruleName: string, fn: (state: MdState) => void) => void
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function taskListPlugin(md: MarkdownItLike): void {
|
|
38
|
+
md.core.ruler.after('inline', 'tessera-tasklist', state => {
|
|
39
|
+
const tokens = state.tokens
|
|
40
|
+
const taskItems = new Set<number>()
|
|
41
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
42
|
+
if (tokens[i]!.type !== 'inline') continue
|
|
43
|
+
let p = -1
|
|
44
|
+
if (tokens[i - 1]?.type === 'paragraph_open') p = i - 2
|
|
45
|
+
if (p === -1 || tokens[p]?.type !== 'list_item_open') continue
|
|
46
|
+
const children = tokens[i]!.children
|
|
47
|
+
if (!children || children.length === 0) continue
|
|
48
|
+
const first = children[0]!
|
|
49
|
+
const m = /^\[([ xX])\]\s+/.exec(first.content ?? '')
|
|
50
|
+
if (!m) continue
|
|
51
|
+
first.content = (first.content ?? '').slice(m[0].length)
|
|
52
|
+
for (let j = 1; j < children.length; j++) {
|
|
53
|
+
const child = children[j]!
|
|
54
|
+
child.content = (child.content ?? '').replace(/^\[([ xX])\]\s+/, '')
|
|
55
|
+
}
|
|
56
|
+
tokens[p]!.attrSet?.('data-type', 'taskItem')
|
|
57
|
+
tokens[p]!.attrSet?.('data-checked', m[1] === ' ' ? 'false' : 'true')
|
|
58
|
+
taskItems.add(p)
|
|
59
|
+
}
|
|
60
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
61
|
+
if (tokens[i]!.type !== 'bullet_list_open') continue
|
|
62
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
63
|
+
if (tokens[j]!.type === 'bullet_list_close') break
|
|
64
|
+
if (tokens[j]!.type === 'list_item_open' && taskItems.has(j)) {
|
|
65
|
+
tokens[i]!.attrSet?.('data-type', 'taskList')
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const md = MarkdownIt({ html: true, linkify: true }).use(
|
|
74
|
+
// structural mismatch is only in the type layer (markdown-it Token typings)
|
|
75
|
+
taskListPlugin as unknown as Parameters<ReturnType<typeof MarkdownIt>['use']>[0],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
interface SerializerState {
|
|
79
|
+
write: (s: string) => void
|
|
80
|
+
renderContent: (n: PMNode) => void
|
|
81
|
+
renderList: (n: PMNode, delim: string, firstDelim: (index: number) => string) => void
|
|
82
|
+
closeBlock: (n: PMNode) => void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stripIds(json: JSONContent): JSONContent {
|
|
86
|
+
const walk = (node: JSONContent): JSONContent => {
|
|
87
|
+
const attrs = node.attrs && 'id' in node.attrs
|
|
88
|
+
? Object.fromEntries(Object.entries(node.attrs).filter(([k]) => k !== 'id'))
|
|
89
|
+
: node.attrs
|
|
90
|
+
return {
|
|
91
|
+
...node,
|
|
92
|
+
attrs,
|
|
93
|
+
content: node.content?.map(walk),
|
|
94
|
+
} as JSONContent
|
|
95
|
+
}
|
|
96
|
+
return walk(json)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function nodeToHtml(node: PMNode): string {
|
|
100
|
+
// strip ids: interchange HTML must not leak internal block identity
|
|
101
|
+
const clean = node.type.schema.nodeFromJSON(stripIds(node.toJSON()))
|
|
102
|
+
const dom = DOMSerializer.fromSchema(node.type.schema).serializeNode(clean) as HTMLElement
|
|
103
|
+
return dom.outerHTML
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createMarkdownSerializer(schema: Schema): MarkdownSerializer {
|
|
107
|
+
const nodes: Record<string, (state: SerializerState, node: PMNode) => void> = {
|
|
108
|
+
...defaultMarkdownSerializer.nodes,
|
|
109
|
+
} as never
|
|
110
|
+
|
|
111
|
+
// prosemirror-markdown's defaults use example-schema snake_case node
|
|
112
|
+
// names; provide camelCase (TipTap) equivalents.
|
|
113
|
+
const rep = (s: string, n: number) => ' '.repeat(n)
|
|
114
|
+
nodes.bulletList = (state, node) => {
|
|
115
|
+
state.renderList(node, ' ', () => '- ')
|
|
116
|
+
state.closeBlock(node)
|
|
117
|
+
}
|
|
118
|
+
nodes.orderedList = (state, node) => {
|
|
119
|
+
const start = Number(node.attrs.order ?? 1)
|
|
120
|
+
const maxW = String(start + node.childCount - 1).length
|
|
121
|
+
const space = rep(' ', maxW + 2)
|
|
122
|
+
state.renderList(node, space, i => {
|
|
123
|
+
const n = String(start + i)
|
|
124
|
+
return rep(' ', maxW - n.length) + n + '. '
|
|
125
|
+
})
|
|
126
|
+
state.closeBlock(node)
|
|
127
|
+
}
|
|
128
|
+
nodes.listItem = (state, node) => {
|
|
129
|
+
state.renderContent(node)
|
|
130
|
+
}
|
|
131
|
+
nodes.codeBlock = (state, node) => {
|
|
132
|
+
state.write('```' + (node.attrs.language ?? '') + '\n')
|
|
133
|
+
state.write(node.textContent)
|
|
134
|
+
state.write('\n```')
|
|
135
|
+
state.closeBlock(node)
|
|
136
|
+
}
|
|
137
|
+
nodes.horizontalRule = (state, node) => {
|
|
138
|
+
state.write('---')
|
|
139
|
+
state.closeBlock(node)
|
|
140
|
+
}
|
|
141
|
+
nodes.hardBreak = state => {
|
|
142
|
+
state.write('\\\n')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
nodes.taskList = (state, node) => {
|
|
146
|
+
state.renderList(node, ' ', () => '- ')
|
|
147
|
+
state.closeBlock(node)
|
|
148
|
+
}
|
|
149
|
+
nodes.taskItem = (state, node) => {
|
|
150
|
+
state.write(node.attrs.checked ? '[x] ' : '[ ] ')
|
|
151
|
+
state.renderContent(node)
|
|
152
|
+
}
|
|
153
|
+
nodes.hint = (state, node) => {
|
|
154
|
+
state.write(nodeToHtml(node))
|
|
155
|
+
state.closeBlock(node)
|
|
156
|
+
}
|
|
157
|
+
nodes.collapsible = (state, node) => {
|
|
158
|
+
state.write(nodeToHtml(node))
|
|
159
|
+
state.closeBlock(node)
|
|
160
|
+
}
|
|
161
|
+
nodes.imageBlock = (state, node) => {
|
|
162
|
+
state.write(nodeToHtml(node))
|
|
163
|
+
state.closeBlock(node)
|
|
164
|
+
}
|
|
165
|
+
nodes.table = (state, node) => {
|
|
166
|
+
// tables carry typed-column metadata in attrs — HTML keeps it round-trip
|
|
167
|
+
state.write(nodeToHtml(node))
|
|
168
|
+
state.closeBlock(node)
|
|
169
|
+
}
|
|
170
|
+
nodes.tableRow = () => {}
|
|
171
|
+
nodes.tableCell = () => {}
|
|
172
|
+
nodes.tableHeader = () => {}
|
|
173
|
+
nodes.embedBlock = (state, node) => {
|
|
174
|
+
state.write(nodeToHtml(node))
|
|
175
|
+
state.closeBlock(node)
|
|
176
|
+
}
|
|
177
|
+
nodes.tocBlock = (state, node) => {
|
|
178
|
+
state.write('<div data-type="tessera-toc"></div>')
|
|
179
|
+
state.closeBlock(node)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// prosemirror-markdown's defaults use the example-schema mark names
|
|
183
|
+
// (strong/em); map TipTap's names explicitly.
|
|
184
|
+
const marks = {
|
|
185
|
+
...defaultMarkdownSerializer.marks,
|
|
186
|
+
bold: { open: '**', close: '**', mixable: true, expelEnclosingWhitespace: true },
|
|
187
|
+
italic: { open: '*', close: '*', mixable: true, expelEnclosingWhitespace: true },
|
|
188
|
+
strike: { open: '~~', close: '~~', mixable: true, expelEnclosingWhitespace: true },
|
|
189
|
+
code: { open: '`', close: '`', escape: false },
|
|
190
|
+
link: {
|
|
191
|
+
open: '[',
|
|
192
|
+
close: (state: { out: string }, mark: { attrs: { href: string } }) => `](${mark.attrs.href})`,
|
|
193
|
+
mixable: false,
|
|
194
|
+
} as never,
|
|
195
|
+
underline: { open: '<u>', close: '</u>', mixable: true, expelEnclosingWhitespace: true },
|
|
196
|
+
highlight: { open: '<mark>', close: '</mark>', mixable: true, expelEnclosingWhitespace: true },
|
|
197
|
+
// transparent passthroughs: these marks carry no Markdown syntax.
|
|
198
|
+
// Omitting them makes prosemirror-markdown THROW on export (crashes the
|
|
199
|
+
// host app) — textStyle always accompanies Color, and aiAttribution rides
|
|
200
|
+
// on AI-written text.
|
|
201
|
+
textStyle: { open: '', close: '', mixable: true },
|
|
202
|
+
color: { open: '', close: '', mixable: true },
|
|
203
|
+
aiAttribution: { open: '', close: '', mixable: true },
|
|
204
|
+
tesseraPlaceholder: { open: '', close: '', mixable: true },
|
|
205
|
+
comment: { open: '', close: '', mixable: true },
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return new MarkdownSerializer(nodes as never, marks as never)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Canonical JSON (or doc node) → Markdown. */
|
|
212
|
+
export function docToMarkdown(doc: PMNode | JSONContent, schema: Schema): string {
|
|
213
|
+
const isNode = typeof (doc as PMNode).nodeSize === 'number' && typeof (doc as PMNode).type === 'object'
|
|
214
|
+
const realDoc = isNode ? (doc as PMNode) : schema.nodeFromJSON(doc as JSONContent)
|
|
215
|
+
return createMarkdownSerializer(schema).serialize(realDoc)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Markdown → canonical JSON (authoritative format). Requires DOM (browser/jsdom). */
|
|
219
|
+
export function markdownToDoc(markdown: string, schema: Schema): JSONContent {
|
|
220
|
+
const html = md.render(markdown)
|
|
221
|
+
const container = document.createElement('div')
|
|
222
|
+
container.innerHTML = html
|
|
223
|
+
const parsed = PMDOMParser.fromSchema(schema).parse(container)
|
|
224
|
+
return parsed.toJSON()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Strip volatile/whitespace-irrelevant fields for round-trip comparisons. */
|
|
228
|
+
export function stableJson(json: JSONContent): JSONContent {
|
|
229
|
+
const clone = JSON.parse(JSON.stringify(json)) as JSONContent
|
|
230
|
+
const walk = (node: JSONContent) => {
|
|
231
|
+
if (node.attrs && 'id' in node.attrs) {
|
|
232
|
+
const { id: _drop, ...rest } = node.attrs
|
|
233
|
+
if (Object.keys(rest).length === 0) {
|
|
234
|
+
delete node.attrs
|
|
235
|
+
} else {
|
|
236
|
+
node.attrs = rest
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// highlight color: null (default) vs "" (parsed) are equivalent absence
|
|
240
|
+
node.marks = node.marks?.map(mark => {
|
|
241
|
+
if (mark.attrs) {
|
|
242
|
+
const attrs = Object.fromEntries(
|
|
243
|
+
Object.entries(mark.attrs).filter(([, v]) => v !== null && v !== ''),
|
|
244
|
+
)
|
|
245
|
+
return { ...mark, attrs }
|
|
246
|
+
}
|
|
247
|
+
return mark
|
|
248
|
+
})
|
|
249
|
+
// code blocks: trailing whitespace-only diff is not semantic
|
|
250
|
+
if (node.type === 'codeBlock' && node.content) {
|
|
251
|
+
node.content = node.content.map(child =>
|
|
252
|
+
child.type === 'text' ? { ...child, text: (child.text ?? '').replace(/\s+$/, '') } : child,
|
|
253
|
+
)
|
|
254
|
+
node.content = node.content.filter(child => !(child.type === 'text' && child.text === ''))
|
|
255
|
+
}
|
|
256
|
+
node.content?.forEach(walk)
|
|
257
|
+
}
|
|
258
|
+
walk(clone)
|
|
259
|
+
return clone
|
|
260
|
+
}
|
package/src/marks/ai.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Mark, mergeAttributes } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attribution mark for AI-written content — the Slite-style "human vs Agent"
|
|
5
|
+
* provenance signal. Lifecycle:
|
|
6
|
+
*
|
|
7
|
+
* pending: true → suggestion awaiting Accept / Reject (styled prominently)
|
|
8
|
+
* pending: false → accepted AI content (subtle permanent attribution)
|
|
9
|
+
*
|
|
10
|
+
* Reject deletes the marked range; Accept flips `pending` and stamps metadata.
|
|
11
|
+
*/
|
|
12
|
+
export interface AiAttributionOptions {
|
|
13
|
+
HTMLAttributes: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const AiAttribution = Mark.create<AiAttributionOptions>({
|
|
17
|
+
name: 'aiAttribution',
|
|
18
|
+
|
|
19
|
+
inclusive: false,
|
|
20
|
+
|
|
21
|
+
addOptions() {
|
|
22
|
+
return {
|
|
23
|
+
HTMLAttributes: {},
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
addAttributes() {
|
|
28
|
+
return {
|
|
29
|
+
pending: {
|
|
30
|
+
default: true,
|
|
31
|
+
parseHTML: element => element.getAttribute('data-pending') !== 'false',
|
|
32
|
+
renderHTML: attributes => ({ 'data-pending': String(attributes.pending) }),
|
|
33
|
+
},
|
|
34
|
+
action: {
|
|
35
|
+
default: null,
|
|
36
|
+
parseHTML: element => element.getAttribute('data-action'),
|
|
37
|
+
renderHTML: attributes => (attributes.action ? { 'data-action': String(attributes.action) } : {}),
|
|
38
|
+
},
|
|
39
|
+
agent: {
|
|
40
|
+
default: 'ai',
|
|
41
|
+
parseHTML: element => element.getAttribute('data-agent') ?? 'ai',
|
|
42
|
+
renderHTML: attributes => ({ 'data-agent': String(attributes.agent) }),
|
|
43
|
+
},
|
|
44
|
+
ts: { default: null },
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
parseHTML() {
|
|
49
|
+
return [{ tag: 'span[data-ai]' }]
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
renderHTML({ HTMLAttributes }) {
|
|
53
|
+
return ['span', mergeAttributes({ 'data-ai': '' }, this.options.HTMLAttributes, HTMLAttributes)]
|
|
54
|
+
},
|
|
55
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Mark, Extension, mergeAttributes } from '@tiptap/core'
|
|
2
|
+
import { TextSelection } from '@tiptap/pm/state'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inline comment mark (v1.1): anchors a comment thread to a text range.
|
|
6
|
+
* Thread data lives in the injected CommentStore; the mark only carries the
|
|
7
|
+
* thread id + resolved state so the doc stays host-persistable.
|
|
8
|
+
*/
|
|
9
|
+
export const CommentMark = Mark.create({
|
|
10
|
+
name: 'comment',
|
|
11
|
+
|
|
12
|
+
inclusive: false,
|
|
13
|
+
|
|
14
|
+
addAttributes() {
|
|
15
|
+
return {
|
|
16
|
+
threadId: {
|
|
17
|
+
default: null,
|
|
18
|
+
parseHTML: element => element.getAttribute('data-thread-id'),
|
|
19
|
+
renderHTML: attributes => ({ 'data-thread-id': String(attributes.threadId ?? '') }),
|
|
20
|
+
},
|
|
21
|
+
resolved: {
|
|
22
|
+
default: false,
|
|
23
|
+
parseHTML: element => element.getAttribute('data-resolved') === 'true',
|
|
24
|
+
renderHTML: attributes => ({ 'data-resolved': String(attributes.resolved) }),
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
parseHTML() {
|
|
30
|
+
return [{ tag: 'span[data-thread-id]' }]
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
renderHTML({ HTMLAttributes }) {
|
|
34
|
+
return ['span', mergeAttributes({ 'data-comment': '' }, HTMLAttributes)]
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
declare module '@tiptap/core' {
|
|
39
|
+
interface Commands<ReturnType> {
|
|
40
|
+
tesseraComment: {
|
|
41
|
+
/** Attach a comment thread id to the current selection. */
|
|
42
|
+
addCommentThread: (threadId: string) => ReturnType
|
|
43
|
+
/** Flip resolved for every mark of `threadId`. */
|
|
44
|
+
setCommentResolved: (threadId: string, resolved: boolean) => ReturnType
|
|
45
|
+
/** Remove all marks of `threadId` (thread deleted). */
|
|
46
|
+
removeCommentThread: (threadId: string) => ReturnType
|
|
47
|
+
/** Select the next comment range from the caret (wraps around). */
|
|
48
|
+
focusNextCommentThread: () => ReturnType
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** All comment thread ranges in document order. */
|
|
54
|
+
export function listCommentRanges(state: import('@tiptap/pm/state').EditorState): { threadId: string; from: number; to: number; resolved: boolean }[] {
|
|
55
|
+
const seen = new Map<string, { threadId: string; from: number; to: number; resolved: boolean }>()
|
|
56
|
+
state.doc.descendants((node, pos) => {
|
|
57
|
+
for (const m of node.marks) {
|
|
58
|
+
if (m.type.name === 'comment' && typeof m.attrs.threadId === 'string') {
|
|
59
|
+
const from = pos
|
|
60
|
+
const to = pos + node.nodeSize
|
|
61
|
+
const existing = seen.get(m.attrs.threadId)
|
|
62
|
+
if (!existing) {
|
|
63
|
+
seen.set(m.attrs.threadId, { threadId: m.attrs.threadId, from, to, resolved: Boolean(m.attrs.resolved) })
|
|
64
|
+
} else {
|
|
65
|
+
existing.to = Math.max(existing.to, to)
|
|
66
|
+
existing.resolved = existing.resolved && Boolean(m.attrs.resolved)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return true
|
|
71
|
+
})
|
|
72
|
+
return [...seen.values()].sort((a, b) => a.from - b.from)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const CommentCommands = Extension.create({
|
|
76
|
+
name: 'tesseraCommentCommands',
|
|
77
|
+
|
|
78
|
+
addCommands() {
|
|
79
|
+
return {
|
|
80
|
+
addCommentThread:
|
|
81
|
+
(threadId: string) =>
|
|
82
|
+
({ commands }) =>
|
|
83
|
+
commands.setMark('comment', { threadId, resolved: false }),
|
|
84
|
+
setCommentResolved:
|
|
85
|
+
(threadId: string, resolved: boolean) =>
|
|
86
|
+
({ state, tr, dispatch }) => {
|
|
87
|
+
let touched = false
|
|
88
|
+
state.doc.descendants((node, pos) => {
|
|
89
|
+
node.marks
|
|
90
|
+
.filter(m => m.type.name === 'comment' && m.attrs.threadId === threadId)
|
|
91
|
+
.forEach(m => {
|
|
92
|
+
const next = state.schema.marks.comment!.create({ ...m.attrs, resolved })
|
|
93
|
+
tr.removeMark(pos, pos + node.nodeSize, m)
|
|
94
|
+
tr.addMark(pos, pos + node.nodeSize, next)
|
|
95
|
+
touched = true
|
|
96
|
+
})
|
|
97
|
+
return true
|
|
98
|
+
})
|
|
99
|
+
if (touched && dispatch) {
|
|
100
|
+
dispatch(tr)
|
|
101
|
+
}
|
|
102
|
+
return touched
|
|
103
|
+
},
|
|
104
|
+
removeCommentThread:
|
|
105
|
+
(threadId: string) =>
|
|
106
|
+
({ state, tr, dispatch }) => {
|
|
107
|
+
let touched = false
|
|
108
|
+
state.doc.descendants((node, pos) => {
|
|
109
|
+
if (node.marks.some(m => m.type.name === 'comment' && m.attrs.threadId === threadId)) {
|
|
110
|
+
tr.removeMark(pos, pos + node.nodeSize, state.schema.marks.comment!)
|
|
111
|
+
touched = true
|
|
112
|
+
}
|
|
113
|
+
return true
|
|
114
|
+
})
|
|
115
|
+
if (touched && dispatch) {
|
|
116
|
+
dispatch(tr)
|
|
117
|
+
}
|
|
118
|
+
return touched
|
|
119
|
+
},
|
|
120
|
+
focusNextCommentThread:
|
|
121
|
+
() =>
|
|
122
|
+
({ state, tr, dispatch }) => {
|
|
123
|
+
const ranges = listCommentRanges(state)
|
|
124
|
+
if (ranges.length === 0) {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
const anchor = state.selection.from
|
|
128
|
+
const next = ranges.find(r => r.from > anchor) ?? ranges[0]!
|
|
129
|
+
if (dispatch) {
|
|
130
|
+
tr.setSelection(TextSelection.create(state.doc, next.from, next.to))
|
|
131
|
+
dispatch(tr)
|
|
132
|
+
}
|
|
133
|
+
return true
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Mark, mergeAttributes, Extension } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Placeholder mark (v1.1, Slite's 占位符): dashed-underline token marking a
|
|
5
|
+
* spot to fill in later — text / person / date / doc-link. ⌘⌥P toggles it on
|
|
6
|
+
* a selection; slash items insert ready-made tokens.
|
|
7
|
+
*/
|
|
8
|
+
export type PlaceholderKind = 'text' | 'person' | 'date' | 'doc'
|
|
9
|
+
|
|
10
|
+
export const PlaceholderMark = Mark.create({
|
|
11
|
+
name: 'tesseraPlaceholder',
|
|
12
|
+
|
|
13
|
+
addAttributes() {
|
|
14
|
+
return {
|
|
15
|
+
kind: {
|
|
16
|
+
default: 'text' as PlaceholderKind,
|
|
17
|
+
parseHTML: element => (element.getAttribute('data-kind') as PlaceholderKind) ?? 'text',
|
|
18
|
+
renderHTML: attributes => ({ 'data-kind': String(attributes.kind) }),
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
parseHTML() {
|
|
24
|
+
return [{ tag: 'span[data-type="tessera-placeholder"]' }]
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
renderHTML({ HTMLAttributes }) {
|
|
28
|
+
return ['span', mergeAttributes({ 'data-type': 'tessera-placeholder' }, HTMLAttributes)]
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
declare module '@tiptap/core' {
|
|
33
|
+
interface Commands<ReturnType> {
|
|
34
|
+
tesseraPlaceholder: {
|
|
35
|
+
togglePlaceholderMark: (kind?: PlaceholderKind) => ReturnType
|
|
36
|
+
/** Insert a placeholder token at the caret (selection replaced). */
|
|
37
|
+
insertPlaceholderToken: (kind: PlaceholderKind, label: string) => ReturnType
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const PlaceholderCommands = Extension.create({
|
|
43
|
+
name: 'tesseraPlaceholderCommands',
|
|
44
|
+
|
|
45
|
+
addCommands() {
|
|
46
|
+
return {
|
|
47
|
+
togglePlaceholderMark:
|
|
48
|
+
(kind: PlaceholderKind = 'text') =>
|
|
49
|
+
({ commands }) =>
|
|
50
|
+
commands.toggleMark('tesseraPlaceholder', { kind }),
|
|
51
|
+
insertPlaceholderToken:
|
|
52
|
+
(kind: PlaceholderKind, label: string) =>
|
|
53
|
+
({ chain }) =>
|
|
54
|
+
chain()
|
|
55
|
+
.insertContent({
|
|
56
|
+
type: 'text',
|
|
57
|
+
text: label,
|
|
58
|
+
marks: [{ type: 'tesseraPlaceholder', attrs: { kind } }],
|
|
59
|
+
})
|
|
60
|
+
.run(),
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
})
|