@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/wordpaste.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word paste cleaning (acceptance §9, v1.1).
|
|
3
|
+
*
|
|
4
|
+
* Word export HTML is full of Office plumbing that ProseMirror's default
|
|
5
|
+
* schema-based cleaning handles badly (mso styles leak as text, namespace
|
|
6
|
+
* tags become unknown nodes, empty paragraphs multiply). When pasted HTML
|
|
7
|
+
* is detected as Word source, these rules run BEFORE PM parses it:
|
|
8
|
+
*
|
|
9
|
+
* R1 drop comments incl. `<!--[if ...]>...<![endif]-->` conditionals
|
|
10
|
+
* R2 drop <style>/<script>/<meta>/<link> blocks and tags
|
|
11
|
+
* R3 strip Office namespace tags entirely (<o:p>, <w:*, <st1:*>, <v:*>)
|
|
12
|
+
* R4 drop class / style / lang attributes (visual plumbing; bold/italic/
|
|
13
|
+
* underline survive via <b>/<i>/<u> tags which PM keeps)
|
|
14
|
+
* R5 drop Word list markers ("l" bullets in mso-list spans are removed
|
|
15
|
+
* with R3/R4; list paragraphs become plain paragraphs — semantic list
|
|
16
|
+
* reconstruction is explicitly out of scope for v1.1)
|
|
17
|
+
* R6 collapses to a normal space
|
|
18
|
+
* R7 empty paragraphs (<p></p>, <p><br></p>, whitespace-only) are removed
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export function isWordHtml(html: string): boolean {
|
|
22
|
+
return (
|
|
23
|
+
html.includes('urn:schemas-microsoft-com:office:word') ||
|
|
24
|
+
html.includes('urn:schemas-microsoft-com:office:office') ||
|
|
25
|
+
/mso-[\w-]+/.test(html) ||
|
|
26
|
+
/\bMso\w+/.test(html) ||
|
|
27
|
+
/<w:/i.test(html) ||
|
|
28
|
+
/<o:/i.test(html)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cleanWordHtml(html: string): string {
|
|
33
|
+
let out = html
|
|
34
|
+
// R1 comments + conditionals
|
|
35
|
+
out = out.replace(/<!--\[if[\s\S]*?<!\[endif\]-->/gi, '')
|
|
36
|
+
out = out.replace(/<!--[\s\S]*?-->/g, '')
|
|
37
|
+
// R2 style/script blocks, meta/link tags, xml prolog
|
|
38
|
+
out = out.replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, '')
|
|
39
|
+
out = out.replace(/<(meta|link)\b[^>]*>/gi, '')
|
|
40
|
+
out = out.replace(/<\?xml[^>]*\?>/gi, '')
|
|
41
|
+
out = out.replace(/<!DOCTYPE[^>]*>/gi, '')
|
|
42
|
+
// R3 office namespace tags
|
|
43
|
+
out = out.replace(/<\/?[a-z][a-z0-9]*:[^>]*>/gi, '')
|
|
44
|
+
// R4 visual-plumbing attributes (double- and single-quoted)
|
|
45
|
+
out = out.replace(/\s(class|style|lang|xml:lang|face)\s*=\s*"[^"]*"/gi, '')
|
|
46
|
+
out = out.replace(/\s(class|style|lang|xml:lang|face)\s*=\s*'[^']*'/gi, '')
|
|
47
|
+
// R6 non-breaking spaces
|
|
48
|
+
out = out.replace(/ /gi, ' ')
|
|
49
|
+
// R7 empty paragraphs (iterate: nested whitespace collapses progressively)
|
|
50
|
+
let prev = ''
|
|
51
|
+
while (out !== prev) {
|
|
52
|
+
prev = out
|
|
53
|
+
out = out.replace(/<p\b[^>]*>(\s|<br\s*\/?>)*<\/p>/gi, '')
|
|
54
|
+
}
|
|
55
|
+
return out.trim()
|
|
56
|
+
}
|
package/src/writeback.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Editor, JSONContent } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write-back protocol (ADR-0001 / product-definition §4): AI and host code
|
|
5
|
+
* mutate the document by stable block IDs — never whole-doc rewrites.
|
|
6
|
+
* Semantics mirror SliteML's modifyRange / appendBlocks / removeBlocks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface BlockHandle {
|
|
10
|
+
/** stable block id (UniqueID extension); null when the block predates ids */
|
|
11
|
+
id: string | null
|
|
12
|
+
type: string
|
|
13
|
+
pos: number
|
|
14
|
+
node: import('@tiptap/pm/model').Node
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Top-level blocks with their ids, in document order. */
|
|
18
|
+
export function getTopLevelBlocks(editor: Editor): BlockHandle[] {
|
|
19
|
+
const blocks: BlockHandle[] = []
|
|
20
|
+
editor.state.doc.forEach((node, offset) => {
|
|
21
|
+
blocks.push({ id: (node.attrs.id as string | undefined) ?? null, type: node.type.name, pos: offset, node })
|
|
22
|
+
})
|
|
23
|
+
return blocks
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findBlockPosById(editor: Editor, id: string): number | null {
|
|
27
|
+
let found: number | null = null
|
|
28
|
+
editor.state.doc.forEach((node, offset) => {
|
|
29
|
+
if (found === null && node.attrs.id === id) {
|
|
30
|
+
found = offset
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
return found
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function newBlockId(): string {
|
|
37
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
38
|
+
return crypto.randomUUID()
|
|
39
|
+
}
|
|
40
|
+
return `tessera-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveBlocks(editor: Editor, blocks: JSONContent[]) {
|
|
44
|
+
return blocks.map(json => {
|
|
45
|
+
const clean = { ...json }
|
|
46
|
+
// Write-back content carries fresh ids of its own: UniqueID skips nodes
|
|
47
|
+
// that already have one, so the ids land inside the same transaction
|
|
48
|
+
// (single undo step) instead of a follow-up append.
|
|
49
|
+
clean.attrs = { ...(clean.attrs ?? {}), id: newBlockId() }
|
|
50
|
+
return editor.state.schema.nodeFromJSON(clean)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ModifyRangeOptions {
|
|
55
|
+
/** first block to replace (default: first block in doc) */
|
|
56
|
+
fromId?: string
|
|
57
|
+
/** last block to replace, inclusive (default: fromId) */
|
|
58
|
+
toId?: string
|
|
59
|
+
content: JSONContent[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Replace the inclusive block range [fromId … toId] with `content`.
|
|
64
|
+
* One transaction → one undo step.
|
|
65
|
+
*/
|
|
66
|
+
export function modifyRange(editor: Editor, options: ModifyRangeOptions): boolean {
|
|
67
|
+
const blocks = getTopLevelBlocks(editor).filter(b => b.id)
|
|
68
|
+
if (blocks.length === 0) {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
const ids = blocks.map(b => b.id as string)
|
|
72
|
+
const fromId = options.fromId ?? ids[0]
|
|
73
|
+
const toId = options.toId ?? fromId
|
|
74
|
+
|
|
75
|
+
let fromIndex = ids.indexOf(fromId)
|
|
76
|
+
let toIndex = ids.indexOf(toId)
|
|
77
|
+
if (fromIndex === -1 || toIndex === -1) {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
if (fromIndex > toIndex) {
|
|
81
|
+
;[fromIndex, toIndex] = [toIndex, fromIndex]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const fromBlock = blocks[fromIndex]
|
|
85
|
+
const toBlock = blocks[toIndex]
|
|
86
|
+
const from = fromBlock.pos
|
|
87
|
+
const to = toBlock.pos + toBlock.node.nodeSize
|
|
88
|
+
|
|
89
|
+
const nodes = resolveBlocks(editor, options.content)
|
|
90
|
+
const tr = editor.state.tr
|
|
91
|
+
tr.replaceWith(from, to, nodes)
|
|
92
|
+
editor.view.dispatch(tr)
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AppendBlocksOptions {
|
|
97
|
+
/** insert after this block (default: end of document) */
|
|
98
|
+
afterId?: string
|
|
99
|
+
content: JSONContent[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Append blocks after `afterId` (or at the document end). */
|
|
103
|
+
export function appendBlocks(editor: Editor, options: AppendBlocksOptions): boolean {
|
|
104
|
+
const nodes = resolveBlocks(editor, options.content)
|
|
105
|
+
if (nodes.length === 0) {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
let insertPos = editor.state.doc.content.size
|
|
109
|
+
if (options.afterId) {
|
|
110
|
+
const pos = findBlockPosById(editor, options.afterId)
|
|
111
|
+
if (pos === null) {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
const node = editor.state.doc.nodeAt(pos)
|
|
115
|
+
if (!node) {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
insertPos = pos + node.nodeSize
|
|
119
|
+
}
|
|
120
|
+
const tr = editor.state.tr
|
|
121
|
+
tr.insert(insertPos, nodes)
|
|
122
|
+
editor.view.dispatch(tr)
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Remove blocks by ids. One transaction → one undo step. */
|
|
127
|
+
export function removeBlocks(editor: Editor, ids: string[]): boolean {
|
|
128
|
+
if (ids.length === 0) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
const ranges: { from: number; to: number }[] = []
|
|
132
|
+
editor.state.doc.forEach((node, offset) => {
|
|
133
|
+
if (node.attrs.id && ids.includes(node.attrs.id)) {
|
|
134
|
+
ranges.push({ from: offset, to: offset + node.nodeSize })
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
if (ranges.length === 0) {
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
const tr = editor.state.tr
|
|
141
|
+
for (let i = ranges.length - 1; i >= 0; i--) {
|
|
142
|
+
tr.delete(ranges[i].from, ranges[i].to)
|
|
143
|
+
}
|
|
144
|
+
editor.view.dispatch(tr)
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Read a block (and its subtree) by id as canonical JSON. */
|
|
149
|
+
export function getBlockJson(editor: Editor, id: string): JSONContent | null {
|
|
150
|
+
const pos = findBlockPosById(editor, id)
|
|
151
|
+
if (pos === null) {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
const node = editor.state.doc.nodeAt(pos)
|
|
155
|
+
return node ? node.toJSON() : null
|
|
156
|
+
}
|