I am experiencing an issue with a custom ProseMirror node view called EmbeddedEditorNodeView
, which is intended to embed another ProseMirror editor. The node is designed to contain block-level content, hence it is configured with the "block+"
content model. However, I’m encountering a specific problem where the node is incorrectly identified.
Details of the implementation and the issue are as follows:
-
Node Specification:
EmbeddedEditorNodeView
is set up with a"block+"
content model, intended to act as a container for an embedded ProseMirror editor. -
Core Issue: With the
content: "block+"
setting for the node, when navigating the document using thearrowHandler
function,nextPos.$head.parent.type.name
returns"paragraph"
instead ofembedded_editor_node
. This misidentification does not happen when the content model is set to"inline*"
, but my requirement is"block+"
. -
Attempts to Resolve: I have tried reordering node definitions, ensuring a unique and identifiable DOM structure, and refining
parseDOM
rules. Despite these efforts, theEmbeddedEditorNodeView
is still not recognized correctly. -
Specific Code Implementation: Here is the specific part of the code related to the issue:
// schema{
import { schema as basicSchema } from "prosemirror-schema-basic";
import { Schema } from "prosemirror-model";
import {computePosition, flip, offset, shift,autoUpdate} from "@floating-ui/dom";
const embedded_editor_nodeSpec = {
attrs: {
"data-type": { default: "input" },
"options": { default: [] },
"data-placeholder": {}
},
group: "block",
content: "block+",
isolating:true,
toDOM: (node) =>{
return ["div", { class: 'embedded_editor_node',
"data-type": node.attrs["data-type"],
"data-placeholder": node.attrs["data-placeholder"],
"options": JSON.stringify(node.attrs["options"]) }, 0];
},
parseDOM: [{
tag: "div", // 适用于您的 DOM 结构
getAttrs: dom => ({
"data-type": dom.getAttribute("data-type") || "input",
"data-placeholder": dom.getAttribute("data-placeholder") || "",
"options": JSON.parse(dom.getAttribute("options") || "[]")
})
}]
};
const embedded_editor_nodeSchema = new Schema({
nodes: basicSchema.spec.nodes.addToStart("embedded_editor_node", embedded_editor_nodeSpec),
marks: basicSchema.spec.marks
});
// menu{
import {insertPoint} from "prosemirror-transform"
import {MenuItem} from "prosemirror-menu"
import {buildMenuItems} from "prosemirror-example-setup"
import {Fragment} from "prosemirror-model"
let menu = buildMenuItems(embedded_editor_nodeSchema)
menu.insertMenu.content.push(new MenuItem({
title: "Insert embedded_editor_node",
label: "Footnote",
select(state) {
return insertPoint(state.doc, state.selection.from, embedded_editor_nodeSchema.nodes.embedded_editor_node) != null
},
run(state, dispatch) {
let {empty, $from, $to} = state.selection, content = Fragment.empty
if (!empty && $from.sameParent($to) && $from.parent.inlineContent)
content = $from.parent.content.cut($from.parentOffset, $to.parentOffset)
dispatch(state.tr.replaceSelectionWith(embedded_editor_nodeSchema.nodes.embedded_editor_node.create(null, content)))
}
}))
// }
// nodeview_start{
import {StepMap} from "prosemirror-transform"
import {keymap} from "prosemirror-keymap"
import {undo, redo} from "prosemirror-history"
class EmbeddedEditorNodeView {
constructor(node, view, getPos) {
// We'll need these later
this.node = node
this.outerView = view
this.getPos = getPos
// The node's representation in the editor (empty, for now)
this.dom = document.createElement("div")
this.dom.classList.add("embedded_editor_node")
this.dom.setAttribute("data-placeholder",node.attrs["data-placeholder"] || "")
console.log("this.node",this.node)
this.innerView = new EditorView(this.dom, {
// You can use any node as an editor document
state: EditorState.create({
doc: this.node,
plugins: [
...exampleSetup({schema: embedded_editor_nodeSchema, menuContent: menu.fullMenu}),keymap({
"Mod-z": () => undo(this.outerView.state, this.outerView.dispatch),
"Mod-y": () => redo(this.outerView.state, this.outerView.dispatch),
"ArrowUp": () => this.maybeEscape("line", -1),
"ArrowDown":() => this.maybeEscape("line", 1),
"ArrowLeft": () => this.maybeEscape("char", -1),
"ArrowRight": () => this.maybeEscape("char", 1)
})]
}),
// This is the magic part
dispatchTransaction: this.dispatchInner.bind(this),
handleDOMEvents: {
mousedown: () => {
// Kludge to prevent issues due to the fact that the whole
// embedded_editor_node is node-selected (and thus DOM-selected) when
// the parent editor is focused.
if (this.outerView.hasFocus()) {
console.log("mousedown")
this.innerView.focus()
}
},
focus:(view)=> {
}
}
})
this.dataType = node.attrs["data-type"];
this.options = node.attrs["options"];
this.dom.setAttribute('data-type',this.dataType)
this.dom.setAttribute('options',this.options)
}
// nodeview_dispatchInner{
dispatchInner(tr) {
let {state, transactions} = this.innerView.state.applyTransaction(tr)
this.innerView.updateState(state)
console.log("dispatchInner",tr.getMeta("fromOutside"))
if (!tr.getMeta("fromOutside")) {
let outerTr = this.outerView.state.tr, offsetMap = StepMap.offset(this.getPos() + 1)
for (let i = 0; i < transactions.length; i++) {
let steps = transactions[i].steps
for (let j = 0; j < steps.length; j++)
outerTr.step(steps[j].map(offsetMap))
}
if (outerTr.docChanged) this.outerView.dispatch(outerTr)
}
}
// }
// nodeview_update{
update(node) {
// 更新下拉菜单的选中状态
this.node = node;
console.log("update",node)
if (!node.sameMarkup(this.node)) return false
this.node = node
if (this.innerView) {
let state = this.innerView.state
let start = node.content.findDiffStart(state.doc.content)
if (start != null) {
let {a: endA, b: endB} = node.content.findDiffEnd(state.doc.content)
let overlap = start - Math.min(endA, endB)
if (overlap > 0) { endA += overlap; endB += overlap }
this.innerView.dispatch(
state.tr
.replace(start, endB, node.slice(start, endA))
.setMeta("fromOutside", true))
}
}
return true
}
// }
// nodeview_setSelection{
selectNode(){
console.log("#selectNode")
this.innerView.focus()
}
setSelection(anchor, head) {
console.log("#setSelection",anchor,head)
const doc = this.innerView.state.doc;
if (anchor < 0 || anchor > doc.content.size || head < 0 || head > doc.content.size) {
console.error('Invalid anchor or head position');
return;
}
const selection = TextSelection.create(this.innerView.state.doc, anchor, head);
this.innerView.dispatch(
this.innerView.state.tr.setSelection(selection)
);
this.innerView.focus();
}
// }
// nodeview_end{
destroy() {
if (this.innerView) {
this.innerView.destroy()
this.innerView = null
}
}
stopEvent(event) {
return this.innerView.dom.contains(event.target);
}
ignoreMutation() { return true }
maybeEscape(unit, dir) {
let { state } = this.innerView, { selection } = state;
if (!selection.empty) return false;
if (dir < 0 ? selection.from > 1 : selection.to < state.doc.content.size) return false;
let targetPos = this.getPos() + (dir < 0 ? 0 : this.node.nodeSize);
let newSelection = Selection.near(this.outerView.state.doc.resolve(targetPos), dir);
this.outerView.dispatch(this.outerView.state.tr.setSelection(newSelection).scrollIntoView());
this.outerView.focus();
return true;
}
}
import {EditorState,Selection,TextSelection} from "prosemirror-state"
import {DOMParser} from "prosemirror-model"
import {EditorView} from "prosemirror-view"
import {exampleSetup} from "prosemirror-example-setup"
function arrowHandler(dir) {
return (state, dispatch, view) => {
if (state.selection.empty && view.endOfTextblock(dir)) {
let side = dir == "left" || dir == "up" ? -1 : 1
let $head = state.selection.$head
console.log("$head",$head)
console.log("$head.after()",$head.after())
console.log("$head.before()",$head.before())
let nextPos = Selection.near(
state.doc.resolve(side > 0 ? $head.after() : $head.before()), side)
console.log("#nextPos.$head.parent.type.name:",nextPos.$head.parent.type.name)
if (nextPos.$head && nextPos.$head.parent.type.name == "embedded_editor_node") {
dispatch(state.tr.setSelection(nextPos))
return true
}
}
return false
}
}
const arrowHandlers = keymap({
ArrowLeft: arrowHandler("left"),
ArrowRight: arrowHandler("right"),
ArrowUp: arrowHandler("up"),
ArrowDown: arrowHandler("down")
});
window.view = new EditorView(document.querySelector("#editor"), {
state: EditorState.create({
doc: DOMParser.fromSchema(embedded_editor_nodeSchema).parse(document.querySelector("#content")),
plugins: exampleSetup({schema: embedded_editor_nodeSchema, menuContent: menu.fullMenu}).concat(arrowHandlers)
}),
nodeViews: {
embedded_editor_node(node, view, getPos) {
return new EmbeddedEditorNodeView(node, view, getPos);
}
}
})
<!DOCTYPE html>
<html>
<head>
<title>ProseMirror Editor</title>
<meta charset="UTF-8">
<script src="dist/prosemirror-bundle.min.js" defer></script>
<link rel="stylesheet" href="dist/editor.css">
</head>
<style>
.embedded_editor_node {
border: 1px solid gray;
border-radius: 5px;
padding: 0px 10px;
margin: 0 5px;
display: inline-block;
}
</style>
<body>
<div id="editor"></div>
<div id="content" style="display: none">
xxx
<div class="embedded_editor_node">zzzz</div>
yyyy
</div>
</body>
</html>
I suspect this problem might be related to how ProseMirror parses and interprets the nested editor structure within EmbeddedEditorNodeView
. However, I am uncertain about the exact cause or the solution.
I am providing the code for a more detailed reference. Could anyone offer insights or suggestions on how to correctly implement this nested node structure, ensuring that an EmbeddedEditorNodeView
with a "block+"
content model is accurately recognized and not confused with a paragraph
?
Any advice or guidance would be greatly appreciated!