@witchcraft/editor 0.1.0 → 0.2.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.
Files changed (35) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/components/Editor.d.vue.ts +11 -1
  3. package/dist/runtime/components/Editor.vue.d.ts +11 -1
  4. package/dist/runtime/components/EditorDemoApp.vue +35 -5
  5. package/dist/runtime/components/EditorDemoControls.d.vue.ts +3 -0
  6. package/dist/runtime/components/EditorDemoControls.vue +55 -12
  7. package/dist/runtime/components/EditorDemoControls.vue.d.ts +3 -0
  8. package/dist/runtime/pm/features/Blocks/components/DragTreeHandle.d.vue.ts +2 -2
  9. package/dist/runtime/pm/features/Blocks/components/DragTreeHandle.vue.d.ts +2 -2
  10. package/dist/runtime/pm/features/Collaboration/Collaboration.d.ts +14 -63
  11. package/dist/runtime/pm/features/Collaboration/Collaboration.js +4 -164
  12. package/dist/runtime/pm/features/Collaboration/createCollaborationPlugins.d.ts +16 -0
  13. package/dist/runtime/pm/features/Collaboration/createCollaborationPlugins.js +82 -0
  14. package/dist/runtime/pm/features/CommandsMenus/commandBarMenuItems.js +5 -1
  15. package/dist/runtime/pm/features/DocumentApi/DocumentApi.d.ts +16 -3
  16. package/dist/runtime/pm/features/DocumentApi/DocumentApi.js +19 -2
  17. package/dist/runtime/pm/features/DocumentApi/composables/useEditorContent.js +2 -0
  18. package/dist/runtime/pm/features/DocumentApi/composables/useTestDocumentApi.d.ts +4 -1
  19. package/dist/runtime/pm/features/DocumentApi/composables/useTestDocumentApi.js +39 -8
  20. package/dist/runtime/pm/features/DocumentApi/types.d.ts +26 -48
  21. package/dist/runtime/pm/schema.d.ts +1 -1
  22. package/package.json +35 -34
  23. package/src/runtime/components/EditorDemoApp.vue +36 -5
  24. package/src/runtime/components/EditorDemoControls.vue +57 -12
  25. package/src/runtime/pm/features/Collaboration/Collaboration.ts +19 -286
  26. package/src/runtime/pm/features/Collaboration/createCollaborationPlugins.ts +129 -0
  27. package/src/runtime/pm/features/CommandsMenus/commandBarMenuItems.ts +5 -2
  28. package/src/runtime/pm/features/DocumentApi/DocumentApi.ts +35 -5
  29. package/src/runtime/pm/features/DocumentApi/composables/useEditorContent.ts +2 -0
  30. package/src/runtime/pm/features/DocumentApi/composables/useTestDocumentApi.ts +56 -8
  31. package/src/runtime/pm/features/DocumentApi/types.ts +30 -52
  32. package/dist/runtime/demo/App.d.vue.ts +0 -3
  33. package/dist/runtime/demo/App.vue +0 -100
  34. package/dist/runtime/demo/App.vue.d.ts +0 -3
  35. package/src/runtime/demo/App.vue +0 -113
@@ -1,295 +1,28 @@
1
- import { type Editor, Extension } from "@tiptap/core"
2
- import { Plugin, PluginKey } from "@tiptap/pm/state"
3
- import type { EditorView } from "@tiptap/pm/view"
4
- import {
5
- redo,
6
- undo,
7
- type UndoPluginState,
8
- ySyncPlugin,
9
- yUndoPlugin,
10
- yUndoPluginKey,
11
- yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"
12
- import * as Y from "yjs"
13
-
14
- const ySyncFilterPluginKey = new PluginKey("ySyncFilter")
15
- // eslint-disable-next-line @typescript-eslint/naming-convention
16
- type YSyncOpts = Parameters<typeof ySyncPlugin>[1]
17
- // eslint-disable-next-line @typescript-eslint/naming-convention
18
- type YUndoOpts = Parameters<typeof yUndoPlugin>[0]
19
-
20
- declare module "@tiptap/core" {
21
- // eslint-disable-next-line @typescript-eslint/naming-convention
22
- interface Commands<ReturnType> {
23
- collaboration: {
24
- /** Sets the fragment to use for the collaboration extension. */
25
- setFragment: (
26
- fragment: Y.XmlFragment | undefined,
27
- options?: { register?: boolean, unregister?: boolean }
28
- ) => ReturnType
29
- /** Enables or disables collaboration. */
30
- enableCollaboration: (enable: boolean) => ReturnType
31
- /**
32
- * Undo recent changes
33
- *
34
- * @example editor.commands.undo()
35
- */
36
- undo: () => ReturnType
37
- /**
38
- * Reapply reverted changes
39
- *
40
- * @example editor.commands.redo()
41
- */
42
- redo: () => ReturnType
43
- }
44
- }
45
- }
46
-
47
- export interface CollaborationStorage {
48
- /**
49
- * Whether collaboration is currently disabled.
50
- * Disabling collaboration will prevent any changes from being synced with other users.
51
- */
52
- isDisabled: boolean
53
- }
54
-
55
- export interface CollaborationOptions {
56
-
57
- /**
58
- * A raw Y.js fragment, can be used instead of `document` and `field`.
59
- *
60
- * @example new Y.Doc().getXmlFragment('body')
61
- */
62
- fragment?: Y.XmlFragment | null
63
-
64
- /**
65
- * Fired when the content from Yjs is initially rendered to Tiptap.
66
- */
67
- onFirstRender?: () => void
68
-
69
- /**
70
- * Options for the Yjs sync plugin.
71
- */
72
- ySyncOptions?: YSyncOpts
73
-
74
- /**
75
- * Options for the Yjs undo plugin.
76
- */
77
- yUndoOptions?: YUndoOpts
78
- /** @internal */
79
- // eslint-disable-next-line @typescript-eslint/naming-convention
80
- _destroySyncPlugin?: () => void
81
- }
82
- type CollabInstance = {
83
- editor: Editor
84
- options: CollaborationOptions
85
- storage: CollaborationStorage
86
- }
87
- type ExtendedUndoManager = UndoPluginState["undoManager"] & {
88
- restore?: () => void
89
- }
90
-
91
- function createUndoPlugin(self: CollabInstance): Plugin { // Quick fix until there is an official implementation (thanks to @hamflx).
92
- // See https://github.com/yjs/y-prosemirror/issues/114 and https://github.com/yjs/y-prosemirror/issues/102
93
- const yUndoPluginInstance = yUndoPlugin(self.options.yUndoOptions)
94
- const originalUndoPluginView = yUndoPluginInstance.spec.view
95
-
96
- yUndoPluginInstance.spec.view = (view: EditorView) => {
97
- const { undoManager } = yUndoPluginKey.getState(view.state) as UndoPluginState & { undoManager: ExtendedUndoManager }
98
-
99
- if (undoManager.restore) {
100
- undoManager.restore()
101
- undoManager.restore = () => {
102
- // noop
103
- }
104
- }
105
-
106
- const viewRet = originalUndoPluginView ? originalUndoPluginView(view) : undefined
107
-
108
- return {
109
- destroy: () => {
110
- const hasUndoManSelf = undoManager.trackedOrigins.has(undoManager)
111
-
112
- const observers = undoManager._observers
113
-
114
- undoManager.restore = () => {
115
- if (hasUndoManSelf) {
116
- undoManager.trackedOrigins.add(undoManager)
117
- }
118
-
119
- undoManager.doc.on("afterTransaction", undoManager.afterTransactionHandler)
120
-
121
- undoManager._observers = observers
122
- }
123
-
124
- if (viewRet?.destroy) {
125
- viewRet.destroy()
126
- }
127
- }
128
- }
129
- }
130
- return yUndoPluginInstance
131
- }
132
- function createSyncPlugin(
133
- self: CollabInstance
134
- ): { plugin: Plugin, destroy: () => void } {
135
- const fragment = self.options.fragment
136
- if (!fragment) {
137
- throw new Error("Collaboration requires a fragment.")
138
- }
139
- const plugin = ySyncPlugin(fragment, {
140
- ...self.options.ySyncOptions,
141
- onFirstRender: self.options.onFirstRender
142
- })
143
-
144
- let off: (() => void) | undefined
145
- function destroy(): void {
146
- off?.()
147
- // delete self.editor.state[ySyncPluginKey.key]
148
- }
149
- if (self.editor.options.enableContentCheck) {
150
- const onBeforeTransaction = (): false | undefined => {
151
- try {
152
- yXmlFragmentToProseMirrorRootNode(fragment, self.editor.schema).check()
153
- } catch (error) {
154
- self.editor.emit("contentError", {
155
- error: error as Error,
156
- editor: self.editor,
157
- disableCollaboration: () => {
158
- fragment.doc?.destroy()
159
- self.storage.isDisabled = true
160
- }
161
- })
162
- // If the content is invalid, return false to prevent the transaction from being applied
163
- return false
164
- }
165
- return undefined
166
- }
167
- fragment.doc?.on("beforeTransaction", onBeforeTransaction)
168
- off = () => {
169
- fragment.doc?.off("beforeTransaction", onBeforeTransaction)
170
- }
171
- }
172
- return { plugin, destroy }
173
- }
174
-
175
- function createSyncFilterPlugin(
176
- self: CollabInstance
177
- ): Plugin | undefined {
178
- if (!self.editor.options.enableContentCheck) return
179
- const fragment = self.options.fragment
180
- if (!fragment) {
181
- throw new Error("Collaboration requires a fragment.")
182
- }
183
- return new Plugin({
184
- key: ySyncFilterPluginKey,
185
- filterTransaction: () => {
186
- // When collaboration is disabled, prevent any sync transactions from being applied
187
- if (self.storage.isDisabled) {
188
- // Destroy the Yjs document to prevent any further sync transactions
189
- fragment.doc?.destroy()
190
-
191
- return true
192
- }
193
-
194
- return true
195
- }
196
- })
197
- }
198
-
199
- function createPlugins(self: CollabInstance): Plugin[] {
200
- const { plugin: syncPlugin, destroy: destroySyncPlugin } = createSyncPlugin(self)
201
- self.options._destroySyncPlugin = destroySyncPlugin
202
- const filterPlugin = createSyncFilterPlugin(self)
203
- const plugins = [
204
- syncPlugin,
205
- createUndoPlugin(self)
206
- ]
207
- if (filterPlugin) {
208
- plugins.push(filterPlugin)
209
- }
210
- return plugins
211
- }
1
+ import BaseCollaboration, { type CollaborationOptions } from "@tiptap/extension-collaboration"
212
2
 
213
3
  /**
214
- * This extension allows you to collaborate with others in real-time.
4
+ * Extension of the base collaboration extension without prosemirror plugins or storage.
5
+ *
6
+ * We can't use tiptap's collaboration extension (or any extension that creates the sync plugin) because it expects to be configured with the document's ydoc per editor.
7
+ *
8
+ * This doesn't mesh well with how the document api works (see {@link DocumentApi}).
9
+ *
10
+ * Instead we let the extension register anything it wants (shortcuts, commands, etc) except the plugins and storage. This way it can just be added to the list of editor extensions normally.
11
+ *
12
+ * Then there is a seperate function to actually create the plugins:
13
+ *
14
+ * {@link createCollaborationPlugins} creates the plugins **per doc** and can be used from your document api without an editor instance. See {@link useTestDocumentApi} for an example.
215
15
  *
216
- * @see https://tiptap.dev/api/extensions/collaboration
16
+ * It's also where the `enableContentCheck` option should be set if you need it. It's IGNORED if passed to the extension.
217
17
  */
218
- // eslint-disable-next-line @typescript-eslint/naming-convention
219
- export const Collaboration = Extension.create<CollaborationOptions, CollaborationStorage>({
220
- name: "collaboration",
221
-
222
- priority: 1000,
223
-
224
- addOptions() {
225
- return {
226
- document: null,
227
- field: "default",
228
- fragment: new Y.Doc().getXmlFragment("prosemirror")
229
- }
230
- },
231
18
 
19
+ // eslint-disable-next-line @typescript-eslint/naming-convention
20
+ export const Collaboration = BaseCollaboration.extend<Omit<CollaborationOptions, "document" | "field" | "fragment">, Record<string, never>>({
232
21
  addStorage() {
233
- return {
234
- isDisabled: false
235
- }
236
- },
237
-
238
- onCreate() {
239
- if (this.editor.extensionManager.extensions.find(extension => extension.name === "history")) {
240
- // eslint-disable-next-line no-console
241
- console.warn(
242
- "[tiptap warn]: \"@tiptap/extension-collaboration\" comes with its own history support and is not compatible with \"@tiptap/extension-history\"."
243
- )
244
- }
22
+ return {}
245
23
  },
246
-
247
- addCommands() {
248
- // const self = this
249
- return {
250
- enableCollaboration: (enable: boolean) => () => {
251
- this.storage.isDisabled = !enable
252
- return true
253
- },
254
- undo: () => ({ tr, state, dispatch }) => {
255
- tr.setMeta("preventDispatch", true)
256
-
257
- const undoManager = yUndoPluginKey.getState(state)!.undoManager as ExtendedUndoManager
258
-
259
- if (undoManager.undoStack.length === 0) {
260
- return false
261
- }
262
-
263
- if (!dispatch) {
264
- return true
265
- }
266
-
267
- return undo(state)
268
- },
269
- redo: () => ({ tr, state, dispatch }) => {
270
- tr.setMeta("preventDispatch", true)
271
-
272
- const undoManager = yUndoPluginKey.getState(state)!.undoManager as ExtendedUndoManager
273
-
274
- if (undoManager.redoStack.length === 0) {
275
- return false
276
- }
277
-
278
- if (!dispatch) {
279
- return true
280
- }
281
-
282
- return redo(state)
283
- }
284
-
285
- }
286
- },
287
- onDestroy() {
288
- this.options._destroySyncPlugin?.()
289
- },
290
-
291
- addProseMirrorPlugins(): Plugin[] {
292
- const self = this
293
- return createPlugins(self)
24
+ addProseMirrorPlugins() {
25
+ return []
294
26
  }
295
27
  })
28
+
@@ -0,0 +1,129 @@
1
+ import type { CollaborationOptions } from "@tiptap/extension-collaboration"
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state"
3
+ import type { EditorView } from "@tiptap/pm/view"
4
+ import {
5
+ ySyncPlugin,
6
+ yUndoPlugin,
7
+ yUndoPluginKey,
8
+ yXmlFragmentToProsemirrorJSON
9
+ } from "@tiptap/y-tiptap"
10
+ import type { Doc } from "yjs"
11
+
12
+ /**
13
+ * Copied from tiptap's collaboration extension with a few minor changes to make it work with our document api.
14
+ *
15
+ * Changes:
16
+ *
17
+ * - A local object is created for the plugin instance to simulate the extension's storage.
18
+ * - When it needs the editor (for the `contentError` event), every editor currently using the doc is iterated over and sent the event.
19
+ * - The normally editor level `enableContentCheck` option should be set here, it has no effect if passed to the extension.
20
+ *
21
+ * See {@link Collaboration} for more info and {@link useTestDocumentApi} for an example of how to use it.
22
+ */
23
+ export function createCollaborationPlugins(
24
+ options: CollaborationOptions & { enableContentCheck: boolean },
25
+ schema: any,
26
+ getConnectedEditors?: () => any
27
+ ): Plugin[] {
28
+ const storage = {
29
+ isDisabled: false
30
+ }
31
+ const fragment = options.fragment
32
+ ? options.fragment
33
+ : (options.document as Doc).getXmlFragment(options.field)
34
+
35
+ // Quick fix until there is an official implementation (thanks to @hamflx).
36
+ // See https://github.com/yjs/y-prosemirror/issues/114 and https://github.com/yjs/y-prosemirror/issues/102
37
+ const yUndoPluginInstance = yUndoPlugin(options.yUndoOptions)
38
+ const originalUndoPluginView = yUndoPluginInstance.spec.view
39
+
40
+ yUndoPluginInstance.spec.view = (view: EditorView) => {
41
+ const { undoManager } = yUndoPluginKey.getState(view.state)
42
+
43
+ if (undoManager.restore) {
44
+ undoManager.restore()
45
+ undoManager.restore = () => {
46
+ // noop
47
+ }
48
+ }
49
+
50
+ const viewRet = originalUndoPluginView ? originalUndoPluginView(view) : undefined
51
+
52
+ return {
53
+ destroy: () => {
54
+ const hasUndoManSelf = undoManager.trackedOrigins.has(undoManager)
55
+
56
+ const observers = undoManager._observers
57
+
58
+ undoManager.restore = () => {
59
+ if (hasUndoManSelf) {
60
+ undoManager.trackedOrigins.add(undoManager)
61
+ }
62
+
63
+ undoManager.doc.on("afterTransaction", undoManager.afterTransactionHandler)
64
+
65
+ undoManager._observers = observers
66
+ }
67
+
68
+ if (viewRet?.destroy) {
69
+ viewRet.destroy()
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ const ySyncPluginOptions: Parameters<typeof ySyncPlugin>[1] = {
76
+ ...options.ySyncOptions,
77
+ onFirstRender: options.onFirstRender
78
+ }
79
+
80
+ const ySyncPluginInstance = ySyncPlugin(fragment, ySyncPluginOptions)
81
+
82
+ if (options.enableContentCheck) {
83
+ fragment.doc?.on("beforeTransaction", () => {
84
+ try {
85
+ const jsonContent = yXmlFragmentToProsemirrorJSON(fragment)
86
+
87
+ if (jsonContent.content.length === 0) {
88
+ return
89
+ }
90
+
91
+ schema.nodeFromJSON(jsonContent).check()
92
+ } catch (error) {
93
+ for (const editor of (getConnectedEditors?.() ?? [])) {
94
+ editor.emit("contentError", {
95
+ error: error as Error,
96
+ editor: editor,
97
+ disableCollaboration: () => {
98
+ fragment.doc?.destroy()
99
+ storage.isDisabled = true
100
+ }
101
+ })
102
+ }
103
+ // If the content is invalid, return false to prevent the transaction from being applied
104
+ return false
105
+ }
106
+ })
107
+ }
108
+
109
+ return [
110
+ ySyncPluginInstance,
111
+ yUndoPluginInstance,
112
+ options.enableContentCheck
113
+ && new Plugin({
114
+ key: new PluginKey("filterInvalidContent"),
115
+ filterTransaction: () => {
116
+ // When collaboration is disabled, prevent any sync transactions from being applied
117
+ if (storage.isDisabled !== false) {
118
+ // Destroy the Yjs document to prevent any further sync transactions
119
+ fragment.doc?.destroy()
120
+
121
+ return true
122
+ }
123
+ // TODO should we be returning false when the transaction is a collaboration transaction?
124
+
125
+ return true
126
+ }
127
+ })
128
+ ].filter(Boolean)
129
+ }
@@ -76,8 +76,11 @@ export const toggleSuperscriptCommand: CommandBarCommand = {
76
76
  icon: { component: SuperscriptIcon }
77
77
  }
78
78
 
79
- export const tableCanShow = (state: EditorState): boolean => state.selection.$from.node(-1).type.name === "tableCell"
80
- && state.selection.$to.node(-1).type.name === "tableCell"
79
+ export const tableCanShow = (state: EditorState): boolean => {
80
+ const fromNode = -1 < state.selection.$from.depth ? state.selection.$from.node(-1) : undefined
81
+ const toNode = -1 < state.selection.$to.depth ? state.selection.$to.node(-1) : undefined
82
+ return fromNode?.type.name === "tableCell" && toNode?.type.name === "tableCell"
83
+ }
81
84
 
82
85
  export const tableAddColBeforeCommand: CommandBarCommand = {
83
86
  type: "command" as const,
@@ -1,9 +1,9 @@
1
1
  import { debounce, type DebounceQueue } from "@alanscodelog/utils/debounce"
2
2
  import { unreachable } from "@alanscodelog/utils/unreachable"
3
3
  import type { Content, EditorOptions } from "@tiptap/core"
4
+ import { Editor } from "@tiptap/core"
4
5
  import type { Schema } from "@tiptap/pm/model"
5
6
  import type { EditorState, Plugin, Transaction } from "@tiptap/pm/state"
6
- import { Editor } from "@tiptap/vue-3"
7
7
  import { isProxy } from "vue"
8
8
 
9
9
  import type { DocId, DocumentApiInterface, EmbedId, OnSaveDocumentCallback, OnUpdateDocumentCallback } from "./types.js"
@@ -30,7 +30,17 @@ import { getEmbedNodeFromDoc } from "./utils/getEmbedNodeFromDoc.js"
30
30
  *
31
31
  * If there are extensions that use onCreate to set state or have plugins that need to change the state on init with appendTransaction, they will not work since there is no view to initialize the plugins. To get around this, plugins can specify a stateInit function that will be called with a transaction from the initial loaded state which it can then modify while having access to `this` and the extension options.
32
32
  *
33
- * The api creates a default instance of the editor to copy plugins from, this can be replaced by passing your own editor instance.
33
+ * ### Internals / How does it work?
34
+ *
35
+ * It can be a bit confusing how this works. It's quite different from how tiptap works.
36
+ *
37
+ * First, the api creates a **single** instance of the editor with the editor options **initially** provided (it can be replaced by passing your own editor instance).
38
+ *
39
+ * On load **per document**, the state of the doc is initialized. The plugins are copied from the single editor instance so that we can properly initialize the state. The plugin's `stateInit` is called, see above.
40
+ *
41
+ * When an editor **component** is mounted, useEditorContent will call the document api's `preEditorInit` to get a configuration for that component. This configuration can be edited in `preEditorInit` and is **per editor component**. It is disconnected from the **per document** configuration.
42
+ *
43
+ * There is no **per document** editor instance. This makes it tricky to work with extensions like Collaboration that expect this setup. They require some weird workarounds unfortunately.
34
44
  *
35
45
  * See {@link useTestDocumentApi} for an example of how to set things up.
36
46
  */
@@ -43,7 +53,7 @@ export class DocumentApi<
43
53
  saving: OnSaveDocumentCallback[]
44
54
  } = { update: [], saved: [], saving: [] }
45
55
 
46
- private readonly _load: (docId: string, schema: Schema, plugins: Plugin[]) => Promise<{ state: EditorState, data?: T }>
56
+ private readonly _load: (docId: string, schema: Schema, plugins: Plugin[], getConnectedEditors: () => Editor[]) => Promise<{ state: EditorState, data?: T }>
47
57
 
48
58
  private readonly _save?: (docId: string) => Promise<void>
49
59
 
@@ -84,6 +94,8 @@ export class DocumentApi<
84
94
 
85
95
  editor: Editor
86
96
 
97
+ connectedEditors: Record<string, Editor[]> = {}
98
+
87
99
  constructor({
88
100
  editorOptions,
89
101
  getTitle,
@@ -104,7 +116,7 @@ export class DocumentApi<
104
116
  getTitle?: (docId: string, blockId?: string) => string
105
117
  getSuggestions: DocumentApiInterface["getSuggestions"]
106
118
  /** Load should create the editor state and return it. It can also optionally return extra data which will be passed to the refCounter's load function. */
107
- load: (docId: string, schema: Schema, plugins: Plugin[]) => Promise<{ state: EditorState, data?: T }>
119
+ load: (docId: string, schema: Schema, plugins: Plugin[], getConnectedEditors: () => Editor[]) => Promise<{ state: EditorState, data?: T }>
108
120
  save?: DocumentApi["_save"]
109
121
  saveDebounce?: number
110
122
  cache: DocumentApi["_cache"]
@@ -236,7 +248,8 @@ export class DocumentApi<
236
248
  const loaded = await this._load(
237
249
  docId,
238
250
  schema,
239
- [...this.editor.extensionManager.plugins]
251
+ [...this.editor.extensionManager.plugins],
252
+ () => this.connectedEditors[docId] ?? []
240
253
  )
241
254
  let state = loaded.state
242
255
  const tr = state.tr
@@ -282,4 +295,21 @@ export class DocumentApi<
282
295
  return getEmbedJson(nodeWanted) ?? json
283
296
  }
284
297
  }
298
+
299
+ connectEditor(docId: string, editor: Editor) {
300
+ if (!this.connectedEditors[docId]) {
301
+ this.connectedEditors[docId] = []
302
+ }
303
+ this.connectedEditors[docId].push(editor)
304
+ }
305
+
306
+ disconnectEditor(docId: string, editor: Editor) {
307
+ if (!this.connectedEditors[docId]) {
308
+ return
309
+ }
310
+ const index = this.connectedEditors[docId].indexOf(editor)
311
+ if (index !== -1) {
312
+ this.connectedEditors[docId].splice(index, 1)
313
+ }
314
+ }
285
315
  }
@@ -58,6 +58,7 @@ export function useEditorContent(
58
58
 
59
59
  recreate(options => documentApi.preEditorInit?.(id.value!, { ...options }, state))
60
60
  await nextTick(async () => {
61
+ documentApi.connectEditor(id.value!, editor.value!)
61
62
  editor.value!.on("transaction", onTransaction)
62
63
  documentApi!.addEventListener("update", onUpdateDocument)
63
64
  documentApi.postEditorInit(id.value!, editor.value!)
@@ -69,6 +70,7 @@ export function useEditorContent(
69
70
  function unload(oldId: string): void {
70
71
  if (oldId !== undefined && documentApi) {
71
72
  documentApi.unload({ docId: oldId })
73
+ documentApi.disconnectEditor(id.value!, editor.value!)
72
74
  if (attached) {
73
75
  documentApi.removeEventListener("update", onUpdateDocument)
74
76
  editor.value?.off("transaction", onTransaction)
@@ -1,24 +1,34 @@
1
1
  import { delay } from "@alanscodelog/utils/delay"
2
2
  import { keys } from "@alanscodelog/utils/keys"
3
3
  import { unreachable } from "@alanscodelog/utils/unreachable"
4
- import type { EditorOptions } from "@tiptap/core"
4
+ import type { Editor, EditorOptions } from "@tiptap/core"
5
5
  import { createDocument, generateJSON } from "@tiptap/core"
6
6
  import type { Schema } from "@tiptap/pm/model"
7
7
  import type { Plugin } from "@tiptap/pm/state"
8
8
  import { EditorState } from "@tiptap/pm/state"
9
+ import { prosemirrorToYDoc } from "@tiptap/y-tiptap"
9
10
  import { type Ref, ref, toRaw } from "vue"
11
+ import type * as Y from "yjs"
10
12
 
11
13
  import { testExtensions } from "../../../testSchema.js"
14
+ import { Collaboration } from "../../Collaboration/Collaboration.js"
15
+ import { createCollaborationPlugins } from "../../Collaboration/createCollaborationPlugins.js"
12
16
  import { DocumentApi } from "../DocumentApi.js"
13
17
  import type { DocumentApiInterface } from "../types.js"
14
18
 
15
- type Cache = Record<string, { state?: EditorState, count: number }>
19
+ type Cache = Record<string, { state?: EditorState, count: number, yDoc?: Y.Doc }>
16
20
 
17
21
  /** Creates a simple instance of the DocumentApi for testing purposes. */
18
22
  export function useTestDocumentApi(
19
23
  editorOptions: Partial<EditorOptions>,
20
24
  embeds: Record<string, { content: any, title?: string }>,
21
- { loadDelay = 0 }: { loadDelay?: number } = { }
25
+ {
26
+ useCollab = false,
27
+ loadDelay = 0
28
+ }: {
29
+ useCollab?: boolean
30
+ loadDelay?: number
31
+ } = {}
22
32
  ): {
23
33
  cache: Ref<Cache>
24
34
  documentApi: DocumentApiInterface
@@ -51,11 +61,29 @@ export function useTestDocumentApi(
51
61
  cache.value[docId].state = state
52
62
  }
53
63
  },
64
+ preEditorInit(docId, options: Partial<EditorOptions>) {
65
+ if (!cache.value[docId]) unreachable()
66
+ const yDoc = cache.value[docId].yDoc
67
+ if (useCollab && !yDoc) unreachable()
68
+ if (!cache.value[docId].state) unreachable()
69
+
70
+ const perDoc = ["history"]
71
+
72
+ options.content = cache.value[docId].state.doc.toJSON()
73
+ options.extensions = [
74
+ ...(options.extensions ?? []).filter(ext => !perDoc.includes(ext.name)),
75
+ ...(useCollab
76
+ ? [Collaboration]
77
+ : [])
78
+ ]
79
+ return options
80
+ },
54
81
  load: async (
55
82
  docId: string,
56
83
  schema: Schema,
57
- plugins: Plugin[]
58
- ): Promise<{ state: EditorState }> => {
84
+ plugins: Plugin[],
85
+ getConnectedEditors: () => Editor[]
86
+ ): Promise<{ state: EditorState, data?: { yDoc?: Y.Doc } }> => {
59
87
  if (loadDelay) {
60
88
  await delay(loadDelay)
61
89
  }
@@ -64,19 +92,39 @@ export function useTestDocumentApi(
64
92
  throw new Error(`No embed found for docId ${docId} in: ${JSON.stringify(embeds, null, "\t")}`)
65
93
  }
66
94
 
95
+ if (cache.value[docId]?.state) {
96
+ return { state: toRaw(cache.value[docId].state) as any, data: { yDoc: cache.value[docId].yDoc } }
97
+ }
98
+
67
99
  const json = generateJSON(embeds[docId].content as any, testExtensions)
68
100
  const doc = createDocument(json, schema)
101
+ const yDoc = useCollab ? prosemirrorToYDoc(doc, "prosemirror") : undefined
102
+
69
103
  const state = EditorState.create({
70
104
  doc,
71
105
  schema,
72
- plugins
106
+ plugins: [
107
+ ...plugins,
108
+ ...(useCollab
109
+ ? createCollaborationPlugins(
110
+ {
111
+ document: yDoc,
112
+ field: "prosemirror",
113
+ enableContentCheck: true
114
+ },
115
+ schema,
116
+ getConnectedEditors
117
+ )
118
+ : [])
119
+ ]
73
120
  })
74
- return { state /** , data: {...any additional data} */ }
121
+
122
+ return { state, data: { yDoc } } as any
75
123
  },
76
124
  refCounter: {
77
125
  load(docId: string, loaded) {
78
126
  // loaded.data can be accessed here if we need it
79
- cache.value[docId] ??= { ...loaded, count: 0 }
127
+ cache.value[docId] ??= { ...loaded, yDoc: loaded.data!.yDoc, count: 0 }
80
128
  cache.value[docId].count++
81
129
  },
82
130
  unload: (docId: string) => {