Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions packages/editor/src/extensions/global-content.spec.ts
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Use the real select-all path here; TextSelection can miss the leaf-block edge case this bug targets.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/extensions/global-content.spec.ts, line 46:

<comment>Use the real select-all path here; `TextSelection` can miss the leaf-block edge case this bug targets.</comment>

<file context>
@@ -0,0 +1,237 @@
+    });
+
+    const { state } = ed;
+    const allSelection = TextSelection.create(
+      state.doc,
+      0,
</file context>

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; }',
});
});
});
90 changes: 90 additions & 0 deletions packages/editor/src/extensions/global-content.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -136,4 +139,91 @@ export const GlobalContent = Node.create<GlobalContentOptions>({
},
};
},

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;
},
}),
];
},
});
Loading