Google Apps ScriptでCKEditorを使ったウェブアプリ【GAS】

自分のウェブアプリケーションに於いて、ユーザ入力してもらうエリアで単なるテキストエリアだと非常に味気ない。そこで、HTML入力も可能な各種CMSやプラグインとしても活用されている「CKEditor」を搭載できたら、一気にアプリが豪華になります。

今回このCKEditorをGASで作ったウェブアプリケーション側に搭載してみて、その中で得た知見などをここにまとめてみようと思います。Google Apps ScriptだけでなくElectronなどのアプリでも活躍すると思います。

今回使用するライブラリ等

CDNで利用しますが、Standardの場合、フォントカラー変更などの一部が無いので、Full PackageのCDNを今回は利用しています。

実行と結果

サンプルの使い方

サンプルスプレッドシートには、CKEditorを含めたコードと実行用のメニューが用意されています。以下の手順でセットアップを実行し、表示します。

  1. スプレッドシートを開く
  2. メニューより、CKEditor -> セットアップを実行(スプレッドシートのIDがスクリプトプロパティに記述されます)
  3. 続けて、CKEditor -> エディタの表示で表示されます。
  4. 左サイドバーをクリックすると対応するHTMLコードがエディタ内に展開されます。
  5. 右上の保存ボタンをクリックするとスプレッドシートに書き込まれます。

図:セットアップをまずしておきましょう

結果

デモでは書き込みは出来ないようにしてあります。

ソースコード

今回はいきなり開くとCKEditorの画面であるため、現実にはダイアログ表示などをした時にその中にCKEditorを表示するパターンが多いと思われる(実際自分のアプリではそのような作り)。そのため、CKEditorの初期化のタイミングや、保存する時に一時配列にスプレッドシートの値を読み込ませてから表示 -> 保存時は一時配列の中身を本来のデータ格納用配列に保存しつつ、スプレッドシート書き込みになるため、よりコードは複雑になります。

また、ダイアログ表示をする場合、CKEditorの縦横のサイズをリサイズ対応する必要があると、その為のリサイズ用の処理を入れておく必要があります。

また、今回はアプリの外観用にVue.js + Vuetifyも利用しています。

CDNを利用

CKEditor本体のライブラリを読み込む時には、いくつかパターンがあり、今回のようなフルセットの場合には、HTMLのHEAD内に以下のコードを追記しておいて読み込ませるだけです。

<script src="https://cdn.ckeditor.com/4.16.1/full/ckeditor.js"></script>

フォントカラーの変更などは、standardには含まれていないようで、fullのパッケージを読み込ませるようにしています。

図:フルパッケージだと機能が豊富

初期化

GAS側コード

GAS側はHTML側からのデータ要求に対して、JSON形式でデータを送り返してあげる必要があります(v-data-tableがその形式で受け入れる為)。今回はスプレッドシートのIDをsheetidというプロパティに格納してあります。

//ckeditorシートのデータを取得して返す
function geteditdata(){
  //ssidを取得
  var prop = PropertiesService.getScriptProperties();
  var ssid = prop.getProperty("sheetid")

  //スプレッドシートデータを取得する
  var sheet = SpreadsheetApp.openById(ssid).getSheetByName("ckeditor")
  var ss =  sheet.getDataRange().getValues();

  //タイトル行を取得する
  var title = ss.splice(0, 1)[0];
  
  //JSONデータを生成する
  return JSON.stringify(ss.map(function(row) {
    var json = {}
    row.map(function(item, index) {
      json[title[index]] = item;
    });
    return json;
  }));
}

HTML側コード

今回はVuetifyのmountedではなく、初回起動時の左サイドバーのパネルをクリック時に初期化をしています。初期化をしなければ、CKEditorが表示されないので注意が必要です。また、2回目クリック時はパネル間移動となるため、そのままではデータが消えてしまう。よって、まずはCKEditorの中身を一時配列に保存してから、次のパネルの値をCKEditorに読み込ませる仕組みにしています(保存ボタンを押した時に初めて、スプレッドシートには反映する)

onClickRec(item){
  //フルスクリーンダイアログで初回表示時には必要
  setGridHeight();

  //まず、現状編集していたデータを書き戻す(フルスクリーンダイアログの時)
  var realtemp = this.edittempid;
  if (realtemp == "") {
    //まだ最初の編集をしていないのでスルー
  } else {
    //現在の内容を反映しておく
    var tlength = this.mtemp.length

    //realtempと一致する場所に値を保存する
    for (var i = 0; i < tlength; i++) {
      //レコードを一個取り出す
      var recman = this.mtemp[i];

      //一致するレコードがあったら、CKEditorの内容をマージ
      if (realtemp == recman.id) {
        //CKEditorの内容を取得する
        if (ckflg == true) {
          var tempCKEditor = ckevent.editor.getData()
          this.mtemp[i].tempstr = tempCKEditor;
        }
      }
    }
  }

  //テンプレ番号を取得
  this.edittempid = item.id;

  //テンプレート名を取得
  this.templatename = item.tempname;

  //display属性をblockに
  //document.getElementById("mailedit").style.display = "block";

  //CKEditorの高さと幅を調整
  var ckHeight = $(window).height() - 138;

  //テンプレートを読み込む
  var tempstr = item.tempstr;

  //CKeditorを有効化
  if (ckflg == false) {
    editorman = CKEDITOR.replace("editor1", {
      height: 10,
      width: "100%",
      on: {
        //起動時に色々実行
        'instanceReady': function (evt) {
          //イベントインスタンスを取得
          ckevent = evt;

          //CKEditorのサイズをフィットするように変更
          evt.editor.resize("100%", $("#approve2").height() - Number(70));

          //エディタにデータをロード
          ckevent.editor.setData(tempstr)
        }
      }
    });
  } else {
    ckHeight = $(window).height() - 138;

    //CKeditorのサイズ変更
    ckevent.editor.resize("100%", ckHeight);

    //エディタにデータをロード
    ckevent.editor.setData(tempstr)
  }

  //ckeditorのフラグを立てる
  ckflg = true;
}
  • 初期化は、CKEDITOR.replaceにて、textareaタグの中身を置き換える形で実行します。
  • この時、後からそのインスタンスをいじれるように、ckevent変数にインスタンスを格納しています。
  • 初期化時に縦横のサイズ指定できますが、今回はapprove2のエリアにフィットするようにevt.editor.resizeにてCKEditorの縦横のサイズを変更させています。
  • パネル間で移動した際に一時配列のmtemp変数には変更を反映してから、次の表示をするようにしています(でないと、パネル移動すると、データが消えてしまい、再度呼び出し時には初期値が表示されてしまう。

値をセットと取得

CKEditor自体は、textareaタグの領域をリプレースする事で表示する仕組みになっています。よって、必ずtextareaが一個は必要になります。そして、このCKEditorは表がWYSIWYGのエディタ、裏側がHTMLのソースコードになっており、HTMLの値をセットすると、表のWYSIWYGの表示が適切なGUIのHTMLエディタの表記になってくれます。

//値をセットする
ckevent.editor.setData(tempstr);

//値を取得する
var ret = ckevent.editor.getData();

予め、初期化時にインスタンスを取得しておき(ckevent)、これに対してsetDataでHTMLデータをセットし、編集後の値はgetDataで取得します。

データの書き込み

今回は1行ずつデータを書き込みではなく、まとめてセーブする形式を取っているので、データはまるごと渡して書き換えるようにしています。

GAS側コード

//データをセーブする
function saverecord(array){
  //ssidを取得
  var prop = PropertiesService.getScriptProperties();
  var ssid = prop.getProperty("sheetid")

  //連想配列なので配列に治す
  var editarr = [];
  var length = array.length
  for(var i = 0;i<length;i++){
    //レコードを一個取り出す
    var rec = array[i];
   
    //一時配列に生成
    var temparr = [];
    temparr.push(rec.id);
    temparr.push(rec.tempname);
    temparr.push(rec.tempstr);

    //配列にpush
    editarr.push(temparr)
  }

  //スプレッドシートを取得
  var sheet = SpreadsheetApp.openById(ssid).getSheetByName("ckeditor");

  //データを貼り付ける
  var lastColumn = editarr[0].length;  //カラムの数を取得する
  var lastRow = editarr.length;      //行の数を取得する
  sheet.getRange(2,1,lastRow,lastColumn).setValues(editarr);   

  return "OK"
}
  • JSONで受け取って配列に直し、一発で書き込みをしています。

HTML側コード

//編集結果をスプレッドシートへ反映する
savetemplate(){
  //まず、現状編集していたデータに書き戻す
  var realtemp = this.edittempid;
  if (realtemp == "") {
    //まだ最初の編集をしていないのでスルー
  } else {
    //現在の内容に反映しておく
    var tlength = this.mtemp.length

    //realtempと一致する場所に値を保存する
    for (var i = 0; i < tlength; i++) {
      //レコードを一個取り出す
      var recman = this.mtemp[i];

      //一致するレコードがあったら、CKEditorの内容をマージ
      if (realtemp == recman.id) {
        //CKEditorの内容を取得する
        if (ckflg == true) {
          var tempCKEditor = ckevent.editor.getData()
          this.mtemp[i].tempstr = tempCKEditor;
        }
      }
    }
  }

  //GAS側へ書き込み
  google.script.run.withSuccessHandler(onSaved).saverecord(this.mtemp);
}
  • 最後の書き込みを一旦セーブしないと、変数には反映されていないので、まず変数に保存するコードが必要です
  • mtempのJSONデータをGAS側へ渡してあげています。

リサイズ対応

サイズ可変のウィンドウであったり、ダイアログなど、その時の外枠のサイズは動的に変わるケースの場合、固定サイズのCKEditor表示だと少々見栄えが悪いです。フルスクリーンやサイズ変更時にはその外枠に合わせてリサイズしてくれたほうがスマートな表示になります。

そこで、リサイズを行った時にサイズ変更を行う関数を実行させて、対処しています。

//Gridの高さを自動補正する関数
function setGridHeight() {
  var layoutHeight = $(window).height()- 10;
  $('#approve1').css('height', layoutHeight - 59 + 'px');
  $('#approve2').css('height', layoutHeight - 59 + 'px');

  //widthを調整
  var layoutWidth = $(window).width() - 230;
  $('#approve2').css('width', layoutWidth + 'px');

  //CKEditorを調整
  var ckHeight = $(window).height() - 138;
  vm.dataTableHeight = $(window).height() - 100;

  if(ckflg == true){
    //CKeditorのサイズ変更
    ckevent.editor.resize("100%", ckHeight);
  }
}

これをVuetifyの初期化マウント時のイベントや、以下のようなリサイズ時のイベントの中で呼び出すようにしておけば、リサイズするとそのサイズに追従する形で、CKEditorがフィットするようになります。横幅は100%していで簡単にフィットします。

//リサイズ時にウィンドウにフィットさせる
window.onresize = function(){
    setGridHeight();
}
        
//gridのサイズを自動でウィンドウにフィットする
$(document).ready(function () {
    setGridHeight();
});

左サイドバーはv-data-tableで作成しているので、こちらは横幅固定で高さだけをウィンドウ枠の高さに合うように調整しています。調整で-140などしてるのはVuetifyのツールバーの高さ分などを差し引きして、高さ調整を行っているためです。

ツールバーのカスタマイズ

表示するボタンやグループを制御

CDNで取り込んだCKEditorですが、今回はfullを選んでいるため、デフォルトで非常に多くのボタンが表示されてしまっています。しかし、実際に使うであろうボタン類というのはそこまで多いわけではないので、これではオーバースペック。ということで、Toolbar Configuratorで必要なものだけを残して、Get Toolbar Configをクリックするとカスタマイズ用のコードが得られます。

また、ローカル用の場合は、こちらのサイトでカスタマイズすると、カスタマイズしたローカル用ファイルが出力されます。

これをそのまま使うのではなく、以下のようにCKEDITOR.replace時のオプション設定として指定する事で、必要なボタンだけを表示する事が可能です。

editorman = CKEDITOR.replace("editor1", {
  height: 10,
  width: "100%",
  on: {
    //起動時に色々実行
    'instanceReady': function (evt) {
      //イベントインスタンスを取得
      ckevent = evt;

      //CKEditorのサイズをフィットするように変更
      evt.editor.resize("100%", $("#approve2").height() - Number(70));

      //エディタにデータをロード
      ckevent.editor.setData(tempstr)
    }
  },
  //表示するボタングループ
  toolbarGroups: [
    { name: 'document', groups: ['mode', 'document', 'doctools'] },
    { name: 'clipboard', groups: ['clipboard', 'undo'] },
    { name: 'editing', groups: ['find', 'selection', 'spellchecker', 'editing'] },
    { name: 'basicstyles', groups: ['basicstyles', 'cleanup'] },
    '/',
    { name: 'forms', groups: ['forms'] },
    { name: 'paragraph', groups: ['indent', 'list', 'blocks', 'align', 'bidi', 'paragraph'] },
    { name: 'links', groups: ['links'] },
    { name: 'insert', groups: ['insert'] },
    { name: 'styles', groups: ['styles'] },
    { name: 'colors', groups: ['colors'] },
    '/',
    { name: 'tools', groups: ['tools'] },
    { name: 'others', groups: ['others'] },
    { name: 'about', groups: ['about'] }
  ],
  //削除するボタン類
  removeButtons: 'Cut,Undo,Redo,Copy,Paste,PasteText,PasteFromWord,Templates,Form,Checkbox,Radio,TextField,Textarea,Select,Button,ImageButton,HiddenField,Blockquote,CreateDiv,BidiLtr,BidiRtl,Language,Anchor,Flash,Smiley,SpecialChar,PageBreak,Iframe,About,Maximize,ShowBlocks'
});
  • toolbarGroupsで表示するツールバーグループを指定
  • removeButtonsにて、非表示にするボタン類を指定
  • これらを手動で作るのは面倒なので、Toolbar Configuratorを使ってオンオフや位置移動をすることで、上記に必要なコードが得られます。

図:デフォルトよりもスッキリな表示になった

カスタムボタンを追加

GASで使う上で欲しくなるのが、Google Driveに画像やファイルをアップして、エディタにそのファイルへのリンクを追加するといった機能。これらはアイコン画像と、コードでカスタムボタンとして追加が可能です。

//ドライブのボタンを追加する
editorman.config.allowedContent = true;

editorman.ui.addButton('Google Drive', {
  label: 'Google Drive',
  command: 'drive_upload',
  toolbar: "insert",
  icon:"https://drive.google.com/uc?export=download&id=1ngX-6CpoTcO1WtK2tqkCNxfA28E311Yq"
});

//ドライブボタンを押した時の挙動
editorman.addCommand('drive_upload', {
  exec: function(edt) {
    //エディタ部分に文字を追加する
    edt.insertHtml("ここに押した時の処理を追加していく");
  }
});

コードとしては、前述のコードのeditormanを定義後に上記のようなコードを追加する。自分の場合、このボタンをクリックしたらGoogle PickerのUpload機能を追加してファイルアップローダとリンクを元に画像をimgタグで表示する機能を搭載させています。

Google Picker アップローダを作る【GAS】

図:ドライブボタンを追加できた

カーソル位置に挿入

前述のGoogle Driveへアップロードし、IDを受け取ったら色々加工して、「現在のカーソルの位置に情報を挿入」という装備をする必要があります。でなければ、標準では一番後方の位置に挿入されてしまうので、具合がよくありません。故に書き込む前にカーソル位置を取得しておいてから書き込むようにします。

例えば前述のGoogle DriveへのアップロードでPicker APIを使った場合、ファイルのMimeTypeに応じて内容を変えて、CKEditorの現在のカーソル位置に挿入というコードは以下のようになります。

//現在のカーソルポジションを取得する
var s = editorman.getSelection();
var selected_ranges = s.getRanges();
s.selectRanges(selected_ranges);

//画像かそうでないかで処理を分岐
var html = "";

switch (mime) {
  case "image/jpeg":
  case "image/png":
  case "image/webp":
  case "image/heif":
  case "image/heic":
  case "image/gif":
    //fileIdから画像へのダイレクトリンクのURLを生成
    var directman = "https://drive.google.com/uc?export=download&id=" + fileId;

    //画像なのでimgタグで埋め込みをする
    html = "<img src='" + directman + "' alt='" + filename + "' >";
    break;
  default:
    let imgman = "<img src='" + icons + "'>&nbsp;";
    html = "<div class='box14'><p><b><a href='" + url + "' target='_blank'>" + imgman + filename + "</a></b></p></div>"
    break;
}

//カーソルポジションにデータを書き込む
editorman.insertHtml(html);
  • 現在のカーソルポジションにて、位置を取得(selectRangesで位置を指定します)
  • MimeTypeに応じて挿入するHTMLを変更する(画像ならば直接imgタグで表示、そうでなければファイルへの直リンク表示)
  • 最期にinsertHtmlにてカーソル位置に貼り付けます。

ダイアログで使う場合の注意点

リンク等のダイアログが操作できない

VuetifyのDialog内にCKEditorを表示させて、いざ文字を入力。文字にハイパーリンクを設定しようとボタンを押し、リンク設定のダイアログが出たまでは良かったのですが、URLのテキストボックスを触ろうとするとフォーカスされず、操作が出来なくなる現象があります。また、この問題を回避してもテキストボックスを触るたびにダイアログが動くバウンスアニメーションが動く為、鬱陶しいことこの上ない。

ということで、この場合の対処法は以下の通り。

<v-dialog v-model="ckdialog" persistent hide-overlay fullscreen :retain-focus="false" no-click-animation>

</v-dialog>

persistentを外すと、CKEditorのダイアログを触った瞬間にVuetifyのダイアログが閉じてしまうので、つける必要があります。また変更点は

  • :retain-focus="false"を付けてフォーカスがテキストボックスに入るように奪取を阻止します
  • no-click-animationを付けてバウンスアニメーションをオフにします

これで、VuetifyとCKEditorの厄介な問題を解決して同居することが可能です。

図:テキストボックスにフォーカスが移動しない問題

要素が見つからないエラー

VuetifyのDialogにCKEditorを直接描画してる場合、ダイアログが開かれたらすぐにエディタの中身が表示されるようにしたい。しかし、その時に以下のようなエラーが出る場合があります。

全社は、置き換えるElementが見つからないというエラー。後者は既に置き換え済みでバッティングしてるというエラー。CKEditorの初期化は最初の1回で良いのでその為のフラグなどを利用しながら、上記の2つ問題点を解消する必要があります。Vue + Vuetifyの場合はWatchを利用して、初期化を行うと良いでしょう。

VueのtempwordmanにHTMLデータが入っています

watch: {
  tempdialog(visible) {
    if (visible) {
      if(vm.ckflg == true){
        CKEDITOR.instances.editor1.setData(vm.tempwordman);

        //CKeditorのサイズ変更
        ckevent.editor.resize("100%", 300);
      }else{
        //すぐ開くと初期化が間に合わないので
        setTimeout(() => {
          //開かれたらCKeditorを初期化する
          //ckeditorを有効化
          editorman = CKEDITOR.replace("editor1",{
            height:300,
            width:"100%",
            on : {
              //起動時に色々実行
              'instanceReady' : function( evt ) {
                ckevent = evt;

                //初期化完了フラグを立てる
                vm.ckflg = true;

                //エディタ領域リサイズ
                ckevent.editor.resize("100%", 300);

                //エディタにデータをロード
                ckevent.editor.setData(vm.tempwordman)
              }
            },
            //表示するボタングループ
            toolbarGroups: [
              { name: 'document', groups: ['mode', 'document', 'doctools'] },
              { name: 'clipboard', groups: ['clipboard', 'undo'] },
              { name: 'editing', groups: ['find', 'selection', 'spellchecker', 'editing'] },
              { name: 'basicstyles', groups: ['basicstyles', 'cleanup'] },
              '/',
              { name: 'forms', groups: ['forms'] },
              { name: 'paragraph', groups: ['indent', 'list', 'blocks', 'align', 'bidi', 'paragraph'] },
              { name: 'links', groups: ['links'] },
              { name: 'insert', groups: ['insert'] },
              { name: 'styles', groups: ['styles'] },
              { name: 'colors', groups: ['colors'] },
              '/',
              { name: 'tools', groups: ['tools'] },
              { name: 'others', groups: ['others'] },
              { name: 'about', groups: ['about'] }
            ],
            //削除するボタン類
            removeButtons: 'Cut,Undo,Redo,Copy,Paste,PasteText,PasteFromWord,Templates,Form,Checkbox,Radio,TextField,Textarea,Select,Button,ImageButton,HiddenField,Blockquote,CreateDiv,BidiLtr,BidiRtl,Language,Anchor,Flash,Smiley,SpecialChar,PageBreak,Iframe,About,Maximize,ShowBlocks'
          });
        }, 300)
      }
    } else {
      //ダイアログクローズ時にエディタの内容をクリアする
      ckevent.editor.setData("")
    }
  },
},
  • watchの場合、thisでVueの値を参照できない為、vmに格納後にvm.tempwordmanとして参照するようにする
  • 最初の初期化の時にだけ、chflgという値に判定値を入れて、二度目は値の代入のみとするようにする(これでeditor-element-conflictを回避出来る)
  • Dialogを開いた直後に初期化を始めるとeditor1というelementが見えないために初期化に失敗するので、setTimeoutにて300msウェイト掛けてから表示させています(これでeditor-incorrect-elementを回避出来る)
  • watchの対象としてるtempdialogがダイアログのオンオフ用の変数です。

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)