@pilotiq/tiptap 3.10.4 → 3.10.6
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/CHANGELOG.md +745 -0
- package/boost/guidelines.md +268 -0
- package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
- package/dist/react/CollabTextRenderer.d.ts.map +1 -1
- package/dist/react/CollabTextRenderer.js +4 -4
- package/dist/react/CollabTextRenderer.js.map +1 -1
- package/dist/react/MarkdownEditor.d.ts.map +1 -1
- package/dist/react/MarkdownEditor.js +4 -5
- package/dist/react/MarkdownEditor.js.map +1 -1
- package/dist/react/TiptapEditor.d.ts.map +1 -1
- package/dist/react/TiptapEditor.js +8 -7
- package/dist/react/TiptapEditor.js.map +1 -1
- package/package.json +6 -3
- package/dist/collabShapes.d.ts +0 -22
- package/dist/collabShapes.d.ts.map +0 -1
- package/dist/collabShapes.js +0 -2
- package/dist/collabShapes.js.map +0 -1
- package/src/Block.ts +0 -75
- package/src/MentionProvider.ts +0 -153
- package/src/PlainTextEditor.dom.test.ts +0 -111
- package/src/PlainTextEditor.test.ts +0 -158
- package/src/PlainTextEditor.ts +0 -229
- package/src/RichTextField.test.ts +0 -447
- package/src/RichTextField.ts +0 -508
- package/src/collabShapes.ts +0 -22
- package/src/extensions/AiInlineDiffExtension.ts +0 -286
- package/src/extensions/AiSuggestionExtension.test.ts +0 -141
- package/src/extensions/AiSuggestionExtension.ts +0 -522
- package/src/extensions/BlockNodeExtension.ts +0 -134
- package/src/extensions/DragHandleExtension.ts +0 -184
- package/src/extensions/GridExtension.test.ts +0 -31
- package/src/extensions/GridExtension.ts +0 -138
- package/src/extensions/MentionExtension.ts +0 -248
- package/src/extensions/MergeTagExtension.ts +0 -75
- package/src/extensions/SlashCommandExtension.test.ts +0 -147
- package/src/extensions/SlashCommandExtension.ts +0 -332
- package/src/extensions/TextSizeMarks.ts +0 -73
- package/src/index.ts +0 -62
- package/src/markdownExtension.ts +0 -19
- package/src/markdownStorage.ts +0 -49
- package/src/plugin.test.ts +0 -19
- package/src/plugin.ts +0 -26
- package/src/react/AiSuggestionBanner.tsx +0 -185
- package/src/react/BlockNodeView.tsx +0 -99
- package/src/react/BlockSidePanel.dom.test.tsx +0 -38
- package/src/react/BlockSidePanel.test.ts +0 -412
- package/src/react/BlockSidePanel.tsx +0 -451
- package/src/react/CollabTextRenderer.tsx +0 -230
- package/src/react/FloatingToolbar.tsx +0 -304
- package/src/react/MarkdownEditor.tsx +0 -606
- package/src/react/MentionMenu.tsx +0 -120
- package/src/react/Palette.tsx +0 -86
- package/src/react/SlashMenu.tsx +0 -129
- package/src/react/TableFloatingToolbar.tsx +0 -154
- package/src/react/TiptapEditor.dom.test.tsx +0 -112
- package/src/react/TiptapEditor.tsx +0 -776
- package/src/react/Toolbar.tsx +0 -438
- package/src/react/toolbarButtons.tsx +0 -579
- package/src/react/useAiInlineDiff.ts +0 -342
- package/src/react/useAiSuggestionBridge.ts +0 -223
- package/src/register.test.ts +0 -14
- package/src/register.ts +0 -42
- package/src/render.test.ts +0 -745
- package/src/render.ts +0 -480
- package/src/surgicalOps.ts +0 -205
- package/src/test/setup.ts +0 -64
package/src/MentionProvider.ts
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Static menu item surfaced by a {@link MentionProvider}. `id` is the wire
|
|
3
|
-
* identifier that survives serialization; `label` is what the editor shows
|
|
4
|
-
* after the trigger char (`@Sleman`). `group` is purely cosmetic — the menu
|
|
5
|
-
* uses it to bucket items under headings.
|
|
6
|
-
*/
|
|
7
|
-
export interface MentionItem {
|
|
8
|
-
id: string
|
|
9
|
-
label: string
|
|
10
|
-
group?: string
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Wire-side shape of a {@link MentionProvider}. */
|
|
14
|
-
export interface MentionProviderMeta {
|
|
15
|
-
trigger: string
|
|
16
|
-
items: MentionItem[]
|
|
17
|
-
/** Set when the provider is backed by an `itemsUsing(fn)` resolver — the
|
|
18
|
-
* client fetches items from the field's `mentionsUrl` instead of using
|
|
19
|
-
* the (empty) inlined list. */
|
|
20
|
-
async?: boolean
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Context handed to `MentionProvider.itemsUsing(fn)` resolvers. Mirrors the
|
|
25
|
-
* subset of {@link RenderContext} the route handler can re-derive at request
|
|
26
|
-
* time — `user` from `Pilotiq.user(req=>…)`, `record` for edit-mode forms,
|
|
27
|
-
* and the raw `request` for adapters that need cookie / header access.
|
|
28
|
-
*/
|
|
29
|
-
export interface MentionResolverContext {
|
|
30
|
-
user?: unknown
|
|
31
|
-
record?: unknown
|
|
32
|
-
request?: unknown
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export type MentionItemsResolver = (
|
|
36
|
-
query: string,
|
|
37
|
-
ctx: MentionResolverContext,
|
|
38
|
-
) => MentionItem[] | Promise<MentionItem[]>
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Builder for a single mention provider — the trigger character (`@` /
|
|
42
|
-
* `#` / …) and the items the popover offers when the user types it.
|
|
43
|
-
*
|
|
44
|
-
* Two shapes:
|
|
45
|
-
* - `MentionProvider.make('@').items([...])` — static items declared at
|
|
46
|
-
* form-build time, inlined into the field meta.
|
|
47
|
-
* - `MentionProvider.make('@').itemsUsing(async (query, ctx) => […])` —
|
|
48
|
-
* async resolver. The client fetches every keystroke; the server runs
|
|
49
|
-
* the user fn and returns the matched items. `items` and `itemsUsing`
|
|
50
|
-
* are mutually exclusive — last call wins, with a warning when both
|
|
51
|
-
* have been set.
|
|
52
|
-
*
|
|
53
|
-
* Read-time label resolution still works through `renderRichTextToHtml(
|
|
54
|
-
* content, { resolveMention })` for cases where the cached label has gone
|
|
55
|
-
* stale.
|
|
56
|
-
*
|
|
57
|
-
* @example Static items
|
|
58
|
-
* ```ts
|
|
59
|
-
* MentionProvider.make('@').items([
|
|
60
|
-
* { id: 'sleman', label: 'Sleman' },
|
|
61
|
-
* { id: 'alex', label: 'Alex' },
|
|
62
|
-
* ])
|
|
63
|
-
* ```
|
|
64
|
-
*
|
|
65
|
-
* @example Async items
|
|
66
|
-
* ```ts
|
|
67
|
-
* MentionProvider.make('@').itemsUsing(async (query) => {
|
|
68
|
-
* const users = await db.users.search(query, { limit: 10 })
|
|
69
|
-
* return users.map(u => ({ id: u.id, label: u.name }))
|
|
70
|
-
* })
|
|
71
|
-
* ```
|
|
72
|
-
*/
|
|
73
|
-
export class MentionProvider {
|
|
74
|
-
private _trigger: string
|
|
75
|
-
private _items: MentionItem[] = []
|
|
76
|
-
private _itemsUsing?: MentionItemsResolver
|
|
77
|
-
|
|
78
|
-
protected constructor(trigger: string) {
|
|
79
|
-
if (typeof trigger !== 'string' || trigger.length !== 1) {
|
|
80
|
-
throw new Error(`MentionProvider trigger must be a single character (got ${JSON.stringify(trigger)})`)
|
|
81
|
-
}
|
|
82
|
-
this._trigger = trigger
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
static make(trigger: string): MentionProvider {
|
|
86
|
-
return new MentionProvider(trigger)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Replace the static item list. Mutually exclusive with `itemsUsing()`. */
|
|
90
|
-
items(items: MentionItem[]): this {
|
|
91
|
-
if (this._itemsUsing !== undefined) {
|
|
92
|
-
console.warn(
|
|
93
|
-
`[pilotiq/tiptap] MentionProvider('${this._trigger}'): items() called after ` +
|
|
94
|
-
`itemsUsing(). The static list now wins; clear itemsUsing first to avoid surprise.`,
|
|
95
|
-
)
|
|
96
|
-
delete this._itemsUsing
|
|
97
|
-
}
|
|
98
|
-
this._items = items
|
|
99
|
-
return this
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Install an async resolver. Called every keystroke with the current
|
|
104
|
-
* query (the text after the trigger char) and a `MentionResolverContext`.
|
|
105
|
-
*
|
|
106
|
-
* Mutually exclusive with `items()` — the last call wins; a warning fires
|
|
107
|
-
* when the previously-set static items are dropped silently.
|
|
108
|
-
*/
|
|
109
|
-
itemsUsing(fn: MentionItemsResolver): this {
|
|
110
|
-
if (this._items.length > 0) {
|
|
111
|
-
console.warn(
|
|
112
|
-
`[pilotiq/tiptap] MentionProvider('${this._trigger}'): itemsUsing() called after ` +
|
|
113
|
-
`items(). The async resolver now wins; the static items array will be ignored.`,
|
|
114
|
-
)
|
|
115
|
-
this._items = []
|
|
116
|
-
}
|
|
117
|
-
this._itemsUsing = fn
|
|
118
|
-
return this
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
getTrigger(): string { return this._trigger }
|
|
122
|
-
getItems(): readonly MentionItem[] { return this._items }
|
|
123
|
-
isAsync(): boolean { return this._itemsUsing !== undefined }
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Run the resolver — `itemsUsing(fn)` when set, otherwise the cached
|
|
127
|
-
* static list. Wraps non-array returns in `[]` so a misbehaving
|
|
128
|
-
* resolver doesn't crash the route handler.
|
|
129
|
-
*
|
|
130
|
-
* Synchronous resolvers are awaited the same way as async ones; the
|
|
131
|
-
* single code path keeps the call-site cheap.
|
|
132
|
-
*/
|
|
133
|
-
async runResolver(query: string, ctx: MentionResolverContext): Promise<MentionItem[]> {
|
|
134
|
-
if (this._itemsUsing === undefined) {
|
|
135
|
-
// Static path mirrors the client-side filter so the server endpoint
|
|
136
|
-
// would be useful for static providers too — but the client never
|
|
137
|
-
// calls it for them (no `async: true` flag → no fetch).
|
|
138
|
-
return [...this._items]
|
|
139
|
-
}
|
|
140
|
-
const result = await this._itemsUsing(query, ctx)
|
|
141
|
-
return Array.isArray(result) ? result : []
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** @internal */
|
|
145
|
-
toMeta(): MentionProviderMeta {
|
|
146
|
-
if (this._itemsUsing !== undefined) {
|
|
147
|
-
// Async providers ship empty `items`; the client checks `async: true`
|
|
148
|
-
// and fetches from the field's `mentionsUrl` per-keystroke instead.
|
|
149
|
-
return { trigger: this._trigger, items: [], async: true }
|
|
150
|
-
}
|
|
151
|
-
return { trigger: this._trigger, items: [...this._items] }
|
|
152
|
-
}
|
|
153
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { Editor } from '@tiptap/core'
|
|
4
|
-
|
|
5
|
-
import { createPlainTextEditor, plainTextOf } from './PlainTextEditor.js'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Phase 6e proof-of-concept — mount a real Tiptap `Editor` against the
|
|
9
|
-
* jsdom DOM that `src/test/setup.ts` boots, exercise typing through the
|
|
10
|
-
* ProseMirror transaction API, and assert via the public surface.
|
|
11
|
-
*
|
|
12
|
-
* The pure-config tests in `PlainTextEditor.test.ts` cover schema-shape
|
|
13
|
-
* fixtures (`plainTextToDoc`); this file proves the config actually
|
|
14
|
-
* behaves correctly when mounted into the editor's lifecycle —
|
|
15
|
-
* `setContent` → `getJSON` → `plainTextOf` round-trip, multi-line
|
|
16
|
-
* separator handling, document constraint enforcement. Component-level
|
|
17
|
-
* coverage that previously lived only in the playground now has a
|
|
18
|
-
* fast in-process test.
|
|
19
|
-
*/
|
|
20
|
-
describe('createPlainTextEditor (DOM)', () => {
|
|
21
|
-
function mountInDom(multiline: boolean): { editor: Editor; el: HTMLDivElement } {
|
|
22
|
-
const el = document.createElement('div')
|
|
23
|
-
document.body.appendChild(el)
|
|
24
|
-
const editor = new Editor({
|
|
25
|
-
...createPlainTextEditor({ multiline, content: '' }),
|
|
26
|
-
element: el,
|
|
27
|
-
})
|
|
28
|
-
return { editor, el }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
it('single-line: setContent + plainTextOf round-trips one paragraph', () => {
|
|
32
|
-
const { editor } = mountInDom(false)
|
|
33
|
-
try {
|
|
34
|
-
editor.commands.setContent('hello world')
|
|
35
|
-
assert.equal(plainTextOf(editor), 'hello world')
|
|
36
|
-
} finally {
|
|
37
|
-
editor.destroy()
|
|
38
|
-
}
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('single-line: schema rejects multi-paragraph content (collapses to one)', () => {
|
|
42
|
-
const { editor } = mountInDom(false)
|
|
43
|
-
try {
|
|
44
|
-
// ProseMirror enforces the doc schema; PlainTextDocument's content
|
|
45
|
-
// expression is `paragraph` (a SINGLE paragraph) in single-line
|
|
46
|
-
// mode. Passing a multi-paragraph JSON tree should be coerced to
|
|
47
|
-
// one paragraph by Tiptap's repair pass rather than throwing.
|
|
48
|
-
editor.commands.setContent({
|
|
49
|
-
type: 'doc',
|
|
50
|
-
content: [
|
|
51
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'line one' }] },
|
|
52
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'line two' }] },
|
|
53
|
-
],
|
|
54
|
-
})
|
|
55
|
-
// The exact repair behavior is "keep what fits, drop the rest" —
|
|
56
|
-
// we don't assert the specific outcome (`line one` only vs the
|
|
57
|
-
// two joined), only that the result is single-paragraph and
|
|
58
|
-
// non-empty. Future Tiptap version bumps that change the repair
|
|
59
|
-
// strategy won't break this test.
|
|
60
|
-
const json = editor.getJSON()
|
|
61
|
-
assert.equal(json.content?.length, 1, 'single paragraph after repair')
|
|
62
|
-
assert.ok(plainTextOf(editor).length > 0, 'non-empty after repair')
|
|
63
|
-
assert.ok(!plainTextOf(editor).includes('\n'), 'no newline in single-line mode')
|
|
64
|
-
} finally {
|
|
65
|
-
editor.destroy()
|
|
66
|
-
}
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('multi-line: setContent across paragraphs serializes with \\n separators', () => {
|
|
70
|
-
const { editor } = mountInDom(true)
|
|
71
|
-
try {
|
|
72
|
-
editor.commands.setContent({
|
|
73
|
-
type: 'doc',
|
|
74
|
-
content: [
|
|
75
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'first' }] },
|
|
76
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'second' }] },
|
|
77
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'third' }] },
|
|
78
|
-
],
|
|
79
|
-
})
|
|
80
|
-
assert.equal(plainTextOf(editor), 'first\nsecond\nthird')
|
|
81
|
-
} finally {
|
|
82
|
-
editor.destroy()
|
|
83
|
-
}
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it('multi-line: empty document returns empty string', () => {
|
|
87
|
-
const { editor } = mountInDom(true)
|
|
88
|
-
try {
|
|
89
|
-
assert.equal(plainTextOf(editor), '')
|
|
90
|
-
} finally {
|
|
91
|
-
editor.destroy()
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('typing via commands.insertContent updates the visible DOM', () => {
|
|
96
|
-
const { editor, el } = mountInDom(false)
|
|
97
|
-
try {
|
|
98
|
-
editor.commands.focus()
|
|
99
|
-
editor.commands.insertContent('typed via command')
|
|
100
|
-
assert.equal(plainTextOf(editor), 'typed via command')
|
|
101
|
-
// The ProseMirror view renders `.ProseMirror` inside the mount
|
|
102
|
-
// element; verify the text is in the visible DOM too (this is
|
|
103
|
-
// the assertion that pure-data fixtures can't make).
|
|
104
|
-
const pm = el.querySelector('.ProseMirror')
|
|
105
|
-
assert.ok(pm, 'ProseMirror view mounted')
|
|
106
|
-
assert.ok(pm!.textContent?.includes('typed via command'), 'text rendered in DOM')
|
|
107
|
-
} finally {
|
|
108
|
-
editor.destroy()
|
|
109
|
-
}
|
|
110
|
-
})
|
|
111
|
-
})
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
createPlainTextEditor,
|
|
6
|
-
plainTextToDoc,
|
|
7
|
-
type PlainTextEditorOptions,
|
|
8
|
-
} from './PlainTextEditor.js'
|
|
9
|
-
|
|
10
|
-
describe('plainTextToDoc — single-line', () => {
|
|
11
|
-
it('empty string yields one empty paragraph', () => {
|
|
12
|
-
assert.deepEqual(plainTextToDoc('', false), {
|
|
13
|
-
type: 'doc',
|
|
14
|
-
content: [{ type: 'paragraph' }],
|
|
15
|
-
})
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('wraps a single run of text in one paragraph', () => {
|
|
19
|
-
assert.deepEqual(plainTextToDoc('hello', false), {
|
|
20
|
-
type: 'doc',
|
|
21
|
-
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
|
|
22
|
-
})
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('strips embedded newlines (LF and CRLF) — single-line schema permits one paragraph only', () => {
|
|
26
|
-
assert.deepEqual(plainTextToDoc('a\nb', false), {
|
|
27
|
-
type: 'doc',
|
|
28
|
-
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'ab' }] }],
|
|
29
|
-
})
|
|
30
|
-
assert.deepEqual(plainTextToDoc('a\r\nb', false), {
|
|
31
|
-
type: 'doc',
|
|
32
|
-
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'ab' }] }],
|
|
33
|
-
})
|
|
34
|
-
})
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
describe('plainTextToDoc — multi-line', () => {
|
|
38
|
-
it('empty string yields one empty paragraph', () => {
|
|
39
|
-
assert.deepEqual(plainTextToDoc('', true), {
|
|
40
|
-
type: 'doc',
|
|
41
|
-
content: [{ type: 'paragraph' }],
|
|
42
|
-
})
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('splits LF-separated lines into separate paragraphs', () => {
|
|
46
|
-
assert.deepEqual(plainTextToDoc('a\nb', true), {
|
|
47
|
-
type: 'doc',
|
|
48
|
-
content: [
|
|
49
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
|
|
50
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] },
|
|
51
|
-
],
|
|
52
|
-
})
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('preserves empty lines as empty paragraphs', () => {
|
|
56
|
-
assert.deepEqual(plainTextToDoc('a\n\nb', true), {
|
|
57
|
-
type: 'doc',
|
|
58
|
-
content: [
|
|
59
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
|
|
60
|
-
{ type: 'paragraph' },
|
|
61
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] },
|
|
62
|
-
],
|
|
63
|
-
})
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('normalises CRLF to single paragraph splits', () => {
|
|
67
|
-
assert.deepEqual(plainTextToDoc('a\r\nb', true), {
|
|
68
|
-
type: 'doc',
|
|
69
|
-
content: [
|
|
70
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
|
|
71
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] },
|
|
72
|
-
],
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
describe('createPlainTextEditor — config shape', () => {
|
|
78
|
-
function names(extensions: ReadonlyArray<{ name: string }>): string[] {
|
|
79
|
-
return extensions.map((e) => e.name)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
it('default config: single-line, editable, schema + single-line keymap only', () => {
|
|
83
|
-
const cfg = createPlainTextEditor()
|
|
84
|
-
assert.equal(cfg.editable, true)
|
|
85
|
-
assert.equal(cfg.content, '')
|
|
86
|
-
const exts = (cfg.extensions ?? []) as Array<{ name: string }>
|
|
87
|
-
assert.deepEqual(names(exts), ['doc', 'paragraph', 'text', 'plainTextSingleLineKeymap'])
|
|
88
|
-
assert.equal(cfg.editorProps, undefined)
|
|
89
|
-
assert.equal(cfg.onUpdate, undefined)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('multiline mode drops the single-line keymap', () => {
|
|
93
|
-
const cfg = createPlainTextEditor({ multiline: true })
|
|
94
|
-
const exts = (cfg.extensions ?? []) as Array<{ name: string }>
|
|
95
|
-
assert.deepEqual(names(exts), ['doc', 'paragraph', 'text'])
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
it('placeholder appends the Placeholder extension', () => {
|
|
99
|
-
const cfg = createPlainTextEditor({ placeholder: 'Type here…' })
|
|
100
|
-
const exts = (cfg.extensions ?? []) as Array<{ name: string }>
|
|
101
|
-
assert.ok(exts.some((e) => e.name === 'placeholder'),
|
|
102
|
-
`expected placeholder extension, got ${names(exts).join(',')}`)
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('caller-supplied extensions land after schema + behavior', () => {
|
|
106
|
-
const fakeExt = { name: 'fake-collab' } as unknown as Parameters<typeof createPlainTextEditor>[0] extends infer T
|
|
107
|
-
? T extends { extensions?: Array<infer E> } ? E : never : never
|
|
108
|
-
const cfg = createPlainTextEditor({ extensions: [fakeExt] })
|
|
109
|
-
const exts = (cfg.extensions ?? []) as Array<{ name: string }>
|
|
110
|
-
assert.equal(exts[exts.length - 1]?.name, 'fake-collab')
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
it('editable can be turned off', () => {
|
|
114
|
-
const cfg = createPlainTextEditor({ editable: false })
|
|
115
|
-
assert.equal(cfg.editable, false)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('seeds content as a doc JSON when text provided (single-line)', () => {
|
|
119
|
-
const cfg = createPlainTextEditor({ content: 'hello' })
|
|
120
|
-
assert.deepEqual(cfg.content, {
|
|
121
|
-
type: 'doc',
|
|
122
|
-
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
|
|
123
|
-
})
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('seeds content as multi-paragraph doc JSON when multiline + text provided', () => {
|
|
127
|
-
const cfg = createPlainTextEditor({ multiline: true, content: 'a\nb' })
|
|
128
|
-
assert.deepEqual(cfg.content, {
|
|
129
|
-
type: 'doc',
|
|
130
|
-
content: [
|
|
131
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
|
|
132
|
-
{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] },
|
|
133
|
-
],
|
|
134
|
-
})
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('empty content stays an empty string sentinel — Collaboration-friendly', () => {
|
|
138
|
-
// When collab is on, callers pass content omitted/'' so Collaboration's
|
|
139
|
-
// y-prosemirror binding takes ownership of the doc without a seed race.
|
|
140
|
-
const cfg = createPlainTextEditor({ content: '' })
|
|
141
|
-
assert.equal(cfg.content, '')
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
it('editorAttributes plumb into editorProps.attributes verbatim', () => {
|
|
145
|
-
const attrs = { class: 'foo bar', 'aria-label': 'Name' }
|
|
146
|
-
const cfg = createPlainTextEditor({ editorAttributes: attrs })
|
|
147
|
-
assert.deepEqual(cfg.editorProps, { attributes: attrs })
|
|
148
|
-
})
|
|
149
|
-
|
|
150
|
-
it('onUpdate is wired only when caller provides it', () => {
|
|
151
|
-
const withCb: PlainTextEditorOptions = { onUpdate: () => {} }
|
|
152
|
-
const cfgA = createPlainTextEditor(withCb)
|
|
153
|
-
assert.equal(typeof cfgA.onUpdate, 'function')
|
|
154
|
-
|
|
155
|
-
const cfgB = createPlainTextEditor()
|
|
156
|
-
assert.equal(cfgB.onUpdate, undefined)
|
|
157
|
-
})
|
|
158
|
-
})
|
package/src/PlainTextEditor.ts
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plain-text editor factory — Tiptap editor config tuned to behave like a
|
|
3
|
-
* native `<input>` (single-line) or `<textarea>` (multi-line), with no marks
|
|
4
|
-
* and a Document schema restricted to paragraph(s) of inline text.
|
|
5
|
-
*
|
|
6
|
-
* Built for `@pilotiq/pilotiq`'s collab-text-field path: when collab is on,
|
|
7
|
-
* the renderer mounts a Tiptap editor instead of a native input so y-prosemirror
|
|
8
|
-
* can anchor selections to Yjs `RelativePosition` items (positional identity).
|
|
9
|
-
* This avoids the cursor-jump + concurrent-insert races inherent to the
|
|
10
|
-
* `Y.Text` + manual `computeDelta` + heuristic `preserveCursor` path.
|
|
11
|
-
*
|
|
12
|
-
* Pure config — no React. Caller passes the returned object to `useEditor` or
|
|
13
|
-
* `new Editor(...)`. Caller is also responsible for passing in the collab
|
|
14
|
-
* extension list (typically `Collaboration` + `CollaborationCursor` from the
|
|
15
|
-
* pilotiq collab adapter); we never import `@tiptap/extension-collaboration`
|
|
16
|
-
* directly so the open-core package stays free of collab peer deps.
|
|
17
|
-
*/
|
|
18
|
-
import {
|
|
19
|
-
Node,
|
|
20
|
-
Extension,
|
|
21
|
-
mergeAttributes,
|
|
22
|
-
type AnyExtension,
|
|
23
|
-
type EditorOptions,
|
|
24
|
-
type Editor,
|
|
25
|
-
} from '@tiptap/core'
|
|
26
|
-
import Placeholder from '@tiptap/extension-placeholder'
|
|
27
|
-
|
|
28
|
-
/** Block separator used by `getText` — newline matches `<textarea>.value`. */
|
|
29
|
-
const BLOCK_SEPARATOR = '\n'
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Bare paragraph block — the only block the plain-text schema permits.
|
|
33
|
-
* No options, no input rules, no toggles. `inline*` content lets any inline
|
|
34
|
-
* node (today just `text`) appear inside.
|
|
35
|
-
*/
|
|
36
|
-
const PlainTextParagraph = Node.create({
|
|
37
|
-
name: 'paragraph',
|
|
38
|
-
group: 'block',
|
|
39
|
-
content: 'inline*',
|
|
40
|
-
priority: 1000,
|
|
41
|
-
parseHTML() {
|
|
42
|
-
return [{ tag: 'p' }]
|
|
43
|
-
},
|
|
44
|
-
renderHTML({ HTMLAttributes }) {
|
|
45
|
-
return ['p', mergeAttributes(HTMLAttributes), 0]
|
|
46
|
-
},
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
/** The text node — Tiptap requires this to be defined explicitly. */
|
|
50
|
-
const PlainTextText = Node.create({
|
|
51
|
-
name: 'text',
|
|
52
|
-
group: 'inline',
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Build a Document node with content restricted to either a single paragraph
|
|
57
|
-
* (single-line mode) or one-or-more paragraphs (multi-line mode). The schema
|
|
58
|
-
* itself blocks paste of incompatible content — ProseMirror will coerce or
|
|
59
|
-
* reject non-matching nodes at parse time.
|
|
60
|
-
*/
|
|
61
|
-
function makePlainTextDocument(multiline: boolean) {
|
|
62
|
-
return Node.create({
|
|
63
|
-
name: 'doc',
|
|
64
|
-
topNode: true,
|
|
65
|
-
content: multiline ? 'paragraph+' : 'paragraph',
|
|
66
|
-
})
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Single-line Enter handler. Tiptap's default `Enter` keymap splits the
|
|
71
|
-
* paragraph — meaningless when the schema only allows exactly one — so we
|
|
72
|
-
* intercept and either delegate to `onSubmit` (caller-supplied) or blur.
|
|
73
|
-
*
|
|
74
|
-
* Filament's plain-text fields blur on Enter; matching that default.
|
|
75
|
-
*/
|
|
76
|
-
function makeSingleLineKeymap(onSubmit: ((editor: Editor) => boolean | void) | undefined) {
|
|
77
|
-
return Extension.create({
|
|
78
|
-
name: 'plainTextSingleLineKeymap',
|
|
79
|
-
addKeyboardShortcuts() {
|
|
80
|
-
const handleEnter = (): boolean => {
|
|
81
|
-
const handled = onSubmit?.(this.editor)
|
|
82
|
-
if (handled === true) return true
|
|
83
|
-
this.editor.commands.blur()
|
|
84
|
-
return true
|
|
85
|
-
}
|
|
86
|
-
return {
|
|
87
|
-
Enter: handleEnter,
|
|
88
|
-
'Mod-Enter': () => true,
|
|
89
|
-
'Shift-Enter': () => true,
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
})
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface PlainTextEditorOptions {
|
|
96
|
-
/** If true, allow multiple paragraphs (textarea-like). Default `false` (input-like). */
|
|
97
|
-
multiline?: boolean
|
|
98
|
-
/** Placeholder text shown while the editor is empty. */
|
|
99
|
-
placeholder?: string
|
|
100
|
-
/** Editable / disabled state. Default `true`. */
|
|
101
|
-
editable?: boolean
|
|
102
|
-
/**
|
|
103
|
-
* Initial textual content. Ignored when a Collaboration extension is passed
|
|
104
|
-
* via `extensions` — Collaboration takes ownership of the document and seeds
|
|
105
|
-
* from the Yjs fragment instead. Use the caller's own first-load seed (see
|
|
106
|
-
* `@pilotiq/tiptap` README) when collab is on.
|
|
107
|
-
*/
|
|
108
|
-
content?: string
|
|
109
|
-
/**
|
|
110
|
-
* Extra extensions to merge into the editor — typically the Collaboration +
|
|
111
|
-
* CollaborationCursor pair from the pilotiq collab adapter. Pass `[]` (or
|
|
112
|
-
* omit) for the non-collab path.
|
|
113
|
-
*/
|
|
114
|
-
extensions?: AnyExtension[]
|
|
115
|
-
/**
|
|
116
|
-
* Called on every editor update with the editor's plain-text value (blocks
|
|
117
|
-
* joined by `'\n'`). Use this to mirror the value into form-state for
|
|
118
|
-
* submission via a hidden `<input>`.
|
|
119
|
-
*/
|
|
120
|
-
onUpdate?: (text: string, editor: Editor) => void
|
|
121
|
-
/**
|
|
122
|
-
* Single-line Enter handler. Return `true` to suppress the default blur
|
|
123
|
-
* behavior. When omitted, Enter simply blurs the editor.
|
|
124
|
-
*/
|
|
125
|
-
onSubmit?: (editor: Editor) => boolean | void
|
|
126
|
-
/**
|
|
127
|
-
* DOM attributes for the editor's contenteditable wrapper — typically
|
|
128
|
-
* `{ class: '…tailwind classes…' }` to style the editor like the native
|
|
129
|
-
* `<input>` / `<textarea>` it replaces.
|
|
130
|
-
*/
|
|
131
|
-
editorAttributes?: Record<string, string>
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Read the editor's current value as plain text, with paragraphs joined by
|
|
136
|
-
* `'\n'`. Mirrors the behavior of `<textarea>.value`.
|
|
137
|
-
*/
|
|
138
|
-
export function plainTextOf(editor: Editor): string {
|
|
139
|
-
return editor.getText({ blockSeparator: BLOCK_SEPARATOR })
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Convert a plain-text string into a Tiptap JSON doc that satisfies the
|
|
144
|
-
* plain-text schema. Multi-line input splits on `'\n'` into separate
|
|
145
|
-
* paragraphs; single-line strips any embedded newlines into a single run.
|
|
146
|
-
* Exported for tests — pure, no editor instance required.
|
|
147
|
-
*/
|
|
148
|
-
export function plainTextToDoc(text: string, multiline: boolean): {
|
|
149
|
-
type: 'doc'
|
|
150
|
-
content: Array<{ type: 'paragraph'; content?: Array<{ type: 'text'; text: string }> }>
|
|
151
|
-
} {
|
|
152
|
-
if (!multiline) {
|
|
153
|
-
const flat = text.replace(/\r?\n/g, '')
|
|
154
|
-
return {
|
|
155
|
-
type: 'doc',
|
|
156
|
-
content: [
|
|
157
|
-
flat ? { type: 'paragraph', content: [{ type: 'text', text: flat }] }
|
|
158
|
-
: { type: 'paragraph' },
|
|
159
|
-
],
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
const lines = text.split(/\r?\n/)
|
|
163
|
-
return {
|
|
164
|
-
type: 'doc',
|
|
165
|
-
content: lines.map((line) =>
|
|
166
|
-
line ? { type: 'paragraph', content: [{ type: 'text', text: line }] }
|
|
167
|
-
: { type: 'paragraph' },
|
|
168
|
-
),
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Build the Tiptap editor config for a plain-text field. Pass the returned
|
|
174
|
-
* object to `useEditor` (React) or `new Editor(...)` (vanilla).
|
|
175
|
-
*
|
|
176
|
-
* The editor schema is deliberately minimal:
|
|
177
|
-
* - `doc` → `paragraph` (single-line) or `paragraph+` (multi-line)
|
|
178
|
-
* - `paragraph` → `inline*`
|
|
179
|
-
* - `text` (inline)
|
|
180
|
-
*
|
|
181
|
-
* No marks, no input rules, no list items, no code blocks — just text. Pasted
|
|
182
|
-
* rich content is stripped to plain text by ProseMirror's schema enforcement.
|
|
183
|
-
*/
|
|
184
|
-
export function createPlainTextEditor(
|
|
185
|
-
options: PlainTextEditorOptions = {},
|
|
186
|
-
): Partial<EditorOptions> {
|
|
187
|
-
const {
|
|
188
|
-
multiline = false,
|
|
189
|
-
placeholder,
|
|
190
|
-
editable = true,
|
|
191
|
-
content = '',
|
|
192
|
-
extensions = [],
|
|
193
|
-
onUpdate,
|
|
194
|
-
onSubmit,
|
|
195
|
-
editorAttributes,
|
|
196
|
-
} = options
|
|
197
|
-
|
|
198
|
-
const schema: AnyExtension[] = [
|
|
199
|
-
makePlainTextDocument(multiline),
|
|
200
|
-
PlainTextParagraph,
|
|
201
|
-
PlainTextText,
|
|
202
|
-
]
|
|
203
|
-
|
|
204
|
-
const behavior: AnyExtension[] = []
|
|
205
|
-
if (!multiline) behavior.push(makeSingleLineKeymap(onSubmit))
|
|
206
|
-
if (placeholder !== undefined) {
|
|
207
|
-
behavior.push(Placeholder.configure({ placeholder }))
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const allExtensions: AnyExtension[] = [...schema, ...behavior, ...extensions]
|
|
211
|
-
|
|
212
|
-
// When Collaboration owns the doc, an explicit `content` would race the
|
|
213
|
-
// Yjs sync. Caller is responsible for omitting `content` in that case; we
|
|
214
|
-
// pass it through verbatim either way.
|
|
215
|
-
const initialContent = content ? plainTextToDoc(content, multiline) : ''
|
|
216
|
-
|
|
217
|
-
const config: Partial<EditorOptions> = {
|
|
218
|
-
editable,
|
|
219
|
-
extensions: allExtensions,
|
|
220
|
-
content: initialContent,
|
|
221
|
-
}
|
|
222
|
-
if (onUpdate) {
|
|
223
|
-
config.onUpdate = ({ editor }) => onUpdate(plainTextOf(editor), editor)
|
|
224
|
-
}
|
|
225
|
-
if (editorAttributes) {
|
|
226
|
-
config.editorProps = { attributes: editorAttributes }
|
|
227
|
-
}
|
|
228
|
-
return config
|
|
229
|
-
}
|