Custom Writer node for centered text

Is there any working example of a custom Writer node for centering text in a paragraph?
I have gone through all posts here in the forum (I believe), looked at several plugins, tried a plethora of combination.
I end up either with the node not being applied at all, or it being applied as a <p class="center"> surrounding (not replacing) the <p> already there. Some combinations lead to the node not being toggle-able, so after applying it, it sticks around forever despite unselecting it, …
The list goes on.

My code is an ugly patchwork of different trial and error attempts. There were more options/combinations, that I have already deleted.
index.js:

window.panel.plugin("frwssr/custom-nodes", {
  writerNodes: {
    centeredtext: {
      get button() {
        return {
          icon: "text-center",
          label: window.panel.$t("Text zentrieren")
        }
      },

      commands({ type, utils }) {
        return () => utils.toggleWrap(type)
        // return () => this.toggle()
        // return () => utils.setBlockType(type)
      },

      get name() {
        return "centeredtext"
      },

      get schema() {
        return {
          content: "block+",
          group: "block",
          defining: true,
          draggable: false,
          parseDOM: [{ 
            tag: "p", 
            priority: 51,
            // getAttrs: (node) => node.classList.contains('centeredtext') ? {} : false,
          }],
          toDOM: () => ["p", { class: "centeredtext" }, 0]
        }
      }
    }
  }
});

index.php:

<?php
Kirby::plugin('frwssr/custom-nodes', [
    'options' => [
      'allowedTags' => [
        'p' => ['class' => true]
    	],
    ],
]);
// Allow <p> tags with specific classes
Kirby\Sane\Html::$allowedTags['p'] = [
    'class' => true,
];

Don’t mind me, I found this forum post with Nico’s excellent feedback (Custom writer node works in panel but not on site - #2 by distantnative) and modified my plugin to the following.
index.js:

window.panel.plugin("frwssr/custom-nodes", {
  writerNodes: {
    centeredtext: {
      get button() {
        return {
          icon: "text-center",
          label: window.panel.$t("Text zentrieren")
        }
      },

      commands({ type, utils }) {
        return () => utils.setBlockType(type)
      },

      get name() {
        return "centeredtext"
      },

      get schema() {
        return {
          content: "text*",
          group: "block",
          defining: true,
          draggable: false,
          parseDOM: [{ 
            tag: "p",
            priority: 51,
            getAttrs: (node) =>
              node.classList.contains("centeredtext") ? {} : false,
          }],
          toDOM: () => ["p", { class: "centeredtext" }, 0]
        }
      }
    }
  }
});

index.php:

<?php
use Kirby\Sane\Html;
Html::$allowedTags['p'] = ['class'];
Kirby::plugin('frwssr/custom-nodes', []);

Now it is working. Thanks, @distantnative!

Only flaw: I have another custom node to right-align text. Activating one of the two custom nodes marks both as active in the nodes dropdown.

Hmmm, that usually means that ProseMirror considers both as active. What does the HTNL look like that gets saved?

The output is as I would expect. Either centered or right-aligned, as selected. Both in the Panel and the frontend. Same, correct outcome.
It is only in the dropdown, that it looks as if both were selected.
At the same time, the nodes button in the toolbar shows the correct alignment icon of the selected option (as can also be seen in the screenshot, I shared yesterday).

No biggie. I can totally live with that. It is only really weird.

What is it, that determins, which button(s) are set to `aria-current=“true”`?
Both custom nodes get assigned that, as soon as one of them is selected.

I would think that the isNodeActive method is at fault here: kirby/panel/src/components/Forms/Writer/Toolbar.vue at main · getkirby/kirby · GitHub

This is my index.js, now.

window.panel.plugin("frwssr/custom-nodes", {
  writerNodes: {
    centeredtext: {
      get button() {
        return {
          icon: "text-center",
          label: window.panel.$t("Text zentrieren")
        }
      },

      commands({ type, utils }) {
        return () => utils.setBlockType(type)
      },

      get name() {
        return "centeredtext"
      },

      get schema() {
        return {
          content: "text*",
          group: "block",
          defining: true,
          draggable: false,
          parseDOM: [{ 
            tag: "p",
            priority: 51,
            getAttrs: (node) =>
              node.classList.contains("centeredtext") ? {} : false,
          }],
          toDOM: () => ["p", { class: "centeredtext" }, 0]
        }
      }
    },
    rightalignedtext: {
      get button() {
        return {
          icon: "text-right",
          label: window.panel.$t("Text rechtsbündig")
        }
      },

      commands({ type, utils }) {
        return () => utils.setBlockType(type)
      },

      get name() {
        return "rightalignedtext"
      },

      get schema() {
        return {
          content: "text*",
          group: "block",
          defining: true,
          draggable: false,
          parseDOM: [{ 
            tag: "p",
            priority: 51,
            getAttrs: (node) =>
              node.classList.contains("rightalignedtext") ? {} : false,
          }],
          toDOM: () => ["p", { class: "rightalignedtext" }, 0]
        }
      }
    }
  }
});

Do you see anything wrong with it, that might lead to this behaviour?
I cannot be the first one to experience this, right?

Here is my plugin that I use to customise the nodes in writer field, including text alignment and also some other styles I commonly need. This wraps the selected element in a class, which you can style in the panel and in your site using css.

plugin index.php

<?php

Kirby::plugin(
    name: 'rm/writer-toolbar',
    version: '1.0.0',
    info: [
        'description' => 'Custom marks and nodes for the writer',
        'license' => 'RM',
        'authors' => [
            [
                'name' => 'RM'
            ]
        ]
    ],
    extends: [
        'hooks' => [
            'kirbytags:before' => function ($text, $data, $options) {
                \Kirby\Sane\Html::$allowedTags['align-left'] = true;
                \Kirby\Sane\Html::$allowedTags['align-center'] = true;
                \Kirby\Sane\Html::$allowedTags['align-right'] = true;
                \Kirby\Sane\Html::$allowedTags['text-heading'] = true;
                \Kirby\Sane\Html::$allowedTags['type-size-notes'] = true;
                return $text;
            }
        ],
    ],
);

plugin index.js

window.panel.plugin('rm/writer-toolbar', {
  writerNodes: {
    textIndent: {
      get button() {
        return {
          id: this.name,
          icon: 'angle-right',
          label: 'Indent',
          name: this.name,
        };
      },

      commands({ utils, schema, type }) {
        return {
          textIndent: () => {
            return utils.setBlockType(type);
          },
        };
      },

      get name() {
        return 'textIndent';
      },

      get schema() {
        return {
          content: 'inline*',
          group: 'block',
          draggable: false,
          parseDOM: [
            {
              tag: 'p',
              priority: 51,
              getAttrs: node => (node.classList.contains('text-indent') ? {} : false),
            },
          ],
          toDOM: () => ['p', { class: 'text-indent' }, 0],
        };
      },
    },

    paragraphHeading: {
      get button() {
        return {
          id: this.name,
          icon: 'headline',
          label: 'Paragraph Heading',
          name: this.name,
          separator: true,
        };
      },

      commands({ utils, schema, type }) {
        return {
          paragraphHeading: () => {
            return utils.setBlockType(type);
          },
        };
      },

      get name() {
        return 'paragraphHeading';
      },

      get schema() {
        return {
          content: 'inline*',
          group: 'block',
          draggable: false,
          parseDOM: [
            {
              tag: 'p',
              priority: 51,
              getAttrs: node => (node.classList.contains('paragraph-heading') ? {} : false),
            },
          ],
          toDOM: () => ['p', { class: 'paragraph-heading' }, 0],
        };
      },
    },

    textSizeHeading: {
      get button() {
        return {
          id: this.name,
          icon: 'font-size',
          label: 'Heading',
          name: this.name,
        };
      },

      commands({ utils, schema, type }) {
        return {
          textSizeHeading: () => {
            return utils.setBlockType(type);
          },
        };
      },

      get name() {
        return 'textSizeHeading';
      },

      get schema() {
        return {
          content: 'inline*',
          group: 'block',
          draggable: false,
          parseDOM: [
            {
              tag: 'p',
              priority: 51,
              getAttrs: node => (node.classList.contains('heading-text') ? {} : false),
            },
          ],
          toDOM: () => ['p', { class: 'heading-text' }, 0],
        };
      },
    },
    textSizeBody: {
      get button() {
        return {
          id: this.name,
          icon: 'font-size',
          label: 'Body',
          name: this.name,
        };
      },

      commands({ utils, schema, type }) {
        return {
          textSizeBody: () => {
            return utils.setBlockType(type);
          },
        };
      },

      get name() {
        return 'textSizeBody';
      },

      get schema() {
        return {
          content: 'inline*',
          group: 'block',
          draggable: false,
          parseDOM: [
            {
              tag: 'p',
              priority: 51,
              getAttrs: node => (node.classList.contains('body-text') ? {} : false),
            },
          ],
          toDOM: () => ['p', { class: 'body-text' }, 0],
        };
      },
    },
    textSizeNotes: {
      get button() {
        return {
          id: this.name,
          icon: 'font-size',
          label: 'Notes',
          name: this.name,
          separator: true,
        };
      },

      commands({ utils, schema, type }) {
        return {
          textSizeNotes: () => {
            return utils.setBlockType(type);
          },
        };
      },

      get name() {
        return 'textSizeNotes';
      },

      get schema() {
        return {
          content: 'inline*',
          group: 'block',
          draggable: false,
          parseDOM: [
            {
              tag: 'p',
              priority: 51,
              getAttrs: node => (node.classList.contains('notes-text') ? {} : false),
            },
          ],
          toDOM: () => ['p', { class: 'notes-text' }, 0],
        };
      },
    },

    textAlignLeft: {
      get button() {
        return {
          id: this.name,
          icon: 'text-left',
          label: 'Left',
          name: this.name,
        };
      },

      commands({ utils, schema, type }) {
        return {
          textAlignLeft: () => {
            return utils.setBlockType(type);
          },
        };
      },

      get name() {
        return 'textAlignLeft';
      },

      get schema() {
        return {
          content: 'inline*',
          group: 'block',
          draggable: false,
          parseDOM: [
            {
              tag: 'p',
              priority: 51,
              getAttrs: node => (node.classList.contains('align-left') ? {} : false),
            },
          ],
          toDOM: () => ['p', { class: 'align-left' }, 0],
        };
      },
    },
    textAlignCenter: {
      get button() {
        return {
          id: this.name,
          icon: 'text-center',
          label: 'Center',
          name: this.name,
        };
      },

      commands({ utils, schema, type }) {
        return {
          textAlignCenter: () => {
            return utils.setBlockType(type);
          },
        };
      },

      get name() {
        return 'textAlignCenter';
      },

      get schema() {
        return {
          content: 'inline*',
          group: 'block',
          draggable: false,
          parseDOM: [
            {
              tag: 'p',
              priority: 51,
              getAttrs: node => (node.classList.contains('align-center') ? {} : false),
            },
          ],
          toDOM: () => ['p', { class: 'align-center' }, 0],
        };
      },
    },
    textAlignRight: {
      get button() {
        return {
          id: this.name,
          icon: 'text-right',
          label: 'Right',
          name: this.name,
          separator: true,
        };
      },

      commands({ utils, schema, type }) {
        return {
          textAlignRight: () => {
            return utils.setBlockType(type);
          },
        };
      },

      get name() {
        return 'textAlignRight';
      },

      get schema() {
        return {
          content: 'inline*',
          group: 'block',
          draggable: false,
          parseDOM: [
            {
              tag: 'p',
              priority: 51,
              getAttrs: node => (node.classList.contains('align-right') ? {} : false),
            },
          ],
          toDOM: () => ['p', { class: 'align-right' }, 0],
        };
      },
    },
  },
});

site > blueprints > fields > writer.yml
This activates the custom nodes in the writer field.

label: Text
type: writer
# toolbar:
#   inline: false
spellcheck: true
marks:
  - italic
  - link
  - clear
nodes:
  - bulletList
  - quote
  - textIndent
  - paragraphHeading
  - textSizeHeading
  - textSizeBody
  - textSizeNotes
  - textAlignLeft
  - textAlignCenter
  - textAlignRight

assets > css > panel.css
This allows you to visualise the nodes applied in the panel. Using the same classes in your main css will allow you to style the text on the frontend.

/*--------------------------------------------------------------
# Panel
---------------------------------------------------------------*/

/* Additional Writer styles for the Writer plugin*/

/* Text Formatting */
.k-text blockquote {
  padding-left: 3em;
  font-size: inherit;
  line-height: inherit;
  padding-inline-start: none;
  border-inline-start: none;
}

.text-indent {
  padding-left: 3em;
  margin-bottom: 0 !important;
}

.paragraph-heading {
  display: block;
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-block-start: calc(var(--text-line-height) * 2em);
}
.paragraph-heading:first-of-type {
  margin-block-start: 0;
}

.align-left {
  display: block;
  text-align: left;
}
.align-center {
  display: block;
  text-align: center;
}
.align-right {
  display: block;
  text-align: right;
}

/* Text sizes */
.heading-text {
  display: block;
  font-size: 1.2em;
}
.body-text {
  display: block;
  font-size: 1em;
}
.notes-text {
  display: block;
  font-size: 0.9em;
}

2 Likes

Thank you so much for sharing your code.
Comparing it, I found mine was missing the id in all the get button() return statements, which lead to all my custom nodes being marked as selected.

1 Like