@verdant-web/tiptap 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/esm/__browserTests__/react.test.d.ts +1 -0
- package/dist/esm/__browserTests__/react.test.js +144 -0
- package/dist/esm/__browserTests__/react.test.js.map +1 -0
- package/dist/esm/fields.d.ts +21 -0
- package/dist/esm/fields.js +37 -0
- package/dist/esm/fields.js.map +1 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/plugins.d.ts +4 -0
- package/dist/esm/plugins.js +71 -0
- package/dist/esm/plugins.js.map +1 -0
- package/dist/esm/react.d.ts +11 -0
- package/dist/esm/react.js +78 -0
- package/dist/esm/react.js.map +1 -0
- package/package.json +78 -0
- package/src/__browserTests__/__screenshots__/react.test.tsx/should-support-non-nullable-tiptap-schema-fields-1.png +0 -0
- package/src/__browserTests__/__screenshots__/react.test.tsx/should-support-nullable-tiptap-schema-fields-with-a-specified-default-doc-1.png +0 -0
- package/src/__browserTests__/react.test.tsx +189 -0
- package/src/fields.ts +67 -0
- package/src/index.ts +2 -0
- package/src/plugins.ts +76 -0
- package/src/react.ts +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Grant Forrest
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { EditorContent } from '@tiptap/react';
|
|
3
|
+
import { StarterKit } from '@tiptap/starter-kit';
|
|
4
|
+
import { createMigration, schema } from '@verdant-web/common';
|
|
5
|
+
import { createHooks } from '@verdant-web/react';
|
|
6
|
+
import { ClientDescriptor } from '@verdant-web/store';
|
|
7
|
+
import { userEvent } from '@vitest/browser/context';
|
|
8
|
+
import { Suspense } from 'react';
|
|
9
|
+
import { afterAll, beforeAll, expect, it } from 'vitest';
|
|
10
|
+
import { render } from 'vitest-browser-react';
|
|
11
|
+
import { createTipTapFieldSchema } from '../fields.js';
|
|
12
|
+
import { useSyncedEditor } from '../react.js';
|
|
13
|
+
const testSchema = schema({
|
|
14
|
+
version: 1,
|
|
15
|
+
collections: {
|
|
16
|
+
posts: schema.collection({
|
|
17
|
+
name: 'post',
|
|
18
|
+
primaryKey: 'id',
|
|
19
|
+
fields: {
|
|
20
|
+
id: schema.fields.id(),
|
|
21
|
+
nullableBody: createTipTapFieldSchema({ default: null }),
|
|
22
|
+
requiredBody: createTipTapFieldSchema({
|
|
23
|
+
default: {
|
|
24
|
+
type: 'doc',
|
|
25
|
+
content: [],
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const hooks = createHooks(testSchema);
|
|
33
|
+
let clientDesc;
|
|
34
|
+
let client;
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
clientDesc = new ClientDescriptor({
|
|
37
|
+
schema: testSchema,
|
|
38
|
+
oldSchemas: [testSchema],
|
|
39
|
+
migrations: [createMigration(testSchema)],
|
|
40
|
+
namespace: 'tiptatp-test',
|
|
41
|
+
});
|
|
42
|
+
client = await clientDesc.open();
|
|
43
|
+
});
|
|
44
|
+
afterAll(() => client.close());
|
|
45
|
+
function renderWithProvider(content) {
|
|
46
|
+
return render(content, {
|
|
47
|
+
wrapper: ({ children }) => (_jsx(Suspense, { children: _jsx(hooks.Provider, { value: clientDesc, children: _jsx(Suspense, { fallback: _jsx("div", { "data-testid": "suspense", children: "Loading..." }), children: children }) }) })),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
it('should support non-nullable tiptap schema fields', async () => {
|
|
51
|
+
const testPost = await client.posts.put({});
|
|
52
|
+
const TipTapTest = () => {
|
|
53
|
+
const editor = useSyncedEditor(testPost, 'requiredBody', {
|
|
54
|
+
editorOptions: { extensions: [StarterKit] },
|
|
55
|
+
});
|
|
56
|
+
return (_jsxs("div", { children: [_jsx("div", { children: "Text editor:" }), _jsx(EditorContent, { style: {
|
|
57
|
+
width: 500,
|
|
58
|
+
height: 300,
|
|
59
|
+
}, editor: editor, id: "#editor", "data-testid": "editor" })] }));
|
|
60
|
+
};
|
|
61
|
+
const screen = await renderWithProvider(_jsx(TipTapTest, {}));
|
|
62
|
+
await expect.element(screen.getByTestId('editor')).toBeVisible();
|
|
63
|
+
const editor = screen.getByTestId('editor').getByRole('textbox');
|
|
64
|
+
await expect.element(editor).toHaveTextContent('');
|
|
65
|
+
// send keystrokes to the editor
|
|
66
|
+
await userEvent.type(editor, 'Hello, world!');
|
|
67
|
+
await expect.element(editor).toHaveTextContent('Hello, world!');
|
|
68
|
+
expect(testPost.get('requiredBody').getSnapshot()).toEqual({
|
|
69
|
+
type: 'doc',
|
|
70
|
+
attrs: {},
|
|
71
|
+
from: null,
|
|
72
|
+
to: null,
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: 'paragraph',
|
|
76
|
+
attrs: {},
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: 'text',
|
|
80
|
+
attrs: {},
|
|
81
|
+
content: [],
|
|
82
|
+
from: null,
|
|
83
|
+
to: null,
|
|
84
|
+
text: 'Hello, world!',
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
from: null,
|
|
88
|
+
to: null,
|
|
89
|
+
text: null,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
text: null,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
it('should support nullable tiptap schema fields with a specified default doc', async () => {
|
|
96
|
+
const testPost = await client.posts.put({});
|
|
97
|
+
const TipTapTest = () => {
|
|
98
|
+
const editor = useSyncedEditor(testPost, 'nullableBody', {
|
|
99
|
+
editorOptions: { extensions: [StarterKit] },
|
|
100
|
+
nullDocumentDefault: {
|
|
101
|
+
type: 'doc',
|
|
102
|
+
content: [],
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
return (_jsxs("div", { children: [_jsx("div", { children: "Text editor:" }), _jsx(EditorContent, { style: {
|
|
106
|
+
width: 500,
|
|
107
|
+
height: 300,
|
|
108
|
+
}, editor: editor, id: "#editor", "data-testid": "editor" })] }));
|
|
109
|
+
};
|
|
110
|
+
const screen = await renderWithProvider(_jsx(TipTapTest, {}));
|
|
111
|
+
await expect.element(screen.getByTestId('editor')).toBeVisible();
|
|
112
|
+
const editor = screen.getByTestId('editor').getByRole('textbox');
|
|
113
|
+
await expect.element(editor).toHaveTextContent('');
|
|
114
|
+
// send keystrokes to the editor
|
|
115
|
+
await userEvent.type(editor, 'Hello, world!');
|
|
116
|
+
await expect.element(editor).toHaveTextContent('Hello, world!');
|
|
117
|
+
expect(testPost.get('nullableBody').getSnapshot()).toEqual({
|
|
118
|
+
type: 'doc',
|
|
119
|
+
attrs: {},
|
|
120
|
+
from: null,
|
|
121
|
+
to: null,
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: 'paragraph',
|
|
125
|
+
attrs: {},
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: 'text',
|
|
129
|
+
attrs: {},
|
|
130
|
+
content: [],
|
|
131
|
+
from: null,
|
|
132
|
+
to: null,
|
|
133
|
+
text: 'Hello, world!',
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
from: null,
|
|
137
|
+
to: null,
|
|
138
|
+
text: null,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
text: null,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
//# sourceMappingURL=react.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.test.js","sourceRoot":"","sources":["../../../src/__browserTests__/react.test.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ShapeFromProperty, StorageAnyFieldSchema, StorageArrayFieldSchema, StorageNumberFieldSchema, StorageObjectFieldSchema, StorageStringFieldSchema } from '@verdant-web/common';
|
|
2
|
+
export type TiptapFieldSchema = StorageObjectFieldSchema<{
|
|
3
|
+
type: StorageStringFieldSchema;
|
|
4
|
+
from: StorageNumberFieldSchema;
|
|
5
|
+
to: StorageNumberFieldSchema;
|
|
6
|
+
attrs: StorageObjectFieldSchema<{
|
|
7
|
+
values: StorageAnyFieldSchema;
|
|
8
|
+
}>;
|
|
9
|
+
content: StorageArrayFieldSchema<TiptapFieldSchema>;
|
|
10
|
+
text: StorageStringFieldSchema;
|
|
11
|
+
marks: StorageArrayFieldSchema<TiptapFieldSchema>;
|
|
12
|
+
}>;
|
|
13
|
+
export type TipTapFieldInitializer = Pick<ShapeFromProperty<TiptapFieldSchema>, 'type'> & Partial<ShapeFromProperty<TiptapFieldSchema>>;
|
|
14
|
+
/**
|
|
15
|
+
* Creates a generic TipTap schema field. You must invoke this and assign it
|
|
16
|
+
* individually to every TipTap document field in your schema, DO NOT reuse
|
|
17
|
+
* the same instance for multiple fields.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createTipTapFieldSchema(options: {
|
|
20
|
+
default: TipTapFieldInitializer | null;
|
|
21
|
+
}): TiptapFieldSchema;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { schema, } from '@verdant-web/common';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a generic TipTap schema field. You must invoke this and assign it
|
|
4
|
+
* individually to every TipTap document field in your schema, DO NOT reuse
|
|
5
|
+
* the same instance for multiple fields.
|
|
6
|
+
*/
|
|
7
|
+
export function createTipTapFieldSchema(options) {
|
|
8
|
+
if (options.default === undefined) {
|
|
9
|
+
throw new Error('createTiptapFieldSchema requires a default value. Specify "null" to make the field nullable.');
|
|
10
|
+
}
|
|
11
|
+
const baseField = schema.fields.object({
|
|
12
|
+
fields: {},
|
|
13
|
+
default: () => {
|
|
14
|
+
if (options.default === null) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return structuredClone(options.default);
|
|
18
|
+
},
|
|
19
|
+
nullable: options.default === null,
|
|
20
|
+
});
|
|
21
|
+
return schema.fields.replaceObjectFields(baseField, {
|
|
22
|
+
type: schema.fields.string(),
|
|
23
|
+
from: schema.fields.number({ nullable: true }),
|
|
24
|
+
to: schema.fields.number({ nullable: true }),
|
|
25
|
+
attrs: schema.fields.map({
|
|
26
|
+
values: schema.fields.any(),
|
|
27
|
+
}),
|
|
28
|
+
content: schema.fields.array({
|
|
29
|
+
items: baseField,
|
|
30
|
+
}),
|
|
31
|
+
text: schema.fields.string({ nullable: true }),
|
|
32
|
+
marks: schema.fields.array({
|
|
33
|
+
items: baseField,
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=fields.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields.js","sourceRoot":"","sources":["../../src/fields.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,MAAM,GAON,MAAM,qBAAqB,CAAC;AAoB7B;;;;GAIG;AACH,MAAM,UAAU,uBAAuB,CAAC,OAEvC;IACA,IAAI,OAAO,CAAC,OAAO,KAAK,SAAS,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CACd,8FAA8F,CAC9F,CAAC;IACH,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;QACtC,MAAM,EAAE,EAAE;QACV,OAAO,EAAE,GAAG,EAAE;YACb,IAAI,OAAO,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC;gBAC9B,OAAO,IAAI,CAAC;YACb,CAAC;YACD,OAAO,eAAe,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QACzC,CAAC;QACD,QAAQ,EAAE,OAAO,CAAC,OAAO,KAAK,IAAI;KAClC,CAAC,CAAC;IACH,OAAO,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,SAAS,EAAE;QACnD,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE;QAC5B,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;QAC9C,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;QAC5C,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC;YACxB,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE;SAC3B,CAAC;QACF,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC;YAC5B,KAAK,EAAE,SAAS;SAChB,CAAC;QACF,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;QAC9C,KAAK,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC;YAC1B,KAAK,EAAE,SAAS;SAChB,CAAC;KACF,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core';
|
|
2
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
3
|
+
import { id } from '@verdant-web/store';
|
|
4
|
+
const NodeIdPlugin = new Plugin({
|
|
5
|
+
key: new PluginKey('node-ids'),
|
|
6
|
+
appendTransaction: (_, oldState, newState) => {
|
|
7
|
+
// no changes
|
|
8
|
+
if (newState.doc === oldState.doc)
|
|
9
|
+
return;
|
|
10
|
+
const tr = newState.tr;
|
|
11
|
+
// force replacement of any duplicates, too
|
|
12
|
+
const usedIds = new Set();
|
|
13
|
+
newState.doc.descendants((node, pos) => {
|
|
14
|
+
var _a;
|
|
15
|
+
console.log(node);
|
|
16
|
+
if (!node.isText && (!node.attrs.id || usedIds.has(node.attrs.id))) {
|
|
17
|
+
const nodeId = id();
|
|
18
|
+
console.log('adding node id', nodeId);
|
|
19
|
+
tr.setNodeMarkup(pos, null, Object.assign(Object.assign({}, node.attrs), { id: nodeId }));
|
|
20
|
+
usedIds.add(nodeId);
|
|
21
|
+
}
|
|
22
|
+
else if ((_a = node.attrs) === null || _a === void 0 ? void 0 : _a.id) {
|
|
23
|
+
usedIds.add(node.attrs.id);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return tr;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const defaultAllNodes = [
|
|
30
|
+
'blockquote',
|
|
31
|
+
'bulletList',
|
|
32
|
+
'codeBlock',
|
|
33
|
+
'heading',
|
|
34
|
+
'listItem',
|
|
35
|
+
'orderedList',
|
|
36
|
+
'paragraph',
|
|
37
|
+
'image',
|
|
38
|
+
'mention',
|
|
39
|
+
'table',
|
|
40
|
+
'taskList',
|
|
41
|
+
'taskItem',
|
|
42
|
+
'youtube',
|
|
43
|
+
];
|
|
44
|
+
export const NodeIdExtension = (options = {}) => Extension.create({
|
|
45
|
+
name: 'nodeId',
|
|
46
|
+
addProseMirrorPlugins() {
|
|
47
|
+
return [NodeIdPlugin];
|
|
48
|
+
},
|
|
49
|
+
addGlobalAttributes() {
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
types: options.nodeTypes || defaultAllNodes,
|
|
53
|
+
attributes: {
|
|
54
|
+
id: {
|
|
55
|
+
default: null,
|
|
56
|
+
keepOnSplit: false,
|
|
57
|
+
parseHTML: (element) => element.getAttribute('data-id'),
|
|
58
|
+
renderHTML: (attributes) => {
|
|
59
|
+
if (!attributes.id)
|
|
60
|
+
return {};
|
|
61
|
+
return {
|
|
62
|
+
'data-id': attributes.id,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
//# sourceMappingURL=plugins.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugins.js","sourceRoot":"","sources":["../../src/plugins.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,EAAE,EAAE,MAAM,oBAAoB,CAAC;AAExC,MAAM,YAAY,GAAG,IAAI,MAAM,CAAC;IAC/B,GAAG,EAAE,IAAI,SAAS,CAAC,UAAU,CAAC;IAC9B,iBAAiB,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE;QAC5C,aAAa;QACb,IAAI,QAAQ,CAAC,GAAG,KAAK,QAAQ,CAAC,GAAG;YAAE,OAAO;QAC1C,MAAM,EAAE,GAAG,QAAQ,CAAC,EAAE,CAAC;QACvB,2CAA2C;QAC3C,MAAM,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;QAClC,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE;;YACtC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YAClB,IAAI,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;gBACpE,MAAM,MAAM,GAAG,EAAE,EAAE,CAAC;gBACpB,OAAO,CAAC,GAAG,CAAC,gBAAgB,EAAE,MAAM,CAAC,CAAC;gBACtC,EAAE,CAAC,aAAa,CAAC,GAAG,EAAE,IAAI,kCACtB,IAAI,CAAC,KAAK,KACb,EAAE,EAAE,MAAM,IACT,CAAC;gBACH,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YACrB,CAAC;iBAAM,IAAI,MAAA,IAAI,CAAC,KAAK,0CAAE,EAAE,EAAE,CAAC;gBAC3B,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC5B,CAAC;QACF,CAAC,CAAC,CAAC;QACH,OAAO,EAAE,CAAC;IACX,CAAC;CACD,CAAC,CAAC;AAEH,MAAM,eAAe,GAAG;IACvB,YAAY;IACZ,YAAY;IACZ,WAAW;IACX,SAAS;IACT,UAAU;IACV,aAAa;IACb,WAAW;IACX,OAAO;IACP,SAAS;IACT,OAAO;IACP,UAAU;IACV,UAAU;IACV,SAAS;CACT,CAAC;AACF,MAAM,CAAC,MAAM,eAAe,GAAG,CAC9B,UAEI,EAAE,EACL,EAAE,CACH,SAAS,CAAC,MAAM,CAAC;IAChB,IAAI,EAAE,QAAQ;IACd,qBAAqB;QACpB,OAAO,CAAC,YAAY,CAAC,CAAC;IACvB,CAAC;IACD,mBAAmB;QAClB,OAAO;YACN;gBACC,KAAK,EAAE,OAAO,CAAC,SAAS,IAAI,eAAe;gBAC3C,UAAU,EAAE;oBACX,EAAE,EAAE;wBACH,OAAO,EAAE,IAAI;wBACb,WAAW,EAAE,KAAK;wBAClB,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC,SAAS,CAAC;wBACvD,UAAU,EAAE,CAAC,UAAU,EAAE,EAAE;4BAC1B,IAAI,CAAC,UAAU,CAAC,EAAE;gCAAE,OAAO,EAAE,CAAC;4BAC9B,OAAO;gCACN,SAAS,EAAE,UAAU,CAAC,EAAE;6BACxB,CAAC;wBACH,CAAC;qBACD;iBACD;aACD;SACD,CAAC;IACH,CAAC;CACD,CAAC,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/core';
|
|
2
|
+
import { UseEditorOptions } from '@tiptap/react';
|
|
3
|
+
import { AnyEntity } from '@verdant-web/store';
|
|
4
|
+
type AllowedKey<Ent extends AnyEntity<any, any, any>> = Ent extends never ? string : Ent extends AnyEntity<any, any, infer Shape> ? keyof Shape : never;
|
|
5
|
+
type EntitySnapshot<Ent extends AnyEntity<any, any, any>, Key extends AllowedKey<Ent>> = Ent extends AnyEntity<any, any, infer Snap> ? Key extends keyof Snap ? Snap[Key] : any : never;
|
|
6
|
+
export declare function useSyncedEditor<Ent extends AnyEntity<any, any, any>, Key extends AllowedKey<Ent>>(parent: Ent, fieldName: Key, { editorOptions: extraOptions, editorDependencies, nullDocumentDefault, }?: {
|
|
7
|
+
editorOptions?: UseEditorOptions;
|
|
8
|
+
editorDependencies?: any[];
|
|
9
|
+
nullDocumentDefault?: EntitySnapshot<Ent, Key>;
|
|
10
|
+
}): Editor | null;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useEditor } from '@tiptap/react';
|
|
2
|
+
import { useWatch } from '@verdant-web/react';
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
4
|
+
export function useSyncedEditor(parent, fieldName, { editorOptions: extraOptions, editorDependencies, nullDocumentDefault, } = {}) {
|
|
5
|
+
const cachedOptions = useRef({
|
|
6
|
+
nullDocumentDefault,
|
|
7
|
+
fieldName,
|
|
8
|
+
});
|
|
9
|
+
cachedOptions.current = {
|
|
10
|
+
nullDocumentDefault,
|
|
11
|
+
fieldName,
|
|
12
|
+
};
|
|
13
|
+
const live = useWatch(parent);
|
|
14
|
+
const field = live[fieldName];
|
|
15
|
+
const updatingRef = useRef(false);
|
|
16
|
+
const update = useCallback((editor) => {
|
|
17
|
+
if (updatingRef.current) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const newData = editor.getJSON();
|
|
21
|
+
const value = parent.get(cachedOptions.current.fieldName);
|
|
22
|
+
if (!value) {
|
|
23
|
+
parent.set(cachedOptions.current.fieldName, newData);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
console.log('new data', newData);
|
|
27
|
+
value.update(newData, {
|
|
28
|
+
merge: false,
|
|
29
|
+
dangerouslyDisableMerge: true,
|
|
30
|
+
replaceSubObjects: false,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}, [parent]);
|
|
34
|
+
const cachedInitialContent = useRef(ensureDocShape(getFieldSnapshot(field, nullDocumentDefault, fieldName)));
|
|
35
|
+
const editor = useEditor(Object.assign(Object.assign({}, extraOptions), { content: cachedInitialContent.current, onUpdate: (ctx) => {
|
|
36
|
+
var _a;
|
|
37
|
+
update(ctx.editor);
|
|
38
|
+
(_a = extraOptions === null || extraOptions === void 0 ? void 0 : extraOptions.onUpdate) === null || _a === void 0 ? void 0 : _a.call(extraOptions, ctx);
|
|
39
|
+
} }), [update, ...(editorDependencies !== null && editorDependencies !== void 0 ? editorDependencies : [])]);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
function updateFromField() {
|
|
42
|
+
if (editor && !editor.isDestroyed) {
|
|
43
|
+
updatingRef.current = true;
|
|
44
|
+
const { from, to } = editor.state.selection;
|
|
45
|
+
editor.commands.setContent(ensureDocShape(getFieldSnapshot(field, cachedOptions.current.nullDocumentDefault, cachedOptions.current.fieldName)), false);
|
|
46
|
+
editor.commands.setTextSelection({ from, to });
|
|
47
|
+
updatingRef.current = false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
updateFromField();
|
|
51
|
+
return field === null || field === void 0 ? void 0 : field.subscribe('changeDeep', (target, info) => {
|
|
52
|
+
if (!info.isLocal || target === field) {
|
|
53
|
+
updateFromField();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}, [field, editor, cachedOptions]);
|
|
57
|
+
return editor;
|
|
58
|
+
}
|
|
59
|
+
// since the schema doesn't enforce this shape but it's
|
|
60
|
+
// needed for the editor to work, we'll ensure it here
|
|
61
|
+
function ensureDocShape(json) {
|
|
62
|
+
var _a;
|
|
63
|
+
for (const node of (_a = json.content) !== null && _a !== void 0 ? _a : []) {
|
|
64
|
+
// remove undefined nodes
|
|
65
|
+
node.content = node.content.filter((n) => !!n).map(ensureDocShape);
|
|
66
|
+
}
|
|
67
|
+
return json;
|
|
68
|
+
}
|
|
69
|
+
function getFieldSnapshot(field, nullDocumentDefault, fieldName) {
|
|
70
|
+
const content = field ? field.getSnapshot() : (nullDocumentDefault !== null && nullDocumentDefault !== void 0 ? nullDocumentDefault : null);
|
|
71
|
+
if (content === null) {
|
|
72
|
+
throw new Error(`The provided field "${String(fieldName)}" is null and a default document was not provided.
|
|
73
|
+
Please provide a default document or ensure the field is not null when calling useSyncedEditor, or make your
|
|
74
|
+
field schema non-null and specify a default document there.`);
|
|
75
|
+
}
|
|
76
|
+
return content;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=react.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.js","sourceRoot":"","sources":["../../src/react.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@verdant-web/tiptap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"access": "public",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/esm/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/esm/index.js",
|
|
10
|
+
"types": "./dist/esm/index.d.ts"
|
|
11
|
+
},
|
|
12
|
+
"./react": {
|
|
13
|
+
"import": "./dist/esm/react.js",
|
|
14
|
+
"types": "./dist/esm/react.d.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/",
|
|
22
|
+
"src/"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@tiptap/core": "^2.11.5",
|
|
26
|
+
"@tiptap/pm": "^2.11.5",
|
|
27
|
+
"@tiptap/react": "^2.11.5",
|
|
28
|
+
"react": "^19.0.0",
|
|
29
|
+
"@verdant-web/react": "40.2.2",
|
|
30
|
+
"@verdant-web/store": "4.1.4"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"@verdant-web/react": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"react": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"@tiptap/react": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@verdant-web/common": "2.7.1"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"typescript": "5.7.3",
|
|
48
|
+
"vitest": "3.0.6",
|
|
49
|
+
"@tiptap/core": "^2.11.5",
|
|
50
|
+
"@tiptap/pm": "^2.11.5",
|
|
51
|
+
"@tiptap/react": "^2.11.5",
|
|
52
|
+
"@tiptap/starter-kit": "^2.11.5",
|
|
53
|
+
"react": "19.0.0",
|
|
54
|
+
"@types/react": "19.0.10",
|
|
55
|
+
"vitest-browser-react": "0.1.1",
|
|
56
|
+
"@vitest/browser": "3.0.6",
|
|
57
|
+
"playwright": "1.50.1",
|
|
58
|
+
"vite": "6.1.1",
|
|
59
|
+
"@vitejs/plugin-react": "4.3.4",
|
|
60
|
+
"react-dom": "19.0.0",
|
|
61
|
+
"@types/react-dom": "19.0.4",
|
|
62
|
+
"@verdant-web/react": "40.2.2",
|
|
63
|
+
"@verdant-web/store": "4.1.4",
|
|
64
|
+
"@verdant-web/cli": "4.8.1",
|
|
65
|
+
"@verdant-web/server": "3.3.6"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"test": "vitest",
|
|
69
|
+
"ci:test:unit": "vitest run",
|
|
70
|
+
"build": "tsc -p tsconfig.json",
|
|
71
|
+
"prepublish": "pnpm run build",
|
|
72
|
+
"link": "pnpm link --global",
|
|
73
|
+
"typecheck": "tsc --noEmit",
|
|
74
|
+
"demo:generate": "verdant -s ./demo/store/schema.ts -o ./demo/store/.generated -r --select=publish --module=esm",
|
|
75
|
+
"demo:dev": "vite --config ./demo/vite.config.ts ./demo",
|
|
76
|
+
"demo:server": "verdant-server"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { EditorContent } from '@tiptap/react';
|
|
2
|
+
import { StarterKit } from '@tiptap/starter-kit';
|
|
3
|
+
import { createMigration, schema } from '@verdant-web/common';
|
|
4
|
+
import { createHooks } from '@verdant-web/react';
|
|
5
|
+
import { ClientDescriptor, ClientWithCollections } from '@verdant-web/store';
|
|
6
|
+
import { userEvent } from '@vitest/browser/context';
|
|
7
|
+
import { ReactNode, Suspense } from 'react';
|
|
8
|
+
import { afterAll, beforeAll, expect, it } from 'vitest';
|
|
9
|
+
import { render } from 'vitest-browser-react';
|
|
10
|
+
import { createTipTapFieldSchema } from '../fields.js';
|
|
11
|
+
import { useSyncedEditor } from '../react.js';
|
|
12
|
+
|
|
13
|
+
const testSchema = schema({
|
|
14
|
+
version: 1,
|
|
15
|
+
collections: {
|
|
16
|
+
posts: schema.collection({
|
|
17
|
+
name: 'post',
|
|
18
|
+
primaryKey: 'id',
|
|
19
|
+
fields: {
|
|
20
|
+
id: schema.fields.id(),
|
|
21
|
+
nullableBody: createTipTapFieldSchema({ default: null }),
|
|
22
|
+
requiredBody: createTipTapFieldSchema({
|
|
23
|
+
default: {
|
|
24
|
+
type: 'doc',
|
|
25
|
+
content: [],
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const hooks = createHooks(testSchema);
|
|
34
|
+
|
|
35
|
+
let clientDesc: ClientDescriptor;
|
|
36
|
+
let client: ClientWithCollections;
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
clientDesc = new ClientDescriptor({
|
|
39
|
+
schema: testSchema,
|
|
40
|
+
oldSchemas: [testSchema],
|
|
41
|
+
migrations: [createMigration(testSchema)],
|
|
42
|
+
namespace: 'tiptatp-test',
|
|
43
|
+
});
|
|
44
|
+
client = await (clientDesc.open() as Promise<ClientWithCollections>);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => client.close());
|
|
48
|
+
|
|
49
|
+
function renderWithProvider(content: ReactNode) {
|
|
50
|
+
return render(content, {
|
|
51
|
+
wrapper: ({ children }) => (
|
|
52
|
+
<Suspense>
|
|
53
|
+
<hooks.Provider value={clientDesc}>
|
|
54
|
+
<Suspense fallback={<div data-testid="suspense">Loading...</div>}>
|
|
55
|
+
{children}
|
|
56
|
+
</Suspense>
|
|
57
|
+
</hooks.Provider>
|
|
58
|
+
</Suspense>
|
|
59
|
+
),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it('should support non-nullable tiptap schema fields', async () => {
|
|
64
|
+
const testPost = await client.posts.put({});
|
|
65
|
+
|
|
66
|
+
const TipTapTest = () => {
|
|
67
|
+
const editor = useSyncedEditor(testPost, 'requiredBody', {
|
|
68
|
+
editorOptions: { extensions: [StarterKit] },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div>
|
|
73
|
+
<div>Text editor:</div>
|
|
74
|
+
<EditorContent
|
|
75
|
+
style={{
|
|
76
|
+
width: 500,
|
|
77
|
+
height: 300,
|
|
78
|
+
}}
|
|
79
|
+
editor={editor}
|
|
80
|
+
id="#editor"
|
|
81
|
+
data-testid="editor"
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const screen = await renderWithProvider(<TipTapTest />);
|
|
88
|
+
await expect.element(screen.getByTestId('editor')).toBeVisible();
|
|
89
|
+
|
|
90
|
+
const editor = screen.getByTestId('editor').getByRole('textbox');
|
|
91
|
+
await expect.element(editor).toHaveTextContent('');
|
|
92
|
+
|
|
93
|
+
// send keystrokes to the editor
|
|
94
|
+
await userEvent.type(editor, 'Hello, world!');
|
|
95
|
+
await expect.element(editor).toHaveTextContent('Hello, world!');
|
|
96
|
+
|
|
97
|
+
expect(testPost.get('requiredBody').getSnapshot()).toEqual({
|
|
98
|
+
type: 'doc',
|
|
99
|
+
attrs: {},
|
|
100
|
+
from: null,
|
|
101
|
+
to: null,
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: 'paragraph',
|
|
105
|
+
attrs: {},
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: 'text',
|
|
109
|
+
attrs: {},
|
|
110
|
+
content: [],
|
|
111
|
+
from: null,
|
|
112
|
+
to: null,
|
|
113
|
+
text: 'Hello, world!',
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
from: null,
|
|
117
|
+
to: null,
|
|
118
|
+
text: null,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
text: null,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should support nullable tiptap schema fields with a specified default doc', async () => {
|
|
126
|
+
const testPost = await client.posts.put({});
|
|
127
|
+
|
|
128
|
+
const TipTapTest = () => {
|
|
129
|
+
const editor = useSyncedEditor(testPost, 'nullableBody', {
|
|
130
|
+
editorOptions: { extensions: [StarterKit] },
|
|
131
|
+
nullDocumentDefault: {
|
|
132
|
+
type: 'doc',
|
|
133
|
+
content: [],
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div>
|
|
139
|
+
<div>Text editor:</div>
|
|
140
|
+
<EditorContent
|
|
141
|
+
style={{
|
|
142
|
+
width: 500,
|
|
143
|
+
height: 300,
|
|
144
|
+
}}
|
|
145
|
+
editor={editor}
|
|
146
|
+
id="#editor"
|
|
147
|
+
data-testid="editor"
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const screen = await renderWithProvider(<TipTapTest />);
|
|
154
|
+
await expect.element(screen.getByTestId('editor')).toBeVisible();
|
|
155
|
+
|
|
156
|
+
const editor = screen.getByTestId('editor').getByRole('textbox');
|
|
157
|
+
await expect.element(editor).toHaveTextContent('');
|
|
158
|
+
|
|
159
|
+
// send keystrokes to the editor
|
|
160
|
+
await userEvent.type(editor, 'Hello, world!');
|
|
161
|
+
await expect.element(editor).toHaveTextContent('Hello, world!');
|
|
162
|
+
|
|
163
|
+
expect(testPost.get('nullableBody').getSnapshot()).toEqual({
|
|
164
|
+
type: 'doc',
|
|
165
|
+
attrs: {},
|
|
166
|
+
from: null,
|
|
167
|
+
to: null,
|
|
168
|
+
content: [
|
|
169
|
+
{
|
|
170
|
+
type: 'paragraph',
|
|
171
|
+
attrs: {},
|
|
172
|
+
content: [
|
|
173
|
+
{
|
|
174
|
+
type: 'text',
|
|
175
|
+
attrs: {},
|
|
176
|
+
content: [],
|
|
177
|
+
from: null,
|
|
178
|
+
to: null,
|
|
179
|
+
text: 'Hello, world!',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
from: null,
|
|
183
|
+
to: null,
|
|
184
|
+
text: null,
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
text: null,
|
|
188
|
+
});
|
|
189
|
+
});
|
package/src/fields.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
schema,
|
|
3
|
+
ShapeFromProperty,
|
|
4
|
+
StorageAnyFieldSchema,
|
|
5
|
+
StorageArrayFieldSchema,
|
|
6
|
+
StorageNumberFieldSchema,
|
|
7
|
+
StorageObjectFieldSchema,
|
|
8
|
+
StorageStringFieldSchema,
|
|
9
|
+
} from '@verdant-web/common';
|
|
10
|
+
|
|
11
|
+
export type TiptapFieldSchema = StorageObjectFieldSchema<{
|
|
12
|
+
type: StorageStringFieldSchema;
|
|
13
|
+
from: StorageNumberFieldSchema;
|
|
14
|
+
to: StorageNumberFieldSchema;
|
|
15
|
+
attrs: StorageObjectFieldSchema<{
|
|
16
|
+
values: StorageAnyFieldSchema;
|
|
17
|
+
}>;
|
|
18
|
+
content: StorageArrayFieldSchema<TiptapFieldSchema>;
|
|
19
|
+
text: StorageStringFieldSchema;
|
|
20
|
+
marks: StorageArrayFieldSchema<TiptapFieldSchema>;
|
|
21
|
+
}>;
|
|
22
|
+
|
|
23
|
+
export type TipTapFieldInitializer = Pick<
|
|
24
|
+
ShapeFromProperty<TiptapFieldSchema>,
|
|
25
|
+
'type'
|
|
26
|
+
> &
|
|
27
|
+
Partial<ShapeFromProperty<TiptapFieldSchema>>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a generic TipTap schema field. You must invoke this and assign it
|
|
31
|
+
* individually to every TipTap document field in your schema, DO NOT reuse
|
|
32
|
+
* the same instance for multiple fields.
|
|
33
|
+
*/
|
|
34
|
+
export function createTipTapFieldSchema(options: {
|
|
35
|
+
default: TipTapFieldInitializer | null;
|
|
36
|
+
}): TiptapFieldSchema {
|
|
37
|
+
if (options.default === undefined) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'createTiptapFieldSchema requires a default value. Specify "null" to make the field nullable.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const baseField = schema.fields.object({
|
|
43
|
+
fields: {},
|
|
44
|
+
default: () => {
|
|
45
|
+
if (options.default === null) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return structuredClone(options.default);
|
|
49
|
+
},
|
|
50
|
+
nullable: options.default === null,
|
|
51
|
+
});
|
|
52
|
+
return schema.fields.replaceObjectFields(baseField, {
|
|
53
|
+
type: schema.fields.string(),
|
|
54
|
+
from: schema.fields.number({ nullable: true }),
|
|
55
|
+
to: schema.fields.number({ nullable: true }),
|
|
56
|
+
attrs: schema.fields.map({
|
|
57
|
+
values: schema.fields.any(),
|
|
58
|
+
}),
|
|
59
|
+
content: schema.fields.array({
|
|
60
|
+
items: baseField,
|
|
61
|
+
}),
|
|
62
|
+
text: schema.fields.string({ nullable: true }),
|
|
63
|
+
marks: schema.fields.array({
|
|
64
|
+
items: baseField,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
}
|
package/src/index.ts
ADDED
package/src/plugins.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/core';
|
|
2
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
3
|
+
import { id } from '@verdant-web/store';
|
|
4
|
+
|
|
5
|
+
const NodeIdPlugin = new Plugin({
|
|
6
|
+
key: new PluginKey('node-ids'),
|
|
7
|
+
appendTransaction: (_, oldState, newState) => {
|
|
8
|
+
// no changes
|
|
9
|
+
if (newState.doc === oldState.doc) return;
|
|
10
|
+
const tr = newState.tr;
|
|
11
|
+
// force replacement of any duplicates, too
|
|
12
|
+
const usedIds = new Set<string>();
|
|
13
|
+
newState.doc.descendants((node, pos) => {
|
|
14
|
+
console.log(node);
|
|
15
|
+
if (!node.isText && (!node.attrs.id || usedIds.has(node.attrs.id))) {
|
|
16
|
+
const nodeId = id();
|
|
17
|
+
console.log('adding node id', nodeId);
|
|
18
|
+
tr.setNodeMarkup(pos, null, {
|
|
19
|
+
...node.attrs,
|
|
20
|
+
id: nodeId,
|
|
21
|
+
});
|
|
22
|
+
usedIds.add(nodeId);
|
|
23
|
+
} else if (node.attrs?.id) {
|
|
24
|
+
usedIds.add(node.attrs.id);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
return tr;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const defaultAllNodes = [
|
|
32
|
+
'blockquote',
|
|
33
|
+
'bulletList',
|
|
34
|
+
'codeBlock',
|
|
35
|
+
'heading',
|
|
36
|
+
'listItem',
|
|
37
|
+
'orderedList',
|
|
38
|
+
'paragraph',
|
|
39
|
+
'image',
|
|
40
|
+
'mention',
|
|
41
|
+
'table',
|
|
42
|
+
'taskList',
|
|
43
|
+
'taskItem',
|
|
44
|
+
'youtube',
|
|
45
|
+
];
|
|
46
|
+
export const NodeIdExtension = (
|
|
47
|
+
options: {
|
|
48
|
+
nodeTypes?: string[];
|
|
49
|
+
} = {},
|
|
50
|
+
) =>
|
|
51
|
+
Extension.create({
|
|
52
|
+
name: 'nodeId',
|
|
53
|
+
addProseMirrorPlugins() {
|
|
54
|
+
return [NodeIdPlugin];
|
|
55
|
+
},
|
|
56
|
+
addGlobalAttributes() {
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
types: options.nodeTypes || defaultAllNodes,
|
|
60
|
+
attributes: {
|
|
61
|
+
id: {
|
|
62
|
+
default: null,
|
|
63
|
+
keepOnSplit: false,
|
|
64
|
+
parseHTML: (element) => element.getAttribute('data-id'),
|
|
65
|
+
renderHTML: (attributes) => {
|
|
66
|
+
if (!attributes.id) return {};
|
|
67
|
+
return {
|
|
68
|
+
'data-id': attributes.id,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
},
|
|
76
|
+
});
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/core';
|
|
2
|
+
import { useEditor, UseEditorOptions } from '@tiptap/react';
|
|
3
|
+
import { useWatch } from '@verdant-web/react';
|
|
4
|
+
import { AnyEntity, ObjectEntity } from '@verdant-web/store';
|
|
5
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
6
|
+
|
|
7
|
+
type AllowedKey<Ent extends AnyEntity<any, any, any>> = Ent extends never
|
|
8
|
+
? string
|
|
9
|
+
: Ent extends AnyEntity<any, any, infer Shape>
|
|
10
|
+
? keyof Shape
|
|
11
|
+
: never;
|
|
12
|
+
|
|
13
|
+
type EntitySnapshot<
|
|
14
|
+
Ent extends AnyEntity<any, any, any>,
|
|
15
|
+
Key extends AllowedKey<Ent>,
|
|
16
|
+
> =
|
|
17
|
+
Ent extends AnyEntity<any, any, infer Snap>
|
|
18
|
+
? Key extends keyof Snap
|
|
19
|
+
? Snap[Key]
|
|
20
|
+
: any
|
|
21
|
+
: never;
|
|
22
|
+
|
|
23
|
+
export function useSyncedEditor<
|
|
24
|
+
Ent extends AnyEntity<any, any, any>,
|
|
25
|
+
Key extends AllowedKey<Ent>,
|
|
26
|
+
>(
|
|
27
|
+
parent: Ent,
|
|
28
|
+
fieldName: Key,
|
|
29
|
+
{
|
|
30
|
+
editorOptions: extraOptions,
|
|
31
|
+
editorDependencies,
|
|
32
|
+
nullDocumentDefault,
|
|
33
|
+
}: {
|
|
34
|
+
editorOptions?: UseEditorOptions;
|
|
35
|
+
editorDependencies?: any[];
|
|
36
|
+
nullDocumentDefault?: EntitySnapshot<Ent, Key>;
|
|
37
|
+
} = {},
|
|
38
|
+
) {
|
|
39
|
+
const cachedOptions = useRef({
|
|
40
|
+
nullDocumentDefault,
|
|
41
|
+
fieldName,
|
|
42
|
+
});
|
|
43
|
+
cachedOptions.current = {
|
|
44
|
+
nullDocumentDefault,
|
|
45
|
+
fieldName,
|
|
46
|
+
};
|
|
47
|
+
const live = useWatch(parent);
|
|
48
|
+
const field = live[fieldName] as ObjectEntity<any, any>;
|
|
49
|
+
const updatingRef = useRef(false);
|
|
50
|
+
const update = useCallback(
|
|
51
|
+
(editor: Editor) => {
|
|
52
|
+
if (updatingRef.current) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const newData = editor.getJSON();
|
|
57
|
+
const value = parent.get(cachedOptions.current.fieldName) as ObjectEntity<
|
|
58
|
+
any,
|
|
59
|
+
any
|
|
60
|
+
> | null;
|
|
61
|
+
if (!value) {
|
|
62
|
+
parent.set(cachedOptions.current.fieldName as any, newData);
|
|
63
|
+
} else {
|
|
64
|
+
console.log('new data', newData);
|
|
65
|
+
value.update(newData, {
|
|
66
|
+
merge: false,
|
|
67
|
+
dangerouslyDisableMerge: true,
|
|
68
|
+
replaceSubObjects: false,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
[parent],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const cachedInitialContent = useRef(
|
|
76
|
+
ensureDocShape(getFieldSnapshot(field, nullDocumentDefault, fieldName)),
|
|
77
|
+
);
|
|
78
|
+
const editor = useEditor(
|
|
79
|
+
{
|
|
80
|
+
...extraOptions,
|
|
81
|
+
content: cachedInitialContent.current,
|
|
82
|
+
onUpdate: (ctx) => {
|
|
83
|
+
update(ctx.editor);
|
|
84
|
+
extraOptions?.onUpdate?.(ctx);
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
[update, ...(editorDependencies ?? [])],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
function updateFromField() {
|
|
92
|
+
if (editor && !editor.isDestroyed) {
|
|
93
|
+
updatingRef.current = true;
|
|
94
|
+
const { from, to } = editor.state.selection;
|
|
95
|
+
editor.commands.setContent(
|
|
96
|
+
ensureDocShape(
|
|
97
|
+
getFieldSnapshot(
|
|
98
|
+
field,
|
|
99
|
+
cachedOptions.current.nullDocumentDefault,
|
|
100
|
+
cachedOptions.current.fieldName,
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
false,
|
|
104
|
+
);
|
|
105
|
+
editor.commands.setTextSelection({ from, to });
|
|
106
|
+
updatingRef.current = false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
updateFromField();
|
|
111
|
+
|
|
112
|
+
return field?.subscribe('changeDeep', (target, info) => {
|
|
113
|
+
if (!info.isLocal || target === field) {
|
|
114
|
+
updateFromField();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}, [field, editor, cachedOptions]);
|
|
118
|
+
|
|
119
|
+
return editor;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// since the schema doesn't enforce this shape but it's
|
|
123
|
+
// needed for the editor to work, we'll ensure it here
|
|
124
|
+
function ensureDocShape(json: any) {
|
|
125
|
+
for (const node of json.content ?? []) {
|
|
126
|
+
// remove undefined nodes
|
|
127
|
+
node.content = node.content.filter((n: any) => !!n).map(ensureDocShape);
|
|
128
|
+
}
|
|
129
|
+
return json;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getFieldSnapshot(
|
|
133
|
+
field: ObjectEntity<any, any> | undefined | null,
|
|
134
|
+
nullDocumentDefault: any,
|
|
135
|
+
fieldName: string | symbol | number,
|
|
136
|
+
) {
|
|
137
|
+
const content = field ? field.getSnapshot() : (nullDocumentDefault ?? null);
|
|
138
|
+
if (content === null) {
|
|
139
|
+
throw new Error(`The provided field "${String(fieldName)}" is null and a default document was not provided.
|
|
140
|
+
Please provide a default document or ensure the field is not null when calling useSyncedEditor, or make your
|
|
141
|
+
field schema non-null and specify a default document there.`);
|
|
142
|
+
}
|
|
143
|
+
return content;
|
|
144
|
+
}
|