From a68334370bcf597942e92a7be3cc50effb819dd4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 09:16:05 +0000 Subject: [PATCH 1/2] fix(editor): prevent deletion of globalContent node on select-all + delete Add a ProseMirror appendTransaction plugin to the GlobalContent extension that automatically restores the globalContent node (with all its data, including theme configuration) when it is removed from the document. This fixes the issue where CMD+A followed by Delete would remove the globalContent node, causing the theme to be lost. The plugin detects when the globalContent node disappears from the document after a transaction that changed the doc, retrieves the previous node's attributes from the old state, and re-inserts it at position 0. The restoration transaction is marked as non-historical so it doesn't pollute the undo stack. Co-authored-by: Danilo Woznica --- .../src/extensions/global-content.spec.ts | 237 ++++++++++++++++++ .../editor/src/extensions/global-content.ts | 45 ++++ 2 files changed, 282 insertions(+) create mode 100644 packages/editor/src/extensions/global-content.spec.ts 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..c55177baa6 100644 --- a/packages/editor/src/extensions/global-content.ts +++ b/packages/editor/src/extensions/global-content.ts @@ -1,4 +1,6 @@ import { type Editor, mergeAttributes, Node } from '@tiptap/core'; +import type { Node as PmNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; const GLOBAL_CONTENT_NODE_TYPE = 'globalContent' as const; @@ -136,4 +138,47 @@ export const GlobalContent = Node.create({ }, }; }, + + addProseMirrorPlugins() { + const nodeType = this.type; + + return [ + new Plugin({ + key: new PluginKey('globalContentProtector'), + + appendTransaction(transactions, oldState, newState) { + const docChanged = transactions.some((tr) => tr.docChanged); + if (!docChanged) { + return null; + } + + const hasGlobalContent = + findGlobalContentPositions(newState.doc).length > 0; + if (hasGlobalContent) { + 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; + }, + }), + ]; + }, }); From aba11d8b9b7243142ce4f6c35abbf110a0984a1e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Apr 2026 11:46:29 +0000 Subject: [PATCH 2/2] refactor(editor): align globalContentProtector with containerEnforcer patterns Adopt two patterns from the container enforcer plugin: 1. Collaboration-aware no-op guard: skip doc.eq(oldDoc) transactions from Liveblocks stabilisation rounds to avoid restoring the node before the real document arrives. 2. View-based restoration: in non-collaborative mode, use the plugin's view hook to track the last-known globalContent node and restore it on update, matching how containerEnforcer proactively checks on view init/update. Co-authored-by: Danilo Woznica --- .../editor/src/extensions/global-content.ts | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/extensions/global-content.ts b/packages/editor/src/extensions/global-content.ts index c55177baa6..23ab797b55 100644 --- a/packages/editor/src/extensions/global-content.ts +++ b/packages/editor/src/extensions/global-content.ts @@ -1,6 +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; @@ -141,20 +142,64 @@ export const GlobalContent = Node.create({ addProseMirrorPlugins() { const nodeType = this.type; + const isCollaborative = hasCollaborationExtension( + this.editor.extensionManager.extensions, + ); return [ new Plugin({ key: new PluginKey('globalContentProtector'), - appendTransaction(transactions, oldState, newState) { - const docChanged = transactions.some((tr) => tr.docChanged); - if (!docChanged) { + 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; } - const hasGlobalContent = - findGlobalContentPositions(newState.doc).length > 0; - if (hasGlobalContent) { + // 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; }