複数行テキストをMarkdownでリアルタイムプレビューする方法

こんにちは、ジョイゾーでkintoneプラグインのサポート窓口をしている伊藤です。

kintoneの「文字列(複数行)」フィールドで、文字の装飾や表の作成ができたらいいなと思ったことはありませんか?
基本機能のリッチエディターフィールドも便利ですが、マウスでボタンをポチポチ押す必要があり、少し手間に感じることも。
特にエンジニアや入力スピードを重視するユーザーにとっては、キーボードから手を離さずにサクサク書けるMarkdown(マークダウン)記法が圧倒的に快適です。

この記事では、Markdown入力をリアルタイムにプレビュー表示するカスタマイズをご紹介します。

弊社では初回開発無料の定額39万円でkintoneアプリを開発する定額型開発サービス「システム39」を提供しております。kintoneの導入やアプリ開発でお困りの方は、お気軽にご相談ください。
*Webでの打ち合わせも可能です。

   kintoneのアプリ開発はこちら <相談無料>    

このカスタマイズのメリット・どんな時に便利?

Markdownプレビューを導入することで、kintoneアプリの使い勝手が劇的に向上します。特に以下のようなメリットがあります。

  • データは「プレーンテキスト」のまま保存
    リッチエディターを使うと、裏側ではHTMLタグを含んだデータとして保存されるため、CSV書き出しや外部API連携時に扱いづらくなることがあります。Markdownなら保存されるデータはただのテキストなので、他システムとの連携やデータ分析が非常にスムーズです。
  • キーボードから手を離さずに爆速入力
    見出しは 「#」、リストは「 - 」、太字は「**」で文字を挟み、文頭に打つだけ。マウス操作が不要になるため、議事録や日報などの入力スピードが格段に上がります。

こんなアプリにおすすめ!

  • 開発チームの仕様書・要件定義アプリ
    コードブロック構文が使えるため相性抜群!
  • 社内Wiki・マニュアル管理アプリ
    表や見出しを使ってドキュメントを綺麗に整理できます
  • 日報・議事録アプリ
    箇条書きを多用する長文入力に最適です

kintoneアプリの準備

カスタマイズを適用するアプリに、以下の2つのフィールドを配置してください。

  • 文字列(複数行)フィールド
    • 任意のフィールド名
    • フィールドコード: markdown_text
  • スペースフィールド
    • スペースの要素ID: markdown_preview
kintoneフォーム設定
文字列複数行フィールドコード: markdown_text
スペースの要素ID: markdown_preview

フォーム設定のポイントとして、左側に「文字列(複数行)」、右側に「スペース」で横並びに配置すると、リアルタイムプレビューが格段に見やすくなります。

実装してみよう!

今回のカスタマイズでは、以下のMarkdown構文対応しています。

  • 見出し(# 〜 ####
  • 太字・斜体・インラインコード
  • リンク
  • 箇条書き(* / -
  • 引用(>
  • コードブロック(```
  • テーブル

まずは、プレビューされたMarkdownを綺麗に表示するためのCSSです。
以下のコードをコピーし、拡張子を .css にして保存(例: markdown_preview.css)したあと、kintoneアプリの「JavaScript / CSSでカスタマイズ」からPC用のCSSファイルとしてアップロードしてください。

/* ==========================================
   Markdownプレビュー用のスタイル
========================================== */
.mdp-preview-body {
  font-family: "Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif;
  line-height: 1.6;
  color: #333;
  font-size: 14px;
}

/* 空の時のプレースホルダー表示 */
.mdp-preview-body--empty {
  color: #999;
  font-style: italic;
}

/* 見出し */
.mdp-preview-body h1,
.mdp-preview-body h2,
.mdp-preview-body h3,
.mdp-preview-body h4 {
  margin-top: 1.5em;
  margin-bottom: 0.5em;
  font-weight: bold;
}
.mdp-preview-body h1 { font-size: 1.5em; border-bottom: 2px solid #eaecef; padding-bottom: 0.3em; }
.mdp-preview-body h2 { font-size: 1.3em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
.mdp-preview-body h3 { font-size: 1.1em; }

/* 段落・リスト */
.mdp-preview-body p { margin-bottom: 1em; }
.mdp-preview-body ul { margin-bottom: 1em; padding-left: 2em; list-style-type: disc; }
.mdp-preview-body li { margin-bottom: 0.25em; }

/* 引用 */
.mdp-preview-body blockquote {
  margin: 0 0 1em 0;
  padding: 0.5em 1em;
  color: #6a737d;
  border-left: 0.25em solid #dfe2e5;
  background-color: #fafbfc;
}

/* インラインコード・コードブロック */
.mdp-preview-body code {
  font-family: Consolas, "Courier New", Courier, Monaco, monospace;
  background-color: #f6f8fa;
  padding: 0.2em 0.4em;
  border-radius: 3px;
  font-size: 90%;
  color: #e83e8c;
}
.mdp-preview-body pre {
  background-color: #f6f8fa;
  padding: 16px;
  overflow: auto;
  border-radius: 3px;
  margin-bottom: 1em;
}
.mdp-preview-body pre code {
  background-color: transparent;
  padding: 0;
  color: #333;
}

/* テーブル(表) */
.mdp-preview-body table {
  border-collapse: collapse;
  width: 100%;
  margin-bottom: 1em;
}
.mdp-preview-body th,
.mdp-preview-body td {
  padding: 8px 12px;
  border: 1px solid #dfe2e5;
}
.mdp-preview-body th {
  background-color: #f6f8fa;
  font-weight: bold;
}

次に動作を制御するプログラムです。
以下のコードを .js として保存(例: markdown_preview.js)したあと、PC用のJavaScriptファイルとしてアップロードしてください。

(function() {
  'use strict';
   
  // 定数はすべて const に統一
  const CONFIG = {
    MARKDOWN_FIELD_CODE: 'markdown_text',    // Markdown入力欄のフィールドコード
    PREVIEW_SPACE_CODE: 'markdown_preview',  // スペースフィールドの要素ID
    SHOW_PREVIEW_ON_DETAIL: true,            // 詳細画面でもプレビューを表示するか
    DEBOUNCE_MS: 150                         // 描画の間引き時間(ミリ秒)
  };
   
  const MiniMarkdown = {
    escapeHtml: function(str) {
      if (!str) return '';
      return str
        .replace(/&/g, '&')
        .replace(/</g, '<')
        .replace(/>/g, '>')
        .replace(/"/g, '"')
        .replace(/'/g, ''');
    },
   
    sanitizeUrl: function(url) {
      const trimmed = url.trim();
      if (/^(javascript|data|vbscript):/i.test(trimmed)) {
        return '#';
      }
      if (!/^(https?:\/\/|mailto:|\/|#)/i.test(trimmed)) {
        return '#';
      }
      return this.escapeHtml(trimmed);
    },
   
    parse: function(markdown) {
      if (!markdown) {
        return '<div class="mdp-preview-body mdp-preview-body--empty">プレビューがここに表示されます</div>';
      }
   
      const escaped = this.escapeHtml(markdown);
      const lines = escaped.split(/\r?\n/);
      const html = [];
      let inList = false;
      let inCode = false;
      let inTable = false;
      let inBlockquote = false; // 引用の連続管理フラグ
      let tableStep = 0;
      let codeBlockContent = [];
   
      for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
   
        if (line.indexOf('```') === 0) {
          if (inCode) {
            html.push('<pre><code>' + codeBlockContent.join('\n') + '</code></pre>');
            codeBlockContent = [];
            inCode = false;
          } else {
            inCode = true;
          }
          continue;
        }
        if (inCode) {
          codeBlockContent.push(line);
          continue;
        }
   
        const isTableLine = line.trim().indexOf('|') === 0 &amp;&amp; line.trim().slice(-1) === '|';
   
        if (inList &amp;&amp; line.slice(0, 2) !== '* ' &amp;&amp; line.slice(0, 2) !== '- ') {
          html.push('</ul>');
          inList = false;
        }
        if (inTable &amp;&amp; !isTableLine) {
          html.push('</tbody></table>');
          inTable = false;
          tableStep = 0;
        }
        if (inBlockquote &amp;&amp; line.indexOf('> ') !== 0) {
          html.push('</blockquote>');
          inBlockquote = false;
        }
   
        if (isTableLine) {
          if (!inTable) {
            html.push('<table><thead>');
            inTable = true;
            tableStep = 1;
          }
   
          const cells = line.trim().slice(1, -1).split('|');
   
          if (tableStep === 1) {
            html.push('<tr>');
            for (let j = 0; j < cells.length; j++) {
              html.push('<th>' + this.parseInline(cells[j].trim()) + '</th>');
            }
            html.push('</tr></thead><tbody>');
            tableStep = 2;
            continue;
          }
   
          if (tableStep === 2) {
            const isDivider = cells.every(function(c) {
              return /^[:\s-]*$/.test(c.trim());
            });
            if (isDivider) {
              tableStep = 3;
              continue;
            }
            tableStep = 3;
          }
   
          html.push('<tr>');
          for (let j = 0; j < cells.length; j++) {
            html.push('<td>' + this.parseInline(cells[j].trim()) + '</td>');
          }
          html.push('</tr>');
          continue;
        }
   
        const headingMatch = line.match(/^(#{1,4})\s+(.+)$/);
        if (headingMatch) {
          const level = headingMatch[1].length;
          html.push('<h' + level + '>' + this.parseInline(headingMatch[2]) + '</h' + level + '>');
          continue;
        }
   
        if (line.indexOf('> ') === 0) {
          if (!inBlockquote) {
            html.push('<blockquote>');
            inBlockquote = true;
          }
          html.push(this.parseInline(line.slice(5)) + '<br>');
          continue;
        }
   
        if (line.slice(0, 2) === '* ' || line.slice(0, 2) === '- ') {
          if (!inList) {
            html.push('<ul>');
            inList = true;
          }
          html.push('<li>' + this.parseInline(line.slice(2)) + '</li>');
          continue;
        }
   
        if (line.trim() === '') {
          continue;
        }
   
        html.push('<p>' + this.parseInline(line) + '</p>');
      }
   
      if (inCode) {
        html.push('<pre><code>' + codeBlockContent.join('\n') + '</code></pre>');
      }
      if (inList) {
        html.push('</ul>');
      }
      if (inTable) {
        html.push('</tbody></table>');
      }
      if (inBlockquote) {
        html.push('</blockquote>');
      }
   
      return '<div class="mdp-preview-body">' + html.join('\n') + '</div>';
    },
   
    parseInline: function(text) {
      text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
      text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
      text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
      text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
      text = text.replace(/_([^_]+)_/g, '<em>$1</em>');
      text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, label, url) {
        const safeUrl = MiniMarkdown.sanitizeUrl(url);
        return '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
      });
      return text;
    }
  };
   
  function debounce(func, delay) {
    let timeout;
    return function() {
      const context = this;
      const args = arguments;
      clearTimeout(timeout);
      timeout = setTimeout(function() {
        func.apply(context, args);
      }, delay);
    };
  }
   
  function getMarkdownField(event) {
    const field = event.record[CONFIG.MARKDOWN_FIELD_CODE];
    if (!field) {
      console.warn('[Markdown Preview] フィールドが見つかりません: ' + CONFIG.MARKDOWN_FIELD_CODE);
      return null;
    }
    return field;
  }
   
  function renderPreview(markdownText, containerEl) {
    if (!containerEl) return;
    containerEl.innerHTML = MiniMarkdown.parse(markdownText);
  }
   
  // 編集・追加画面
  kintone.events.on(['app.record.create.show', 'app.record.edit.show'], function(event) {
    if (!getMarkdownField(event)) return event;
   
    const spaceEl = kintone.app.record.getSpaceElement(CONFIG.PREVIEW_SPACE_CODE);
    if (!spaceEl) {
      console.warn('[Markdown Preview] スペースフィールドが見つかりません: ' + CONFIG.PREVIEW_SPACE_CODE);
      return event;
    }
   
    spaceEl.innerHTML = '<div class="mdp-live-preview"></div>';
    const previewContainer = spaceEl.querySelector('.mdp-live-preview');
   
    const initialValue = event.record[CONFIG.MARKDOWN_FIELD_CODE].value || '';
    renderPreview(initialValue, previewContainer);
   
    const handleInput = debounce(function(e) {
      if (e.target &amp;&amp; (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) {
        const currentRecord = kintone.app.record.get();
        if (currentRecord &amp;&amp; currentRecord.record[CONFIG.MARKDOWN_FIELD_CODE]) {
          renderPreview(currentRecord.record[CONFIG.MARKDOWN_FIELD_CODE].value || '', previewContainer);
        }
      }
    }, CONFIG.DEBOUNCE_MS);
 
    document.removeEventListener('input', window._mdpHandleInput);
    window._mdpHandleInput = handleInput;
    document.addEventListener('input', window._mdpHandleInput);
   
    return event;
  });
   
  // 詳細画面
  kintone.events.on('app.record.detail.show', function(event) {
    if (!CONFIG.SHOW_PREVIEW_ON_DETAIL) return event;
   
    const field = getMarkdownField(event);
    if (!field) return event;
   
    const spaceEl = kintone.app.record.getSpaceElement(CONFIG.PREVIEW_SPACE_CODE);
    if (!spaceEl) {
      console.warn('[Markdown Preview] 詳細画面でスペースフィールドが見つかりません: ' + CONFIG.PREVIEW_SPACE_CODE);
      return event;
    }
   
    if (spaceEl.querySelector('.mdp-detail-preview')) return event;
   
    const previewWrapper = document.createElement('div');
    previewWrapper.className = 'mdp-detail-preview';
    previewWrapper.style.padding = '15px';
    previewWrapper.style.border = '1px solid #e3e7e8';
    previewWrapper.style.backgroundColor = '#fff';
    previewWrapper.style.borderRadius = '4px';
    previewWrapper.style.minHeight = '50px';
   
    renderPreview(field.value || '', previewWrapper);
     
    spaceEl.innerHTML = '';
    spaceEl.appendChild(previewWrapper);
   
    return event;
  });
   
})();

kintoneの画面を確認!!

実際にフォームとJavaScriptカスタマイズの設定を完了して、kintoneの詳細画面を開いてみましょう!

kintoneの詳細画面で文字列複数行フィールドとプレビュー欄が表示されている

このように文字列(複数行)フィールドとプレビューと表示されたスペースが表示されていることがわかります!

ここに、編集画面で以下のようにMarkdown記法でテキストを入力すると、、、

kintone編集画面で文字列複数行にMarkdown記法でテキストを入力

リアルタイムで文字列(複数行)に入力したテキストに対して、プレビュー欄に以下の装飾が綺麗に反映されているのがわかります。

  • 見出し(文字が大きく強調される)
  • 箇条書き・引用(インデントや背景色がつく)
  • コードブロック(プログラミングのコードが見やすく枠で囲まれる)
  • テーブル(表)(Excelのような罫線付きの表になる)

保存を押すと以下のような表示になります!

保存を行い、プレビュー欄に装飾されたテキストが表示される

これまでは「見出しを入れて、表を作って…」とリッチエディターでポチポチ操作していたりした方も多いはず。
このカスタマイズを入れれば、プレビューを見ながらキーボード操作だけでサクサクとドキュメントを作成・編集ができちゃいます!

まとめ

データ連携のしやすさ(プレーンテキストの維持)と、視覚的なわかりやすさを両立できるのが、Markdownカスタマイズの最大の強みです。

日報やマニュアル、仕様書アプリなどの利便性が大きく上がりますので、ぜひお試しください!

弊社では初回開発無料の定額39万円でkintoneアプリを開発する定額型開発サービス「システム39」を提供しております。kintoneの導入やアプリ開発でお困りの方は、お気軽にご相談ください。
*Webでの打ち合わせも可能です。

   kintoneのアプリ開発はこちら <相談無料>    

同じカテゴリーの記事