@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Node, mergeAttributes, InputRule } from '@tiptap/core'
|
|
2
|
+
import { TextSelection } from '@tiptap/pm/state'
|
|
3
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
4
|
+
|
|
5
|
+
export interface CollapsibleOptions {
|
|
6
|
+
HTMLAttributes: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare module '@tiptap/core' {
|
|
10
|
+
interface Commands<ReturnType> {
|
|
11
|
+
collapsible: {
|
|
12
|
+
/** Replace the current empty paragraph with a collapsible block (input rule: `>>` + space). */
|
|
13
|
+
insertCollapsible: () => ReturnType
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function collapsibleJSON(): Record<string, unknown> {
|
|
19
|
+
return {
|
|
20
|
+
type: 'collapsible',
|
|
21
|
+
attrs: { open: true },
|
|
22
|
+
content: [
|
|
23
|
+
{ type: 'collapsibleSummary' },
|
|
24
|
+
{
|
|
25
|
+
type: 'collapsibleContent',
|
|
26
|
+
content: [{ type: 'paragraph' }],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Slite-style Collapsible block: always-visible summary line + collapsible content,
|
|
34
|
+
* nested blocks allowed. Trigger: type `>>` followed by a space on an empty line.
|
|
35
|
+
*/
|
|
36
|
+
export const Collapsible = Node.create<CollapsibleOptions>({
|
|
37
|
+
name: 'collapsible',
|
|
38
|
+
group: 'block',
|
|
39
|
+
content: 'collapsibleSummary collapsibleContent',
|
|
40
|
+
defining: true,
|
|
41
|
+
isolating: true,
|
|
42
|
+
|
|
43
|
+
addOptions() {
|
|
44
|
+
return {
|
|
45
|
+
HTMLAttributes: {},
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
addAttributes() {
|
|
50
|
+
return {
|
|
51
|
+
open: {
|
|
52
|
+
default: true,
|
|
53
|
+
parseHTML: element => element.getAttribute('data-open') !== 'false',
|
|
54
|
+
renderHTML: attributes => ({ 'data-open': String(attributes.open) }),
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
parseHTML() {
|
|
60
|
+
return [{ tag: 'div[data-type="collapsible"]' }]
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
renderHTML({ HTMLAttributes }) {
|
|
64
|
+
return ['div', mergeAttributes({ 'data-type': 'collapsible' }, this.options.HTMLAttributes, HTMLAttributes), 0]
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
addCommands() {
|
|
68
|
+
return {
|
|
69
|
+
insertCollapsible: () => ({ state, chain }) => {
|
|
70
|
+
const { $from } = state.selection
|
|
71
|
+
if ($from.parent.type.name !== 'paragraph') {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
const from = $from.before()
|
|
75
|
+
const to = $from.after()
|
|
76
|
+
return chain()
|
|
77
|
+
.insertContentAt({ from, to }, collapsibleJSON())
|
|
78
|
+
.command(({ tr, dispatch }) => {
|
|
79
|
+
if (dispatch) {
|
|
80
|
+
tr.setSelection(TextSelection.near(tr.doc.resolve(from + 2)))
|
|
81
|
+
}
|
|
82
|
+
return true
|
|
83
|
+
})
|
|
84
|
+
.run()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
addInputRules() {
|
|
90
|
+
return [
|
|
91
|
+
new InputRule({
|
|
92
|
+
find: /^(>{2}) $/,
|
|
93
|
+
handler: ({ state, range, chain }) => {
|
|
94
|
+
const $from = state.doc.resolve(range.from)
|
|
95
|
+
if ($from.parent.type.name !== 'paragraph') {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const from = $from.before()
|
|
99
|
+
const to = $from.after()
|
|
100
|
+
chain()
|
|
101
|
+
.insertContentAt({ from, to }, collapsibleJSON())
|
|
102
|
+
.command(({ tr, dispatch }) => {
|
|
103
|
+
if (dispatch) {
|
|
104
|
+
tr.setSelection(TextSelection.near(tr.doc.resolve(from + 2)))
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
107
|
+
})
|
|
108
|
+
.run()
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
addProseMirrorPlugins() {
|
|
115
|
+
const nodeName = this.name
|
|
116
|
+
return [
|
|
117
|
+
new Plugin({
|
|
118
|
+
key: new PluginKey('tesseraCollapsibleToggle'),
|
|
119
|
+
props: {
|
|
120
|
+
// Click on the summary toggles open/closed; collapsed state survives reload
|
|
121
|
+
// because it is a node attribute, not CSS state.
|
|
122
|
+
handleClickOn: (view, _pos, node, nodePos, event) => {
|
|
123
|
+
if (node.type.name !== nodeName) {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
const target = event.target as HTMLElement | null
|
|
127
|
+
if (!target?.closest('[data-type="collapsible-summary"]')) {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
view.dispatch(
|
|
131
|
+
view.state.tr.setNodeMarkup(nodePos, undefined, {
|
|
132
|
+
...node.attrs,
|
|
133
|
+
open: !node.attrs.open,
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
return true
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
]
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
export const CollapsibleSummary = Node.create({
|
|
145
|
+
name: 'collapsibleSummary',
|
|
146
|
+
group: '',
|
|
147
|
+
content: 'inline*',
|
|
148
|
+
defining: true,
|
|
149
|
+
|
|
150
|
+
parseHTML() {
|
|
151
|
+
return [{ tag: 'div[data-type="collapsible-summary"]' }]
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
renderHTML({ HTMLAttributes }) {
|
|
155
|
+
return ['div', mergeAttributes({ 'data-type': 'collapsible-summary' }, HTMLAttributes), 0]
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
export const CollapsibleContent = Node.create({
|
|
160
|
+
name: 'collapsibleContent',
|
|
161
|
+
group: '',
|
|
162
|
+
content: 'block+',
|
|
163
|
+
|
|
164
|
+
parseHTML() {
|
|
165
|
+
return [{ tag: 'div[data-type="collapsible-content"]' }]
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
renderHTML({ HTMLAttributes }) {
|
|
169
|
+
return ['div', mergeAttributes({ 'data-type': 'collapsible-content' }, HTMLAttributes), 0]
|
|
170
|
+
},
|
|
171
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sandboxed iframe embed (v1.1): default sandbox="" — NO scripts, NO
|
|
5
|
+
* same-origin. Hosts may opt into `allowScripts` per instance for app-like
|
|
6
|
+
* embeds; allow-same-origin is never combined with allow-scripts.
|
|
7
|
+
*/
|
|
8
|
+
export const EmbedBlock = Node.create({
|
|
9
|
+
name: 'embedBlock',
|
|
10
|
+
group: 'block',
|
|
11
|
+
atom: true,
|
|
12
|
+
draggable: true,
|
|
13
|
+
|
|
14
|
+
addAttributes() {
|
|
15
|
+
return {
|
|
16
|
+
src: { default: null },
|
|
17
|
+
title: { default: null },
|
|
18
|
+
height: { default: 360 },
|
|
19
|
+
allowScripts: { default: false },
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
parseHTML() {
|
|
24
|
+
return [{ tag: 'div[data-type="tessera-embed"]' }]
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
28
|
+
return [
|
|
29
|
+
'div',
|
|
30
|
+
mergeAttributes(HTMLAttributes, {
|
|
31
|
+
'data-type': 'tessera-embed',
|
|
32
|
+
'data-src': String(node.attrs.src ?? ''),
|
|
33
|
+
'data-height': String(node.attrs.height),
|
|
34
|
+
'data-allow-scripts': String(node.attrs.allowScripts),
|
|
35
|
+
}),
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
addCommands() {
|
|
40
|
+
return {
|
|
41
|
+
insertEmbed:
|
|
42
|
+
(attrs: { src: string; height?: number; allowScripts?: boolean }) =>
|
|
43
|
+
({ commands }) =>
|
|
44
|
+
commands.insertContent({ type: this.name, attrs }),
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
declare module '@tiptap/core' {
|
|
50
|
+
interface Commands<ReturnType> {
|
|
51
|
+
embedBlock: {
|
|
52
|
+
insertEmbed: (attrs: { src: string; height?: number; allowScripts?: boolean }) => ReturnType
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
export type HintVariant = 'info' | 'success' | 'warning' | 'danger' | 'neutral'
|
|
4
|
+
|
|
5
|
+
export interface HintOptions {
|
|
6
|
+
HTMLAttributes: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare module '@tiptap/core' {
|
|
10
|
+
interface Commands<ReturnType> {
|
|
11
|
+
hint: {
|
|
12
|
+
/** Wrap the current block into a hint container (input rule: `!! ` + space). */
|
|
13
|
+
setHint: (variant?: HintVariant) => ReturnType
|
|
14
|
+
toggleHint: () => ReturnType
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Slite-style Hint block: a callout container that holds any block content.
|
|
21
|
+
* Trigger: type `!!` followed by a space at the start of a line.
|
|
22
|
+
*/
|
|
23
|
+
export const Hint = Node.create<HintOptions>({
|
|
24
|
+
name: 'hint',
|
|
25
|
+
group: 'block',
|
|
26
|
+
content: 'block+',
|
|
27
|
+
defining: true,
|
|
28
|
+
|
|
29
|
+
addOptions() {
|
|
30
|
+
return {
|
|
31
|
+
HTMLAttributes: {},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
addAttributes() {
|
|
36
|
+
return {
|
|
37
|
+
variant: {
|
|
38
|
+
default: 'info' as HintVariant,
|
|
39
|
+
parseHTML: element => (element.getAttribute('data-variant') as HintVariant) || 'info',
|
|
40
|
+
renderHTML: attributes => ({ 'data-variant': String(attributes.variant) }),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
parseHTML() {
|
|
46
|
+
return [{ tag: 'div[data-type="hint"]' }]
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
renderHTML({ HTMLAttributes }) {
|
|
50
|
+
return ['div', mergeAttributes({ 'data-type': 'hint' }, this.options.HTMLAttributes, HTMLAttributes), 0]
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
addCommands() {
|
|
54
|
+
return {
|
|
55
|
+
setHint: (variant: HintVariant = 'info') => ({ commands }) => commands.wrapIn(this.name, { variant }),
|
|
56
|
+
toggleHint: () => ({ commands }) => commands.toggleWrap(this.name),
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
addInputRules() {
|
|
61
|
+
return [
|
|
62
|
+
wrappingInputRule({
|
|
63
|
+
find: /^(!{2}) $/,
|
|
64
|
+
type: this.type,
|
|
65
|
+
keepMarks: true,
|
|
66
|
+
keepAttributes: true,
|
|
67
|
+
}),
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
export type ImageAlign = 'left' | 'center' | 'full'
|
|
4
|
+
|
|
5
|
+
export interface ImageBlockOptions {
|
|
6
|
+
HTMLAttributes: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare module '@tiptap/core' {
|
|
10
|
+
interface Commands<ReturnType> {
|
|
11
|
+
imageBlock: {
|
|
12
|
+
setImage: (options: {
|
|
13
|
+
src: string
|
|
14
|
+
alt?: string
|
|
15
|
+
title?: string
|
|
16
|
+
width?: number
|
|
17
|
+
align?: ImageAlign
|
|
18
|
+
}) => ReturnType
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Block-level image with width + alignment attributes. Binary data goes
|
|
25
|
+
* through the injected UploadService; this node only stores the resulting URL.
|
|
26
|
+
*/
|
|
27
|
+
export const ImageBlock = Node.create<ImageBlockOptions>({
|
|
28
|
+
name: 'imageBlock',
|
|
29
|
+
inline: false,
|
|
30
|
+
group: 'block',
|
|
31
|
+
draggable: true,
|
|
32
|
+
|
|
33
|
+
addOptions() {
|
|
34
|
+
return {
|
|
35
|
+
HTMLAttributes: {},
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
addAttributes() {
|
|
40
|
+
return {
|
|
41
|
+
src: { default: null },
|
|
42
|
+
alt: { default: null },
|
|
43
|
+
title: { default: null },
|
|
44
|
+
width: { default: null },
|
|
45
|
+
align: { default: 'center' as ImageAlign },
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
parseHTML() {
|
|
50
|
+
return [
|
|
51
|
+
{ tag: 'img[data-type="tessera-image"]' },
|
|
52
|
+
// plain <img> pasted from outside becomes a block image
|
|
53
|
+
{ tag: 'img[src]:not([data-type])' },
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
58
|
+
const style = node.attrs.width ? `width: ${Number(node.attrs.width)}px` : undefined
|
|
59
|
+
return [
|
|
60
|
+
'img',
|
|
61
|
+
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
|
62
|
+
'data-type': 'tessera-image',
|
|
63
|
+
'data-align': String(node.attrs.align),
|
|
64
|
+
style,
|
|
65
|
+
}),
|
|
66
|
+
]
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
addCommands() {
|
|
70
|
+
return {
|
|
71
|
+
setImage:
|
|
72
|
+
options =>
|
|
73
|
+
({ commands }) =>
|
|
74
|
+
commands.insertContent({ type: this.name, attrs: options }),
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
})
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { Node as PMNode } from '@tiptap/pm/model'
|
|
2
|
+
import type { EditorState } from '@tiptap/pm/state'
|
|
3
|
+
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Typed-column table (v1.1 quality focus — Slite's worst-reviewed feature is
|
|
7
|
+
* our opportunity). Column kinds live on the table node (`types`, indexed by
|
|
8
|
+
* column position); cell values for non-text kinds live on the cell attr
|
|
9
|
+
* `value`. Text columns keep rich inline content in the cell itself.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately NOT supported (per Slite teardown §3.6): cell merging and
|
|
12
|
+
* in-cell calculations.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type TableColumnKind =
|
|
16
|
+
| 'text'
|
|
17
|
+
| 'checkbox'
|
|
18
|
+
| 'select'
|
|
19
|
+
| 'multiSelect'
|
|
20
|
+
| 'number'
|
|
21
|
+
| 'date'
|
|
22
|
+
| 'link'
|
|
23
|
+
|
|
24
|
+
export const TABLE_COLUMN_KINDS: TableColumnKind[] = [
|
|
25
|
+
'text',
|
|
26
|
+
'checkbox',
|
|
27
|
+
'select',
|
|
28
|
+
'multiSelect',
|
|
29
|
+
'number',
|
|
30
|
+
'date',
|
|
31
|
+
'link',
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
declare module '@tiptap/core' {
|
|
35
|
+
interface Commands<ReturnType> {
|
|
36
|
+
tesseraTable: {
|
|
37
|
+
/** Insert a typed table. Kinds default to `text` for every column. */
|
|
38
|
+
insertTableTyped: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType
|
|
39
|
+
/** Change the kind of column `index` (0-based). */
|
|
40
|
+
setColumnType: (index: number, kind: TableColumnKind) => ReturnType
|
|
41
|
+
/** Stable-sort body rows by column `index`. */
|
|
42
|
+
sortTableByColumn: (index: number, direction: 'asc' | 'desc') => ReturnType
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** CSV of the table containing the caret (utility, not a command). */
|
|
48
|
+
export function tableToCsvAt(editor: import('@tiptap/core').Editor): string | null {
|
|
49
|
+
const table = locateTable(editor.state)
|
|
50
|
+
return table ? tableNodeToCsv(table.node) : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function locateTable(state: EditorState): { pos: number; node: PMNode } | null {
|
|
54
|
+
const { $from } = state.selection
|
|
55
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
56
|
+
const node = $from.node(depth)
|
|
57
|
+
if (node.type.name === 'table') {
|
|
58
|
+
return { pos: $from.before(depth), node }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function columnCount(table: PMNode): number {
|
|
65
|
+
const firstRow = table.content.firstChild
|
|
66
|
+
return firstRow ? firstRow.childCount : 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function normalizeTypes(types: unknown, cols: number): TableColumnKind[] {
|
|
70
|
+
const list = Array.isArray(types) ? ([...types] as TableColumnKind[]) : []
|
|
71
|
+
while (list.length < cols) {
|
|
72
|
+
list.push('text')
|
|
73
|
+
}
|
|
74
|
+
return list.slice(0, cols)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cellSortValue(row: PMNode, index: number, kind: TableColumnKind): string | number | boolean | null {
|
|
78
|
+
const cell = row.maybeChild(index)
|
|
79
|
+
if (!cell) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
const value = cell.attrs.value as unknown
|
|
83
|
+
switch (kind) {
|
|
84
|
+
case 'checkbox':
|
|
85
|
+
return value === true || value === 'true'
|
|
86
|
+
case 'number': {
|
|
87
|
+
if (typeof value === 'number') {
|
|
88
|
+
return value
|
|
89
|
+
}
|
|
90
|
+
const parsed = Number(String(value ?? cell.textContent).trim())
|
|
91
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
92
|
+
}
|
|
93
|
+
case 'date':
|
|
94
|
+
return typeof value === 'string' && value ? value : null
|
|
95
|
+
case 'select':
|
|
96
|
+
return Array.isArray(value) ? ((value as string[])[0] ?? null) : ((value as string) ?? null)
|
|
97
|
+
case 'multiSelect':
|
|
98
|
+
return Array.isArray(value) ? [...(value as string[])].sort().join(' ‧ ') : null
|
|
99
|
+
default:
|
|
100
|
+
return cell.textContent.trim() || null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function csvEscape(text: string): string {
|
|
105
|
+
if (/[",\n]/.test(text)) {
|
|
106
|
+
return `"${text.replace(/"/g, '""')}"`
|
|
107
|
+
}
|
|
108
|
+
return text
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Serialize a table node to CSV (text form of typed values). */
|
|
112
|
+
export function tableNodeToCsv(table: PMNode): string {
|
|
113
|
+
const types = normalizeTypes(table.attrs.types, columnCount(table))
|
|
114
|
+
const lines: string[] = []
|
|
115
|
+
table.forEach(row => {
|
|
116
|
+
const isHeaderRow = row.content.firstChild?.type.name === 'tableHeader'
|
|
117
|
+
const cells: string[] = []
|
|
118
|
+
row.forEach((cell, _offset, index) => {
|
|
119
|
+
// header cells are labels — always plain text regardless of column kind
|
|
120
|
+
const kind = isHeaderRow ? 'text' : (types[index] ?? 'text')
|
|
121
|
+
const value = cell.attrs.value as unknown
|
|
122
|
+
let text: string
|
|
123
|
+
switch (kind) {
|
|
124
|
+
case 'checkbox':
|
|
125
|
+
text = value === true || value === 'true' ? '✔' : ''
|
|
126
|
+
break
|
|
127
|
+
case 'select':
|
|
128
|
+
case 'multiSelect':
|
|
129
|
+
text = Array.isArray(value) ? (value as string[]).join(' / ') : String(value ?? '')
|
|
130
|
+
break
|
|
131
|
+
case 'date':
|
|
132
|
+
text = String(value ?? '')
|
|
133
|
+
break
|
|
134
|
+
case 'number':
|
|
135
|
+
case 'link':
|
|
136
|
+
text = value === null || value === undefined ? cell.textContent : String(value)
|
|
137
|
+
break
|
|
138
|
+
default:
|
|
139
|
+
text = cell.textContent
|
|
140
|
+
}
|
|
141
|
+
cells.push(csvEscape(text.trim()))
|
|
142
|
+
})
|
|
143
|
+
lines.push(cells.join(','))
|
|
144
|
+
})
|
|
145
|
+
return lines.join('\n')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const AiTable = Table.extend({
|
|
149
|
+
name: 'table',
|
|
150
|
+
|
|
151
|
+
addAttributes() {
|
|
152
|
+
return {
|
|
153
|
+
...this.parent?.(),
|
|
154
|
+
types: {
|
|
155
|
+
default: [] as TableColumnKind[],
|
|
156
|
+
parseHTML: element => {
|
|
157
|
+
try {
|
|
158
|
+
const raw = element.getAttribute('data-types')
|
|
159
|
+
return raw ? (JSON.parse(raw) as TableColumnKind[]) : []
|
|
160
|
+
} catch {
|
|
161
|
+
return []
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
renderHTML: attributes => ({
|
|
165
|
+
'data-types': JSON.stringify(attributes.types ?? []),
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
freezeFirstCol: {
|
|
169
|
+
default: true,
|
|
170
|
+
parseHTML: element => element.getAttribute('data-freeze-first') !== 'false',
|
|
171
|
+
renderHTML: attributes => ({ 'data-freeze-first': String(attributes.freezeFirstCol) }),
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
addCommands() {
|
|
177
|
+
const parent = this.parent?.() ?? {}
|
|
178
|
+
return {
|
|
179
|
+
...parent,
|
|
180
|
+
insertTableTyped:
|
|
181
|
+
(options = {}) =>
|
|
182
|
+
({ commands }) =>
|
|
183
|
+
commands.insertTable({
|
|
184
|
+
rows: options.rows ?? 3,
|
|
185
|
+
cols: options.cols ?? 3,
|
|
186
|
+
withHeaderRow: options.withHeaderRow ?? true,
|
|
187
|
+
}),
|
|
188
|
+
setColumnType:
|
|
189
|
+
(index: number, kind: TableColumnKind) =>
|
|
190
|
+
({ state, dispatch, tr }) => {
|
|
191
|
+
const table = locateTable(state)
|
|
192
|
+
if (!table || index < 0 || index >= columnCount(table.node)) {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
const types = normalizeTypes(table.node.attrs.types, columnCount(table.node))
|
|
196
|
+
types[index] = kind
|
|
197
|
+
if (dispatch) {
|
|
198
|
+
tr.setNodeMarkup(table.pos, undefined, { ...table.node.attrs, types })
|
|
199
|
+
dispatch(tr)
|
|
200
|
+
}
|
|
201
|
+
return true
|
|
202
|
+
},
|
|
203
|
+
sortTableByColumn:
|
|
204
|
+
(index: number, direction: 'asc' | 'desc') =>
|
|
205
|
+
({ state, dispatch, tr }) => {
|
|
206
|
+
const table = locateTable(state)
|
|
207
|
+
if (!table) {
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
const rows: PMNode[] = []
|
|
211
|
+
table.node.forEach(row => rows.push(row))
|
|
212
|
+
const hasHeader = rows.length > 0 && rows[0]!.content.firstChild?.type.name === 'tableHeader'
|
|
213
|
+
const header = hasHeader ? rows[0]! : null
|
|
214
|
+
const body = hasHeader ? rows.slice(1) : rows.slice()
|
|
215
|
+
if (body.length === 0 || index >= columnCount(table.node)) {
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
const types = normalizeTypes(table.node.attrs.types, columnCount(table.node))
|
|
219
|
+
const kind = types[index] ?? 'text'
|
|
220
|
+
const dir = direction === 'asc' ? 1 : -1
|
|
221
|
+
const sorted = [...body].sort((a, b) => {
|
|
222
|
+
const va = cellSortValue(a, index, kind)
|
|
223
|
+
const vb = cellSortValue(b, index, kind)
|
|
224
|
+
if (va === vb) {
|
|
225
|
+
return 0
|
|
226
|
+
}
|
|
227
|
+
if (va === null) {
|
|
228
|
+
return 1
|
|
229
|
+
}
|
|
230
|
+
if (vb === null) {
|
|
231
|
+
return -1
|
|
232
|
+
}
|
|
233
|
+
return va < vb ? -dir : dir
|
|
234
|
+
})
|
|
235
|
+
const start = table.pos + 1 + (header ? header.nodeSize : 0)
|
|
236
|
+
const end = table.pos + table.node.nodeSize - 1
|
|
237
|
+
if (dispatch) {
|
|
238
|
+
tr.replaceWith(start, end, sorted)
|
|
239
|
+
dispatch(tr)
|
|
240
|
+
}
|
|
241
|
+
return true
|
|
242
|
+
},
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
export { TableRow as AiTableRow }
|
|
248
|
+
|
|
249
|
+
function valueAttr() {
|
|
250
|
+
return {
|
|
251
|
+
default: null,
|
|
252
|
+
parseHTML: (element: HTMLElement) => {
|
|
253
|
+
const raw = element.getAttribute('data-value')
|
|
254
|
+
if (raw === null || raw === '') {
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
return JSON.parse(raw)
|
|
259
|
+
} catch {
|
|
260
|
+
return raw
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
renderHTML: (attributes: Record<string, unknown>) => ({
|
|
264
|
+
'data-value':
|
|
265
|
+
attributes.value === null || attributes.value === undefined ? '' : JSON.stringify(attributes.value),
|
|
266
|
+
}),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const AiTableCell = TableCell.extend({
|
|
271
|
+
addAttributes() {
|
|
272
|
+
return {
|
|
273
|
+
...this.parent?.(),
|
|
274
|
+
value: valueAttr(),
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
export const AiTableHeader = TableHeader.extend({
|
|
280
|
+
addAttributes() {
|
|
281
|
+
return {
|
|
282
|
+
...this.parent?.(),
|
|
283
|
+
value: valueAttr(),
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
})
|
package/src/nodes/toc.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from '@tiptap/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Table-of-contents block (v1.1, Slite /outline): renders the document
|
|
5
|
+
* outline (H1–H4) with anchor jumps. The list itself is derived state — the
|
|
6
|
+
* node stores nothing, so it can never go stale in the canonical JSON.
|
|
7
|
+
*/
|
|
8
|
+
export const TocBlock = Node.create({
|
|
9
|
+
name: 'tocBlock',
|
|
10
|
+
group: 'block',
|
|
11
|
+
atom: true,
|
|
12
|
+
draggable: true,
|
|
13
|
+
|
|
14
|
+
parseHTML() {
|
|
15
|
+
return [{ tag: 'div[data-type="tessera-toc"]' }]
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
renderHTML({ HTMLAttributes }) {
|
|
19
|
+
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'tessera-toc' })]
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
addCommands() {
|
|
23
|
+
return {
|
|
24
|
+
insertToc:
|
|
25
|
+
() =>
|
|
26
|
+
({ commands }) =>
|
|
27
|
+
commands.insertContent({ type: this.name }),
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
declare module '@tiptap/core' {
|
|
33
|
+
interface Commands<ReturnType> {
|
|
34
|
+
tocBlock: {
|
|
35
|
+
insertToc: () => ReturnType
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|