Custom writer node/mark for KirbyTag

Hi. I’m trying to add a custom button to the writer field toolbar. I’ve read the examples here (Writer marks/nodes | Kirby CMS), but I’m not trying to insert a custom mark nor a custom node; instead, I want the button to insert a kirbytag.

Something like:
(song: {songtitle})

where {songtitle} is the selected test. Don’t know if anyone can help… maybe @distantnative?

Thanks
Francesco

Mixing KirbyTags and the HTML-based writer field can get quite tricky: The writer field uses ProseMirror under the hood and stores its content as HTML. ProseMirror will also try to parse the HTML DOM when reading the content into the writer field again. Which means, you probably have to wrap your KirbyTag also in some kind of HTML tag, e.g. a <span class="song">(song: MySongTitle)</span>, to work with the logic of ProseMirror/the writer field.

Then the (song: text will still remain editable though. While in the context of the writer field it would probably be a lot nicer to render an inline node styled as a little pill or so. But there it gets quite tricky to keep the song title editable while still writing it as (song: songtitle) in the HTML stored in the content file.

I haven’t had a great success myself trying it on the side. Depending on what your KirbyTag renders as output, it might be a lot easier to implement that directly as node for the writer field. Instead of taking that detour of the KirbyTag.

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: ")";
}
2 Likes

I didn’t try out your code yet, but let me thank you for the time you spent to answer me. I really appreciated it.

As it always happens, after I went through all of this, I realized I might’ve taken your initial question further than you might’ve asked for.

If you do not want any of that smart logic, styling, pill-like behavior… this is how you can set up a simple custom mark that just inserts the KirbyTag as simple text:

panel.plugin("test/test", {
	writerMarks: {
		song: {
			get button() {
				return {
					icon: "audio",
					label: "Song",
				};
			},

			commands() {
				return () => {
					const { selection } = this.editor.state;

					let value = "";

					if (selection.empty === false) {
						const content = selection.content().content;
						value = content.textBetween(0, content.size);
					}

					this.editor.insertText(`(song: ${value})`, true);
				};
			},
		},
	}
});

I might’ve overshot the mark with the first one (still happy I did, learned a lot about ProseMirror myself again).

1 Like

Wonderful. This is exactly what I wanted to get. Thank you so much.
What if I want to insert the kirbytag between <code> tag? I tried several ways, but I always get some javascript errors… :roll_eyes:

Something like
<code>(song: Let it be)</code>

Thanks

I’m so happy I found a way to insert both the kirbytag and the html <code> tag! Thank you @distantnative!

panel.plugin("hvsr/sub2", {
writerMarks: {
	sub2: {
		get button() {
			return {
				icon: "chat",
				label: "test sub2",
			};
		},

		commands() {
			return () => {
				const { selection } = this.editor.state;

				let value = "";

				if (selection.empty === false) {
					const content = selection.content().content;
					value = content.textBetween(0, content.size);
				}

				this.editor.insertText(`(song: ${value} artist: ) `, true);	
				this.toggle();	
			}
		},

		get name() {
			return "sub2";
		},

		get schema() {
			return {
				parseDOM: [{ tag: "code" }],
				toDOM: () => ["code", 0]
			};
		},
	}
}

});