a-blog cms Ver.2.11に搭載されるPaperEditorについて

PaperEditorとは

a-blog cms Ver.2.11から搭載予定の新しいEditorです。私(steelydylan)が主に開発しています。
Dropbox PaperやNoteなどをイメージしてもらえればわかりやすいと思います。
Ver.2.8から導入された、LiteEditorが、インライン要素に対してのみのEditorなのに対して、PaperEditorはブロックとインライン両方を定義して使うことができます。さらにEditorに定義した要素以外が入り込まないようにペースト時や編集時に常にサニタイズされるため、誰が書いても決められたフォーマットで記事を書くことができます。
実はこの記事自体もPaperEditorで書いています!

PaperEditorにはあらかじめ以下のように利用できるブロックがあります。

  • Paragraph
  • H1
  • H2
  • H3
  • H4
  • H5
  • Ul
  • Ol
  • Blockquote
  • Code
  • Table

以下のようなイメージで編集できます。

用意されたブロックの中にないブロックでも、自分で定義して追加することも可能です。PaperEditorはすべてがExtensionという考え方で構築されていて、Extensionの設定をからにすると、先ほど紹介したブロックはおろか、インライン装飾や、ブロック削除機能、ブロックの前後移動などもできなくなります。なので、PaperEditorにまだない機能は、Extensionという形でどんどん追加していける設計になっています。

体験しよう

最新版のPaperEditorはこちらから体験できます。

https://competent-einstein-976429.netlify.com/

PaperEditorの強み

PaperEditorの強みはざっとこんなところです。

  • Guthenbergと比べて初心者でも独自ブロックを作りやすい
  • 記事を全てコピーして貼り付けることができる。
  • 貼り付けられた記事にある必要ないタグ情報はすべてクリアされた上で貼り付けられる
  • スマートフォンでも編集できる

とくに、スマートフォンでも編集できるのは非常に大きな利点で、 Facebookが出している、Draft.jsでもスマートフォンでは編集できないですし、Guthenbergはかろうじてスマートフォンで記事が書けますが決して書きやすいとは言えません。その点、PaperEditorはスマートフォンファーストで開発されているので、AndroidでもiPhoneでもiPadでも非常に書き心地がいいです。

a-blog cmsとPaperEditor

PaperEditorはa-blog cmsにVer.2.11から標準搭載されます。PaperEditorは組み込みJSとしてカスタムフィールドに利用でき、カスタムフィールドメーカーでもVer.2.11からPaperEditorを選択できるようになりますのでソースコードの生成は簡単です。PaperEditorのカスタムフィールドでは、入力された内容を 変数名@html でHTMLとして値を取り出すことができます。
またPaperEditorユニットも新たに追加されます。

PaperEditorの技術スタック

PaperEditorは以下の技術で構成されています。

  • React
  • Redux
  • TypeScript
  • ProseMirror

ProseMirrorはすごく優秀なエディターを作るためのライブラリでこれには本当にお世話になりました。PaperEditorの入力に当たる部分はProseMirrorで、UIにあたる、P, H2, H3などの選択メニューや上下移動ボタンなどはReactで作っています。このように全てをReactにせず、入力にあたる部分をそれに特化した技術で作ることでより書き心地のよいUIを提供することができました。

PaperEditorのカスタマイズ方法

ではPaperEditorのカスタマイズ方法です。

PaperEditorには、CustomBlockとCustomMarkというPaperEditorに項目を増やすためのブロックが用意されています。CustomBlockがブロック要素を追加するためのボタンを作るためのクラスで、CustomMarkがインライン要素を追加するためのクラスになっていて以下のようなカスタマイズで簡単にボタンを作ることができます。


ACMS.Config.PaperEditorAdds = function(Extensions) {
  return [
  new Extensions.CustomBlock({
     tagName: 'div',
     className: 'acms-alert',
     customName: 'alert',
     icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1792 1792"><title>アラート</title><path d="M1777.67,1567.49,960.05,49.07c-35.23-65.43-92.87-65.43-128.1,0L14.33,1567.49c-35.22,65.43-3.25,119,71.06,119H1706.61C1780.92,1686.45,1812.89,1632.92,1777.67,1567.49ZM1024,1536H768V1280h256Zm0-384H768L704,576h384Z"/></svg>'
  }),
  //さらに追加
  new Extensions.CustomMark({
     tagName: 'small',
     customName: 'small',
     icon: 's'
  })
  ]
};

この二つのクラスにより、基本的なブロック追加はGuthenbergと比べて用意に行うことができます。

Reactを使ったより高度なカスタマイズ

ここからはReactを使ったより高度なカスタマイズになります。ここから先は、本気でカスタマイズしたい人だけみていただければと思います。かなり本格的なカスタマイズになるのでこういう感じで拡張できるんだという雰囲気だけつかんでいただければ!

カスタマイズ例(ハイライトブロックを作る)

ここでは、入力されるたびにハイライトがされるブロックを作ってみましょう。ブロックを作るにはこのようなインターフェースでクラスを作る必要があります。

class Code implements Extension {
  get name(): string; //ブロック名
  get group(): 'block' | 'mark'; //インラインなのかブロックなのか
  get showMenu(): boolean; //メニューを表示するかしないか
  get schema():  ProseMirror.Schema // ProseMirrorのスキーマ
  get icon(): React.ReactNode //表示するアイコンのJSX
  active(state: ProseMirrror.EditorState): boolean // Stateに応じてブロックが選択されているかどうかを判定する
  enable(state: ProseMirror.EditorState): boolean // Stateに応じてブロックが選択可能かどうかを判定する
  onClick(state: ProseMirror.EditorState, dispatch: ProseMirror.Dispatch): void //クリックされた時の処理
  customMenu({ state: ProseMirror.EditorState, dispatch: ProseMirror.Dispatch}): ReactNode //ブロック選択時に新たに追加されるメニュー
  get plugins(): ProseMirror.Plugin[] // 依存するProseMirrorのプラグイン
}

そして、作ったクラスを以下のように、ACMS.Config.PaperEditorAddsの中で配列に渡してあげます。

ACMS.Config.PaperEditorAdds = function(Extensions) {
   return [new Code()];
}

最終的にコードをハイライトするブロックはこんな感じになります。

plugin.ts

import { Plugin } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { setBlockType } from 'prosemirror-commands'
import { findBlockNodes } from 'prosemirror-utils'
import low from 'lowlight'
import { getParentNodeFromState } from '../../utils'

function getDecorations({ doc, name }) {
  const decorations = []
  const blocks = findBlockNodes(doc).filter(
    item => item.node.type.name === name
  )
  const flatten = list =>
    list.reduce((a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), [])

  function parseNodes(nodes, className = []) {
    return nodes.map(node => {
      const classes = [
        ...className,
        ...(node.properties ? node.properties.className : [])
      ]

      if (node.children) {
        return parseNodes(node.children, classes)
      }

      return {
        text: node.value,
        classes
      }
    })
  }

  blocks.forEach(block => {
    let startPos = block.pos + 1;
    // @ts-ignore
    const items = block.node.content.content.map(item => {
      if (item.text) {
        return item.text;
      }
      return '\n'
    })
    const textContent = items.join('')
    const nodes = low.highlight(block.node.attrs.lang, textContent).value
    flatten(parseNodes(nodes))
      .map(node => {
        const from = startPos;
        const to = from + node.text.length;

        startPos = to

        return {
          ...node,
          from,
          to
        }
      })
      .forEach(node => {
        const decoration = Decoration.inline(node.from, node.to, {
          class: node.classes.join(' ')
        })
        decorations.push(decoration)
      })
  })

  return DecorationSet.create(doc, decorations)
}

export default function HighlightPlugin({ name }) {
  return new Plugin({
    state: {
      init: (_, { doc }) => getDecorations({ doc, name }),
      apply: (transaction, decorationSet, oldState, state) => {
        // TODO: find way to cache decorations
        // see: https://discuss.prosemirror.net/t/how-to-update-multiple-inline-decorations-on-node-change/1493

        const nodeName = state.selection.$head.parent.type.name
        const previousNodeName = oldState.selection.$head.parent.type.name

        if (
          transaction.docChanged &&
          [nodeName, previousNodeName].includes(name)
        ) {
          return getDecorations({ doc: transaction.doc, name })
        }

        return decorationSet.map(transaction.mapping, transaction.doc)
      }
    },
    props: {
      decorations(state) {
        return this.getState(state)
      }
    }
  })
}

code.tsx

import * as React from 'react'
import { setBlockType } from 'prosemirror-commands'
import uuid from 'uuid'
import Plugin from './plugin'
import {Extension, ExtensionProps, blockActive, getParentNodeFromState } from 'paper-editor';
import Button from '../../components/button'
import CodeIcon from '../../components/icons/Code';

type Lang = {
  label: React.ReactNode
  lang: string
}

export default class Code extends Extension {
  defaultLang = 'js'
  langs: Lang[] = [
    {
      label: '<span style="font-size: 12px;">JS</span>',
      lang: 'js'
    },
    {
      label: '<span style="font-size: 12px;">PHP</span>',
      lang: 'php'
    },
    {
      label: '<span style="font-size: 12px;">XML</span>',
      lang: 'xml'
    },
    {
      label: '<span style="font-size: 12px;">CSS</span>',
      lang: 'css'
    }
  ]

  constructor(props?: ExtensionProps) {
    super(props)
    if (props) {
      this.langs = props.langs
    }
  }

  get name() {
    return 'code'
  }

  get group() {
    return 'block'
  }

  get showMenu() {
    return true
  }

  get hideInlineMenuOnFocus() {
    return true
  }

  get schema() {
    if (this.customSchema) {
      return this.customSchema
    }
    const { defaultLang } = this
    return {
      content: 'inline*',
      group: 'block',
      parseDOM: [
        {
          tag: 'code',
          getAttrs(dom) {
            return {
              id: dom.getAttribute('id') || uuid()
            }
          }
        }
      ],
      toDOM: node => {
        return [
          'pre',
          {
            id: node.attrs.id || uuid(),
            className: this.className
          },
          ['code', 0]
        ]
      },
      attrs: {
        id: {
          default: ''
        },
        lang: {
          default: defaultLang
        }
      }
    }
  }

  get icon() {
    return <CodeIcon style={{ width: '24px', height: '24px' }} />
  }

  active(state) {
    return blockActive(state.schema.nodes.code)(state)
  }

  enable(state) {
    return setBlockType(state.schema.nodes.code)(state)
  }

  onClick(state, dispatch) {
    setBlockType(state.schema.nodes.code)(state, dispatch)
  }

  customMenu({ state, dispatch }) {
    const node = getParentNodeFromState(state)
    const { langs } = this
    return (
      <>
        {langs.map(lang => (
          <Button
            active={node && node.attrs.lang === lang.lang}
            type="button"
            onClick={() => {
              setBlockType(state.schema.nodes.code, {
                lang: lang.lang
              })(state, dispatch)
            }}
          >
            {typeof lang.label !== 'string' ? (
              lang.label
            ) : (
              <span dangerouslySetInnerHTML={{ __html: lang.label }} />
            )}
          </Button>))}
      </>
    )
  }

  get plugins() {
    return [
      Plugin({
        name: 'code'
      })
    ]
  }
}

以上です!

以上です!現在、オープンソースにもしたいなと思っていて社内で許可を待っている状態です。
PaperEditorの応援、また問題点などがあったら個人的に教えていただけたらと思います!
公開したらきっとSmartPhotoを超えるOSSになるはずです!
よろしくお願いします。


Home