|
1 | 1 | import { describe, expect, it, vi } from 'vitest'; |
| 2 | +import { NodeSelection } from 'prosemirror-state'; |
2 | 3 | import type { Node as ProseMirrorNode } from 'prosemirror-model'; |
3 | 4 | import type { Editor } from '../../core/Editor.js'; |
4 | 5 | import { resolveCurrentSelectionInfo } from './selection-info-resolver.js'; |
@@ -143,14 +144,31 @@ function doc(blocks: ProseMirrorNode[]): ProseMirrorNode { |
143 | 144 | return createNode('doc', blocks, { isBlock: false, inlineContent: false }); |
144 | 145 | } |
145 | 146 |
|
| 147 | +function makeRealNodeSelection( |
| 148 | + from: number, |
| 149 | + to: number, |
| 150 | + node: { type: { name: string }; isBlock: boolean; isLeaf: boolean; isInline: boolean; nodeSize: number }, |
| 151 | +): NodeSelection { |
| 152 | + const sel = Object.create(NodeSelection.prototype); |
| 153 | + Object.defineProperty(sel, 'from', { value: from, configurable: true }); |
| 154 | + Object.defineProperty(sel, 'to', { value: to, configurable: true }); |
| 155 | + Object.defineProperty(sel, 'empty', { value: false, configurable: true }); |
| 156 | + Object.defineProperty(sel, 'node', { value: node, configurable: true }); |
| 157 | + return sel as NodeSelection; |
| 158 | +} |
| 159 | + |
146 | 160 | /** Minimal editor stub whose doc + selection are controllable per test. */ |
147 | | -function makeEditor(docNode: ProseMirrorNode, selection: { from: number; to: number; empty?: boolean }): Editor { |
| 161 | +function makeEditor( |
| 162 | + docNode: ProseMirrorNode, |
| 163 | + selection: { from: number; to: number; empty?: boolean; node?: unknown }, |
| 164 | +): Editor { |
148 | 165 | const empty = selection.empty ?? selection.from === selection.to; |
| 166 | + const pmSelection = 'node' in selection ? selection : { from: selection.from, to: selection.to, empty }; |
149 | 167 | const listeners = new Map<string, Array<() => void>>(); |
150 | 168 | return { |
151 | 169 | state: { |
152 | 170 | doc: docNode, |
153 | | - selection: { from: selection.from, to: selection.to, empty }, |
| 171 | + selection: pmSelection, |
154 | 172 | storedMarks: null, |
155 | 173 | }, |
156 | 174 | on(event: string, listener: () => void) { |
@@ -215,6 +233,41 @@ describe('resolveCurrentSelectionInfo', () => { |
215 | 233 | ]); |
216 | 234 | }); |
217 | 235 |
|
| 236 | + it('returns null target for a NodeSelection over an addressable text block', () => { |
| 237 | + // SelectionInfo.target is only for text selections. A NodeSelection |
| 238 | + // over a text-bearing block still represents the node, not a user text |
| 239 | + // range that can safely feed comments.create. |
| 240 | + const paragraph = textBlock('p1', 'Hello'); |
| 241 | + const docNode = doc([paragraph]); |
| 242 | + const selection = makeRealNodeSelection(1, 1 + paragraph.nodeSize, paragraph as any); |
| 243 | + const editor = makeEditor(docNode, selection); |
| 244 | + |
| 245 | + const info = resolveCurrentSelectionInfo(editor, {}); |
| 246 | + |
| 247 | + expect(info.empty).toBe(false); |
| 248 | + expect(info.target).toBeNull(); |
| 249 | + }); |
| 250 | + |
| 251 | + it('returns null target for a NodeSelection over a text-bearing structured content block', () => { |
| 252 | + // Presentation clicks can select a block SDT as a NodeSelection. Even |
| 253 | + // though the wrapper contains textblocks, the selection itself is not |
| 254 | + // a text selection and should not be projected into a TextTarget. |
| 255 | + const innerParagraph = textBlock('p-inside-sdt', 'Field text'); |
| 256 | + const blockSdt = createNode('structuredContentBlock', [innerParagraph], { |
| 257 | + isBlock: true, |
| 258 | + inlineContent: false, |
| 259 | + attrs: { sdBlockId: 'sdt-1' }, |
| 260 | + }); |
| 261 | + const docNode = doc([blockSdt]); |
| 262 | + const selection = makeRealNodeSelection(1, 1 + blockSdt.nodeSize, blockSdt as any); |
| 263 | + const editor = makeEditor(docNode, selection); |
| 264 | + |
| 265 | + const info = resolveCurrentSelectionInfo(editor, {}); |
| 266 | + |
| 267 | + expect(info.empty).toBe(false); |
| 268 | + expect(info.target).toBeNull(); |
| 269 | + }); |
| 270 | + |
218 | 271 | it('returns null target when no selected block has an addressable blockId', () => { |
219 | 272 | // Block without sdBlockId / id / blockId — resolver skips it. |
220 | 273 | const textNode = createNode('text', [], { text: 'Hello' }); |
|
0 commit comments