📃

【Movable Type】タグフィールドの入力をコントロールする

に公開

概要

Movable Type(以下MT)の記事やウェブページの「タグ」フィールドにおいて、入力(指定)するタグを別で管理しつつ、投稿画面での入力をコントロールするコード例です。

仕様

以下の仕様で作成しています。

  • 選択するタグはコンテンツデータとして管理する
    • 一覧をJSONファイルに出力する
  • 登録したタグを投稿画面のフィールドに展開し、選択したものを「タグ」フィールドにセットする
    • 選択したタグを再度選択するとタグが外れる
    • 入力できるのは最大2つまで(コード内の記述で変更可)
    • 「タグ」フィールドの直接編集を禁止する

管理画面のjs挿入はMTAppjQueryを使用しています。また、パーツデザインはMT7 スタイルガイドを参照しています。

実装デモはこのようなイメージです。

タグ制御実装デモ(gifアニメ)

選択タグ登録画面・出力

コンテンツタイプで以下のフィールドを作成します。名称は任意です。

  • コンテンツタイプ名:タグ登録
  • 作成するフィールド
    • タグ名(テキスト)
    • タグベースネーム(テキスト)

登録したタグの一覧はJSONデータとして出力させます。

<mt:For compress="3">
  <mt:Contents content_type="[コンテンツタイプID]">
    <mt:ContentsHeader>[</mt:ContentsHeader>
    {
      "id": <$mt:ContentID$>,
      "tagName": "<mt:ContentField content_field='タグ名'><mt:ContentFieldValue encode_json='1'></mt:ContentField>",
      "tagBasename": "<mt:ContentField content_field='タグベースネーム'><mt:ContentFieldValue encode_json='1'></mt:ContentField>"
    }<mt:Unless name="__last__">,</mt:Unless>
    <mt:ContentsFooter>]</mt:ContentsFooter>
  </mt:Contents>
</mt:For>

MTAppjQueryで追加されるグローバルモディファイアcompress="3"を指定することで、空空白や改行を圧縮したJSONを出力できます。

user.jsの記述

user.jsに記述して投稿画面に反映します。

既存のタグフィールドを編集できないようにする

タグフィールドを直接編集できないように制御します。

//タグフィールド編集不可
const tagField = document.getElementById('tags-field');
const tagInput = tagField.querySelector('#tags');
tagInput.setAttribute('readonly', true);
tagInput.classList.replace('form-control', 'form-control-plaintext');
tagInput.setAttribute('placeholder', 'タグリストで選択したタグが挿入されます');

MTAppjQueryでも以下のようにすることで編集不可とできますが、「編集」ボタンを押すことで手入力できてしまうので、今回は上記のように直接制御しています。

mtapp.customize({
  basename: 'tags',
  edit: true
});

選択タグ一覧を表示するためのフィールドを追加する

タグフィールドのエリア内に、選択できるタグ一覧を表示するためのエリア(<div id="tagInsert"></div>)を追加します。

//タグ挿入フィールド追加
tagField.querySelector('.mt-draggable--entryField').insertAdjacentHTML('beforeend', '<div id="tagInsert" class="ml-4 mr-4"></div>');
const taglistselectArea = document.getElementById('tagInsert');

追加したdivのid属性値は選択時の処理で使用するので、変数taglistselectAreaに格納します。

タグ選択時の処理部分

タグ追加・削除時の処理を記述します。

//処理
async function checkAndSelectTag() {
  try {
    const res = await fetch('/tag_insert/taglist.json');
    if (!res.ok) throw new Error('jsonファイルが生成されていません');

    const text = await res.text();
    if (text.trim() === '') {
      taglistselectArea.insertAdjacentHTML('beforeend', '<div class="alert alert-warning" role="alert">選択できるタグはありません</div>');
      return;
    }

    const data = JSON.parse(text);
    if ((Array.isArray(data) && data.length === 0) || (typeof data === 'object' && Object.keys(data).length === 0)) {
      taglistselectArea.insertAdjacentHTML('beforeend', '<div class="alert alert-warning" role="alert">選択できるタグはありません</div>');
      return;
    }

    //タグリストをボタンとして配置
    data.forEach(tag => {
      const selected = tagInput.value.includes(tag.tagName);
      taglistselectArea.insertAdjacentHTML('afterbegin', createTagButton(tag, selected));
    });

    //タグ選択時のイベント処理
    taglistselectArea.querySelectorAll('button').forEach(elm => {
      elm.addEventListener('click', () => handleTagClick(elm));
    });

  } catch (err) {
    console.error(err)
    taglistselectArea.insertAdjacentHTML('beforeend', '<div class="alert alert-danger" role="alert">Error: 管理者にお問い合わせください</div>')
  }
}
checkAndSelectTag();

const res = await fetch('/tag_insert/taglist.json');で先に作成・出力したJSONファイルを取得し、処理をしています(パスは適宜書き換えてください)
上記のコード例では関数部分の記述を省略していますが、おおまかな実装コードは以上です。

コード例(全体)

js全体のコード例です。長いので畳みます。

js全体コード例
//タグフィールド編集不可
const tagField = document.getElementById('tags-field');
const tagInput = tagField.querySelector('#tags');
tagInput.setAttribute('readonly', true);
tagInput.classList.replace('form-control', 'form-control-plaintext');
tagInput.setAttribute('placeholder', 'タグリストで選択したタグが挿入されます');

//タグ挿入フィールド追加
tagField.querySelector('.mt-draggable--entryField').insertAdjacentHTML('beforeend', '<div id="tagInsert" class="ml-4 mr-4"></div>');
const taglistselectArea = document.getElementById('tagInsert');

//ボタンSVG
function createSvg(type) {
  if (type === 'add') {
    return `<svg role="img" class="mt-icon mt-icon--sm"><title>追加</title><use xlink:href="/mt-static/images/sprite.svg#ic_add" /></svg>`;
  } else {
    return `<svg role="img" class="mt-icon--inverse mt-icon--sm"><title>減らす</title><use xlink:href="/mt-static/images/sprite.svg#ic_remove" /></svg>`;
  }
}

//タグボタン生成関数
function createTagButton(tag, selected) {
  const btnClass = selected ? 'btn btn-primary btn-sm mr-3' : 'btn btn-default btn-sm mr-3';
  const svg = selected ? createSvg('remove') : createSvg('add');
  return `<button type="button" class="${btnClass}" id="taglist-${tag.tagBasename}">${svg}${tag.tagName}</button>`;
}

//タグ名取得関数(`button`から`svg`を除去)
function getTagName(elm) {
  return elm.textContent.replace(elm.querySelector('svg').textContent, '').trim();
}

//タグ追加・削除処理
function handleTagClick(elm) {
  const tagName = getTagName(elm);

  if (elm.classList.contains('btn-primary')) { //タグが選択されている場合の処理
    elm.classList.replace('btn-primary', 'btn-default');
    elm.querySelector('svg').remove();
    elm.insertAdjacentHTML('afterbegin', createSvg('add'));

    //文字の位置チェック
    const index = tagInput.value.indexOf(tagName);
    if (index === 0) { //文字位置が最初(0)の場合
      //後ろにカンマ(,)があるかどうか
      tagInput.value = tagInput.value.endsWith(tagName)
        ? tagInput.value.replace(tagName, '')
        : tagInput.value.replace(`${tagName},`, '');
    } else {
      tagInput.value = tagInput.value.replace(`,${tagName}`, '');
    }
  } else {
    if (tagInput.value.split(',').length !== 2) { //タグは2つまで追加可能
      elm.classList.replace('btn-default', 'btn-primary');
      elm.querySelector('svg').remove();
      elm.insertAdjacentHTML('afterbegin', createSvg('remove'));

      //「タグ」フィールドが空でなければ先頭にカンマ(,)を入れて追加する
      tagInput.value = tagInput.value === '' ? tagName : `${tagInput.value},${tagName}`;
    } else {
      alert('これ以上タグを追加できません(追加できるタグは2つまでです)');
    }
  }
}

//処理
async function checkAndSelectTag() {
  try {
    const res = await fetch('/tag_insert/taglist.json');
    if (!res.ok) throw new Error('jsonファイルが生成されていません');

    const text = await res.text();
    if (text.trim() === '') {
      taglistselectArea.insertAdjacentHTML('beforeend', '<div class="alert alert-warning" role="alert">選択できるタグはありません</div>');
      return;
    }

    const data = JSON.parse(text);
    if ((Array.isArray(data) && data.length === 0) || (typeof data === 'object' && Object.keys(data).length === 0)) {
      taglistselectArea.insertAdjacentHTML('beforeend', '<div class="alert alert-warning" role="alert">選択できるタグはありません</div>');
      return;
    }

    //タグリストをボタンとして配置
    data.forEach(tag => {
      const selected = tagInput.value.includes(tag.tagName);
      taglistselectArea.insertAdjacentHTML('afterbegin', createTagButton(tag, selected));
    });

    //タグ選択時のイベント処理
    taglistselectArea.querySelectorAll('button').forEach(elm => {
      elm.addEventListener('click', () => handleTagClick(elm));
    });

  } catch (err) {
    console.error(err)
    taglistselectArea.insertAdjacentHTML('beforeend', '<div class="alert alert-danger" role="alert">Error: 管理者にお問い合わせください</div>');
  }
}
checkAndSelectTag();

タグを2つ以上選択しようとするとダイアログが表示されるようにしていますが、MTのスタイルガイドに準拠するのであれば、警告テキストを表示させるのがいいかもしれません。

PowerCMSで同様の実装をする場合

PowerCMSの場合は、カスタムフィールド作成画面において「スニペット」を追加することで対応が可能です。タグの登録・出力は別ブログを作成するなどで対応します。

  • システムオブジェクト:記事
  • 名前:任意の名称
  • 種類:スニペット
  • オプション:input要素のname属性値を指定(js側の処理には使用しません)
  • 規定値:以下コード例
<div id="tagInsert"></div>
<link rel="stylesheet" href="[cssファイルのパス]">
<script src="[jsファイルのパス]" defer></script>

規定値のフィールドにcssやjsを記述してもいいのですが、管理やメンテナンスの面から外部ファイルとすることをおすすめします。

コードは基本的に上記例のものを利用できますが、別途スタイルを記述する必要があります。


本記事の内容は以上です。

Discussion