【Summernote v0.8.8】画像にリンクをつける

WYSIWYGエディタのSummenoteで画像にリンクをつけたり、外したりできるようカスタマイズしました。

デフォルトで画像アップロードの際にリンクをつける機能はあるのですが、
ファインダーを別途つけており、カスタマイズする必要がありました。

summernote.jsを直接編集しています。カスタマイズは慎重に。自己責任でお願いします。

今回は、アップロードした画像をクリックした時に、センタリングなどのボタンと合わせて「リンク」と「リンクを外す」アイコンを追加したいと思います。

環境

summernote v0.8.8。
bootstrap v4.0.0

Summernoteで画像にリンクさせる簡単な流れ

ボタンを作る流れ

  1. uiオブジェクトにリンクボタンとリンク解除ボタンを追加
  2. 画像をクリックした時に表示されるポップオーバーに作ったボタンを追加
  3. ポップオーバーの動作を定義した変数を作る
  4. その変数をSummernoteのモジュールに追加

画像にリンクをつける流れ

  1. リンクボタンをクリックした時のダイアログの変数を作る
  2. それをSummernoteのモジュールに追加
  3. 画像リンク処理、リンク解除処理をEditorクラスに追加
  4. ボタンと処理を紐付け

「リンク」ボタンを追加する

ボタンを作成

uiオブジェクトにに新しいボタンを定義します。

var ui = {
    editor: editor,

 〜〜〜〜〜〜〜〜〜〜〜

  $.extend($.summernote.lang, {
    'en-US': {
       〜〜〜〜〜〜〜〜〜〜〜
      image: {
        〜〜〜〜〜〜〜〜〜〜〜
        maximumFileSizeError: 'Maximum file size exceeded.',
        //ここにリンクボタンを追加します-----
        //新たにinsertLinkというボタンを作って、リンクのアイコンを使います
        insertLink: 'Link Image',
        //unlinkというボタンを作って、アンリンクのアイコンを使います。
        unlink: 'Unlink',
     //ここにリンクボタンを追加します-----
        url: 'Image URL',
        remove: 'Remove Image'
      },

Summernoteのポップオーバーに追加

ポップオーバーはSummernoteにもともと付いている機能です。

uiオブジェクトで新しく作成したボタンを、そのポップオーバーで表示するように追加します。

$.summernote = $.extend($.summernote, {
    version: '0.8.8',
    ui: ui,
    dom: dom,
    〜〜〜〜〜

      // popover
      popover: {
        image: [
          ['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']],
          ['float', ['floatLeft', 'floatRight', 'floatNone']],
      //作ったボタンを追加--------
          ['image', ['insertLink','unlink' ]],
          //作ったボタンを追加--------
          ['remove', ['removeMedia']]
        ],
        〜〜〜〜〜

ポップオーバーの処理をする変数を作成

ポップオーバーでの振る舞いを処理する変数を作成します。

var ImagePopover = function (context) {
    var self = this;
    var ui = $.summernote.ui;

    var $editable = context.layoutInfo.editable;
    var editable = $editable[0];
    var options = context.options;

    this.events = {
      'summernote.disable': function () {
        self.hide();
      }
    };

    this.shouldInitialize = function () {
      return !list.isEmpty(options.popover.image);
    };

    this.initialize = function () {
      this.$popover = ui.popover({
        className: 'note-image-popover'
      }).render().appendTo('body');
      var $content = this.$popover.find('.popover-content,.note-popover-content');

      context.invoke('buttons.build', $content, options.popover.image);
    };

    this.destroy = function () {
      this.$popover.remove();
    };

    this.update = function (target) {
      if (dom.isImg(target)) {
        var pos = dom.posFromPlaceholder(target);
        var posEditor = dom.posFromPlaceholder(editable);

        this.$popover.css({
          display: 'block',
          left: pos.left,
          top: Math.min(pos.top, posEditor.top)
        });
      } else {
        this.hide();
      }
    };

    this.hide = function () {
      this.$popover.hide();
    };
  };

それをsummernoteのモジュールに追加

$.summernote = $.extend($.summernote, {
    version: '0.8.8',
    ui: ui,
    dom: dom,

    plugins: {},

    options: {
      modules: {
       〜〜
        'linkDialog': LinkDialog,
        'linkPopover': LinkPopover,

     //作った変数を追加-------
        'imagePopover': ImagePopover,
        //作った変数を追加-------

        〜〜〜〜
      },

リンクボタンをクリックした時に、ダイアログを表示させる

次に、リンクボタンをクリックした時に、ダイアログを表示させてURLを入力できるようにします。

ダイアログを作成

ダイアログを処理する変数を作ります。ダイアログのhtml、振る舞い、全てここに入っています。

var ImageLinkDialog = function (context) {
    var self = this;
    var ui = $.summernote.ui;

    var $editor = context.layoutInfo.editor;
    var options = context.options;
    var lang = options.langInfo;

    this.initialize = function () {
      var $container = options.dialogsInBody ? $(document.body) : $editor;

      var body = '<div class="form-group note-form-group">' +
          '<label class="note-form-label">' + lang.link.url + '</label>' +
          '<input class="note-link-url form-control note-form-control ' +
          'note-input" type="text" value="http://" />' +
          '</div>' +
          (!options.disableLinkTarget ?
              $('<div/>').append(ui.checkbox({ id: 'sn-checkbox-open-in-new-window', text: lang.link.openInNewWindow, checked: true }).render())
                  .html()
              : '');
      var footer = '<button href="#" class="btn btn-primary note-btn note-btn-primary ' +
          'note-link-btn disabled" disabled>' + lang.link.insert + '</button>';

      this.$dialog = ui.dialog({
        className: 'note-image-link-dialog',
        title: lang.link.insert,
        fade: options.dialogsFade,
        body: body,
        footer: footer
      }).render().appendTo($container);
    };

    this.destroy = function () {
      ui.hideDialog(this.$dialog);
      this.$dialog.remove();
    };

    this.bindEnterKey = function ($input, $btn) {
      $input.on('keypress', function (event) {
        if (event.keyCode === key.code.ENTER) {
          $btn.trigger('click');
        }
      });
    };

    /**
     * toggle update button
     */
    this.toggleLinkBtn = function ($linkBtn, $linkText, $linkUrl) {
      ui.toggleBtn($linkBtn, $linkText.val() && $linkUrl.val());
    };
    /**
     * Show link dialog and set event handlers on dialog controls.
     *
     * @param {Object} linkInfo
     * @return {Promise}
     */
    /**
     * Show link dialog and set event handlers on dialog controls.
     *
     * @param {jQuery} $dialog
     * @param {Object} linkInfo
     * @return {Promise}
     */
    this.showImageLinkDialog = function (linkInfo) {
      return $.Deferred(function (deferred) {
        var $linkUrl = self.$dialog.find('.note-link-url'),
            $linkBtn = self.$dialog.find('.note-link-btn'),
            $openInNewWindow = self.$dialog.find('input[type=checkbox]');

        ui.onDialogShown(self.$dialog, function () {
          context.triggerEvent('dialog.shown');

          var handleLinkUrlUpdate = function () {
            ui.toggleBtn($linkBtn,  $linkUrl.val());
          };

          $linkUrl.on('input', handleLinkUrlUpdate).on('paste', function () {
            setTimeout(handleLinkUrlUpdate, 0);
          }).val(linkInfo.url).trigger('focus');

          ui.toggleBtn($linkBtn,  $linkUrl.val());
          self.bindEnterKey($linkUrl, $linkBtn);

          var isChecked = linkInfo.isNewWindow !== undefined ?
              linkInfo.isNewWindow : context.options.linkTargetBlank;

          $openInNewWindow.prop('checked', isChecked);

          $linkBtn.one('click', function (event) {
            event.preventDefault();

            deferred.resolve({
              range: linkInfo.range,
              url: $linkUrl.val(),
              isNewWindow: $openInNewWindow.is(':checked')
            });
            ui.hideDialog(self.$dialog);
          });
        });

        ui.onDialogHidden(self.$dialog, function () {
          // detach events
          $linkUrl.off('input paste keypress');
          $linkBtn.off('click');

          if (deferred.state() === 'pending') {
            deferred.reject();
          }
        });

        ui.showDialog(self.$dialog);

      }).promise();
    };

    /**
     * @param {Object} layoutInfo
     */
    this.show = function () {
      var linkInfo = context.invoke('editor.getLinkInfo');
      context.invoke('editor.saveRange');
      this.showImageLinkDialog(linkInfo).then(function (linkInfo) {
        context.invoke('editor.restoreRange');
        context.invoke('editor.createImageLink', linkInfo);
      }).fail(function () {
        context.invoke('editor.restoreRange');
      });
    };
    context.memo('help.linkDialog.show', options.langInfo.help['linkDialog.show']);
  };

2020/1/1追記 リンク情報取得を修正

リンクを編集する際に、カーソルが画像の前後にないと、リンク情報(hrefなど)が取得できていませんでした。

そこで、リンク情報取得する関数を追加します。

 var Editor = function (context) {

   〜〜〜〜〜〜〜〜〜〜〜
   //画像をクリックした際に、画像に貼ってあるリンク情報を取得する
   /**
     * returns Link link info
     *
     * @return {Object}
     * @return {WrappedRange} return.range
     * @return {String} return.text
     * @return {Boolean} [return.isNewWindow=true]
     * @return {String} [return.url=""]
     */
    this.getImgLinkInfo = function () {
      var $handle = context.layoutInfo.editingArea,
          target = $handle.find('.note-control-selection').data('target'),
          $target = $(target),
          $target_anchor = $target.parent('a');

      var startRange = range.createFromNodeBefore($target.get(0));
      var startPoint = startRange.getStartPoint();
      var endRange = range.createFromNodeAfter($target.get(0));
      var endPoint = endRange.getEndPoint();

      var rng = range.create(
          startPoint.node,
          startPoint.offset,
          endPoint.node,
          endPoint.offset
      ).select();

      var linkInfo = {
        range: rng,
        url: $target_anchor.length ? $target_anchor.attr('href') : ''
      };

      // Define isNewWindow when anchor exists.
      if ($target_anchor.length) {
        linkInfo.isNewWindow = $target_anchor.attr('target') === '_blank';
      }

      return linkInfo;
    };
   〜〜〜〜〜〜〜〜〜〜〜
}

var ImageLinkDialogのshow()でリンク情報を取得しているので、そこを差し替えます。

this.showImageLinkDialog = function (linkInfo) {
   〜〜〜〜〜〜〜〜〜〜〜
     this.show = function () {
      //ここのgetLinkInfoを作成したgetImgLinkInfoへ差し替え
      var linkInfo = context.invoke('editor.getLinkInfo');
      //↓↓↓↓↓↓↓これに差し替え↓↓↓↓↓↓↓↓
      var linkInfo = context.invoke('editor.getImgLinkInfo');
      //↑↑↑↑↑↑↑/これに差し替え↑↑↑↑↑↑↑↑
      context.invoke('editor.saveRange');
      this.showImageLinkDialog(linkInfo).then(function (linkInfo) {
        context.invoke('editor.restoreRange');
        context.invoke('editor.createImageLink', linkInfo);
      }).fail(function () {
        context.invoke('editor.restoreRange');
      });
    };
   〜〜〜〜〜〜〜〜〜〜〜
}

summernoteのモジュールに追加

$.summernote = $.extend($.summernote, {
    version: '0.8.8',
    ui: ui,
    dom: dom,

    plugins: {},

    options: {
      modules: {
       〜〜
        'linkDialog': LinkDialog,
        'linkPopover': LinkPopover,
        'imagePopover': ImagePopover,
     //作った変数を追加-------
     'imageLinkDialog':ImageLinkDialog,
        //作った変数を追加-------
        〜〜〜〜
      },

これでリンクボタンをクリックした時に、URLを入れるダイアログが表示されるようになりました。

画像にリンクを貼る処理を行う

Editorクラスの中に、画像にリンクを貼る処理を入れます。

これらの処理は、前項で作成したダイアログ(var ImageLinkDialog)の処理の中で発火させます。

var ImageLinkDialogの中にある、context.invoke(‘editor.createImageLink’, linkInfo);がそれです。

 /**
   * @class Editor
   */
  var Editor = function (context) {
    var self = this;

  〜〜〜〜〜〜〜〜〜〜〜
  //ダイアログでsubmitを押した後の処理を入れます。
  //ここで、画像にリンクを張っています。
  this.createImageLink = this.wrapCommand(function (linkInfo) {
      var linkUrl = linkInfo.url;
      var isNewWindow = linkInfo.isNewWindow;
      var rng = linkInfo.range || this.createRange();
      var $handle = context.layoutInfo.editingArea,
          target = $handle.find('.note-control-selection').data('target'),
          $target = $(target);

      if (typeof linkUrl === 'string') {
        linkUrl = linkUrl.trim();
      }

      if (options.onCreateLink) {
        linkUrl = options.onCreateLink(linkUrl);
      } else {
        // if url doesn't match an URL schema, set http:// as default
        linkUrl = /^[A-Za-z][A-Za-z0-9+-.]*\:[\/\/]?/.test(linkUrl) ?
            linkUrl : 'http://' + linkUrl;
      }
    
      //2020/1/1修正
      rng = rng.deleteContents();
      var anchors = [];
      var anchor = rng.insertNode($('<A>' + $target.prop('outerHTML')+ '</A>')[0]);
      anchors.push(anchor);

      $.each(anchors, function (idx, anchor) {
        $(anchor).attr('href', linkUrl);
        if (isNewWindow) {
          $(anchor).attr('target', '_blank');
        } else {
          $(anchor).removeAttr('target');
        }
      });
   //2020/1/1修正

      var startRange = range.createFromNodeBefore(list.head(anchors));
      var startPoint = startRange.getStartPoint();
      var endRange = range.createFromNodeAfter(list.last(anchors));
      var endPoint = endRange.getEndPoint();

      range.create(
          startPoint.node,
          startPoint.offset,
          endPoint.node,
          endPoint.offset
      ).select();
    });
  〜〜〜〜〜〜〜〜〜〜〜

2020/1/1
※Nodeを追加する、リンクをつける箇所を修正しています。

リンク解除の処理

同じくEditorクラスの中にリンク解除の処理も入れます。

this.unlinkImageLink = function () {
      var rng = this.createRange();
      if (rng.isOnAnchor()) {
        var anchor = dom.ancestor(rng.sc, dom.isAnchor);
        rng = range.createFromNode(anchor);
        rng.select();

        beforeCommand();
        document.execCommand('unlink');
        afterCommand();
      }
    };

最後に、ボタンと動作を紐付けます。

this.addToolbarButtons = function () {

  〜〜〜〜〜〜〜〜〜〜〜
  //ポップオーバーのボタンを追加-----
  this.addLinkPopoverButtons = function () {
      //リンクをつけるリンクボタンとその処理
      context.memo('button.insertLink', function () {
        return ui.button({
          contents: ui.icon(options.icons.link),
          tooltip: lang.link.edit,
          click: context.createInvokeHandler('imageLinkDialog.show')
        }).render();
      });
     //リンクを解除するアイコンボタンとその処理
     context.memo('button.unlink', function () {
        return ui.button({
          contents: ui.icon(options.icons.unlink),
          tooltip: lang.link.unlink,
          click: context.createInvokeHandler('editor.unlinkImageLink')
        }).render();
      });
    };
 〜〜〜〜〜〜〜〜〜〜〜

これで、画像をクリックした時にリンクボタンが表示されて、そこからリンクをつける事ができます。

ご参考にさせて頂きました

https://github.com/summernote/summernote/commit/55773561371b13788b60e36dd0dd3bda79ab3b25

この方のコードがなかったら、達成できていなかったです。感謝します。