Ok, I managed to put together a somewhat working version. I’ll leave it here as it might help you and other who want to dive deeper into the world of ProseMirror and custom nodes/marks. However, as you’ll see it starts quickly to get rather complex and twisty.
With Kirby 4 in the nodes dropdown. With Kirby 5, it moves as inline button next to the marks.
panel.plugin("test/test", {
writerNodes: {
song: {
get button() {
return {
icon: "audio",
label: "Song",
inline: true,
};
},
commands({ type }) {
return () => {
return (state, dispatch) => {
// Get the selected text, if any
const content = [];
const selection = state.selection.content().content;
const text = selection.textBetween(0, selection.size);
// If there is text, add it as value to the KirbyTag node
if (text) {
content.push(state.schema.text(text));
}
// Replace the selection with the new node
dispatch(
state.tr
.replaceSelectionWith(type.create(null, content))
.scrollIntoView()
);
};
};
},
inputRules({ type, utils, schema }) {
// Create an input rule to match the `(song: )` wrapper
const rule = utils.nodeInputRule(/\(song:\s*([^)]+)\)$/, type);
// Override the original input rule handler
// to inject the value of the KirbyTag into the created node
const handler = rule.handler;
rule.handler = (state, match, start, end) => {
// Create default transaction that inserts node without content
const tr = handler(state, match, start, end);
// Roll back default transaction
tr.step(tr.steps[0].invert(tr.docs[0]));
// Add custom transaction step that inserts the node with content
tr.replaceWith(
start,
end,
type.create(null, [schema.text(match[1])])
);
return tr;
};
return [rule];
},
get name() {
return "song";
},
get schema() {
return {
content: "inline*",
group: "inline",
inline: true,
draggable: true,
parseDOM: [
{
tag: "span.tag-song",
getContent: (node, schema) => {
// Extract the value from the KirbyTag `(song: )` wrapper
const match = node.textContent.match(/\(song:\s*([^)]+)\)/);
const text = match ? match[1] : "";
// Turn value text into a Fragment
return [schema.text(text)];
},
},
],
toDOM: (node) => {
return [
"span",
{
class: "tag-song",
},
// Add the `(song: )` wrapper to be stored in the content file
`(song: ${node.content.textBetween(0, node.content.size)})`,
];
},
};
},
get view() {
return () =>
// Create a NodeView class to only display the KirbyTag value.
// The `(song: )` wrapper is added via CSS.
new (class {
constructor() {
this.dom = this.contentDOM = document.createElement("span");
this.dom.classList.add("tag-song");
}
})();
},
},
},
});
.k-writer-input span.tag-song {
padding: 0.125rem;
font-family: var(--font-mono);
font-size: 0.9em;
color: var(--color-purple-700);
background-color: var(--color-purple-200);
border: 1px solid var(--color-purple-300);
border-radius: var(--rounded-sm);
}
.k-writer-input span.tag-song:before {
content: "(song: ";
}
.k-writer-input span.tag-song:after {
content: ")";
}