Adding custom class / blocks / nodes to the writer field

I want to be able to add classes to text within my writer field, similar to how you can add tailwind classes; if I wanted to add text-lg or text-md while keeping it as a <p> and not having to change it to a h3 for example.

I’ve looked at this Writer marks/nodes | Kirby CMS.

but I wasn’t able to achieve what I was looking for.

But you are not actually using Tailwind, and I guess that wouldn’t really help you, because the plugin for Tailwind would use the same styles for the same sort of elements.

I think to achieve this, you would actually need a custom node, that allows a dialog where you could add the custom classes. So for a p element, you either need to overwrite the default p node or create a custom p node, and so on for any other nodes that might be affected.

These differently styled p tags have no semantic meaning, then?

No I’m not using tailwind, it was just an example of the logic I’m trying to go for.

Is there a guide on how to create custom nodes?

Thanks, I’ve follwed this as best I could and I nearly have it working but, when I go to apply a node, all the nodes are selected…

My plugin js

panel.plugin('aengustukel/custom-text-nodes', {
  writerNodes: {
      display1: {
          get button() {
              return {
                  icon: 'text',
                  label: 'Display 1',
              };
          },
          commands({ type, utils }) {
              return () => utils.setBlockType(type);
          },
          get name() {
              return 'display1';
          },
          get schema() {
              return {
                  content: 'inline*',
                  group: 'block',
                  parseDOM: [
                      {
                          tag: 'p.display-1',
                      },
                  ],
                  toDOM: () => ['p', { class: 'display-1' }, 0],
              };
          },
      },
      display2: {
          get button() {
              return {
                  icon: 'text',
                  label: 'Display 2',
              };
          },
          commands({ type, utils }) {
              return () => utils.setBlockType(type);
          },
          get name() {
              return 'display2';
          },
          get schema() {
              return {
                  content: 'inline*',
                  group: 'block',
                  parseDOM: [
                      {
                          tag: 'p.display-2',
                      },
                  ],
                  toDOM: () => ['p', { class: 'display-2' }, 0],
              };
          },
      },
      display3: {
          get button() {
              return {
                  icon: 'text',
                  label: 'Display 3',
              };
          },
          commands({ type, utils }) {
              return () => utils.setBlockType(type);
          },
          get name() {
              return 'display3';
          },
          get schema() {
              return {
                  content: 'inline*',
                  group: 'block',
                  parseDOM: [
                      {
                          tag: 'p.display-3',
                      },
                  ],
                  toDOM: () => ['p', { class: 'display-3' }, 0],
              };
          },
      },
      headline1: {
          get button() {
              return {
                  icon: 'text',
                  label: 'Headline 1',
              };
          },
          commands({ type, utils }) {
              return () => utils.setBlockType(type);
          },
          get name() {
              return 'headline1';
          },
          get schema() {
              return {
                  content: 'inline*',
                  group: 'block',
                  parseDOM: [
                      {
                          tag: 'p.headline-1',
                      },
                  ],
                  toDOM: () => ['p', { class: 'headline-1' }, 0],
              };
          },
      },
      headline2: {
          get button() {
              return {
                  icon: 'text',
                  label: 'Headline 2',
              };
          },
          commands({ type, utils }) {
              return () => utils.setBlockType(type);
          },
          get name() {
              return 'headline2';
          },
          get schema() {
              return {
                  content: 'inline*',
                  group: 'block',
                  parseDOM: [
                      {
                          tag: 'p.headline-2',
                      },
                  ],
                  toDOM: () => ['p', { class: 'headline-2' }, 0],
              };
          },
      },
      headline3: {
          get button() {
              return {
                  icon: 'text',
                  label: 'Headline 3',
              };
          },
          commands({ type, utils }) {
              return () => utils.setBlockType(type);
          },
          get name() {
              return 'headline3';
          },
          get schema() {
              return {
                  content: 'inline*',
                  group: 'block',
                  parseDOM: [
                      {
                          tag: 'p.headline-3',
                      },
                  ],
                  toDOM: () => ['p', { class: 'headline-3' }, 0],
              };
          },
      },
      body1: {
          get button() {
              return {
                  icon: 'text',
                  label: 'Body 1',
              };
          },
          commands({ type, utils }) {
              return () => utils.setBlockType(type);
          },
          get name() {
              return 'body1';
          },
          get schema() {
              return {
                  content: 'inline*',
                  group: 'block',
                  parseDOM: [
                      {
                          tag: 'p.body-1',
                      },
                  ],
                  toDOM: () => ['p', { class: 'body-1' }, 0],
              };
          },
      },
      body3: {
          get button() {
              return {
                  icon: 'text',
                  label: 'Body 3',
              };
          },
          commands({ type, utils }) {
              return () => utils.setBlockType(type);
          },
          get name() {
              return 'body3';
          },
          get schema() {
              return {
                  content: 'inline*',
                  group: 'block',
                  parseDOM: [
                      {
                          tag: 'p.body-3',
                      },
                  ],
                  toDOM: () => ['p', { class: 'body-3' }, 0],
              };
          },
      },
  },
});

and php:


Kirby::plugin('aengustukel/custom-text-nodes', [
    'options' => [
      'allowedTags' => [
        'p' => ['class' => true]
    	],
    ],
]);

// Allow <p> tags with specific classes
Kirby\Sane\Html::$allowedTags['p'] = [
    'class' => true,
];

What happens when I apply a node to a paragraph:

You might find some helpful inspiration in my Semantic Markup plugin which has a collection of custom Writer Marks that include optional classes or related attributes. Adding these optionally turned out to be hard because of how Prose Mirror/the Writer field works, but if you have specific classes (for example), you might look at the plugin source the previous version which if I remember correctly had a hard-coded example that doesn’t require a Vue dialog and more complex logic.

Still haven’t been able to solve all of the nodes being selected at once… any advice?