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