Jump to content

Recommended Posts

Posted

Several questions have arisen regarding the v5 Editor Extension system. While we have yet to create a fully fleshed out dev docs center, I wanted to share a tentative, high-level rundown of what's possible.

What you need

Before continuing, I want to state this material is intended for developers. It is expected you have prerequisite knowledge of JavaScript, and have also read through and understand the tiptap core concepts. Lastly, I want to restate the extension framework is a work in progress subject to change, and therefore the specifics in this post are all tentative, whereas the final v5 docs will be posted elsewhere.

Introduction

It is possible to add 3rd party plugins which have direct access to the Tiptap APIs from the following editor methods

  • ips.ui.editorv5.registerExtension(name, definition)

  • ips.ui.editorv5.registerNode(name, definition)

  • ips.ui.editorv5.registerMark(name, definition)

The definition is forwarded to the corresponding Tiptap extension generator Extension.create(definition), Node.create(definition) or Mark.create(definition). Several important concepts and differences between the Tiptap definitions and the ones in ips.ui.editorv5 are noted below

Add Buttons

Perhaps the most important addition to the underlying Tiptap definition is the ability to specify an addButtons() method which adds buttons to the toolbar

The method should return an object mapping strings to ButtonDefinitions, for example

const buttonDef = {
  addButtons() {
    return {
      test1: {
        html: '<div>Red background</div>', 
        command: ({commands}) => commands.toggleMark(this.name), 
        locations: [
          { after: 'bold'}
        ], 
        isAvailable: editor => editor.can().setMark(this.name), 
        isActive: editor => editor.isActive('bold')
      }
    }
  }
}

Button Lang Strings

Make sure to register a lang string for the button either in the jslang.php file of the app or by calling ips.setString(key, value) directly. This string is used for the tooltip on the button, set it to an empty string to remove the tooltip.

The button lang key is generated as follows ipsCustom{ExtensionType}__{ExtensionName}_{ButtonKey} where

  • ExtensionType is Extension, Node or Mark and is determined by the type of plugin

  • ExtensionName is the name you passed to registerExtension, registerNode or registerMark

  • ButtonKey is the key in the object returned by addButtons()

Button Content

  • There are 2 ways to add content to a button:

    1. ButtonDefinition.html - String - This string is parsed, sanitized then set as the innerHTML of the button in the toolbar.

      1. All attributes except 'class', 'style', 'id', 'src', 'title', 'rel', 'alt' are removed

      2. All the following elements are removed: 'script', 'link', 'style', 'pre', 'code', 'iframe', 'object', 'embed', 'noscript', 'video'

      3. Only the first element is preserved. For ex, <div>some content></div><img /> would have to be wrapped in another element

    2. ButtonDefinition.element - HTMLElement - Alternatively, you can use a HTMLElement instance. It is sanitized in the same way as the html

  • Also, note that, even if you specify an HTMLElement, only a copy of its outerHTML is inserted in the toolbar, so things like event listeners or references to the element will not return the element in the toolbar

ButtonDefinition.command - function({...CommandProps, event?: MouseEvent}):void

  • Called whenever the button is clicked. This function is passed the arguments object provided by editor.commands.command(props), with the additional property event which is the click event.

ButtonDefinition.isActive - function(Editor):boolean|boolean|undefined

  • This is called to determine if the button should have an 'active' class applied. It's passed the editor instance. Alternatively, you can pass a boolean to make it always active or inactive. If omitted, the button will never have the 'active' styling

ButtonDefinition.isAvailable - function(editor):boolean|boolean|undefined

  • This is called to determine if the button should be available in the toolbar. Like the isActive property, it gets passed the editor and returns a boolean

ButtonDefinition.locations - (LocationDef|Location|null)[]

  • The locations determine where the button will appear. Each element should be either the Location directly or a LocationDef. You null is interpreted as "put this at the end of the toolbar"

  • Omit the locations array to add the button to the end of the toolbar

  • Location - string - A location is a string representing a button key of another button in the toolbar.

    • You can also put the button in dropdown toolbars using the following strings as a Location

      • align__nested - the text align options

      • format__nested - the extra options that exist inside the ... dropdown

      • insert__nested - the + insert button (where the boxes and quotes natively live)

  • LocationDef - Object

    • LocationDef.before - Location|undefined - Optional: The key of another item in that toolbar that this button should be placed before. If there is no button with the provided key, the button is added to the end of the toolbar.

    • LocationDef.after - Location|undefined - Optional: Same as before, except this button will appear after the provided button.

    • If you define both LocationDef.before and LocationDef.after, the after rule will apply unless that item is nonexistent

    • See the [data-toolbar-item] attribute of items in the toolbar to get the key of items.

Use traditional JS functions

All the root-level methods of the definition object get bound to the built extension, so it is very important you use traditional functions instead of arrow functions for the root-level methods. For example, the following would not work

ips.ui.editorv5.registerNode('myextension', {
  addButtons: () => ({
    makeRed: {
      html: "<div>make red</div>",
      isActive: editor => editor.isActive(this.name),
      ...
    }
  })
})

But this would

ips.ui.editorv5.registerNode('myextension', {
  addButtons() {
    return {
      makeRed: {
        html: "<div>make red</div>", 
        isActive: editor => editor.isActive(this.name), 
        ...
      }
    }
  }
})

Conversely, it is recommended to use arrow functions instead of traditional functions for any closures (functions created inside the root-level methods). Doing it this way ensures the this keyword is preserved. For example

ips.ui.editorv5.registerNode('myextension', {
  addKeyboardShortcuts() {
    return {
      // BAD
      Enter({editor}) {
        return editor.commands.toggleMark(this.name) // `this` may not be defined as expected
      },
      
      // GOOD
      Escape: ({editor}) => {
        return editor.commands.unsetMark(this.name) // `this` refers to the extension definition itself
      }
    }
  }
})

The plugin name

Unlike the Tiptap API, you don't need to pass a name in the definition itself. The editor method will automatically add the name depending on the extension type (Node, Mark, or Extension) and the name argument of the register<Node|Mark|Extension>() method. That being said, keep in mind

  • You should be especially careful to make sure your name is unique. There can only be one extension of a given type with the same name.

  • Use this.name in the definition methods rather than the string name you give. All the root-level methods of the definition are bound to the generated extension so no worries about this not being defined

  • Be careful adding __ (double underscores) to the name, or to the restrictions (see below) for that matter. This can potentially lead to conflicting lang strings... for example if the extension is my__extension, and someone creates another extension named my with a restriction extension, the restriction's lang string will likely conflict with this extensions' lang string.

Restrictions

The Editor has 3 possible states: Minimal, Standard and Advanced. Community Admins can configure which user groups use which state, and can add restrictions to a given state level. For example, the feature "convert links to iframes" can be restricted to the Advanced Editor; in this case, the raw_embed restriction will be present unless the Advanced Editor is used.

From the perspective of apps, however, each restriction is a string, and you can see if a restriction is active by checking editor.options.restrictions.has(restriction) (editor.options.restrictions is of the type Set<string>). When a restriction is present the functionality corresponding to it should be disabled. The editor has no knowledge whether it's in the Minimal, Standard or Advanced state, but only whether the restriction string is present.

You can define restrictions for your extension by adding an array of strings as the property restrictions in your definition. Every restriction

  1. Must contain only letters, numbers dashes and underscores.

  2. Gets prefixed before passing to internal handlers with ipsCustom(Node|Mark|Extension)__{extension_name}__, where Node|Mark|Extension corresponds to the extension type, and extension_name is the name arg passed when registering the extension. It is a lot to type but this ensures there won't be conflicts with other restrictions. This prefixed name is also what gets added to the set editor.options.restrictions

  3. Must have a lang string containing the human-readable name registered. The lang string tells admins what the feature does, and a warning is raised when there is not one. This can be added either to jslang.php in an app before building it, or by calling ips.setString(restrictionKey, 'Human Readable Name')

For example, in the js:

ips.ui.editorv5.registerNode('myNode', {
  restrictions: [
    "can_add"
  ],
  addButtons() {
    return {
      addSomething: {
        isAvailable: editor => !editor.options.restrictions.has('ipsCustomNode__myNode__can_add'),
        html: '+',
      }
    }
  }
})

To add the lang string in the JS, you'd add this before or immediately after registering the node

ips.setString("ipsCustomNode__myCustomNode__can_add", "Can add myNode nodes?");

Or, ideally, in jslang.php, add

$lang = array(
  // ... other keys ...
  
  "ipsCustomNode__myCustomNode__can_add" => "Can add myNode nodes?",
);

A note on Privileges vs Restrictions

A privilege-based system assumes a feature is disabled by default, whereas a restriction-based system assumes it's enabled by default. The Editor Restriction System is, well, restriction-based. Therefore, though it is technically possible to use a restriction as a privilege, where a feature is disabled unless the restriction is present, it is strongly discouraged because it creates a counter-intuitive UI for Admins: it will disappear when a user is less restricted.

The Empty Restriction

You can also define a restriction with an empty string as the unprefixed key. When this restriction is present, the extension isn't added to the editor instance. For every other key, the onus is on the developer to ensure the restriction is enforced accordingly.

ips.ui.editorv5.registerExtension('restrictibleExtension', {
  restrictions: [ "" ] // "" disables the extension altogether
})

ips.setString('ipsCustomExtension__restrictibleExtension__', "Can use the restrictible third party extension")

Descriptions

For any given extension restriction lang string, you can add a more detailed restriction by creating a lang string with the same key suffixed by :description. This description appears in the AdminCP Restriction Configuration Form.

For example, if the restriction itself after prefixing is ipsCustomNode__mynode__, the description would be stored in the lang string ipsCustomNode__mynode__:description.

HTML Escaping

Lastly, it is also worth noting, for security purposes, any html inside the restriction name or description will be sanitized. For example, the lang definition "<code>" is inserted in the AdminCP Restriction Form's source HTML as &lt;code&gt;

IPS Plugins are Sandboxed

All the IPS plugin definitions are sandboxed, meaning any properties in the definition that are not whitelisted will be removed.

The biggest property that is removed is addProseMirrorPlugins(), but any property not in the following keys will be removed

[
    'name',
    'group',
    'isInline',
    'isBlock',
    'isText',
    'selectable',
    'exitable',
    'atom',
    'addOptions',
    'addKeyboardShortcuts',
    'addAttributes',
    'addGlobalAttributes',
    'renderHTML',
    'parseHTML',
    'onCreate',
    'onUpdate',
    'onBeforeCreate',
    'onFocus',
    'onBlur',
    'onBeforeCreate',
    'onSelectionUpdate',
    'onDestroy',
    'onTransaction',
    'priority',
    'defaultOptions',
    'addInputRules',
    'marks',
    'addNodeView',
    'whitespace',
    'defining',
    'isolating',
    'renderText'
]

Priority

The priority key's default value is higher than the Tiptap default. It is recommended to not specify this in the definition unless you know for sure you need to override other custom plugin commands, keyboard shortcuts, or HTML converters, there is no need to set this.

How to load the code?

Method 1: Using an App (best practice)

In most cases, it's best to add your extension inside a custom app. In most cases, the extension will define a new type of content that is not whitelisted by the backend Parser, so it is lost during sanitization.

This is why it is beneficial to use an app which has both the extension and a parser rule to whitelist the element/attributes. It also adds organization to your customization so that you won't forget where or how it's setup.

To do this, simply put your plugins in *.js files in <your app dir>/dev/editor/. When the app is built and installed it is all compiled and loaded seamlessly on the front end

Method 2: Using the ips:editorBeforeInit event

For simpler plugins that do not require any new content types (for example a <span> with a style attribute), you can indeed add them inside a <script> tag anywhere in the page, with either inline or remote JS code. To ensure that your extension is registered after ips.ui.editorv5 is created but before the editor is actually created, hook onto the event ips:editorBeforeInit which is fired on the document. For example

<script type="application/ecmascript">
  document.addEventListener('ips:editorBeforeInit', () => {
    ips.ui.editorv5.registerMark('redBackground', {
      parseHTML() {
        return [{tag: 'span', getAttrs: node => node.style.backgroundColor === "red" && null}]
      },
      renderHTML() {
        return ['span', {style: 'background-color: red'}, 0]
      },
      ...
    })
  }, {once: true}) // the once:true flag is very useful because it prevents the same code from needlessly running and replacing the extension over and over
</script>

This code can go anywhere, but directly in the theme is a good place to start. Note it gets cumbersome very quickly this way

The actual Event is a CustomEvent instance that has the following in its detail property

  • elem : HTMLElement - The element that has the data-ipseditorv5 attribute

  • textarea: HTMLTextAreaElement - The actual textarea that is being replaced with an editor in a form

  • content: string - The starting content of the editor.

  • options: Object - The config that is about to be passed to the editor. You can modify this object to change the default configuration, but note that this is stateful - you'd have to read/update the options each time the event fires instead of just once like for registering extensions.

Font family support

It is possible, when listening to this event, to add or change supported font families. At the core, a font family consists of

  • A main property. This is either a global family default (see below) or a string to be added as the first entry in the font-family CSS property

  • An array of fallbacks. The entries, like main, are either a string or a global font family default

The editor option ipsFontFamily.additionalFontDefinitions defines additional fonts as an object mapping the name to the font definition. For example, to add fonts from the ips:editorBeforeInit event

document.addEventListener('ips:editorBeforeInit', e => {
    e.detail.options.ipsFontFamily = {
        additionalFontDefinitions: {
            "Comic Sans": { // the user will see "comic sans" in the dropdown
                main: "Comic Sans MS", // this is added to the CSS property
                fallbacks: [7] // 7 corresponds to 'sans-serif'. The compiled font family property will be `font-family: "Comic Sans MS", sans-serif;
          }
        }
    };
})

Extending Fonts

For any builtin fonts, you can define them in your plugin to change their behavior.

For example, if you have MS-specific versions of Helvetica installed on your community, you could set fallbacks: ['Helvetica MS']. This will make Helvetica MS the first fallback, while preserving the default fallbacks after it.

Global Family Constants

In css, certain keywords are accepted as the font family which will translate to dynamic CSS behavior or OS preset fonts. The following numbers can be used in lieu of a font string to embed these keywords. Note the type number MUST be used in the js, not the stringified object property shown below

{
    "6": "serif",
    "7": "sans-serif",
    "8": "monospace",
    "9": "cursive",
    "10": "fantasy",
    "16": "emoji",
    "17": "math",
    "18": "fangsong"
}

Builtin support for unknown fonts

The option ipsFontFamily.allowUnknownFonts works as follows.

When processing HTML, if there exists a span[style] tag in the HTML that defines a font-family and the font is unknown, the editor will construct a temporary font family definition and preserve its value. That being said, the custom font will be forgotten should the user change the font to a builtin choice; to ensure an option remains visible, you'll need to define it in your extension. It is generally good practice to not allow these fonts as they will have varying support across devices.

Example Plugin

This is an example of an Extension which will register a Mark that creates <kbd> elements to denote keyboard shortcuts

ips.ui.editorv5.registerMark('myKbdElement', {
  exitable: true,
  parseHTML() {
    return [{ tag: 'kbd' }]
  },
  renderHTML() {
    return [
      'kbd',
      {
        style: `
border-color: var(--i-color_hard);
background-color: var(--i-background_2)`
      },
      0
    ]
  },
  addButtons() {
    return {
      test1: {
        html: '<span><i class="fa-solid fa-keyboard"></i></span>',
        
        command: ({chain}) => chain().toggleMark(this.name).focus().run(),
        
        // This puts the button before bold, after the 'link' button, inside the `...` menu and at the end of the toolbar
        locations: [
          { before: 'bold' },
          'link',
          'format__nested',
          null
        ],
        
        isAvailable: editor => editor.can().setMark(this.name),
        
        isActive: editor => editor.isActive(this.name)
      }
    }
  },
  addKeyboardShortcuts() {
    return {
      // "Mod" is command or ctrl, depending on system defaults;
      "Mod-Shift-K": ({editor}) => editor.chain().toggleMark(this.name).focus().run()
    }
  },
  restrictions: [
    ""
  ]
})

// This sets the restriction string. Ideally, this should go in the jslang.php of your app but it is ok to do this here
ips.setString("ipsCustomMark__myKbdElement__", "Can apply keyboard styles");
ips.setString("ipsCustomMark__myKbdElement_test1", "Keyboard Command");

Note that, in this case of <kbd> elements, you'd also have to add a parser rule via the AdminCP Dev Center to allow them to be saved.

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...