From 39bd75f60a651d8a520a126130b158736c6d55ab Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 14 Feb 2026 07:26:25 -0300 Subject: [PATCH] fix(placeholder): guard against depth-0 selection in placeholder decoration --- .../src/extensions/placeholder/placeholder.js | 1 + .../placeholder/placeholder.test.js | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 packages/super-editor/src/extensions/placeholder/placeholder.test.js diff --git a/packages/super-editor/src/extensions/placeholder/placeholder.js b/packages/super-editor/src/extensions/placeholder/placeholder.js index 7fe298931a..95df29a3dd 100644 --- a/packages/super-editor/src/extensions/placeholder/placeholder.js +++ b/packages/super-editor/src/extensions/placeholder/placeholder.js @@ -29,6 +29,7 @@ export const Placeholder = Extension.create({ if (plainText !== '') return DecorationSet.empty; const { $from } = state.selection; + if ($from.depth === 0) return DecorationSet.empty; const decoration = Decoration.node($from.before(), $from.after(), { 'data-placeholder': this.options.placeholder, class: 'sd-editor-placeholder', diff --git a/packages/super-editor/src/extensions/placeholder/placeholder.test.js b/packages/super-editor/src/extensions/placeholder/placeholder.test.js new file mode 100644 index 0000000000..028128dd86 --- /dev/null +++ b/packages/super-editor/src/extensions/placeholder/placeholder.test.js @@ -0,0 +1,78 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'inline*', group: 'block' }, + text: { group: 'inline' }, + }, +}); + +/** + * Recreates the placeholder plugin logic for testing without the Extension system. + */ +function createPlaceholderPlugin(placeholderText = 'Type something...') { + const applyDecoration = (state) => { + const plainText = state.doc.textBetween(0, state.doc.content.size, ' ', ' '); + if (plainText !== '') return DecorationSet.empty; + + const { $from } = state.selection; + if ($from.depth === 0) return DecorationSet.empty; + const decoration = Decoration.node($from.before(), $from.after(), { + 'data-placeholder': placeholderText, + class: 'sd-editor-placeholder', + }); + return DecorationSet.create(state.doc, [decoration]); + }; + + return new Plugin({ + key: new PluginKey('placeholder'), + state: { + init: (_, state) => applyDecoration(state), + apply: (tr, oldValue, oldState, newState) => applyDecoration(newState), + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); +} + +describe('placeholder plugin', () => { + it('adds decoration on empty document with cursor in paragraph', () => { + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create()]); + const plugin = createPlaceholderPlugin(); + const state = EditorState.create({ doc, schema, plugins: [plugin] }); + + const decorations = plugin.getState(state); + expect(decorations.find()).toHaveLength(1); + expect(decorations.find()[0].type.attrs['data-placeholder']).toBe('Type something...'); + }); + + it('returns empty decorations when document has text', () => { + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create(null, [schema.text('Hello')])]); + const plugin = createPlaceholderPlugin(); + const state = EditorState.create({ doc, schema, plugins: [plugin] }); + + const decorations = plugin.getState(state); + expect(decorations).toBe(DecorationSet.empty); + }); + + it('does not throw when selection is at doc root (depth 0)', () => { + const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create()]); + const plugin = createPlaceholderPlugin(); + const state = EditorState.create({ doc, schema, plugins: [plugin] }); + + // TextSelection.create at pos 0 lands at doc root (depth 0) + const tr = state.tr; + tr.setSelection(TextSelection.create(doc, 0)); + const newState = state.apply(tr); + + expect(plugin.getState(newState)).toBe(DecorationSet.empty); + }); +});