diff --git a/packages/editor/src/extensions/global-content.spec.ts b/packages/editor/src/extensions/global-content.spec.ts new file mode 100644 index 0000000000..7d4c258e10 --- /dev/null +++ b/packages/editor/src/extensions/global-content.spec.ts @@ -0,0 +1,237 @@ +import type { JSONContent } from '@tiptap/core'; +import { Editor } from '@tiptap/core'; +import { TextSelection } from '@tiptap/pm/state'; +import { afterEach, describe, expect, it } from 'vitest'; +import { StarterKit } from './index'; + +vi.mock('@/actions/ai', () => ({ + uploadImageViaAI: vi.fn(), +})); + +describe('GlobalContent Node', () => { + let editor: Editor | null = null; + + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + function createEditor(content: JSONContent) { + editor = new Editor({ + content, + extensions: [StarterKit], + }); + editor.view.dispatch(editor.state.tr); + return editor; + } + + it('preserves globalContent when selecting all and deleting', () => { + const themeData = { theme: 'basic', css: 'body { color: red; }' }; + + const ed = createEditor({ + type: 'doc', + content: [ + { + type: 'globalContent', + attrs: { data: themeData }, + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world' }], + }, + ], + }); + + const { state } = ed; + const allSelection = TextSelection.create( + state.doc, + 0, + state.doc.content.size, + ); + ed.view.dispatch(state.tr.setSelection(allSelection).deleteSelection()); + + const json = ed.getJSON(); + const globalContentNodes = json.content!.filter( + (n) => n.type === 'globalContent', + ); + + expect(globalContentNodes).toHaveLength(1); + expect(globalContentNodes[0].attrs!.data).toEqual(themeData); + }); + + it('preserves globalContent data after multiple select-all + delete cycles', () => { + const themeData = { theme: 'minimal', styles: [{ id: 'test' }] }; + + const ed = createEditor({ + type: 'doc', + content: [ + { + type: 'globalContent', + attrs: { data: themeData }, + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'First content' }], + }, + ], + }); + + for (let i = 0; i < 3; i++) { + const { state } = ed; + const allSelection = TextSelection.create( + state.doc, + 0, + state.doc.content.size, + ); + ed.view.dispatch(state.tr.setSelection(allSelection).deleteSelection()); + } + + const json = ed.getJSON(); + const globalContentNodes = json.content!.filter( + (n) => n.type === 'globalContent', + ); + + expect(globalContentNodes).toHaveLength(1); + expect(globalContentNodes[0].attrs!.data).toEqual(themeData); + }); + + it('does not duplicate globalContent when it already exists', () => { + const ed = createEditor({ + type: 'doc', + content: [ + { + type: 'globalContent', + attrs: { data: { theme: 'basic' } }, + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }); + + ed.commands.insertContent({ + type: 'paragraph', + content: [{ type: 'text', text: 'New paragraph' }], + }); + + const json = ed.getJSON(); + const globalContentNodes = json.content!.filter( + (n) => n.type === 'globalContent', + ); + + expect(globalContentNodes).toHaveLength(1); + }); + + it('restores globalContent at position 0 when deleted via replaceWith', () => { + const themeData = { theme: 'basic', css: '.test {}' }; + + const ed = createEditor({ + type: 'doc', + content: [ + { + type: 'globalContent', + attrs: { data: themeData }, + }, + { + type: 'container', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Content' }], + }, + ], + }, + ], + }); + + const { state } = ed; + const { schema } = state; + const newParagraph = schema.nodes.paragraph.create( + null, + schema.text('Replaced content'), + ); + const tr = state.tr.replaceWith(0, state.doc.content.size, newParagraph); + ed.view.dispatch(tr); + + const json = ed.getJSON(); + const globalContentNodes = json.content!.filter( + (n) => n.type === 'globalContent', + ); + + expect(globalContentNodes).toHaveLength(1); + expect(globalContentNodes[0].attrs!.data).toEqual(themeData); + }); + + it('does not restore globalContent if it was never present', () => { + const ed = createEditor({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'No global content here' }], + }, + ], + }); + + ed.commands.clearContent(); + + const json = ed.getJSON(); + const globalContentNodes = (json.content ?? []).filter( + (n) => n.type === 'globalContent', + ); + + expect(globalContentNodes).toHaveLength(0); + }); + + it('setGlobalContent command creates globalContent if not present', () => { + const ed = createEditor({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }); + + ed.commands.setGlobalContent('theme', 'basic'); + + const json = ed.getJSON(); + const globalContentNodes = json.content!.filter( + (n) => n.type === 'globalContent', + ); + + expect(globalContentNodes).toHaveLength(1); + expect(globalContentNodes[0].attrs!.data.theme).toBe('basic'); + }); + + it('setGlobalContent command updates existing globalContent data', () => { + const ed = createEditor({ + type: 'doc', + content: [ + { + type: 'globalContent', + attrs: { data: { theme: 'basic' } }, + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }); + + ed.commands.setGlobalContent('css', 'body { color: blue; }'); + + const json = ed.getJSON(); + const globalContentNodes = json.content!.filter( + (n) => n.type === 'globalContent', + ); + + expect(globalContentNodes).toHaveLength(1); + expect(globalContentNodes[0].attrs!.data).toEqual({ + theme: 'basic', + css: 'body { color: blue; }', + }); + }); +}); diff --git a/packages/editor/src/extensions/global-content.ts b/packages/editor/src/extensions/global-content.ts index a6781e10a2..23ab797b55 100644 --- a/packages/editor/src/extensions/global-content.ts +++ b/packages/editor/src/extensions/global-content.ts @@ -1,4 +1,7 @@ import { type Editor, mergeAttributes, Node } from '@tiptap/core'; +import type { Node as PmNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { hasCollaborationExtension } from '../utils/is-collaboration'; const GLOBAL_CONTENT_NODE_TYPE = 'globalContent' as const; @@ -136,4 +139,91 @@ export const GlobalContent = Node.create({ }, }; }, + + addProseMirrorPlugins() { + const nodeType = this.type; + const isCollaborative = hasCollaborationExtension( + this.editor.extensionManager.extensions, + ); + + return [ + new Plugin({ + key: new PluginKey('globalContentProtector'), + + view: isCollaborative + ? undefined + : (editorView) => { + let lastGlobalContentNode: PmNode | null = null; + + const findAndCache = (doc: PmNode) => { + let found: PmNode | null = null; + doc.descendants((node) => { + if (node.type.name === GLOBAL_CONTENT_NODE_TYPE) { + found = node; + return false; + } + }); + if (found) { + lastGlobalContentNode = found; + } + return found; + }; + + findAndCache(editorView.state.doc); + + return { + update(view) { + const doc = view.state.doc; + const current = findAndCache(doc); + + if (!current && lastGlobalContentNode) { + const restoredNode = nodeType.create( + lastGlobalContentNode.attrs, + ); + const tr = view.state.tr; + tr.insert(0, restoredNode); + tr.setMeta('addToHistory', false); + cachedGlobalPosition = 0; + view.dispatch(tr); + } + }, + }; + }, + + appendTransaction(_transactions, oldState, newState) { + if (findGlobalContentPositions(newState.doc).length > 0) { + return null; + } + + // Skip no-op transactions from collaboration extensions (e.g. + // Liveblocks stabilisation rounds) to avoid restoring the node + // before the real document content arrives — same guard used by + // the containerEnforcer plugin. + if (newState.doc.eq(oldState.doc)) { + return null; + } + + let previousNode: PmNode | null = null; + oldState.doc.descendants((node) => { + if (node.type.name === GLOBAL_CONTENT_NODE_TYPE) { + previousNode = node; + return false; + } + }); + + if (!previousNode) { + return null; + } + + const restoredNode = nodeType.create((previousNode as PmNode).attrs); + const tr = newState.tr; + tr.insert(0, restoredNode); + tr.setMeta('addToHistory', false); + cachedGlobalPosition = 0; + + return tr; + }, + }), + ]; + }, });