Stuck creating a Writer Mark with a dialog

I’ve been tinkering with creating some Writer Marks that require a dialog to add attributes to the resulting HTML. I’ve got as far as successfully defining the attributes, opening a custom dialog to enter values for each of the attributes but am stuck on actually getting them to save back to the Writer field itself.

Hoping someone might be able to point me in the right direction (or correct anything I’ve just flat out done wrong) because I’ve had no luck digging through the documentation and anything else that might be helpful.

Here’s a quick breakdown of the plugin code as it stands:

index.php

<?php
use Kirby\Cms\App as Kirby;
Kirby::plugin('scottboms/writer-span', [
  'name' => 'Span',
  'author' => 'Scott Boms',
  'version' => '1.0.0',
  'options' => [
    // Define any plugin options here if needed
  ],
  'translations' => [
    'en' => [
      'writer.spanAttribute' => 'Add span with attributes',
    ]
  ]
]);

src/index.js

import SpanDialog from "./components/SpanDialog.vue";

// Register the main plugin
window.panel.plugin("scottboms/writer-span", {
  components: {
    "writer-span-dialog": SpanDialog,
  },

  writerMarks: {
    span: {
      get button() {
        return {
          icon: 'code',
          label: 'Add Span'
        }
      },

      commands() {
        return {
          span: () => {
            console.log("Opening dialog with component: writer-span-dialog");
            window.panel.dialog.open({
              component: "writer-span-dialog",
              props: {
                editor: this.editor,
                value: {
                  // default values that appear automatically
                  // if any text is entered here
                  class: "",
                  title: ""
                }
              }
            });
          }
        };
      },

      insertSpan: (attrs = {}) => {
        console.log('insertSpan called with attrs:', attrs);
        const { selection } = this.editor.state;

        // Check if the selection is empty and the span mark is not active
        if (
          selection.empty &&
          this.editor.activeMarks.includes("span") === false
        ) {
          console.log('Inserting text:', attrs.class);
          this.editor.insertText(attrs.class, true);
        }

        // Apply the attributes if class or title is present
        if (attrs.class || attrs.title) {
          console.log('Applying span mark with attrs:', attrs);
          return this.editor.chain().focus().toggleMark('span', attrs).run();
        } else {
          console.log('No attributes to apply');
        }
      },

      removeSpan: () => {
        console.log('removeSpan called');
        return this.editor.chain().focus().unsetMark('span').run();
      },

      toggleSpan: (attrs = {}) => {
        if (attrs.class?.length > 0) {
          this.editor.command("insertSpan", attrs);
        } else {
          this.editor.command("removeSpan");
        }
      },

      get defaults() {
        console.log('defaults');
        return {
          class: null,
          title: null
        }
      },

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

      get schema() {
        return {
          parseDOM: [{
            tag: 'span',
            getAttrs: (dom) => ({
              class: dom.getAttribute('class'),
              title: dom.getAttribute('title')
            })
          }],
          toDOM: (node) => {
            return ['span', {
              class: node.attrs.class || null,
              title: node.attrs.title || null
            }, 0];
          }
        }
      },

      plugins() {
        return [
          {
            props: {
              handleClick: (view, pos, event) => {
                const attrs = this.editor.getMarkAttrs("span");
                if(
                  attrs.class &&
                  attrs.title
                ) {
                  event.stopPropagation();
                  window.open(attrs.class, attrs.title);
                }
              }
            }
          }
        ];

      }
    }
  }
});

src/components/SpanDialog.vue

<template>
  <k-dialog
    class="writer-span-dialog"
    ref="dialog"
    :visible="true"
    :submit-button="true"
    :cancel-button="true"
    size="medium"
    @submit="submit"
    @cancel="$emit('cancel')">
    <k-form
      :fields="fields"
      v-bind="$props"
      :value="values"
      @input="handleInput"
      @cancel="$emit('cancel')"
      @submit="submit" />
  </k-dialog>
</template>

<script>
console.log('span dialog loaded...');

export default {
  name: "writer-span-dialog",
  props: {
    editor: {
      type: Object,
      required: true
    },
    value: {
      type: Object,
      default: () => ({
        class: "",
        title: ""
      })
    }
  },
  data() {
    return {
      values: this.value,
      fields: {
        class: {
          label: "Class",
          type: "text",
          placeholder: "Enter one or more CSS classes"
        },
        title: {
          label: "Title",
          type: "text",
          placeholder: "Enter a title"
        }
      }
    };
  },
  methods: {
    openDialog() {
      if (this.$refs.dialog) {
        this.$refs.dialog.showDialog();
      }
    },
    closeDialog() {
      if (this.$refs.dialog) {
        this.$refs.dialog.close();
      }
    },
    mounted() {
      this.openDialog();
    },
    handleInput(values) {
      console.log('Form input values:', values);
      this.values = values;
      this.$emit('input', values);
    },
    submit() {
      console.log('Submit button clicked with values:', this.values);
      if (this.values.class || this.values.title) {
        console.log('Submitting values:', this.values);
        this.editor.command("insertSpan", {
          class: this.values.class || null,
          title: this.values.title || null
        });
      }
      this.closeDialog();
    },
  }
};
</script>

You won’t be able to do that in the submit method of the dialog component as that has no access to the editor (EDIT: although now seeing that you pass it as props so that could work, but I’d suggest still to follow my following suggestion - at least personally I prefer to have those actions in the mark file directly). Instead in the dialog open call in your mark, define on.submit handler that can then use the values from the dialog to create/update the mark.

Similarly like here in my example code: Adding a writer mark that opens a dialog for input - #2 by distantnative

Hope this helps to get you started in the right direction.

1 Like

Thanks so much Nico. Will try that and see where I end up.

Ok, follow up here since I’ve made some progress after adjusting things based on Nico’s comments, but feel like I’ve hit a wall with one last thing.

Basically, I’ve removed all but a few seemingly necessary bits of JS from the Vue component so everything is in the index.js file. It now can wrap (toggle) text in a <span></span> and passes the class attribute correctly into the content file. But I’m a bit suck on why it’s also not passing the title attribute as well. The object is definitely being created and passes along. I’m probably missing something obvious…

index.js

import SpanDialog from "./components/SpanDialog.vue";

// Register the main plugin
window.panel.plugin("scottboms/writer-span", {
  components: {
    "writer-span-dialog": SpanDialog,
  },

  writerMarks: {
    span: {
      get button() {
        return {
          icon: 'code',
          label: 'Add Span'
        }
      },

      commands() {
        return {
          span: (event) => {
            console.log("Opening dialog with component: writer-span-dialog");
            if (event.altKey || event.metaKey) {
              return this.remove();
            }
            this.editor.emit("span", this.editor);

            window.panel.dialog.open({
              component: "writer-span-dialog",
              props: {
                value: {
                  // get values from the current mark
                  // or null if no mark is active
                  class: this.editor.getMarkAttrs("span").class || null,
                  title: this.editor.getMarkAttrs("span").title || null
                }
              },
              on: {
                cancel: () => {
                  this.editor.focus();
                  //console.log("Closing dialog.");
                },
                submit: (values) => {
                  //console.log("Submitting dialog with values", values);
                  this.editor.command("toggleSpan", values);
                  window.panel.dialog.close();
                }
              }
            });
          },
          insertSpan: (attrs = {}) => {
            console.log('insertSpan called with attrs:', attrs);
            const { selection } = this.editor.state;

            // if no text is selected and span mark is not active
				    // we insert the span as text
            if (selection.empty && this.editor.activeMarks.includes("span") === false) {
              this.editor.insertText(attrs, true);
            }

            if (attrs.class?.length > 0 || attrs.title?.length > 0) {
              return this.update(attrs);
            }
          },
          removeSpan: () => {
            return this.remove();
          },
          toggleSpan: (attrs = {}) => {
            if (attrs.class?.length > 0 || attrs.title?.length > 0) {
              console.log('Inserting span with class:', attrs.class, 'and title:', attrs.title);
              this.editor.command("insertSpan", attrs);
            } else {
              console.log('Removing span');
              this.editor.command("removeSpan");
            }
          }
        };
      },

      get defaults() {
        //console.log('defaults');
        return {
          class: null,
          title: null
        }
      },

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

      plugins() {
        return [
          {
            props: {
              handleClick: (view, pos, event) => {
                const attrs = this.editor.getMarkAttrs("span");
                console.log('These are the set attrs:', attrs);
                if(
                  attrs.class &&
                  event.altKey === true &&
                  event.target instanceof HTMLSpanElement
                ) {
                  event.stopPropagation();
                  window.panel.dialog.open(attrs.class, attrs.title);
                }
              }
            }
          }
        ];
      },

      get schema() {
        return {
          attrs: {
            class: {
              default: null 
            },
            title: {
              default: null
            }
          },
          inclusive: false,
          defining: true,
          draggable: false,
          parseDOM: [
            {
              tag: 'span',
              getAttrs: (dom) => ({
                class: dom.getAttribute('class'),
                title: dom.getAttribute('title')
              })
            }
          ],

          toDOM: (node) => [
            'span', {
              ...node.attrs
            },0
          ]
        };
      },
    }
  }
})

src/components/SpanDialog.vue

<template>
  <k-dialog
    class="writer-span-dialog"
    ref="dialog"
    :visible="true"
    :submit-button="true"
    :cancel-button="true"
    size="medium"
    @submit="$emit('submit')"
    @cancel="$emit('cancel')">
    <k-form
      :fields="fields"
      v-bind="$props"
      :value="values"
      @input="$emit('input', $event)" />
  </k-dialog>
</template>

<script>
console.log('span dialog loaded...');
export default {
  name: "writer-span-dialog",
  props: {
    editor: {
      type: Object,
      required: true
    },
    value: {
      type: Object,
      default: () => ({
        class: '',
        title: ''
      })
    }
  },
  data() {
    return {
      values: this.value,
      fields: {
        class: {
          label: "Class",
          type: "text",
          placeholder: "Enter one or more CSS classes"
        },
        title: {
          label: "Title",
          type: "text",
          placeholder: "Enter a title"
        }
      }
    };
  }
};
</script>

I see calls to this.update() and this.remove() but I don’t think these methods exist in your example. Also, this.editor.insertText(attrs, true); looks odd to me as insertText expects to receive the text string as first argument, but attrs I guess is rather the object of dialog values.