@pilotiq/tiptap 3.2.0 → 3.2.1

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,91 @@
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 { type AnyExtension, type EditorOptions, type Editor } from '@tiptap/core';
19
+ export interface PlainTextEditorOptions {
20
+ /** If true, allow multiple paragraphs (textarea-like). Default `false` (input-like). */
21
+ multiline?: boolean;
22
+ /** Placeholder text shown while the editor is empty. */
23
+ placeholder?: string;
24
+ /** Editable / disabled state. Default `true`. */
25
+ editable?: boolean;
26
+ /**
27
+ * Initial textual content. Ignored when a Collaboration extension is passed
28
+ * via `extensions` — Collaboration takes ownership of the document and seeds
29
+ * from the Yjs fragment instead. Use the caller's own first-load seed (see
30
+ * `@pilotiq/tiptap` README) when collab is on.
31
+ */
32
+ content?: string;
33
+ /**
34
+ * Extra extensions to merge into the editor — typically the Collaboration +
35
+ * CollaborationCursor pair from the pilotiq collab adapter. Pass `[]` (or
36
+ * omit) for the non-collab path.
37
+ */
38
+ extensions?: AnyExtension[];
39
+ /**
40
+ * Called on every editor update with the editor's plain-text value (blocks
41
+ * joined by `'\n'`). Use this to mirror the value into form-state for
42
+ * submission via a hidden `<input>`.
43
+ */
44
+ onUpdate?: (text: string, editor: Editor) => void;
45
+ /**
46
+ * Single-line Enter handler. Return `true` to suppress the default blur
47
+ * behavior. When omitted, Enter simply blurs the editor.
48
+ */
49
+ onSubmit?: (editor: Editor) => boolean | void;
50
+ /**
51
+ * DOM attributes for the editor's contenteditable wrapper — typically
52
+ * `{ class: '…tailwind classes…' }` to style the editor like the native
53
+ * `<input>` / `<textarea>` it replaces.
54
+ */
55
+ editorAttributes?: Record<string, string>;
56
+ }
57
+ /**
58
+ * Read the editor's current value as plain text, with paragraphs joined by
59
+ * `'\n'`. Mirrors the behavior of `<textarea>.value`.
60
+ */
61
+ export declare function plainTextOf(editor: Editor): string;
62
+ /**
63
+ * Convert a plain-text string into a Tiptap JSON doc that satisfies the
64
+ * plain-text schema. Multi-line input splits on `'\n'` into separate
65
+ * paragraphs; single-line strips any embedded newlines into a single run.
66
+ * Exported for tests — pure, no editor instance required.
67
+ */
68
+ export declare function plainTextToDoc(text: string, multiline: boolean): {
69
+ type: 'doc';
70
+ content: Array<{
71
+ type: 'paragraph';
72
+ content?: Array<{
73
+ type: 'text';
74
+ text: string;
75
+ }>;
76
+ }>;
77
+ };
78
+ /**
79
+ * Build the Tiptap editor config for a plain-text field. Pass the returned
80
+ * object to `useEditor` (React) or `new Editor(...)` (vanilla).
81
+ *
82
+ * The editor schema is deliberately minimal:
83
+ * - `doc` → `paragraph` (single-line) or `paragraph+` (multi-line)
84
+ * - `paragraph` → `inline*`
85
+ * - `text` (inline)
86
+ *
87
+ * No marks, no input rules, no list items, no code blocks — just text. Pasted
88
+ * rich content is stripped to plain text by ProseMirror's schema enforcement.
89
+ */
90
+ export declare function createPlainTextEditor(options?: PlainTextEditorOptions): Partial<EditorOptions>;
91
+ //# sourceMappingURL=PlainTextEditor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PlainTextEditor.d.ts","sourceRoot":"","sources":["../src/PlainTextEditor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAIL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,MAAM,EACZ,MAAM,cAAc,CAAA;AAsErB,MAAM,WAAW,sBAAsB;IACrC,wFAAwF;IACxF,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,YAAY,EAAE,CAAA;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IACjD;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,IAAI,CAAA;IAC7C;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC1C;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG;IAChE,IAAI,EAAE,KAAK,CAAA;IACX,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,WAAW,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAC;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC,CAAA;CACvF,CAmBA;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,aAAa,CAAC,CA2CxB"}
@@ -0,0 +1,157 @@
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 { Node, Extension, mergeAttributes, } from '@tiptap/core';
19
+ import Placeholder from '@tiptap/extension-placeholder';
20
+ /** Block separator used by `getText` — newline matches `<textarea>.value`. */
21
+ const BLOCK_SEPARATOR = '\n';
22
+ /**
23
+ * Bare paragraph block — the only block the plain-text schema permits.
24
+ * No options, no input rules, no toggles. `inline*` content lets any inline
25
+ * node (today just `text`) appear inside.
26
+ */
27
+ const PlainTextParagraph = Node.create({
28
+ name: 'paragraph',
29
+ group: 'block',
30
+ content: 'inline*',
31
+ priority: 1000,
32
+ parseHTML() {
33
+ return [{ tag: 'p' }];
34
+ },
35
+ renderHTML({ HTMLAttributes }) {
36
+ return ['p', mergeAttributes(HTMLAttributes), 0];
37
+ },
38
+ });
39
+ /** The text node — Tiptap requires this to be defined explicitly. */
40
+ const PlainTextText = Node.create({
41
+ name: 'text',
42
+ group: 'inline',
43
+ });
44
+ /**
45
+ * Build a Document node with content restricted to either a single paragraph
46
+ * (single-line mode) or one-or-more paragraphs (multi-line mode). The schema
47
+ * itself blocks paste of incompatible content — ProseMirror will coerce or
48
+ * reject non-matching nodes at parse time.
49
+ */
50
+ function makePlainTextDocument(multiline) {
51
+ return Node.create({
52
+ name: 'doc',
53
+ topNode: true,
54
+ content: multiline ? 'paragraph+' : 'paragraph',
55
+ });
56
+ }
57
+ /**
58
+ * Single-line Enter handler. Tiptap's default `Enter` keymap splits the
59
+ * paragraph — meaningless when the schema only allows exactly one — so we
60
+ * intercept and either delegate to `onSubmit` (caller-supplied) or blur.
61
+ *
62
+ * Filament's plain-text fields blur on Enter; matching that default.
63
+ */
64
+ function makeSingleLineKeymap(onSubmit) {
65
+ return Extension.create({
66
+ name: 'plainTextSingleLineKeymap',
67
+ addKeyboardShortcuts() {
68
+ const handleEnter = () => {
69
+ const handled = onSubmit?.(this.editor);
70
+ if (handled === true)
71
+ return true;
72
+ this.editor.commands.blur();
73
+ return true;
74
+ };
75
+ return {
76
+ Enter: handleEnter,
77
+ 'Mod-Enter': () => true,
78
+ 'Shift-Enter': () => true,
79
+ };
80
+ },
81
+ });
82
+ }
83
+ /**
84
+ * Read the editor's current value as plain text, with paragraphs joined by
85
+ * `'\n'`. Mirrors the behavior of `<textarea>.value`.
86
+ */
87
+ export function plainTextOf(editor) {
88
+ return editor.getText({ blockSeparator: BLOCK_SEPARATOR });
89
+ }
90
+ /**
91
+ * Convert a plain-text string into a Tiptap JSON doc that satisfies the
92
+ * plain-text schema. Multi-line input splits on `'\n'` into separate
93
+ * paragraphs; single-line strips any embedded newlines into a single run.
94
+ * Exported for tests — pure, no editor instance required.
95
+ */
96
+ export function plainTextToDoc(text, multiline) {
97
+ if (!multiline) {
98
+ const flat = text.replace(/\r?\n/g, '');
99
+ return {
100
+ type: 'doc',
101
+ content: [
102
+ flat ? { type: 'paragraph', content: [{ type: 'text', text: flat }] }
103
+ : { type: 'paragraph' },
104
+ ],
105
+ };
106
+ }
107
+ const lines = text.split(/\r?\n/);
108
+ return {
109
+ type: 'doc',
110
+ content: lines.map((line) => line ? { type: 'paragraph', content: [{ type: 'text', text: line }] }
111
+ : { type: 'paragraph' }),
112
+ };
113
+ }
114
+ /**
115
+ * Build the Tiptap editor config for a plain-text field. Pass the returned
116
+ * object to `useEditor` (React) or `new Editor(...)` (vanilla).
117
+ *
118
+ * The editor schema is deliberately minimal:
119
+ * - `doc` → `paragraph` (single-line) or `paragraph+` (multi-line)
120
+ * - `paragraph` → `inline*`
121
+ * - `text` (inline)
122
+ *
123
+ * No marks, no input rules, no list items, no code blocks — just text. Pasted
124
+ * rich content is stripped to plain text by ProseMirror's schema enforcement.
125
+ */
126
+ export function createPlainTextEditor(options = {}) {
127
+ const { multiline = false, placeholder, editable = true, content = '', extensions = [], onUpdate, onSubmit, editorAttributes, } = options;
128
+ const schema = [
129
+ makePlainTextDocument(multiline),
130
+ PlainTextParagraph,
131
+ PlainTextText,
132
+ ];
133
+ const behavior = [];
134
+ if (!multiline)
135
+ behavior.push(makeSingleLineKeymap(onSubmit));
136
+ if (placeholder !== undefined) {
137
+ behavior.push(Placeholder.configure({ placeholder }));
138
+ }
139
+ const allExtensions = [...schema, ...behavior, ...extensions];
140
+ // When Collaboration owns the doc, an explicit `content` would race the
141
+ // Yjs sync. Caller is responsible for omitting `content` in that case; we
142
+ // pass it through verbatim either way.
143
+ const initialContent = content ? plainTextToDoc(content, multiline) : '';
144
+ const config = {
145
+ editable,
146
+ extensions: allExtensions,
147
+ content: initialContent,
148
+ };
149
+ if (onUpdate) {
150
+ config.onUpdate = ({ editor }) => onUpdate(plainTextOf(editor), editor);
151
+ }
152
+ if (editorAttributes) {
153
+ config.editorProps = { attributes: editorAttributes };
154
+ }
155
+ return config;
156
+ }
157
+ //# sourceMappingURL=PlainTextEditor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PlainTextEditor.js","sourceRoot":"","sources":["../src/PlainTextEditor.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export { RichTextField, DEFAULT_TOOLBAR_GROUPS, DEFAULT_TEXT_COLORS, DEFAULT_HIG
2
2
  export { Block, type BlockMeta } from './Block.js';
3
3
  export { MentionProvider, type MentionItem, type MentionProviderMeta, } from './MentionProvider.js';
4
4
  export { registerTiptap } from './register.js';
5
+ export { createPlainTextEditor, plainTextOf, plainTextToDoc, type PlainTextEditorOptions, } from './PlainTextEditor.js';
5
6
  export { tiptap } from './plugin.js';
6
7
  export { TiptapEditor } from './react/TiptapEditor.js';
7
8
  export { AiSuggestionExtension, aiSuggestionPluginKey, upsertSuggestion, upsertSuggestions, removeSuggestion, remapSuggestions, sortForApproveAll, clampPos, type AiSuggestion, type AiSuggestionExtensionOptions, } from './extensions/AiSuggestionExtension.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,wBAAwB,EACxB,KAAK,WAAW,EAChB,KAAK,4BAA4B,EACjC,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,aAAa,GACnB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,KAAK,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAA;AAClD,OAAO,EACL,eAAe,EACf,KAAK,WAAW,EAChB,KAAK,mBAAmB,GACzB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,QAAQ,EACR,KAAK,YAAY,EACjB,KAAK,4BAA4B,GAClC,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,kCAAkC,CAAA;AACxE,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,KAAK,qBAAqB,EAC1B,KAAK,UAAU,EACf,KAAK,UAAU,GAChB,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,wBAAwB,EACxB,KAAK,WAAW,EAChB,KAAK,4BAA4B,EACjC,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,aAAa,GACnB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,KAAK,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAA;AAClD,OAAO,EACL,eAAe,EACf,KAAK,WAAW,EAChB,KAAK,mBAAmB,GACzB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EACL,qBAAqB,EACrB,WAAW,EACX,cAAc,EACd,KAAK,sBAAsB,GAC5B,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,QAAQ,EACR,KAAK,YAAY,EACjB,KAAK,4BAA4B,GAClC,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,kCAAkC,CAAA;AACxE,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,KAAK,qBAAqB,EAC1B,KAAK,UAAU,EACf,KAAK,UAAU,GAChB,MAAM,aAAa,CAAA"}
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export { RichTextField, DEFAULT_TOOLBAR_GROUPS, DEFAULT_TEXT_COLORS, DEFAULT_HIG
2
2
  export { Block } from './Block.js';
3
3
  export { MentionProvider, } from './MentionProvider.js';
4
4
  export { registerTiptap } from './register.js';
5
+ export { createPlainTextEditor, plainTextOf, plainTextToDoc, } from './PlainTextEditor.js';
5
6
  export { tiptap } from './plugin.js';
6
7
  export { TiptapEditor } from './react/TiptapEditor.js';
7
8
  export { AiSuggestionExtension, aiSuggestionPluginKey, upsertSuggestion, upsertSuggestions, removeSuggestion, remapSuggestions, sortForApproveAll, clampPos, } from './extensions/AiSuggestionExtension.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,wBAAwB,GAOzB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,KAAK,EAAkB,MAAM,YAAY,CAAA;AAClD,OAAO,EACL,eAAe,GAGhB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,QAAQ,GAGT,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,kCAAkC,CAAA;AACxE,OAAO,EACL,oBAAoB,EACpB,eAAe,GAIhB,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,wBAAwB,GAOzB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,KAAK,EAAkB,MAAM,YAAY,CAAA;AAClD,OAAO,EACL,eAAe,GAGhB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EACL,qBAAqB,EACrB,WAAW,EACX,cAAc,GAEf,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,EACL,qBAAqB,EACrB,qBAAqB,EACrB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,QAAQ,GAGT,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,kCAAkC,CAAA;AACxE,OAAO,EACL,oBAAoB,EACpB,eAAe,GAIhB,MAAM,aAAa,CAAA"}
@@ -0,0 +1,26 @@
1
+ import { type CollabTextRendererProps } from '@pilotiq/pilotiq/react';
2
+ /**
3
+ * Tiptap-backed plain-text editor for pilotiq's `TextField` / `TextareaField`
4
+ * / similar single-line / multi-line text fields when collab is on.
5
+ *
6
+ * Lifts the cursor-bookkeeping burden off the field renderer: y-prosemirror
7
+ * anchors selections to `Yjs.RelativePosition` items, so concurrent and
8
+ * mid-word remote edits translate the local cursor correctly without any
9
+ * heuristic. Replaces the legacy `Y.Text` + `computeDelta` + `preserveCursor`
10
+ * path documented in `docs/plans/text-fields-tiptap-backed-collab.md`.
11
+ *
12
+ * Mount conditions (enforced upstream by `TextLikeInput`):
13
+ * - A `<RecordCollabRoom>` is mounted up-tree (`useCollabRoom() !== null`).
14
+ * - A collab extension factory was registered (`getCollabExtensions() !== null`).
15
+ * - The field hasn't opted out via `.collab(false)`.
16
+ * - The field is not masked (`.mask(pattern)`).
17
+ * - The field is top-level (not a Repeater / Builder row leaf).
18
+ *
19
+ * If either the room or the factory disappears at runtime (e.g. the plugin
20
+ * was never installed), we still render an editor — it's just a non-collab
21
+ * plain Tiptap. That's a regression vs `<input>` ergonomically but never
22
+ * crashes; in practice the upstream gate prevents this branch from mounting
23
+ * when collab isn't wired.
24
+ */
25
+ export declare function CollabTextRenderer({ name, multiline, defaultValue, placeholder, disabled, onChange, onBlur, onSubmit, className, editorAttributes, }: CollabTextRendererProps): React.ReactElement;
26
+ //# sourceMappingURL=CollabTextRenderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CollabTextRenderer.d.ts","sourceRoot":"","sources":["../../src/react/CollabTextRenderer.tsx"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,uBAAuB,EAC7B,MAAM,wBAAwB,CAAA;AAG/B;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,IAAI,EACJ,SAAS,EACT,YAAY,EACZ,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,SAAS,EACT,gBAAgB,GACjB,EAAE,uBAAuB,GAAG,KAAK,CAAC,YAAY,CAwH9C"}
@@ -0,0 +1,149 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import { useEditor, EditorContent } from '@tiptap/react';
4
+ import { useCollabRoom, getCollabExtensions, } from '@pilotiq/pilotiq/react';
5
+ import { createPlainTextEditor, plainTextOf, plainTextToDoc } from '../PlainTextEditor.js';
6
+ /**
7
+ * Tiptap-backed plain-text editor for pilotiq's `TextField` / `TextareaField`
8
+ * / similar single-line / multi-line text fields when collab is on.
9
+ *
10
+ * Lifts the cursor-bookkeeping burden off the field renderer: y-prosemirror
11
+ * anchors selections to `Yjs.RelativePosition` items, so concurrent and
12
+ * mid-word remote edits translate the local cursor correctly without any
13
+ * heuristic. Replaces the legacy `Y.Text` + `computeDelta` + `preserveCursor`
14
+ * path documented in `docs/plans/text-fields-tiptap-backed-collab.md`.
15
+ *
16
+ * Mount conditions (enforced upstream by `TextLikeInput`):
17
+ * - A `<RecordCollabRoom>` is mounted up-tree (`useCollabRoom() !== null`).
18
+ * - A collab extension factory was registered (`getCollabExtensions() !== null`).
19
+ * - The field hasn't opted out via `.collab(false)`.
20
+ * - The field is not masked (`.mask(pattern)`).
21
+ * - The field is top-level (not a Repeater / Builder row leaf).
22
+ *
23
+ * If either the room or the factory disappears at runtime (e.g. the plugin
24
+ * was never installed), we still render an editor — it's just a non-collab
25
+ * plain Tiptap. That's a regression vs `<input>` ergonomically but never
26
+ * crashes; in practice the upstream gate prevents this branch from mounting
27
+ * when collab isn't wired.
28
+ */
29
+ export function CollabTextRenderer({ name, multiline, defaultValue, placeholder, disabled, onChange, onBlur, onSubmit, className, editorAttributes, }) {
30
+ const room = useCollabRoom();
31
+ const factory = getCollabExtensions();
32
+ const collabActive = !!(room && factory);
33
+ // Built once per editor mount. The factory closes over the room's `ydoc`
34
+ // + `provider` and the field name to produce a `Collaboration` (and
35
+ // optional `CollaborationCursor`) extension targeting the field's
36
+ // `Y.XmlFragment`. Re-running on every render would tear down the editor.
37
+ const collabExtensions = useMemo(() => {
38
+ if (!collabActive || !room || !factory)
39
+ return [];
40
+ return factory({
41
+ ydoc: room.ydoc,
42
+ provider: room.provider,
43
+ fieldName: name,
44
+ ...(room.user ? { user: room.user } : {}),
45
+ });
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ }, [collabActive]);
48
+ const editor = useEditor(createPlainTextEditor({
49
+ multiline,
50
+ ...(placeholder !== undefined ? { placeholder } : {}),
51
+ editable: !disabled,
52
+ // When Collaboration owns the doc, omit `content` so the editor
53
+ // doesn't race the y-prosemirror sync. The post-`synced` effect below
54
+ // seeds the fragment on first connect when it's still empty. When
55
+ // collab is off, seed from defaultValue directly.
56
+ content: collabActive ? '' : defaultValue,
57
+ extensions: collabExtensions,
58
+ onUpdate: (text) => onChange(text),
59
+ ...(onSubmit ? { onSubmit: () => { onSubmit(); return false; } } : {}),
60
+ ...(className || editorAttributes
61
+ ? {
62
+ editorAttributes: {
63
+ ...(editorAttributes ?? {}),
64
+ ...(className ? { class: className } : {}),
65
+ },
66
+ }
67
+ : {}),
68
+ }),
69
+ // Re-mount when collab toggles. Other props (multiline, name, etc) are
70
+ // stable per mount under the upstream gate.
71
+ [collabActive]);
72
+ // Mirror the editor's editable state with the prop. `useEditor` snapshots
73
+ // `editable` at first call, so we update it imperatively on changes.
74
+ useEffect(() => {
75
+ if (!editor)
76
+ return;
77
+ editor.setEditable(!disabled);
78
+ }, [editor, disabled]);
79
+ // First-load seed when collab is active. Collaboration starts the editor
80
+ // empty regardless of `defaultValue`; once the provider syncs the room
81
+ // state from the server we check whether the field's `Y.XmlFragment`
82
+ // was ever written. Empty + we have an initial value = first session for
83
+ // this record — push the SSR-rendered default into the editor once.
84
+ //
85
+ // Race caveat: two peers simultaneously mounting against a brand-new
86
+ // record (both seeing `fragment.length === 0`) can both seed and produce
87
+ // duplicated text. Same window as `TiptapEditor`'s rich-text seed path.
88
+ // Acceptable for now; can be tightened later via a deterministic
89
+ // first-writer election or a server-side seed handoff.
90
+ const [hasSeeded, setHasSeeded] = useState(false);
91
+ useEffect(() => {
92
+ if (!editor || !collabActive || !room || hasSeeded)
93
+ return;
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ const ydoc = room.ydoc;
96
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
+ const provider = room.provider;
98
+ if (!ydoc || !provider)
99
+ return;
100
+ const trySeed = () => {
101
+ try {
102
+ const fragment = ydoc.getXmlFragment(name);
103
+ if (fragment && fragment.length === 0 && defaultValue) {
104
+ editor.commands.setContent(plainTextToDoc(defaultValue, multiline));
105
+ }
106
+ setHasSeeded(true);
107
+ }
108
+ catch {
109
+ setHasSeeded(true);
110
+ }
111
+ };
112
+ if (provider.synced) {
113
+ trySeed();
114
+ return;
115
+ }
116
+ provider.once('synced', trySeed);
117
+ return () => {
118
+ try {
119
+ provider.off?.('synced', trySeed);
120
+ }
121
+ catch { /* ignore */ }
122
+ };
123
+ // Seed once per editor instance — keyed remount above resets `hasSeeded`.
124
+ // eslint-disable-next-line react-hooks/exhaustive-deps
125
+ }, [editor, collabActive, room]);
126
+ // Bubble the editor's blur event up to the host. Tiptap exposes this via
127
+ // `editor.on('blur', ...)`. The simpler `onBlur` prop on `EditorContent`
128
+ // fires on the DOM node, but selection inside contenteditable can land on
129
+ // child nodes; the Tiptap event is the canonical "editor lost focus".
130
+ useEffect(() => {
131
+ if (!editor)
132
+ return;
133
+ const handler = () => onBlur();
134
+ editor.on('blur', handler);
135
+ return () => { editor.off('blur', handler); };
136
+ }, [editor, onBlur]);
137
+ // Best-effort getText safety net — onUpdate should fire on every
138
+ // y-prosemirror sync, but if a remote update somehow doesn't trigger
139
+ // `onUpdate`, the wrapper's hidden input goes stale. Re-emit on every
140
+ // editor render tick. No-op when text matches the last emit.
141
+ useEffect(() => {
142
+ if (!editor)
143
+ return;
144
+ onChange(plainTextOf(editor));
145
+ // eslint-disable-next-line react-hooks/exhaustive-deps
146
+ }, [editor]);
147
+ return _jsx(EditorContent, { editor: editor });
148
+ }
149
+ //# sourceMappingURL=CollabTextRenderer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CollabTextRenderer.js","sourceRoot":"","sources":["../../src/react/CollabTextRenderer.tsx"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"register.d.ts","sourceRoot":"","sources":["../src/register.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,IAAI,IAAI,CAGrC"}
1
+ {"version":3,"file":"register.d.ts","sourceRoot":"","sources":["../src/register.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,IAAI,IAAI,CASrC"}
package/dist/register.js CHANGED
@@ -1,6 +1,7 @@
1
- import { registerFieldRenderer } from '@pilotiq/pilotiq/react';
1
+ import { registerFieldRenderer, registerCollabTextRenderer } from '@pilotiq/pilotiq/react';
2
2
  import { registerRichTextRenderer } from '@pilotiq/pilotiq/richtext';
3
3
  import { TiptapEditor } from './react/TiptapEditor.js';
4
+ import { CollabTextRenderer } from './react/CollabTextRenderer.js';
4
5
  import { renderRichTextToHtml, isRichTextValue } from './render.js';
5
6
  /**
6
7
  * Register the Tiptap editor as the pilotiq renderer for `fieldType: 'richtext'`.
@@ -23,5 +24,11 @@ import { renderRichTextToHtml, isRichTextValue } from './render.js';
23
24
  export function registerTiptap() {
24
25
  registerFieldRenderer('richtext', TiptapEditor);
25
26
  registerRichTextRenderer(renderRichTextToHtml, isRichTextValue);
27
+ // Phase B — opt every plain-text field in the panel into y-prosemirror
28
+ // backing when collab is on. `TextLikeInput` checks this registry; if it's
29
+ // populated AND a `<RecordCollabRoom>` is up-tree AND the field hasn't opted
30
+ // out via `.collab(false)`, the renderer mounts `CollabTextRenderer`
31
+ // instead of the legacy `Y.Text` + `computeDelta` + `preserveCursor` path.
32
+ registerCollabTextRenderer(CollabTextRenderer);
26
33
  }
27
34
  //# sourceMappingURL=register.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"register.js","sourceRoot":"","sources":["../src/register.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AAC9D,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAA;AACpE,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAEnE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,cAAc;IAC5B,qBAAqB,CAAC,UAAU,EAAE,YAAY,CAAC,CAAA;IAC/C,wBAAwB,CAAC,oBAAoB,EAAE,eAAe,CAAC,CAAA;AACjE,CAAC"}
1
+ {"version":3,"file":"register.js","sourceRoot":"","sources":["../src/register.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAA;AAC1F,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAA;AACpE,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAA;AAClE,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAEnE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,cAAc;IAC5B,qBAAqB,CAAC,UAAU,EAAE,YAAY,CAAC,CAAA;IAC/C,wBAAwB,CAAC,oBAAoB,EAAE,eAAe,CAAC,CAAA;IAC/D,uEAAuE;IACvE,2EAA2E;IAC3E,6EAA6E;IAC7E,qEAAqE;IACrE,2EAA2E;IAC3E,0BAA0B,CAAC,kBAAkB,CAAC,CAAA;AAChD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
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": {
@@ -79,7 +79,7 @@
79
79
  "react": "^19",
80
80
  "react-dom": "^19",
81
81
  "typescript": "^5",
82
- "@pilotiq/pilotiq": "^0.8.0"
82
+ "@pilotiq/pilotiq": "^0.12.0"
83
83
  },
84
84
  "author": "Suleiman Shahbari",
85
85
  "scripts": {
@@ -0,0 +1,158 @@
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
+ })
@@ -0,0 +1,229 @@
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
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,12 @@ export {
17
17
  type MentionProviderMeta,
18
18
  } from './MentionProvider.js'
19
19
  export { registerTiptap } from './register.js'
20
+ export {
21
+ createPlainTextEditor,
22
+ plainTextOf,
23
+ plainTextToDoc,
24
+ type PlainTextEditorOptions,
25
+ } from './PlainTextEditor.js'
20
26
  export { tiptap } from './plugin.js'
21
27
  export { TiptapEditor } from './react/TiptapEditor.js'
22
28
  export {
@@ -0,0 +1,165 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { useEditor, EditorContent, type Extension } from '@tiptap/react'
3
+ import type { AnyExtension } from '@tiptap/core'
4
+ import {
5
+ useCollabRoom,
6
+ getCollabExtensions,
7
+ type CollabTextRendererProps,
8
+ } from '@pilotiq/pilotiq/react'
9
+ import { createPlainTextEditor, plainTextOf, plainTextToDoc } from '../PlainTextEditor.js'
10
+
11
+ /**
12
+ * Tiptap-backed plain-text editor for pilotiq's `TextField` / `TextareaField`
13
+ * / similar single-line / multi-line text fields when collab is on.
14
+ *
15
+ * Lifts the cursor-bookkeeping burden off the field renderer: y-prosemirror
16
+ * anchors selections to `Yjs.RelativePosition` items, so concurrent and
17
+ * mid-word remote edits translate the local cursor correctly without any
18
+ * heuristic. Replaces the legacy `Y.Text` + `computeDelta` + `preserveCursor`
19
+ * path documented in `docs/plans/text-fields-tiptap-backed-collab.md`.
20
+ *
21
+ * Mount conditions (enforced upstream by `TextLikeInput`):
22
+ * - A `<RecordCollabRoom>` is mounted up-tree (`useCollabRoom() !== null`).
23
+ * - A collab extension factory was registered (`getCollabExtensions() !== null`).
24
+ * - The field hasn't opted out via `.collab(false)`.
25
+ * - The field is not masked (`.mask(pattern)`).
26
+ * - The field is top-level (not a Repeater / Builder row leaf).
27
+ *
28
+ * If either the room or the factory disappears at runtime (e.g. the plugin
29
+ * was never installed), we still render an editor — it's just a non-collab
30
+ * plain Tiptap. That's a regression vs `<input>` ergonomically but never
31
+ * crashes; in practice the upstream gate prevents this branch from mounting
32
+ * when collab isn't wired.
33
+ */
34
+ export function CollabTextRenderer({
35
+ name,
36
+ multiline,
37
+ defaultValue,
38
+ placeholder,
39
+ disabled,
40
+ onChange,
41
+ onBlur,
42
+ onSubmit,
43
+ className,
44
+ editorAttributes,
45
+ }: CollabTextRendererProps): React.ReactElement {
46
+ const room = useCollabRoom()
47
+ const factory = getCollabExtensions()
48
+ const collabActive = !!(room && factory)
49
+
50
+ // Built once per editor mount. The factory closes over the room's `ydoc`
51
+ // + `provider` and the field name to produce a `Collaboration` (and
52
+ // optional `CollaborationCursor`) extension targeting the field's
53
+ // `Y.XmlFragment`. Re-running on every render would tear down the editor.
54
+ const collabExtensions = useMemo<AnyExtension[]>(() => {
55
+ if (!collabActive || !room || !factory) return []
56
+ return factory({
57
+ ydoc: room.ydoc,
58
+ provider: room.provider,
59
+ fieldName: name,
60
+ ...(room.user ? { user: room.user } : {}),
61
+ }) as Extension[]
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, [collabActive])
64
+
65
+ const editor = useEditor(
66
+ createPlainTextEditor({
67
+ multiline,
68
+ ...(placeholder !== undefined ? { placeholder } : {}),
69
+ editable: !disabled,
70
+ // When Collaboration owns the doc, omit `content` so the editor
71
+ // doesn't race the y-prosemirror sync. The post-`synced` effect below
72
+ // seeds the fragment on first connect when it's still empty. When
73
+ // collab is off, seed from defaultValue directly.
74
+ content: collabActive ? '' : defaultValue,
75
+ extensions: collabExtensions,
76
+ onUpdate: (text) => onChange(text),
77
+ ...(onSubmit ? { onSubmit: () => { onSubmit(); return false } } : {}),
78
+ ...(className || editorAttributes
79
+ ? {
80
+ editorAttributes: {
81
+ ...(editorAttributes ?? {}),
82
+ ...(className ? { class: className } : {}),
83
+ },
84
+ }
85
+ : {}),
86
+ }),
87
+ // Re-mount when collab toggles. Other props (multiline, name, etc) are
88
+ // stable per mount under the upstream gate.
89
+ [collabActive],
90
+ )
91
+
92
+ // Mirror the editor's editable state with the prop. `useEditor` snapshots
93
+ // `editable` at first call, so we update it imperatively on changes.
94
+ useEffect(() => {
95
+ if (!editor) return
96
+ editor.setEditable(!disabled)
97
+ }, [editor, disabled])
98
+
99
+ // First-load seed when collab is active. Collaboration starts the editor
100
+ // empty regardless of `defaultValue`; once the provider syncs the room
101
+ // state from the server we check whether the field's `Y.XmlFragment`
102
+ // was ever written. Empty + we have an initial value = first session for
103
+ // this record — push the SSR-rendered default into the editor once.
104
+ //
105
+ // Race caveat: two peers simultaneously mounting against a brand-new
106
+ // record (both seeing `fragment.length === 0`) can both seed and produce
107
+ // duplicated text. Same window as `TiptapEditor`'s rich-text seed path.
108
+ // Acceptable for now; can be tightened later via a deterministic
109
+ // first-writer election or a server-side seed handoff.
110
+ const [hasSeeded, setHasSeeded] = useState(false)
111
+ useEffect(() => {
112
+ if (!editor || !collabActive || !room || hasSeeded) return
113
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
114
+ const ydoc = room.ydoc as any
115
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
116
+ const provider = room.provider as any
117
+ if (!ydoc || !provider) return
118
+
119
+ const trySeed = (): void => {
120
+ try {
121
+ const fragment = ydoc.getXmlFragment(name)
122
+ if (fragment && fragment.length === 0 && defaultValue) {
123
+ editor.commands.setContent(plainTextToDoc(defaultValue, multiline))
124
+ }
125
+ setHasSeeded(true)
126
+ } catch {
127
+ setHasSeeded(true)
128
+ }
129
+ }
130
+
131
+ if (provider.synced) {
132
+ trySeed()
133
+ return
134
+ }
135
+ provider.once('synced', trySeed)
136
+ return () => {
137
+ try { provider.off?.('synced', trySeed) } catch { /* ignore */ }
138
+ }
139
+ // Seed once per editor instance — keyed remount above resets `hasSeeded`.
140
+ // eslint-disable-next-line react-hooks/exhaustive-deps
141
+ }, [editor, collabActive, room])
142
+
143
+ // Bubble the editor's blur event up to the host. Tiptap exposes this via
144
+ // `editor.on('blur', ...)`. The simpler `onBlur` prop on `EditorContent`
145
+ // fires on the DOM node, but selection inside contenteditable can land on
146
+ // child nodes; the Tiptap event is the canonical "editor lost focus".
147
+ useEffect(() => {
148
+ if (!editor) return
149
+ const handler = (): void => onBlur()
150
+ editor.on('blur', handler)
151
+ return () => { editor.off('blur', handler) }
152
+ }, [editor, onBlur])
153
+
154
+ // Best-effort getText safety net — onUpdate should fire on every
155
+ // y-prosemirror sync, but if a remote update somehow doesn't trigger
156
+ // `onUpdate`, the wrapper's hidden input goes stale. Re-emit on every
157
+ // editor render tick. No-op when text matches the last emit.
158
+ useEffect(() => {
159
+ if (!editor) return
160
+ onChange(plainTextOf(editor))
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ }, [editor])
163
+
164
+ return <EditorContent editor={editor} />
165
+ }
package/src/register.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { registerFieldRenderer } from '@pilotiq/pilotiq/react'
1
+ import { registerFieldRenderer, registerCollabTextRenderer } from '@pilotiq/pilotiq/react'
2
2
  import { registerRichTextRenderer } from '@pilotiq/pilotiq/richtext'
3
3
  import { TiptapEditor } from './react/TiptapEditor.js'
4
+ import { CollabTextRenderer } from './react/CollabTextRenderer.js'
4
5
  import { renderRichTextToHtml, isRichTextValue } from './render.js'
5
6
 
6
7
  /**
@@ -24,4 +25,10 @@ import { renderRichTextToHtml, isRichTextValue } from './render.js'
24
25
  export function registerTiptap(): void {
25
26
  registerFieldRenderer('richtext', TiptapEditor)
26
27
  registerRichTextRenderer(renderRichTextToHtml, isRichTextValue)
28
+ // Phase B — opt every plain-text field in the panel into y-prosemirror
29
+ // backing when collab is on. `TextLikeInput` checks this registry; if it's
30
+ // populated AND a `<RecordCollabRoom>` is up-tree AND the field hasn't opted
31
+ // out via `.collab(false)`, the renderer mounts `CollabTextRenderer`
32
+ // instead of the legacy `Y.Text` + `computeDelta` + `preserveCursor` path.
33
+ registerCollabTextRenderer(CollabTextRenderer)
27
34
  }