@pilotiq/tiptap 3.10.5 → 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.
Files changed (55) hide show
  1. package/CHANGELOG.md +745 -0
  2. package/boost/guidelines.md +268 -0
  3. package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
  4. package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
  5. package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
  6. package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
  7. package/package.json +4 -3
  8. package/src/Block.ts +0 -75
  9. package/src/MentionProvider.ts +0 -153
  10. package/src/PlainTextEditor.dom.test.ts +0 -111
  11. package/src/PlainTextEditor.test.ts +0 -158
  12. package/src/PlainTextEditor.ts +0 -229
  13. package/src/RichTextField.test.ts +0 -447
  14. package/src/RichTextField.ts +0 -508
  15. package/src/extensions/AiInlineDiffExtension.ts +0 -286
  16. package/src/extensions/AiSuggestionExtension.test.ts +0 -141
  17. package/src/extensions/AiSuggestionExtension.ts +0 -522
  18. package/src/extensions/BlockNodeExtension.ts +0 -134
  19. package/src/extensions/DragHandleExtension.ts +0 -184
  20. package/src/extensions/GridExtension.test.ts +0 -31
  21. package/src/extensions/GridExtension.ts +0 -138
  22. package/src/extensions/MentionExtension.ts +0 -248
  23. package/src/extensions/MergeTagExtension.ts +0 -75
  24. package/src/extensions/SlashCommandExtension.test.ts +0 -147
  25. package/src/extensions/SlashCommandExtension.ts +0 -332
  26. package/src/extensions/TextSizeMarks.ts +0 -73
  27. package/src/index.ts +0 -62
  28. package/src/markdownExtension.ts +0 -19
  29. package/src/markdownStorage.ts +0 -49
  30. package/src/plugin.test.ts +0 -19
  31. package/src/plugin.ts +0 -26
  32. package/src/react/AiSuggestionBanner.tsx +0 -185
  33. package/src/react/BlockNodeView.tsx +0 -99
  34. package/src/react/BlockSidePanel.dom.test.tsx +0 -38
  35. package/src/react/BlockSidePanel.test.ts +0 -412
  36. package/src/react/BlockSidePanel.tsx +0 -451
  37. package/src/react/CollabTextRenderer.tsx +0 -228
  38. package/src/react/FloatingToolbar.tsx +0 -304
  39. package/src/react/MarkdownEditor.tsx +0 -603
  40. package/src/react/MentionMenu.tsx +0 -120
  41. package/src/react/Palette.tsx +0 -86
  42. package/src/react/SlashMenu.tsx +0 -129
  43. package/src/react/TableFloatingToolbar.tsx +0 -154
  44. package/src/react/TiptapEditor.dom.test.tsx +0 -112
  45. package/src/react/TiptapEditor.tsx +0 -777
  46. package/src/react/Toolbar.tsx +0 -438
  47. package/src/react/toolbarButtons.tsx +0 -579
  48. package/src/react/useAiInlineDiff.ts +0 -342
  49. package/src/react/useAiSuggestionBridge.ts +0 -223
  50. package/src/register.test.ts +0 -14
  51. package/src/register.ts +0 -42
  52. package/src/render.test.ts +0 -745
  53. package/src/render.ts +0 -480
  54. package/src/surgicalOps.ts +0 -205
  55. package/src/test/setup.ts +0 -64
@@ -0,0 +1,161 @@
1
+ # Toolbar & Extensibility
2
+
3
+ Three things in scope here: customizing the always-on toolbar, opting into non-default block primitives (`details`, `grid`, `lead`, `small`), and the pitfalls that bite when you extend the editor beyond the defaults.
4
+
5
+ ## Default toolbar layout
6
+
7
+ ```ts
8
+ [
9
+ ['bold', 'italic', 'underline', 'strike', 'subscript', 'superscript', 'link'],
10
+ ['h2', 'h3'],
11
+ ['alignStart', 'alignCenter', 'alignEnd'],
12
+ ['blockquote', 'codeBlock', 'bulletList', 'orderedList'],
13
+ ['undo', 'redo'],
14
+ ]
15
+ ```
16
+
17
+ Each inner array is a visually-separated group in the rendered toolbar. The selection-anchored floating toolbar (separate surface) shows a smaller subset on text selection — by default `bold / italic / link / clearFormatting`.
18
+
19
+ ## Three customization styles
20
+
21
+ Use whichever matches your intent:
22
+
23
+ ### Replace the whole layout
24
+
25
+ ```ts
26
+ RichTextField.make('body').toolbarButtons([
27
+ ['bold', 'italic', 'underline', 'strike', 'link'],
28
+ ['h2', 'h3'],
29
+ ['textColor', 'highlight'],
30
+ ['bulletList', 'orderedList'],
31
+ ['attachFiles', 'table', 'details', 'grid', 'gridDelete'],
32
+ ['undo', 'redo'],
33
+ ])
34
+ ```
35
+
36
+ ### Augment the defaults
37
+
38
+ ```ts
39
+ RichTextField.make('body')
40
+ .enableToolbarButtons(['lead', 'small']) // append to last group
41
+ .disableToolbarButtons(['table']) // drop from every group
42
+ ```
43
+
44
+ `enableToolbarButtons` always appends to the last group; reach for `toolbarButtons` if you need ids in a specific group.
45
+
46
+ ### Hide chrome entirely
47
+
48
+ ```ts
49
+ RichTextField.make('body')
50
+ .toolbar(false) // hide the always-on top toolbar
51
+ .floatingToolbar(false) // disable the selection-anchored quick toolbar
52
+ .slashCommand(false) // disable the slash menu
53
+ ```
54
+
55
+ All three are independent. A minimal-distraction config: `.toolbar(false).floatingToolbar(false)` but keep the slash menu so the user still has a way to insert blocks.
56
+
57
+ ## Recognized button ids
58
+
59
+ ```
60
+ Inline marks bold italic underline strike subscript superscript code lead small
61
+ Headings paragraph h1 h2 h3 h4 h5 h6
62
+ Alignment alignStart alignCenter alignEnd alignJustify
63
+ Block prims blockquote codeBlock bulletList orderedList horizontalRule
64
+ Style textColor highlight clearFormatting
65
+ Files attachFiles
66
+ Tables table tableAddColumnBefore tableAddColumnAfter tableDeleteColumn
67
+ tableAddRowBefore tableAddRowAfter tableDeleteRow
68
+ tableMergeCells tableSplitCell tableDelete
69
+ tableToggleHeaderRow tableToggleHeaderCell
70
+ Disclosure details
71
+ Layout grid gridDelete
72
+ Editing link undo redo
73
+ ```
74
+
75
+ **Unknown ids are silently dropped.** The union is forward-compatible — adding a new id later won't break existing field configs that referenced a typo or pre-release id.
76
+
77
+ ## Opt-in primitives (not in default toolbar)
78
+
79
+ These nodes ship but aren't surfaced unless you explicitly add their toolbar id (or they appear in the slash menu via the same mechanism). The renderer (`renderRichTextToHtml`) serializes them whether or not the toolbar exposes them — so a document with `details` content created elsewhere still round-trips correctly even if the local field config doesn't include the button.
80
+
81
+ ### `lead` / `small` size marks
82
+
83
+ Two inline marks for paragraph-style size variants:
84
+
85
+ - **`lead`** renders as `<span class="lead">…</span>`. Consumer owns the `.lead` CSS — there's no built-in stylesheet (`@pilotiq/pilotiq`'s typography preset typically has one; check yours).
86
+ - **`small`** renders as semantic `<small>…</small>`.
87
+
88
+ Surfaced as toolbar button ids `'lead'` / `'small'` and slash-menu entries under the **Style** group. Mirror the editor output in your own server-side render (`renderRichTextToHtml` does this correctly).
89
+
90
+ ### `details` (collapsible disclosure)
91
+
92
+ A node trio (`details` / `detailsSummary` / `detailsContent`) wired from `@tiptap/extension-details@3.22.4` — v3 consolidated the three classes into a single peer dep (the `-summary` / `-content` child packages don't exist on the v3 line).
93
+
94
+ - Toolbar id: `'details'` (opt-in)
95
+ - Slash entry under the **Insert** group
96
+ - Render emits standard `<details><summary>…</summary>…</details>` HTML
97
+ - Open / closed state round-trips via the node's `open: boolean` attr; the renderer adds the platform `open` attribute when `attrs.open === true`
98
+
99
+ ### `grid` / `gridDelete` (2-column / 3-column layout)
100
+
101
+ A node pair (`grid` + `gridColumn`) defined inline under `extensions/GridExtension.ts` — Tiptap doesn't ship a first-party grid extension. Schema constrains `grid` to `gridColumn{2,3}` so the user can't construct a 1-col or 4+-col grid through any path (toolbar / slash / paste).
102
+
103
+ - Toolbar ids: `'grid'` (insert; defaults to 2 columns when clicked) + `'gridDelete'` (unwrap the enclosing grid)
104
+ - Slash entries: `Two-column grid`, `Three-column grid` under the **Insert** group
105
+ - Render emits `<div class="pilotiq-grid pilotiq-grid-cols-N">…<div>col</div>…</div>` — consumer owns the CSS (same posture as `lead` / `small`)
106
+ - Out-of-range column counts (anything other than 2 or 3) clamp to 2 in both editor `parseHTML` and renderer. `clampGridColumns(value)` is exported from `GridExtension.ts` for tests; the render-side has its own micro-helper to keep `render.ts` Tiptap-runtime-free.
107
+
108
+ ## File attachments (`attachFiles` button)
109
+
110
+ The `attachFiles` toolbar button uploads via the panel's registered `UploadAdapter`. Per-field setters:
111
+
112
+ ```ts
113
+ RichTextField.make('body')
114
+ .fileAttachmentsAcceptedFileTypes(['image/*', 'application/pdf'])
115
+ .fileAttachmentsMaxSize(5 * 1024 * 1024) // 5 MB cap
116
+ .fileAttachmentsDirectory('articles') // sub-directory hint
117
+ .fileAttachmentsVisibility('public') // adapter-dependent
118
+ .resizableImages() // drag-handle resize on images
119
+ ```
120
+
121
+ The upload route enforces `maxSize` server-side — a tampered client can't bypass. **Without an `UploadAdapter` registered on the panel, the `attachFiles` button silently hides.** Wire one with `Pilotiq.uploads({ adapter: localUpload({...}) })` (or any adapter from `@pilotiq/media`, S3, R2, etc.).
122
+
123
+ This auto-hide behavior is intentional — see [[feedback-pilotiq-panel-module-client-safe]]: the `attachFiles` button checks `RenderContext.hasUploadAdapter` at meta-resolve time. The field renders correctly with the button absent rather than showing a broken control.
124
+
125
+ ## Drag handle — three-step drop dance
126
+
127
+ The framework ships per-block drag handles in `extensions/DragHandleExtension.ts`. If you write a **custom block with external drag UX** (not the default per-block handle), the drop handler must do three things or you get the dreaded snap-back-to-origin bug:
128
+
129
+ 1. **`setNodeSelection(pos)`** on the editor view to select the drop target
130
+ 2. **Set `view.dragging = { slice, move: true }`** before the drop dispatches
131
+ 3. **`serializeForClipboard(view, slice)`** so ProseMirror has the serialized content to insert
132
+
133
+ Missing any one of the three causes the dragged node to disappear from its drop position and snap back to where it came from. The framework's built-in handle does all three correctly; custom drag implementations (e.g. an external palette dragging onto the canvas) must too. See [[feedback-tiptap-drag-handle-pm-dragging]] for the full repro.
134
+
135
+ ## Module identity — dedupe Tiptap peers
136
+
137
+ `@tiptap/core` and `@tiptap/pm` keep state on module-level singletons. **Multiple copies break the editor** (silent: `instanceof` checks fail, schema lookups miss, NodeViews don't mount). Add them to `resolve.dedupe` in your Vite config:
138
+
139
+ ```ts
140
+ // vite.config.ts
141
+ export default defineConfig({
142
+ resolve: {
143
+ dedupe: ['@tiptap/core', '@tiptap/pm', '@tiptap/react'],
144
+ },
145
+ // …
146
+ })
147
+ ```
148
+
149
+ This is unrelated to pilotiq's general `optimizeDeps.exclude: ['@pilotiq/pilotiq']` rule (see [[feedback-vite-optimizedeps-exclude]]) — both can be needed simultaneously.
150
+
151
+ ## Toolbar-driven slash entries
152
+
153
+ The slash menu's **Style** and **Format** groups derive from the *active* toolbar buttons. If you hide `textColor` from the toolbar via `.disableToolbarButtons(['textColor'])`, it also disappears from the slash menu. Same for alignment — if no `alignStart` / `alignCenter` / `alignEnd` button is active, the Format group is empty and collapses.
154
+
155
+ The **Insert** group is the inverse: it always shows the core insertable nodes regardless of toolbar config (paragraph, headings, lists, code block, blockquote, horizontal rule). Opt-in primitives (`details`, `grid`) only appear in the Insert group when their toolbar id is in the active config.
156
+
157
+ ## See also
158
+
159
+ - `custom-blocks.md` — `Block.make(...)` user blocks appear in the slash menu after the framework groups.
160
+ - `slash-menu-and-mentions.md` — how merge tags and mentions interact with the slash menu surface.
161
+ - [[feedback-tiptap-block-name-collision]] — the `'block'` name pitfall (also covered in `custom-blocks.md`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.10.5",
3
+ "version": "3.10.6",
4
4
  "description": "Tiptap rich-text editor adapter for @pilotiq/pilotiq — slash menu, draggable blocks, custom-block API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,7 +12,8 @@
12
12
  "type": "module",
13
13
  "files": [
14
14
  "dist",
15
- "src"
15
+ "boost",
16
+ "CHANGELOG.md"
16
17
  ],
17
18
  "main": "./dist/index.js",
18
19
  "types": "./dist/index.d.ts",
@@ -89,7 +90,7 @@
89
90
  "react-dom": "^19",
90
91
  "tiptap-markdown": "^0.9",
91
92
  "typescript": "^5",
92
- "@pilotiq/pilotiq": "^0.24.1"
93
+ "@pilotiq/pilotiq": "^0.24.2"
93
94
  },
94
95
  "author": "Suleiman Shahbari",
95
96
  "scripts": {
package/src/Block.ts DELETED
@@ -1,75 +0,0 @@
1
- import type { Field } from '@pilotiq/pilotiq'
2
- import type { FieldMeta } from '@pilotiq/pilotiq'
3
-
4
- /**
5
- * JSON-serializable block descriptor sent to the editor renderer.
6
- */
7
- export interface BlockMeta {
8
- name: string
9
- label: string
10
- icon: string | undefined
11
- schema: FieldMeta[]
12
- }
13
-
14
- /**
15
- * Builder for a custom block type that can be inserted into a `RichTextField`
16
- * via the slash menu and rendered inline as an editable form.
17
- *
18
- * @example
19
- * ```ts
20
- * Block.make('callout')
21
- * .label('Callout')
22
- * .icon('💡')
23
- * .schema([
24
- * TextField.make('title'),
25
- * TextareaField.make('content').required(),
26
- * ])
27
- * ```
28
- */
29
- export class Block {
30
- private _name: string
31
- private _label?: string
32
- private _icon?: string
33
- private _schema: Field[] = []
34
-
35
- protected constructor(name: string) {
36
- this._name = name
37
- }
38
-
39
- static make(name: string): Block {
40
- return new Block(name)
41
- }
42
-
43
- /** Display label shown in the slash menu. Defaults to the block name. */
44
- label(label: string): this {
45
- this._label = label
46
- return this
47
- }
48
-
49
- /** Emoji or icon string shown in the slash menu. */
50
- icon(icon: string): this {
51
- this._icon = icon
52
- return this
53
- }
54
-
55
- /** Fields rendered inline when this block is inserted. */
56
- schema(fields: Field[]): this {
57
- this._schema = fields
58
- return this
59
- }
60
-
61
- getName(): string { return this._name }
62
- getLabel(): string { return this._label ?? this._name }
63
- getIcon(): string | undefined { return this._icon }
64
- getSchema(): readonly Field[] { return this._schema }
65
-
66
- /** @internal */
67
- toMeta(): BlockMeta {
68
- return {
69
- name: this._name,
70
- label: this._label ?? this._name,
71
- icon: this._icon,
72
- schema: this._schema.map((f) => f.toMeta() as FieldMeta),
73
- }
74
- }
75
- }
@@ -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
- })