@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/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'
@@ -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
+ }
@@ -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
+ })