Skip to content

Commit e5ee7cf

Browse files
authored
fix(placeholder): guard against depth-0 selection in placeholder decoration (#2025)
1 parent 7371750 commit e5ee7cf

2 files changed

Lines changed: 79 additions & 0 deletions

File tree

packages/super-editor/src/extensions/placeholder/placeholder.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const Placeholder = Extension.create({
2929
if (plainText !== '') return DecorationSet.empty;
3030

3131
const { $from } = state.selection;
32+
if ($from.depth === 0) return DecorationSet.empty;
3233
const decoration = Decoration.node($from.before(), $from.after(), {
3334
'data-placeholder': this.options.placeholder,
3435
class: 'sd-editor-placeholder',
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// @ts-check
2+
import { describe, it, expect } from 'vitest';
3+
import { Schema } from 'prosemirror-model';
4+
import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
5+
import { Decoration, DecorationSet } from 'prosemirror-view';
6+
7+
const schema = new Schema({
8+
nodes: {
9+
doc: { content: 'paragraph+' },
10+
paragraph: { content: 'inline*', group: 'block' },
11+
text: { group: 'inline' },
12+
},
13+
});
14+
15+
/**
16+
* Recreates the placeholder plugin logic for testing without the Extension system.
17+
*/
18+
function createPlaceholderPlugin(placeholderText = 'Type something...') {
19+
const applyDecoration = (state) => {
20+
const plainText = state.doc.textBetween(0, state.doc.content.size, ' ', ' ');
21+
if (plainText !== '') return DecorationSet.empty;
22+
23+
const { $from } = state.selection;
24+
if ($from.depth === 0) return DecorationSet.empty;
25+
const decoration = Decoration.node($from.before(), $from.after(), {
26+
'data-placeholder': placeholderText,
27+
class: 'sd-editor-placeholder',
28+
});
29+
return DecorationSet.create(state.doc, [decoration]);
30+
};
31+
32+
return new Plugin({
33+
key: new PluginKey('placeholder'),
34+
state: {
35+
init: (_, state) => applyDecoration(state),
36+
apply: (tr, oldValue, oldState, newState) => applyDecoration(newState),
37+
},
38+
props: {
39+
decorations(state) {
40+
return this.getState(state);
41+
},
42+
},
43+
});
44+
}
45+
46+
describe('placeholder plugin', () => {
47+
it('adds decoration on empty document with cursor in paragraph', () => {
48+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create()]);
49+
const plugin = createPlaceholderPlugin();
50+
const state = EditorState.create({ doc, schema, plugins: [plugin] });
51+
52+
const decorations = plugin.getState(state);
53+
expect(decorations.find()).toHaveLength(1);
54+
expect(decorations.find()[0].type.attrs['data-placeholder']).toBe('Type something...');
55+
});
56+
57+
it('returns empty decorations when document has text', () => {
58+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [schema.text('Hello')])]);
59+
const plugin = createPlaceholderPlugin();
60+
const state = EditorState.create({ doc, schema, plugins: [plugin] });
61+
62+
const decorations = plugin.getState(state);
63+
expect(decorations).toBe(DecorationSet.empty);
64+
});
65+
66+
it('does not throw when selection is at doc root (depth 0)', () => {
67+
const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create()]);
68+
const plugin = createPlaceholderPlugin();
69+
const state = EditorState.create({ doc, schema, plugins: [plugin] });
70+
71+
// TextSelection.create at pos 0 lands at doc root (depth 0)
72+
const tr = state.tr;
73+
tr.setSelection(TextSelection.create(doc, 0));
74+
const newState = state.apply(tr);
75+
76+
expect(plugin.getState(newState)).toBe(DecorationSet.empty);
77+
});
78+
});

0 commit comments

Comments
 (0)