From 96edf362ac7c0490586a53d6a20f84861bccbee6 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Mon, 26 Jan 2026 16:46:52 +0200 Subject: [PATCH 1/5] fix: create leading caret plugin --- .../extensions/paragraph/ParagraphNodeView.js | 5 ++- .../paragraph/leadingCaretPlugin.js | 35 +++++++++++++++++++ .../src/extensions/paragraph/paragraph.js | 3 +- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.js diff --git a/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.js b/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.js index 45efd65b61..28111530b2 100644 --- a/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.js +++ b/packages/super-editor/src/extensions/paragraph/ParagraphNodeView.js @@ -37,7 +37,10 @@ export class ParagraphNodeView { calculateResolvedParagraphProperties(this.editor, this.node, this.editor.state.doc.resolve(this.getPos())); this.dom = document.createElement('p'); - this.contentDOM = document.createElement('span'); + const contentEl = document.createElement('span'); + contentEl.classList.add('sd-paragraph-content'); + + this.contentDOM = contentEl; this.dom.appendChild(this.contentDOM); if (this.#checkIsList()) { this.#initList(node.attrs.listRendering); diff --git a/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.js b/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.js new file mode 100644 index 0000000000..fe0a432c01 --- /dev/null +++ b/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.js @@ -0,0 +1,35 @@ +import { Plugin } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; + +const shouldAddLeadingCaret = (node) => { + if (node.type.name !== 'paragraph') return false; + if (node.childCount === 0) return false; + const first = node.child(0); + if (first.type.name === 'fieldAnnotation') return true; + if (first.type.name !== 'run') return false; + if (first.childCount === 0) return false; + return first.child(0).type.name === 'fieldAnnotation'; +}; + +export function createLeadingCaretPlugin() { + const leadingCaretPlugin = new Plugin({ + props: { + decorations(state) { + if (typeof document === 'undefined') return null; + const decorations = []; + state.doc.descendants((node, pos) => { + if (!shouldAddLeadingCaret(node)) return false; + const widgetPos = pos + 1; + const deco = Decoration.widget(widgetPos, () => document.createTextNode('\u200B'), { + key: `sd-leading-caret-${pos}`, + side: -1, + }); + decorations.push(deco); + return false; + }); + return decorations.length ? DecorationSet.create(state.doc, decorations) : null; + }, + }, + }); + return leadingCaretPlugin; +} diff --git a/packages/super-editor/src/extensions/paragraph/paragraph.js b/packages/super-editor/src/extensions/paragraph/paragraph.js index 4ddb7b748f..aa96fc5689 100644 --- a/packages/super-editor/src/extensions/paragraph/paragraph.js +++ b/packages/super-editor/src/extensions/paragraph/paragraph.js @@ -10,6 +10,7 @@ import { toggleList } from '@core/commands/index.js'; import { restartNumbering } from '@core/commands/restartNumbering.js'; import { ParagraphNodeView } from './ParagraphNodeView.js'; import { createNumberingPlugin } from './numberingPlugin.js'; +import { createLeadingCaretPlugin } from './leadingCaretPlugin.js'; import { createDropcapPlugin } from './dropcapPlugin.js'; import { shouldSkipNodeView } from '../../utils/headless-helpers.js'; import { parseAttrs } from './helpers/parseAttrs.js'; @@ -315,6 +316,6 @@ export const Paragraph = OxmlNode.create({ }, }, }); - return [dropcapPlugin, numberingPlugin, listEmptyInputPlugin]; + return [dropcapPlugin, numberingPlugin, listEmptyInputPlugin, createLeadingCaretPlugin()]; }, }); From b39b348cc531117f411f92423bdffe6759c9e91f Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Mon, 26 Jan 2026 18:20:56 +0200 Subject: [PATCH 2/5] fix: update constants --- packages/layout-engine/pm-adapter/src/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/layout-engine/pm-adapter/src/constants.ts b/packages/layout-engine/pm-adapter/src/constants.ts index 587ec65273..88103a44c2 100644 --- a/packages/layout-engine/pm-adapter/src/constants.ts +++ b/packages/layout-engine/pm-adapter/src/constants.ts @@ -142,6 +142,7 @@ export const ATOMIC_INLINE_TYPES = new Set([ 'footnoteReference', 'passthroughInline', 'bookmarkEnd', + 'fieldAnnotation', ]); /** From a2d3691d47fccdc9ac29fb48e7494aebe12624c6 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Mon, 26 Jan 2026 21:14:35 +0200 Subject: [PATCH 3/5] fix: annotation caret anchor --- .../painters/dom/src/renderer.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 013b44e3ab..2b7330d3ef 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -4192,12 +4192,41 @@ export class DomPainter { } annotation.dataset.layoutEpoch = String(this.layoutEpoch); + this.appendAnnotationCaretAnchor(annotation, run); + // Apply SDT metadata this.applySdtDataset(annotation, run.sdt); return annotation; } + /** + * Adds a hidden DOM anchor at pmEnd so caret placement after the annotation is correct. + */ + private appendAnnotationCaretAnchor(annotation: HTMLElement, run: FieldAnnotationRun): void { + if (!this.doc || run.pmEnd == null) return; + + const caretAnchor = this.doc.createElement('span'); + caretAnchor.dataset.pmStart = String(run.pmEnd); + caretAnchor.dataset.pmEnd = String(run.pmEnd); + caretAnchor.dataset.layoutEpoch = String(this.layoutEpoch); + caretAnchor.classList.add('annotation-caret-anchor'); + caretAnchor.style.position = 'absolute'; + caretAnchor.style.left = '100%'; + caretAnchor.style.top = '0'; + caretAnchor.style.width = '0'; + caretAnchor.style.height = '1em'; + caretAnchor.style.overflow = 'hidden'; + caretAnchor.style.pointerEvents = 'none'; + caretAnchor.style.userSelect = 'none'; + caretAnchor.style.opacity = '0'; + caretAnchor.textContent = '\u200B'; + if (!annotation.style.position) { + annotation.style.position = 'relative'; + } + annotation.appendChild(caretAnchor); + } + /** * Renders a single line of a paragraph block. * From 8fe7c5fc30eddde05eb5be7902e54df96e2a9de6 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Tue, 27 Jan 2026 15:48:18 +0200 Subject: [PATCH 4/5] fix: annotation deletion --- .../findRemovedFieldAnnotations.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/super-editor/src/extensions/field-annotation/fieldAnnotationHelpers/findRemovedFieldAnnotations.js b/packages/super-editor/src/extensions/field-annotation/fieldAnnotationHelpers/findRemovedFieldAnnotations.js index 5ddffbef30..0c75c3ee42 100644 --- a/packages/super-editor/src/extensions/field-annotation/fieldAnnotationHelpers/findRemovedFieldAnnotations.js +++ b/packages/super-editor/src/extensions/field-annotation/fieldAnnotationHelpers/findRemovedFieldAnnotations.js @@ -1,4 +1,5 @@ import { ReplaceStep } from 'prosemirror-transform'; +import { findChildren } from '@core/helpers/findChildren'; export function findRemovedFieldAnnotations(tr) { let removedNodes = []; @@ -34,6 +35,17 @@ export function findRemovedFieldAnnotations(tr) { } }); + if (removedNodes.length) { + const removedNodesIds = removedNodes.map((item) => item.node.attrs.fieldId); + const found = findChildren( + tr.doc, + (node) => node.type.name === 'fieldAnnotation' && removedNodesIds.includes(node.attrs.fieldId), + ); + const foundSet = new Set(found.map((item) => item.node.attrs.fieldId)); + const removedNodesFiltered = removedNodes.filter((item) => !foundSet.has(item.node.attrs.fieldId)); + removedNodes = removedNodesFiltered; + } + return removedNodes; } From de3828cd23f9512241b12944651b276743b36569 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 27 Jan 2026 22:14:37 -0800 Subject: [PATCH 5/5] fix: leading caret traversal into nested paragraphs --- .../paragraph/leadingCaretPlugin.js | 2 +- .../paragraph/leadingCaretPlugin.test.js | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.test.js diff --git a/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.js b/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.js index fe0a432c01..65e7aac61e 100644 --- a/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.js +++ b/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.js @@ -18,7 +18,7 @@ export function createLeadingCaretPlugin() { if (typeof document === 'undefined') return null; const decorations = []; state.doc.descendants((node, pos) => { - if (!shouldAddLeadingCaret(node)) return false; + if (!shouldAddLeadingCaret(node)) return true; const widgetPos = pos + 1; const deco = Decoration.widget(widgetPos, () => document.createTextNode('\u200B'), { key: `sd-leading-caret-${pos}`, diff --git a/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.test.js b/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.test.js new file mode 100644 index 0000000000..abd1f94ae9 --- /dev/null +++ b/packages/super-editor/src/extensions/paragraph/leadingCaretPlugin.test.js @@ -0,0 +1,58 @@ +// @ts-check +import { describe, it, expect } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState } from 'prosemirror-state'; +import { createLeadingCaretPlugin } from './leadingCaretPlugin.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + blockquote: { + content: 'paragraph+', + group: 'block', + toDOM: () => ['blockquote', 0], + parseDOM: [{ tag: 'blockquote' }], + }, + paragraph: { + content: 'inline*', + group: 'block', + toDOM: () => ['p', 0], + parseDOM: [{ tag: 'p' }], + }, + run: { + content: 'inline*', + inline: true, + group: 'inline', + toDOM: () => ['span', { 'data-run': 'true' }, 0], + parseDOM: [{ tag: 'span[data-run]' }], + }, + fieldAnnotation: { + inline: true, + group: 'inline', + atom: true, + toDOM: () => ['span', { 'data-field-annotation': 'true' }], + parseDOM: [{ tag: 'span[data-field-annotation]' }], + }, + text: { group: 'inline' }, + }, +}); + +const buildDocWithNestedAnnotation = () => { + const paragraph = schema.nodes.paragraph.create(null, [ + schema.nodes.run.create(null, [schema.nodes.fieldAnnotation.create(), schema.text('Hello')]), + ]); + return schema.nodes.doc.create(null, [schema.nodes.blockquote.create(null, [paragraph])]); +}; + +describe('leadingCaretPlugin', () => { + it('adds a leading caret decoration for nested paragraphs', () => { + const doc = buildDocWithNestedAnnotation(); + const plugin = createLeadingCaretPlugin(); + const state = EditorState.create({ doc, schema, plugins: [plugin] }); + + const decorations = plugin.spec.props.decorations(state); + + expect(decorations).not.toBeNull(); + expect(decorations.find()).toHaveLength(1); + }); +});