8
7

More than 3 years have passed since last update.

Electronでタスク管理アプリ作ってみた

Last updated at Posted at 2020-11-13

本記事について

こんにちは。あかいです。
この記事は、勉強を兼ねてElectronでタスク管理アプリを作成した際の備忘録です。
環境構築はすでに記事が出回っているので、作成までの検討事項を簡単にまとめます。
なお、簡単のため、アプリはローカルに閉じるものとし、Exe化までは行いません。

以下の内容を載せています。
・Electron概要
・検討までの流れ
・ソースの一部の解説
 →タスク名変更時の処理の流れ
 →ドラッグ&ドロップの処理の流れ
 →データ保存の処理の流れ
・ソース全体

作成物

まず、今回作成したのは以下です。よくあるKanbanboardをイメージしています。
初心者なので、できるだけシンプルな構成となるよう1ページにしています。
test1.gif
test2.gif

環境

以下の通りです。

  • Node.js   : v12.19.0
  • jquery   : v3.5.1(CDN)
  • jquery-ui : v1.12.1(CDN)
  • Electron   : v10.1.3(ローカルインストール)
  • Bootstrap : v4.5.0(CDN)

(参考:Electronの環境構築(for Windows))

フレームワーク、ライブラリは、機能が実現でき、できるだけ学習コストが低そうな、環境構築の手間の少ないもの、を選んでいます。
WebまわりのGUIはReactやViewの記事が多くヒットしますが、初学ということで、長年使用されているjqueryとしました。

Electron概要

ドキュメント:https://www.electronjs.org/docs

Electron は ChromiumとNode.jsを利用しているため、HTML, CSS, JavaScriptを利用してアプリを開発することができます。

Electronではフロントエンドの技術でデスクトップアプリを作成することができます。
プロセスは、メインプロセスとレンダラープロセスに分けられます。
メインプロセスはpacakge.jsonにおいて、mainで指定したエントリポイントを起点として起動します。

package.json
{
  "name": "taskboard",
  "version": "1.0.0",
  "main": "index.js",
  "author": "",
  "description": ""
}

例えば上記のpackage.jsonであれば、index.js(任意名称、main.jsでも可)を起点として起動しますので、このindex.jsにElectronのAPIによるメインウィンドウの起動などを記述します。ここではElectron APIやNode.jsが使用できます。

index.js(一部)
"use strct";

// Electronのモジュール
const electron = require("electron");

// アプリケーションをコントロールするモジュール
const app = electron.app;

// ウィンドウを作成するモジュール
const BrowserWindow = electron.BrowserWindow;

// Electronの初期化完了後に実行
app.on("ready", () => {
  // ウィンドウサイズを1000*800(フレームサイズを含まない)に設定する
  // HTML側でNode.js使用可能とする(レンダラープロセスで使用可能)
  mainWindow = new BrowserWindow({
    width: 1000,
    height: 800,
    minWidth: 700,
    minHeight: 700,
    webPreferences: {
      nodeIntegration: true,
      nodeIntegrationInWorker : true
    }
  }); // <= レンダラープロセス(mainWindow)
}

レンダラープロセスはメインプロセスから呼び出され(BrowserWindow インスタンスとして生成され)ます。
したがって、メインプロセスは必ず1つですが、レンダラープロセスは複数存在しえます。
(本家のドキュメントが参考になります。https://www.electronjs.org/docs/tutorial/quick-start#create-a-basic-application)

レンダラープロセスは、メインプロセスのBrowserWindowで指定したHTMLや、そのHTML内で読み込んだcss, jsファイルで記述します。
通常のWebページと同じですが、Electronでは、レンダラープロセスのjsファイル内において(制限付きの)Electron APIやNode.jsが使用できます※。

※ElectronAPI自体の制限やデフォルトでは使えない場合があります。つまったところを参照してください。

作成までの流れ

1. 「タスク」でブレスト
2. タスク管理の仕様をまとめる
3. 実装のポリシー決め
4. 画面のワイヤーフレーム
5. 画面実装
6. 機能実装
7. テスト・要件の確認

1.「タスク」でブレスト

仕様を決定するにあたって、まずタスクとは何かを5分間で考えました。
「タスク」とは
 ・期限がある
 ・ステータスがある(登録済み、開始、待ち、終了)
 ・階層構造(プロジェクト→大タスク→中タスク...)がある。(際限がない)
 ・ステークホルダがある
 ・リマインドされる
 ・一覧がある(一意に特定される)
 ・詳細がある(メモ、関連)
 ・関連がある
 ・分類がある(習慣、臨時)
 ・進捗率がある
 ・所有者がある
 ・アウトプットがある(完了状態率などの統計情報、報告書など)

2.タスク管理の仕様をまとめる

ブレインストーミングの結果をグルーピングし、要件としてまとめます。

 ●構造

 ・(ルート→)プロジェクト→タスク→サブタスク(打ち止めとする)
 ・タスク間関連(フローチャート。今回は対象外とする。)

 ●要素

要素 プロジェクト タスク サブタスク
id
名称
日付(開始、完了、期限)
詳細(メモ)
ステークホルダ
下位の統計情報
状態(待ち、実行中、完了)
分類(タグ)
関連(方向、プロパティ)
 ex)タスクA→タスクB、順序
添付ファイル

(凡例)
 ○:要素として持ちうる
 ✕:要素として持たない
 ◎:今回の対象とする
 △:一部、今回の対象とする

 ●機能

機能 プロジェクト タスク サブタスク
要素の変更
追加
削除
統計情報の計算
フローの出力
報告書の出力
添付のアップ・ダウンロード

(凡例)
 ○:機能として持ちうる
 ✕:機能として持たない
 ◎:今回の対象とする

3.実装のポリシー決め

  • まずは動くものを作成し、作りながら改善する →CSS設計は考えず、必要あればリファクタリング
  • 機能はメインプロセスに集約し、レンダラープロセスは表示に専念
    • データ管理はメインプロセスで行い、レンダラープロセスはメインプロセスに問い合わせて表示を更新する
    • 簡単のため、レンダラープロセスの画面描画は一部の変更であってもすべて再描画する
    • 問い合わせはIPC通信を用いて、API的に使用する
    • IPC通信は非同期には行わず、すべて同期通信とする(後述のデータ保存の独立性のため)
  • 簡単のため、シングルページとする
  • データの保存にはファイルを利用する
    • JSON形式で保存し、簡単のため、毎回フルでの書き出しとする
    • 独立性のため、データの保存は並行して行わない

以下にポリシーに基づく処理の概略図を示します。
ElectronはElectronの概要 に示す通り、メインプロセスとレンダラープロセスで動作します。今回はシングルページのためレンダラープロセスは一つだけです。メインプロセスからBrowserWindow()でウィンドウを作成し、mainWindow.loadURL(\`file://${__dirname}/index.html\`)でindex.htmlを読み込みます。
index.html内で指定したcss, jsをCDNおよびローカルから読み込み、メインプロセスにデータを要求し、メインプロセスはデータが存在しなければ、ローカルのjsonファイル(data.json)を読み込みます。(data.jsonには後述のデータ構造のjsonデータが保存されています。)
レンダラープロセスは、メインプロセスから返却されたデータに基づき、画面を描画します。
ユーザーが画面入力した場合は、起動時と同様に、レンダラープロセスからIPC同期通信で、入力に基づく要求をメインプロセス送り、メインプロセス側でデータ変更処理をかけた後、jsonファイルを更新し、レンダラープロセスに成功可否を連絡します。
レンダラープロセスでは、成功を受けた場合にメインプロセスにデータ要求をし、応答に基づいてページを再描画します。

image.png

次に示すのは、メインプロセスで操作し、ファイルに保存するオブジェクトの形式です。
メインプロセスにてこのオブジェクトを操作・保存し、レンダラプロセスで受け取って描画します。

data.json(作成例)
{
  "projects": [
    {
      "id": "project1",
      "name": "プロジェクト1",
      "tasks": [
        {
          "id": "task1",
          "name": "タスク名",
          "status": "Wait",
          "start_date": "2020/11/12",
          "due_date": "",
          "end_date": "",
          "detail": "",
          "subtasks": []
        },
        {
          "id": "task2",
          "name": "タスク名",
          "status": "Wait",
          "start_date": "2020/11/12",
          "due_date": "",
          "end_date": "2020/11/12",
          "detail": "",
          "subtasks": []
        },
        {
          "id": "task3",
          "name": "タスク名",
          "status": "Doing",
          "start_date": "2020/11/12",
          "due_date": "",
          "end_date": "",
          "detail": "",
          "subtasks": []
        }
      ]
    },
    {
      "id": "project2",
      "name": "プロジェクト2",
      "tasks": []
    },
    {
      "id": "project3",
      "name": "プロジェクト名",
      "tasks": []
    }
  ]
}

4.画面のワイヤーフレーム

image.png
Googleスライドで作成しました。タスクを示すカードの下部に
・ステークホルダの表示
・タスク同士の関連を示す前後のタスク(前:タスク0、後:1)
がありますが、後々簡単のために取りやめました。

5.画面実装

画面表示は作成物と同様のためここでは省略します。
BootstrapやCSS、jqueryの実装例を検索しつつ、つぎはぎしました。ソースは最後に載せます。
今回はBEMなどのcss設計は全く意識していません。命名や実装に一貫性がないかもしれませんが、とりあえず動く、が目標のためご容赦ください。
(次回があればCSS設計完全ガイドを参考にしようと思っています。)

ファイル構成は次です。前述のindex.htmlを起点とし、画面左部(left_menu.css, left_menu.js)と画面右部(right_body.css, right_body.js)、共通処理(common.css, renderer.js)に分けて記載しています。
(data.json, index.js, pachage.jsonは前述。start.batはelectronの起動コマンドを記述しているだけです。)
image.png

(参考)

start.bat
rem .\node_modules\.bin\electron . --inspect-brk
.\node_modules\.bin\electron .
exit 0



では、画面周りの処理内容を下記のタスク名を例に簡単に解説します。

HTML

タスク名は以下のようなHTMLです。なお、タスク全体はBootstrapのcardで作成しています。

index.html(一部)
<!-- カードタイトルここから -->
<div class="card_title col-8">
    <div class="wrap_task_name" >
      <input type="text" class="task_name">
    </div>
    <div class="task_name_toolchip"></div>
</div>
<!-- カードタイトルここまで -->

表示最大幅を超えた入力に備えて、「タスク名…」で表示するためにwrap要素を追加しています。
また、「タスク名…」表記時にツールチップですべてを表示するようにします。

CSS

right_body.css(一部)
.task_name {
    font-size: 1.5rem;
    margin: auto auto;
    width: 100%;
    background-color: whitesmoke;
    border-radius: 0.3rem;
    border: hidden;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.wrap_task_name {
    display: inline-block;
    overflow: hidden
}

.card_title {
    position:relative; /* for toolchip */
}

.task_name_toolchip {
    max-width: 26rem;
    display: none;
    position: absolute;
    top: 3.5em;
    left: 5rem;
    z-index: 9999;
    padding: 0.3em 0.5em;
    color: #FFFFFF;
    background: rgb(124, 124, 124);
    border-radius: 0.5em;
}

.task_name_toolchip:after {
    width: 100%;
    content: "";
    display: block;
    position: absolute;
    left: 0.5em;
    top: -0.8rem;
    border-top:0.8rem solid transparent;
    border-left:0.8rem solid rgb(124, 124, 124);
}

overflow: hidden;
text-overflow: ellipsis;
が「タスク名…」表記のための部分です。また、wrapにもoverflow: hiddenを設定する必要があります。
(参考:入らなかった文字を三点リーダで省略表示)

javascript

タスク名の編集のためのjavascriptです。大きく分けて、フォーカス時操作とフォーカスアウト時操作,ツールチップ表示の3つを記述しています。

right_body.js(一部)
// イベント操作
// 対象:タスク名
// 動作:フォーカス
// 内容:編集があった場合にタスク名を変更する。
$(document).on('focus', '.task_name', (e) => {
    $(e.target).select();
    // エンター押下時にフォーカスアウト(Shift+EnterはOK)
    $(e.target).keypress(function(e){ 
        if(! event.shiftKey){
            if (e.keyCode == 13) {
                $(e.target).blur();
            }
        }
    });
});
// フォーカスアウト時に変更反映
$(document).on('blur', '.task_name', (e) => {
    // 空なら変更せず再ロード。空でなければ変更を反映する。
    if ($(e.target).val()) {
        project_id = $('#left_menu div.project.active').attr("id");
        task_id = $(e.target).parents(".card").attr("id");
        task_name = $(e.target).val();
        rc = ipcRenderer.sendSync('changeTaskName', {project_id: project_id, task_id: task_id, task_name: task_name});
        if (rc) {
            $(e.target).scrollLeft(0); // はみ出た分表示がずれるので最初の位置に戻す
        }
    }
    LOAD_TASKS();
});
// イベント操作
// 対象:タスク名
// 動作:マウスオーバー
// 内容:ツールチップを表示する。
$(document).on('mouseover', '.task_name', (e) => {
    if ($(e.target).parent().width() < e.target.scrollWidth) {
        var task_name = $(e.target).val();
        $(e.target).closest(".card_title").find(".task_name_toolchip").text(task_name);
        $(e.target).closest(".card_title").find(".task_name_toolchip").css("display", "block");
        $(e.target).on('mouseleave', () => {
            $(e.target).closest(".card_title").find(".task_name_toolchip").css("display", "none");
        })
    }
});

まず、$(document).on('focus', '.task_name', (e) => {}のアロー関数に、task_nameクラスにフォーカスがあった場合のイベント処理を記述しています。
DOM変更時にイベントを反映させるため、$(セレクタ).on("イベント名", function())でなく、$(document).on("セレクタ", "イベント名", function())としています。
なお、task_nameクラスはindex.htmlで<input type="text" class="task_name">と定義しています。
ここのアロー関数内で記述しているのは2点で、
 ・ $(e.target).select();で、画面からユーザが編集しやすいように、タスク名を全選択する
 ・$(e.target).keypress(function(e){}でエンター(Shiftとの同時押しを除く)押下時にフォーカスアウトする
です。

次にフォーカスアウト時の操作を$(document).on('blur', '.task_name', (e) => {}に記述しています。
フォーカスアウト時には、タスク名が空でなければ変更を反映するようにしています。空の状態で編集を終えた場合は、LOAD_TASKS();(rendere.jsに記載)のところでタスク一覧の再読み込みを実施しているため、編集前の状態に戻ります。
タスク名が空でなければ、プロジェクトID、タスクID、変更したタスク名を取得し、rc = ipcRenderer.sendSync('changeTaskName', {project_id: project_id, task_id: task_id, task_name: task_name});の部分で、メインプロセスにタスク名変更処理をIPC同期通信で要求しています。プロジェクトIDは画面左部のプロジェクト一覧のうちアクティブな要素から、タスクIDは自身の親要素のうちタスクIDを要素のIDとして持っているcardクラスから取得しています。なお、一つ一つのタスクはBootstrapのcardで作成しています。
メインプロセスから正常終了処理が返ってきた場合にはタスク名の表示位置を戻す処理を$(e.target).scrollLeft(0); // はみ出た分表示がずれるので最初の位置に戻すで行っています。

ツールチップの表示はマウスオーバー時にCSSのdisplayをBlockに変更、マウスリムーブ時にCSSのdisplayをNoneに変更しています。
if ($(e.target).parent().width() < e.target.scrollWidth) {}この部分で表示最大幅を超えているかのチェックをしています。

画面の参考

また、ここでは特に触れませんでしたが、以下2点の実装時の参考先を載せておきます。

※ ドラッグ&ドロップの処理について、追記しました。(●タスクの順番変更処理(2020/11/15))

6.機能実装

機能はメインプロセスに実装します。既述のように、レンダラープロセスからAPI的にIPC通信を行います。
したがって、メインプロセス側ではレンダラープロセスからの通信要求を待ち受けて処理を返すような記述になります。
以下にメインプロセス側でのIPC通信の待ち受けの例を挙げます。

index.js(一部)
// on change taskName.
ipcMain.on('changeTaskName', (event, arg) => {
  // "arg" is "{project_id: , task_id: , task_name:}"
  [index_project, index_task] = searchTask(arg.project_id, arg.task_id);
  if (index_project !== -1 && index_task !== -1) {
    projects[index_project].tasks[index_task].name = arg.task_name;
    event.returnValue = true; // if successfull, return true.
    data_control.set("projects", projects);
  } else {
    event.returnValue = false; // if failed, return false.
  }
  return
});

ここではタスク名の変更を例に見ていきます。
メイン側ではipcMainという名称のAPIです。ipcMain.on('changeTaskName', (event, arg) => {}この部分のアロー関数内で、処理内容を記述しています。
同期通信の場合は、必ずreturnが必要となるので、event.returnValue = true; // if successfull, return true.の部分などでリターン内容を設定して、returnしています。
処理内容を簡単に見ていくと、
[index_project, index_task] = searchTask(arg.project_id, arg.task_id);この部分は、別途実装したsearchTask関数で、プロジェクトID、タスクIDから、projectsオブジェクト(プロジェクトすべての情報が入ったオブジェクト、data.jsonで読み書きするのもこのオブジェクト)内のアレイのインデックスを取得しています。
searchTask関数では、対象が見つからなかった場合に-1をインデックスとして返しますので、次の行のif (index_project !== -1 && index_task !== -1) {では対象タスクが見つかった場合に処理を続行するようなif文としています。
対象が見つかった場合は、projectsオブジェクトのタスク名をprojects[index_project].tasks[index_task].name = arg.task_name;で書き換えて、returnに成功を意図するtrueを返すよう設定しています。ちなみに、returnはオブジェクトも返せます。
最後に、次の行のdata_control.set("projects", projects);でdata.jsonにキー:projects、バリュー:projectsオブジェクト、として書き込みます。

次にdata_controlを確認します。

data_control.js(一部)
// data_control
// require.
const fs = require('fs');

let data_json = {}

// set as key-value. value can be json, string or list. 
exports.set = function (key, value) {  // set.
  data_json[key] = value;
  // rewrite into file.
  const data_string = JSON.stringify(data_json);
  fs.writeFile(file_path, data_string, (err) => {
    if (err) throw err;
  });
}

data_controlはデータのファイル書き込み、読み出し用の自作モジュールです。
data_controlは以下の実装ポリシーのもと、書き込み・読み出し時の、排他処理 と 検索処理を省いています。

  • IPC通信は非同期には行わず、すべて同期通信とする(後述のデータ保存の独立性のため)
  • JSON形式で保存し、簡単のため、毎回フルでの書き出しとする
  • 独立性のため、データの保存は並行して行わない

上のスクリプトでは、データ保存時の処理を記述しています。
ElectronではNode.jsのモジュールが使用可能です。ファイル操作のためconst fs = require('fs');で、fsをrequireします。
exports.set = function (key, value) {}にset関数を記述しています。set関数では、data_json[key] = value;でdata_json(書き出し用データ)にKey-value形式でデータを挿入し、const data_string = JSON.stringify(data_json);でjsonオブジェクトから文字列に変換、fs.writeFile(file_path, data_string, (err) => {}で書き込みの流れとなっています。書き込みエラー時には単にエラーを投げるようにしています。

(参考:Node.jsのfsモジュールの使い方)

7.テスト・要件の確認

軽く動かしてみて異常がないか、2. タスク管理の仕様をまとめるを満たしているかを確認しました。
本来は、異常ケース含めてテストすべきですが、今回は省略しました。
また、性能は体感で問題なければOKとし、セキュリティもローカルで閉じることから問題なしとしました。

つまったところ

2点あります。
レンダラープロセスでは、

  • Node.jsが使えない
  • jqueryが使えない

使用したElectronのバージョンでは、デフォルト設定でレンダラプロセスを起こすと(Windowを作ると)、レンダラープロセス内でNode.jsが使えません。すなわち、require('electron')できないので、ElectronのAPIも使えません。
これは、セキュリティ対策のようで、以下のようにnodeIntegration: trueを設定してあげればOKです。

index.js(一部)
mainWindow = new BrowserWindow({
  width: 1000,
  height: 800,
  webPreferences: {
    nodeIntegration: true
  }
});

(参考:【エラー対処】Electronでレンダラープロセスrequireができない)

これで、レンダラープロセスでもNode.jsは使えますが、まだjqueryが使えません。
これはjqueryの既知の問題のようで、

jQuery contains something along this lines:

if ( typeof module === "object" && typeof module.exports === "object" ) {
 // set jQuery in \`module\`
} else {
 // set jQuery in \`window\`
}

module is defined, even in the browser-side scripts. This causes jQuery to ignore the window object and use module, so >the other scripts won't find $ nor jQuery in global scope..

の通り、jqueryのソース内に、moduleが存在すればjqueryをwindowに設定しない分岐があり、このために$, jqueryがグローバルに設定されません。以下の参考記事では、

<script>if (typeof module === 'object') {window.module = module; module = undefined;}</script>
    jqueryなどの読み込み
<script>if (window.module) {module = window.module;}</script>

のように、jquery読み込み前にmoduleオブジェクトを退避し、moduleオブジェクトをundefinedに設定してから、jqueryを読み込んでグローバルに設定させ、読み込み後に退避したmoduleをもとに戻す作業で解決しています。
これにならって作成し、問題なく挙動しています。

(参考:ElectronでjQueryが読み込めない問題の解決策と原因)
(参考:jQuery isn't set globally because "module" is defined #254)

以上の2点ように、レンダラープロセスでモジュールが使えるような設定をしましたが、これはアプリがローカルに閉じることを前提としているために可能な対処です。
nodeIntegration: trueを設定すると、レンダラープロセスでネイティブな操作が可能になります。
したがって、ローカルでないアプリケーションを作成する場合、レンダラープロセスでのディレクトリ操作などが可能となるため、クロスサイトスクリプティングの危険度が上がります。

今回は特に実施していませんが、セキュアな通信には以下が参考になりそうです。
(参考:ElectronでcontextBridgeによる安全なIPC通信)
(参考:Electron(v10.1.5現在)の IPC 通信入門 - よりセキュアな方法への変遷)

なお、nodeIntegration: falseの場合、jqueryの読み込みのための処理も不要となるようですので、contextBrideを使用した通信にすると、nodeIntegrationをtrueにする必要がなくなるので、jqueryの読み込みの問題も発生しないかもしれません。
(参考:ElectronでjQueryがundefinedになる)

追記

● タスクの順番変更処理(2020/11/15)

GUIで並び替えた際の、メインプロセス側での順番の変更処理を実装しそびれていました。
ソースのindex.js, renderer.jsを修正しました。
この修正によりドラッグ&ドロップで順番を入れ替えた後リフレッシュしても順番が保たれます。
あわせて簡単に説明します。

index.js(ソート部)
//on sort task.
ipcMain.on('sortTask', (event, arg) => {
  //"arg" is "{project_id: project_id, key: "array", task_array: task_array}"
  if (arg.key === "array") {
    task_array = arg.task_array;
    var index = -1;
    var rc = false;
    index = projects.findIndex((project) => {return project.id === arg.project_id;});
    if (index !== -1) {
      projects[index].tasks.sort((task1, task2) => {
        var index_task1 = task_array.findIndex((task_id) => {return task_id === task1.id});
        var index_task2 = task_array.findIndex((task_id) => {return task_id === task2.id});
        return index_task1 - index_task2;
      });
      rc = true;
    } else {
      rc = false;
    }
  }
  event.returnValue = rc;
  return
});

まず、メインプロセス側で並び替え用のIPC通信を待ち受けます。拡張を考慮して、並び替え方法として key を与えています。
今回はkey: "array"として、並べ替えたいタスクIDの配列(task_array)を与えて、その他のタスクはそのままに、task_array内のタスクIDの順序となるよう並び替えます。
例えば、タスク一覧のIDの順序がtask1, task2, task3, task4, task5となっているところに、task_array=[task3, task1]と与えられた場合は、task2, task4, task5の順序は保ったまま、task3, task1となるよう入れ替えます。
projects[index].tasksでアクティブなプロジェクトのタスク一覧にアクセスし、.sort((task1, task2) => {})で並び替えます。
sortのコールバック関数では、task1, task2 をとりだして比較する処理を記述します。
コールバック関数でのreturnの値によって次のように動作します。

  • 正の値:task2が前に来るようソート
  • 負の値:task1が前に来るようソート
  • 0:何もしない

arrayオブジェクト.findIndex()では要素がなければ―1を返すので、return index_task1 - index_task2を考慮して表にすると次になります。

task1 task2 task1とtask2の位置関係 コールバック関数のリターン 並び替え
task1 > task2 task_arrayの順序となるようソート
task1 < task2 task_arrayの順序となるようソート
なし task_array内のタスクが後ろに行くようソート
なし task_array内のタスクが後ろに行くようソート
なし 0 並び変えない

(凡例)
○:task_array内に存在する
✕:task_array内に存在しない

例えば、タスク一覧のIDの順序がtask1, task2, task3, task4, task5となっているところに、task_array=[task3, task1]と与えられた場合は、task2, task4, task5, task3, task1となります。

index.js(ソート部)
// ソート可能にする。ロード時に読み込みが必要なため、$(function(){})で定義。
$(function() {
    $('.cards').each((index, element) => {
        $(element).sortable({
            // オプション
            connectWith: '.cards',
            revert: 100,
            cursor: 'move',
            delay: 100,
            // ドロップした時のイベント(e.targetに受け取り側が、ui.itemにドラッグした要素がはいる)
            receive: (e, ui) => {
                // ステータスを受け取り側に合わせて変更
                var task_status;
                switch ($(e.target).attr("id")) {
                    case "cards_wait":
                        task_status = "Wait";
                        break;
                    case "cards_doing":
                        task_status = "Doing";
                        break;
                    case "cards_done":
                        task_status = "Done";
                        break;
                    default:
                        return false;
                }
                project_id = $('#left_menu div.project.active').attr("id");
                task_id = $(ui.item).attr("id");
                rc = ipcRenderer.sendSync("changeTaskStatus", {project_id: project_id, task_id: task_id, task_status:task_status});
                if (rc) {
                    // ステータストグルの変更
                    $(e.target).find(`input[type=radio].${task_status}`).prop('checked', true);
                    // Doneクラスのリセット後、ステータスがDoneなら追加(ユーザ入力でないためchangeで発火されない)
                    $(ui.item).removeClass("Done");
                    if (task_status === "Done") {
                        $(ui.item).addClass("Done");
                    }
                }
                return rc
            },
            update: (e, ui) => {
                let task_id = ui.item.attr("id");
                let task_array = $(element).sortable("toArray");
                let rc = true;
                if (task_array.includes(task_id)) {
                    project_id = $('#left_menu div.project.active').attr("id");
                    rc = ipcRenderer.sendSync("sortTask", {project_id: project_id, key: "array", task_array: task_array});
                }
                return rc
            }
        });
    });
});

ドラッグ&ドロップはjquery uiのsortableを使用しています。.cardsセレクタでタスクを格納している要素(Wait, Doing, Done)にアクセスしています。connectWithオプションでドロップ可能な要素を指定します。これによってWaitからDoingなどの移動が可能になります。これを指定しないとWaitはWait、DoingはDoing、DoneはDone内でしか移動できません。
receiveupdateにはそれぞれ、別の領域から要素をドロップされた場合、ドラッグ&ドロップが完了した場合、の処理を記述します。
receiveでは、受け取り側のidにより、Wait、Doing、Doneのどこにドロップされたかを判定して、タスクの状態を変更しています。
updateでは、let task_array = $(element).sortable("toArray");で要素の並びを取得して、rc = ipcRenderer.sendSync("sortTask", {project_id: project_id, key: "array", task_array: task_array});でメインプロセスに順序の更新要求を出しています。updateイベントは、ドラッグ&ドロップ元、ドラッグ&ドロップ先の両方で発火するイベントです。したがって、if (task_array.includes(task_id)) {}にて、取得した要素の並びがドラッグ&ドロップ元か、先かを判定して、ドラッグ&ドロップ先なら更新します。(ドラッグ&ドロップ元の要素は並び替えが発生しません。)

● レポート出力(2020/11/29)

プロジェクトごとのWait、Doing、Doneの状況をマークダウンで出力する機能を追加しました。
Doneのうち、終了日が1週間以上前のものは表示しないようにしています。
electronのメニュー機能で、output reportなるメニューを追加し、押下時にファイル保存ダイアログが表示されるようになっています。

出力例

image.png
(https://stackedit.io/)

ここでは、出力内容の変換は省略して、メニューの追加部分だけ説明します。

index.js(一部)
// メニューをコントロールするモジュール
const Menu = electron.Menu;

// ダイアログを作成するモジュール
const dialog = electron.dialog;


//中略

const templateMenu = [
  {
    label: 'File',
    role: 'fileMenu',
    submenu: [
      {
        label: 'output report',
        accelerator: 'CmdorCtrl+O',
        click: async () => {
          let filePath = dialog.showSaveDialogSync(
            mainWindow,
            {
              filters: [
                  {
                      name: 'Documents',
                      extensions: ['txt']
                  }
              ]
            }
          );
          if (filePath) {
            rc = data_control.out_projects(filePath);
          }
        }
      },
      { 
        label: 'close',
        role: 'close' 
      }
    ] 
  },
  {
    label: 'Edit',
    role: 'editMenu'
  },
  {
    label: 'View',
    role: 'viewMenu'
  },
];

//中略

// Electronの初期化完了後に実行
app.on("ready", () => {
  // ウィンドウサイズを1000*800(フレームサイズを含まない)に設定する
  // HTML側でNode.js使用可能とする(レンダラープロセスで使用可能)
  mainWindow = new BrowserWindow({
    width: 1000,
    height: 800,
    minWidth: 700,
    minHeight: 700,
    webPreferences: {
      nodeIntegration: true
    }
  });

  //使用するhtmlファイルを指定する
  mainWindow.loadURL(`file://${__dirname}/index.html`);

  Menu.setApplicationMenu(myMenu);

  // ウィンドウが閉じられたらアプリも終了
  mainWindow.on("closed", () => {
    mainWindow = null;
  });
});

ElectronのMenuモジュール、dialogモジュールを使用します。templateMenuに表示するメニューの内容を記載しています。
click: async () => {}の処理が、output reportを押下したときに実行されます。
dialog.showSaveDialogSyncでダイアログを開き、保存するファイル名のフルパス(filePath)を指定します。
ファイルが指定されたら、data_control.out_projects(filePath);で、データ変換→出力を実施しています。

ソース

index.js
index.js
"use strct";

// モジュールのインポート
// Electronのモジュール
const electron = require("electron");

// data_controlモジュール
const data_control = require("./js/data_control")
data_control.init(`${__dirname}/data`)

// アプリケーションをコントロールするモジュール
const app = electron.app;

// メニューをコントロールするモジュール
const Menu = electron.Menu;

// ウィンドウを作成するモジュール
const BrowserWindow = electron.BrowserWindow;

// ダイアログを作成するモジュール
const dialog = electron.dialog;

// 通信用
const ipcMain = electron.ipcMain

// 変数定義
// メインウィンドウはGCされないようにグローバル宣言
let mainWindow = null;

// 定数定義
const templateMenu = [
  {
    label: 'File',
    role: 'fileMenu',
    submenu: [
      {
        label: 'output report',
        accelerator: 'CmdorCtrl+O',
        click: async () => {
          let filePath = dialog.showSaveDialogSync(
            mainWindow,
            {
              filters: [
                  {
                      name: 'Documents',
                      extensions: ['txt']
                  }
              ]
            }
          );
          if (filePath) {
            rc = data_control.out_projects(filePath);
          }
        }
      },
      { 
        label: 'close',
        role: 'close' 
      }
    ] 
  },
  {
    label: 'Edit',
    role: 'editMenu'
  },
  {
    label: 'View',
    role: 'viewMenu'
  },
];

const myMenu = Menu.buildFromTemplate(templateMenu);


// 関数定義
// project作成用
function makeProject(project_id) {
  let project = {
    id: project_id,
    name:"プロジェクト名",
    tasks: []
  }
  return project;
}

// task作成用
function makeTask(task_id) {
  let task = {
    id: task_id,
    name: "タスク名",
    status: "Wait",
    start_date: "",
    due_date: "",
    end_date: "",
    detail: "",
    subtasks: []
  }
  return task;
}

// subtask作成用
function makeSubtask(subtask_id) {
  let subtask = {
    id: subtask_id,
    name: "サブタスク",
    checked: false,
  }
  return subtask;
}

// 日付取得用
function today(){
  var dt = new Date();
  // JST
  dt.setHours(dt.getHours() + 9);
  var y = dt.getFullYear();
  var m = ("00" + (dt.getMonth()+1)).slice(-2);
  var d = ("00" + dt.getDate()).slice(-2);
  var result = y + "/" + m + "/" + d;
  return result;
}

// データ取得
let projects = data_control.get("projects");
if(!projects) {
  projects = [];
}
if (!projects.length) {
  let project = makeProject("project1"); 
  projects = [project];
  data_control.set("projects", projects);
}

// 全てのウィンドウが閉じたら終了
app.on("window-all-closed", () => {
  if (process.platform != "darwin") {
    app.quit();
  }
});

// Electronの初期化完了後に実行
app.on("ready", () => {
  // ウィンドウサイズを1000*800(フレームサイズを含まない)に設定する
  // HTML側でNode.js使用可能とする(レンダラープロセスで使用可能)
  mainWindow = new BrowserWindow({
    width: 1000,
    height: 800,
    minWidth: 700,
    minHeight: 700,
    webPreferences: {
      nodeIntegration: true
    }
  });

  //使用するhtmlファイルを指定する
  mainWindow.loadURL(`file://${__dirname}/index.html`);

  Menu.setApplicationMenu(myMenu);

  // ウィンドウが閉じられたらアプリも終了
  mainWindow.on("closed", () => {
    mainWindow = null;
  });
});


////////////// IPC //////////////
// on data.
// return data to renderer.
ipcMain.on('data', (event, arg) => {
  let data = data_control.get(arg);
  event.returnValue = data;
  return
})

// on message.
// console.log(arg)
ipcMain.on('message', (event, arg) => {
  console.log(arg);
})

// on confirm.
ipcMain.on('confirm', (event, arg) =>{
  // arg = {title: , message: }
  var options = {
    type: 'info',
    buttons: ["OK", "Cancel"],
    title: arg.title,
    message: arg.message
  };    
  index = dialog.showMessageBoxSync(mainWindow, options);
  if (index === 0) { // "OK"
    event.returnValue = true;
  } else if (index === 1) { // "Cancel"
    event.returnValue = false;
  }
  return  
})

// on add project.
ipcMain.on('addProject', (event) => {
  // projectId採番
  for (let i = 1; true; i++){
    if (projects.findIndex((project) => {return project.id === `project${i}`;}) === -1) {
        PROJECT_ID = i;
        break;
    }
  }
  // project作成
  let project = makeProject(`project${PROJECT_ID}`);
  // project追加
  projects.push(project);
  // データ保存
  data_control.set("projects", projects);
  // 成功時はtrueをreturn
  event.returnValue = true;
  return
})

// on delete project.
ipcMain.on('deleteProject', (event, arg) => {
  // "arg" is "project_id"
  projects = projects.filter((project) => {return project.id !== arg;});
  data_control.set("projects", projects);
  event.returnValue = true;
  return
})

// on change projectName.
ipcMain.on('changeProjectName', (event, arg) => {
  // "arg" : {project_id: , project_name: }
  var index = -1;
  index = projects.findIndex((project) => {
    return project.id === arg.project_id;
  })
  if (index !== -1) {
    project = projects[index];
    project.name = arg.project_name;
    projects[index] = project;
    data_control.set("projects", projects);
    event.returnValue = true;
  } else {
    event.returnValue = false;
  }
  return
})

// on add task.
ipcMain.on('addTask', (event, arg) => {
  // "arg" : project_id
  var index = -1;
  index = projects.findIndex((project) => {return project.id === arg;})
  if (index !== -1) {
    project = projects[index];
    tasks = project.tasks;
    // taskId採番
    for (let i = 1; true; i++){
      if (tasks.findIndex((task) => {return task.id === `task${i}`;}) === -1) {
          TASK_ID = i;
          break;
      }
    }
    // task作成
    let task = makeTask(`task${TASK_ID}`);
    task.start_date = today();
    // task追加
    tasks.push(task);
    projects[index].tasks = tasks;
    // データ保存
    data_control.set("projects", projects);
    // 作成したtaskをreturnにセット
    event.returnValue = true;
  } else {
    event.returnValue = false;
  }
  return
});

// on delete task.
ipcMain.on('deleteTask', (event, arg) => {
  // "arg" is "{project_id: , task_id: }"
  var index = -1;
  index = projects.findIndex((project) => {return project.id === arg.project_id;});
  if (index !== -1) {
    projects[index].tasks = projects[index].tasks.filter((task) => {return task.id !== arg.task_id;});
    event.returnValue = true;
    data_control.set("projects", projects);
  } else {
    event.returnValue = false;
  }
  return
});
//on sort task.
ipcMain.on('sortTask', (event, arg) => {
  //"arg" is "{project_id: project_id, key: "array", task_array: task_array}"
  if (arg.key === "array") {
    task_array = arg.task_array;
    var index = -1;
    var rc = false;
    index = projects.findIndex((project) => {return project.id === arg.project_id;});
    if (index !== -1) {
      projects[index].tasks.sort((task1, task2) => {
        var index_task1 = task_array.findIndex((task_id) => {return task_id === task1.id});
        var index_task2 = task_array.findIndex((task_id) => {return task_id === task2.id});
        return index_task1 - index_task2;
      });
      data_control.set("projects", projects);
      rc = true;
    } else {
      rc = false;
    }
  }
  event.returnValue = rc;
  return
});
// on change taskName.
ipcMain.on('changeTaskName', (event, arg) => {
  // "arg" is "{project_id: , task_id: , task_name:}"
  [index_project, index_task] = searchTask(arg.project_id, arg.task_id);
  if (index_project !== -1 && index_task !== -1) {
    projects[index_project].tasks[index_task].name = arg.task_name;
    event.returnValue = true; // if successfull, return true.
    data_control.set("projects", projects);
  } else {
    event.returnValue = false; // if failed, return false.
  }
  return
});
// on change taskStatus.
ipcMain.on('changeTaskStatus', (event, arg) => {
  // "arg" is "{project_id: , task_id: , task_status:}"
  [index_project, index_task] = searchTask(arg.project_id, arg.task_id);
  if (index_project !== -1 && index_task !== -1) {
    projects[index_project].tasks[index_task].status = arg.task_status;
    if (arg.task_status === "Done") {
      projects[index_project].tasks[index_task].end_date = today();
    }else{
      projects[index_project].tasks[index_task].end_date = undefined;
    }
    event.returnValue = true; // if successfull, return true.
    data_control.set("projects", projects);
  } else {
    event.returnValue = false; // if failed, return false.
  }
  return
});
// on change taskDetail.
ipcMain.on('changeTaskDetail', (event, arg) => {
  // "arg" is "{project_id: , task_id: , task_detail:}"
  [index_project, index_task] = searchTask(arg.project_id, arg.task_id);
  if (index_project !== -1 && index_task !== -1) {
    projects[index_project].tasks[index_task].detail = arg.task_detail;
    event.returnValue = true; // if successfull, return true.
    data_control.set("projects", projects);
  } else {
    event.returnValue = false; // if failed, return false.
  }
  return
});
// on change TaskStartDate.
ipcMain.on('changeTaskDate', (event, arg) => {
  // "arg" is "{project_id: , task_id: , task_(start|due|end)_date:}"
  var rc = true;
  [index_project, index_task] = searchTask(arg.project_id, arg.task_id);
  if (index_project !== -1 && index_task !== -1) {
    if ("task_start_date" in arg) {
      projects[index_project].tasks[index_task].start_date = arg.task_start_date;
    } else if ("task_due_date" in arg) {
      projects[index_project].tasks[index_task].due_date = arg.task_due_date;
    } else if ("task_end_date" in arg) {
      projects[index_project].tasks[index_task].end_date = arg.task_end_date;
    } else {
      rc = false; // if failed, return false.
    }
    if (rc) {
      data_control.set("projects", projects);
    }
  } else {
    rc = false; // if failed, return false.
  }
  event.returnValue = rc;
  return
});

// on add subtask.
ipcMain.on('addSubtask', (event, arg) => {
  // "arg" is "{project_id: , task_id:}"
  [index_project, index_task] = searchTask(arg.project_id, arg.task_id);
  if (index_project !== -1 && index_task !== -1) {
    subtasks = projects[index_project].tasks[index_task].subtasks;
    // subtaskId採番
    for (let i = 1; true; i++){
      if (subtasks.findIndex((subtask) => {return subtask.id === `subtask${i}`;}) === -1) {
          SUBTASK_ID = i;
          break;
      }
    }
    // subtask作成
    let subtask = makeSubtask(`subtask${SUBTASK_ID}`);
    // task追加
    subtasks.push(subtask);
    projects[index_project].tasks[index_task].subtasks = subtasks;
    // データ保存
    data_control.set("projects", projects);
    // 成否をreturnにセット
    event.returnValue = true;
  } else {
    event.returnValue = false;
  }
  return
});
// on delete subtask.
ipcMain.on('deleteSubtask', (event, arg) => {
  // "arg" is "{project_id: , task_id: , subtask_id:}"
  [index_project, index_task] = searchTask(arg.project_id, arg.task_id);
  if (index_project !== -1 && index_task !== -1) {
    subtasks = projects[index_project].tasks[index_task].subtasks;
    projects[index_project].tasks[index_task].subtasks = subtasks.filter((subtask) => {return subtask.id !== arg.subtask_id;});
    data_control.set("projects", projects);
    event.returnValue = true;
  } else {
    event.returnValue = false;
  }
  return
});
// on change subtask_name.
ipcMain.on('changeSubtaskName', (event, arg) => {
  // "arg" is "{project_id: , task_id: , subtask_id: , subtask_name:}"
  [index_project, index_task, index_subtask] = searchSubtask(arg.project_id, arg.task_id, arg.subtask_id);
  if (index_project !== -1 && index_task !== -1 && index_subtask !== -1) {
    projects[index_project].tasks[index_task].subtasks[index_subtask].name = arg.subtask_name;
    data_control.set("projects", projects);
    event.returnValue = true;
  } else {
    event.returnValue = false;
  }
  return
});
// on change subtask_checked.
ipcMain.on('changeSubtaskChecked', (event, arg) => {
  // "arg" is "{project_id: , task_id: , subtask_id: , subtask_checked:}"
  [index_project, index_task, index_subtask] = searchSubtask(arg.project_id, arg.task_id, arg.subtask_id);
  if (index_project !== -1 && index_task !== -1 && index_subtask !== -1) {
    projects[index_project].tasks[index_task].subtasks[index_subtask].checked = arg.subtask_checked;
    data_control.set("projects", projects);
    event.returnValue = true;
  } else {
    event.returnValue = false;
  }
  return
});

///////////////// function /////////////////
// Search the index of task from project_id and task_id.
function searchTask(project_id, task_id) {
  var index_project = -1;
  var index_task = -1;
  index_project = projects.findIndex((project) => {return project.id === project_id;});
  if (index_project !== -1) {
    index_task = projects[index_project].tasks.findIndex((task) => {return task.id === task_id;});
  }
  return [index_project, index_task];
}
// Search the index of subtask from project_id, task_id and subtask_id.
function searchSubtask(project_id, task_id, subtask_id) {
  var index_project = -1;
  var index_task = -1;
  var index_subtask = -1;
  index_project = projects.findIndex((project) => {return project.id === project_id;});
  if (index_project !== -1) {
    index_task = projects[index_project].tasks.findIndex((task) => {return task.id === task_id;});
    if (index_task !== -1) {
      index_subtask = projects[index_project].tasks[index_task].subtasks.findIndex((subtask) => {return subtask.id === subtask_id;});
    }
  }
  return [index_project, index_task, index_subtask];
}

data_control.js
data_control.js
// data_control
// require.
const fs = require('fs');

// vers.
let data_json = {}
let file_path = ""
let file_path_menu = ""

exports.init = function (data_dir) {
  file_path = data_dir + "/data.json"
  file_path_menu = data_dir + "/menu.json"
  // load data file as json.
  let data_string = "";
  if (fs.existsSync(file_path)) {
    data_string = fs.readFileSync(file_path, 'utf8');
    if (data_string) {
      data_json = JSON.parse(data_string);
    }
  }
}

// set as key-value. value can be json, string or list. 
exports.set = function (key, value) {  // set.
  data_json[key] = value;
  // rewrite into file.
  const data_string = JSON.stringify(data_json);
  fs.writeFile(file_path, data_string, (err) => {
    if (err) throw err;
  });
}

exports.get = function (key) {
  // get.
  const value = data_json[key]
  if (value) {
    return value;
  }else{
    return undefined;
  }
}

exports.out_projects = function (file_path) {
  const projects = data_json["projects"];
  if (!projects) {
    return false;
  }
  let output = "";
  for (let p = 0; p < projects.length; p++){
    let project = projects[p];
    output = write_project_to_output(output, project);
  }
  // write out.
  fs.writeFile(file_path, output, (err) => {
    if (err) throw err;
  });
}

function write_project_to_output(output, project) {
  // project_name
  output = output + "# ★" + project.name + "\n\n";
  let tasks_wait = [];
  let tasks_doing = [];
  let tasks_done = [];
  for (let t = 0; t < project.tasks.length; t++){
    let task = project.tasks[t];
    switch (task.status) {
      case "Wait":
        tasks_wait.push(task);
        break;
      case "Doing":
        tasks_doing.push(task);
        break;
      case "Done":
        tasks_done.push(task);
    }
  }
  // Doing task
  output = output + "## [Doing]\n\n"
  for (let t = 0; t < tasks_doing.length; t++){
    let task = tasks_doing[t];
    output = write_task_to_output(output, task);
  }
  output = output + "----------\n\n";
  // Done task
  output = output + "## [Done]\n\n"
  for (let t = 0; t < tasks_done.length; t++){
    let task = tasks_done[t];
    output = write_task_to_output(output, task);
  }
  output = output + "----------\n\n";
  // Wait task
  output = output + "## [Wait]\n\n"
  for (let t = 0; t < tasks_wait.length; t++){
    let task = tasks_wait[t];
    output = write_task_to_output(output, task);
  }
  output = output + "----------\n\n";
  // End of project
  output = output + "\n"
  return output;
}

function write_task_to_output(output, task) {
  if (task.end_date) {
    // JST -> UTC
    let end_date = new Date(task.end_date);
    // Within 1 week. Because of the devaluation into date, it may shift up to + 23:59.
    if ((new Date() - end_date)/24/60/60/1000 >= 7 + 1) {
      return output
    }
  }
  // task
  output = output + "### ・" + task.name + "\n";
  // task detail
  if (task.detail) {
    output = output + '  >' + task.detail.replace(/\n/g, '\n  >') + "\n";
  }
  // subtasks
  for (let s = 0; s < task.subtasks.length; s++){
    let subtask = task.subtasks[s];
    if (subtask.checked) {
      output = output + "  + [x] " + subtask.name + "\n";
    }else {
      output = output + "  + [ ] " + subtask.name + "\n";
    }
  }
  // End of task
  output = output + "\n"
  return output;
}

index.html
index.html
<!doctype html>
<html lang="ja">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
    <!-- Jquery UI theme CSS -->
    <link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1/themes/smoothness/jquery-ui.css" >
    <!-- My CSS -->
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/left_menu.css">
    <link rel="stylesheet" href="css/right_body.css">
    <title>タスク管理</title>
  </head>
  <body>
    <header >
      <h1 id="title">Task Board</h1>
    </header>
    <div class="container-fluid" id="contents">
      <!----------------------- モーダルエリアここから ----------------------->
      <section id="modalArea" class="modalArea fixed-top">
        <div id="modalBg" class="modalBg"></div>
        <div class="modalWrapper">
          <div class="modalContents">
            <h1>削除しますか?</h1>
            <button id="delete_project">削除</button>
          </div>
          <div id="closeModal" class="closeModal">
            ×
          </div>
        </div>
      </section>
      <!----------------------- モーダルエリアここまで ----------------------->

      <div class="row" id="main_content">
        <!----------------------- サイドバーここから ----------------------->
        <div class="col-3" id="left_menu">
            <h3>Projects</h3>
            <hr size=1 color="white">
            <div class="rounded-pill shadow" id="add_project"></div>
            <ul id="menu_list">
              <template id="project_template">
                <li><div class="project rounded" id="project" contenteditable="false">project</div></li>
              </template>
            </ul>
        </div>
        <!----------------------- サイドバーここまで ----------------------->

        <!----------------------- コンテンツここから ----------------------->
        <div class="col-9" id="right_body">
          <!-- カード一覧ここから -->
          <!-- カード追加ここから -->
          <div class="rounded-pill shadow" id="add_task">+ タスク追加</div>
          <!-- カード追加ここまで -->
          <div class="cards_list row">
            <div class="board col-4">
              <div class="board_name rounded">Wait</div>
              <div class="cards rounded" id="cards_wait">
                <!-- コンテンツテンプレート挿入位置1 -->
              </div>
            </div>
            <div class="board col-4">
              <div class="board_name rounded">Doing</div>
              <div class="cards rounded" id="cards_doing">
                <!-- コンテンツテンプレート挿入位置2 -->
              </div>
            </div>
            <div class="board col-4">
              <div class="board_name rounded">Done</div>
              <div class="cards rounded" id="cards_done">
                <!-- コンテンツテンプレート挿入位置3 -->
              </div>
            </div>
          </div>
          <!-- カード一覧ここまで -->
        </div>
        <!----------------------- コンテンツここまで ----------------------->
      </div>
      <!----------------------- テンプレートここから ----------------------->
      <!-- タスクテンプレートここから -->
      <template id="task_template">
        <!-- カードここから -->
        <div class="card shadow" id="task">
          <!-- カードヘッダーここから -->
          <div class="card-header">
            <div class="row">
              <!-- カードタイトルここから -->
              <div class="card_title col-8">
                  <div class="wrap_task_name" >
                    <input type="text" class="task_name">
                  </div>
                  <div class="task_name_toolchip"></div>
              </div>
              <!-- カードタイトルここまで -->
              <!-- statusトグルここから -->
              <div class="col-4" id="status">
                <input type="radio" class="status_input Wait" name="status_radio" id="Wait" value="Wait" checked>
                <label class = "status_label" for="Wait">Wait</label>
                <input type="radio" class="status_input Doing" name="status_radio" id="Doing" value="Doing">
                <label class = "status_label" for="Doing">Doing</label>
                <input type="radio" class="status_input Done" name="status_radio" id="Done" value="Done">
                <label class = "status_label" for="Done">Done</label>
              </div>
              <!-- statusトグルここまで -->
            </div>
          </div>
          <!-- カードヘッダーここまで -->
          <div class="card-body">
            <!-- 日付表示ここから -->
            <div class="row">
              <div class="col" id="start_date">
                <label class="date_label"  for="Start">開始日:</label>
                <input type="text" id="Start" class="date_input Start form-control" placeholder="日付を選択" readonly>
              </div>
              <div class="col" id="due_date">
                <label class="date_label"  for="Due">期日:</label>
                <input type="text" id="Due" class="date_input Due form-control" placeholder="日付を選択" readonly>
              </div>
              <div class="col" id="end_date">
                <label class="date_label" for="End">完了日:</label>
                <input type="text" id="End" class="date_input End form-control" placeholder="日付を選択" readonly>
              </div>
            </div>
            <!-- 日付表示ここまで -->
            <!-- 詳細ここから -->
            <textarea type="textarea" class="form-control rounded task_detail" id="detail_textarea" placeholder="詳細入力"></textarea>
            <!-- 詳細ここまで -->
            <!-- チェックリストここから -->
            <!-- サブタスク追加ここから -->
            <div class="rounded-pill" id="add_subtask">+ サブタスク追加</div>
            <!-- サブタスク追加ここまで -->
            <div class="checklist" id="test">
              <!-- チェックボックステンプレート挿入位置 -->
            </div>
            <!-- チェックリストここまで -->
            <!-- カード削除ボタンここから -->
            <div class="wrap_delete_task col"><div class="rounded-pill float-right" id="delete_task"></div></div>
            <!-- カード削除ボタンここまで -->
          </div>
        </div>
        <!-- カードここまで -->
      </template>
      <!-- タスクテンプレートここまで -->
      <!-- チェックボックステンプレートここから -->
      <template id="subtask_template">
        <div class="checkbox row">
          <div class="wrap_checkbox_input">
            <input type="checkbox" class="checkbox_input" id="checkbox_input">
            <label class="checkbox_label" for="checkbox_input"></label>
          </div>
          <div class="wrap_subtask_name">
            <input type="text" class="subtask_name" placeholder="サブタスク">
          </div>
          <div class="subtask_name_toolchip"></div>
          <div id="delete_subtask">削除</div>
        </div>
      </template>
      <!-- チェックボックステンプレートここまで -->
      <!----------------------- テンプレートここまで ----------------------->
    </div>
    <!-- Optional JavaScript -->
    <script>if (typeof module === 'object') {window.module = module; module = undefined;}</script>
    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <script  src="https://code.jquery.com/jquery-3.5.1.js"  integrity="sha256-QWo7LDvxbWT2tbbQ97B53yJnYU3WhH/C8ycbRAkjPDc="  crossorigin="anonymous"></script>
    <!-- minified jquery -->
    <!--
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
    -->
    <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
    <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1/i18n/jquery.ui.datepicker-ja.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>

    <script>if (window.module) {module = window.module;}</script>
    <!-- My JavaScript -->
    <script src="js/renderer.js"></script> <!-- rendere.js first. -->
    <script src="js/left_menu.js"></script>
    <script src="js/right_body.js"></script>
  </body>
</html>

common.css
common.css
html {
    font-size: 62.5%;
}

header {
    background: whitesmoke;
    margin: 0;
}

#title {
    margin: 0 1rem;
    height: 5vh;
}

body {
    background: whitesmoke;
    overflow: hidden;
}

#contents {
    height: 95vh;
}

#main_content {
    background: linear-gradient(-120deg, #584a3d, #453650);
}

/*スクロールバー*/
::-webkit-scrollbar {
    width: 1rem;
    height: 1rem;
}
::-webkit-scrollbar-track {
    border-radius: 1rem;
    background-color:rgba(245, 245, 245, 0.1);
}
::-webkit-scrollbar-thumb {
    border-radius: 1rem;
    background-color: rgba(128, 128, 128, 0.5);
}

input[type="text"]:focus {
    border: 0.2rem solid lightskyblue;
    outline: 0;
}

left_menu.css
left_menu.css
#left_menu {
  background-color: rgba(30,30,30,0.5);
  color: white;
  height: 95vh;
  overflow: auto;
  max-width: 30rem;
}

#left_menu ul {
  display: flex;
  flex-flow: column;
  padding-left: 0;
  margin: 0;
  list-style: none;
}

#left_menu li {
  text-align: center;
}

#left_menu div {
  font-size: 1.5rem;
  transform  : scale(0.8, 0.8);
  margin: 0.1rem;
}

#left_menu div:hover {
  background-color: rgb(56, 56, 56);
  cursor : pointer;
  transition : .2s;
  transform: scale(0.9, 0.9);
}

#left_menu div[class*="active"] {
  background-color: rgba(109, 131, 68);
  transition : .2s;
  transform: scale(1, 1);
}

.project:focus {
  border: 0.2rem solid lightskyblue;
  outline: 0;
}

#add_project {
  width: 5rem;
  margin: 0.3rem auto !important;
  display: block;
  text-align: center;
  border: 0.15rem solid white;
  color: white;
  background-color: grey !important;
  font-weight: bold;
  transform: scale(0.8, 0.8);
  user-select: none;
}

#add_project:hover {
  background-color:  #754775 !important;
}

/* モーダルCSS */
.modalArea {
  display: none;
  position: fixed;
  z-index: 10; /*サイトによってここの数値は調整 */
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.modalBg {
  width: 100%;
  height: 100%;
  background-color: rgba(30,30,30,0.9);
}

.modalWrapper {
  position: absolute;
  top: 50%;
  left: 50%;
  transform:translate(-50%,-50%);
  width: 70%;
  padding: 5vh 10vw;
  background-color: #fff;
}

.closeModal {
  position: absolute;
  top: 1rem;
  right: 2rem;
  font-size: 2rem;
  cursor: pointer;
}

right_body.css
right_body.css
#right_body {
    height: 95vh;
    margin: 0;
    padding: 0;
    scroll-behavior: smooth;  
    overflow-x: auto;  
    overflow-y: hidden;
}

 /* カード */
.card {
    min-width: 35rem;
    max-width: 35rem;
    margin: 1rem auto 1rem auto;
}

.card.Done {
    opacity: 0.5 !important;
}

.board {
    min-height: 97vh;
    margin: 0;
}

.board_name {
    min-width: 40rem;
    max-width: 40rem;
    font-size: 3vh;
    margin: 0 auto;
    text-align: center;
    color: white;
}

.cards {
    border: 0.2rem solid grey;
    min-width: 40rem;
    max-width: 40rem;
    height: 88vh;
    margin-bottom: 3rem;
    padding-bottom: 3rem;
    align-items: center;
    overflow: auto;
    margin: 0 auto;
}

.cards_list {
    margin: 0;
    min-width: 130rem;
    max-width: 150rem;
}

#add_task {
    padding: 0.5rem;
    font-size: 1.5rem;
    width: 11rem;
    display: block;
    text-align: center;
    border: 0.15rem solid white;
    color: white;
    background-color: grey;
    font-weight: bold;
    transform: scale(0.9, 0.9);
    position:fixed;
    right:2rem;
    bottom:3rem;
    z-index: 1;
    user-select: none;
}

#add_task:hover {
    background-color:  #754775;
    cursor : pointer;
    transition : .2s;
    transform: scale(1, 1);
}

#delete_task {
    font-size: 1rem;
    width: 2rem;
    height: 2rem;
    display: flex;
    justify-content: center;
    align-items: center;
    margin: 0;
    padding: 0;
    color: grey;
    font-weight: bold;
    transform: scale(1, 1);
    z-index: 1;
    border: 0.1rem solid grey;
}
#delete_task:hover {
    cursor : pointer;
    transition : .2s;
    transform: scale(1.2, 1.2);
    color: red;
    border-color: red;
}
.wrap_delete_task {
    margin: 1rem;
}

/* タスク名 */
.task_name {
    font-size: 1.5rem;
    margin: auto auto;
    width: 100%;
    background-color: whitesmoke;
    border-radius: 0.3rem;
    border: hidden;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.wrap_task_name {
    display: inline-block;
    overflow: hidden
}

.card_title {
    position:relative; /* for toolchip */
}

.task_name_toolchip {
    max-width: 26rem;
    display: none;
    position: absolute;
    top: 3.5em;
    left: 5rem;
    z-index: 9999;
    padding: 0.3em 0.5em;
    color: #FFFFFF;
    background: rgb(124, 124, 124);
    border-radius: 0.5em;
}

.task_name_toolchip:after {
    width: 100%;
    content: "";
    display: block;
    position: absolute;
    left: 0.5em;
    top: -0.8rem;
    border-top:0.8rem solid transparent;
    border-left:0.8rem solid rgb(124, 124, 124);
}

/* statusトグル */
#status {
    display: flex;
    justify-content: flex-end;
}
.status_input {
    display    : none;
    border: 0.1rem solid grey;
  }
/* statusトグル 選択なし */
.status_input + label {
    display    : inline-block;
    height : 2.5rem;
    opacity    : 0.7;
    cursor     : pointer;
    transition : .2s;
    transform  : scale(0.9, 0.9);
    border: 0.1rem solid grey;
    border-radius: 0.5rem;
    padding: 0.3rem 0.6rem;
}
/* statusトグル 選択あり */
.status_input:checked + label {
    opacity    : 1;
    transform  : scale(1, 1);
}

.status_input + label:hover {
    transform  : scale(1, 1);
}
.Wait + label {
    color: orange;
    border: 0.1rem solid orange;
}
.Doing + label {
    color: rgb(0, 156, 0);
    border: 0.1rem solid rgb(0, 156, 0);
}
.Done + label {
    color: rgb(37, 21, 21);
    border: 0.1rem solid rgb(37, 21, 21);
}

.Wait:checked + label {
    background: orange !important;
    color: white  !important;
}
.Doing:checked + label {
    background: rgb(0, 156, 0) !important;
    color: white  !important;
}
.Done:checked + label {    
    background: rgb(37, 21, 21)  !important;
    color: white  !important;
}

/* タスク詳細説明 */
#detail_textarea {
    max-height: 20rem;
    margin: 1rem auto;
}

/* チェックリスト */
.checkbox {
    padding-top: 1rem;
    position:relative; /* for toolchip */
}

.wrap_checkbox_input {
    display: inline-block;
    padding-left: 2rem;
    padding-right: 1rem;
    height: 1.5rem;
}

input[type="checkbox"] { display: none; }

input[type="checkbox"] + label {
    position: relative;
    padding-left: 1.5rem;
    padding-bottom: 1.5rem;
    cursor: pointer;
}

input[type="checkbox"] + label:before {
  content: '';
  width: 1.5rem;
  height: 1.5rem;
  border: 0.1rem solid grey;
  border-radius: 1rem;
  position: absolute;
  left: 0;
  top: 0;
  transition: all 0.2s;
}

input[type="checkbox"]:checked + label:before {
  width: 0.75rem;
  height: 1.5rem;
  top: -0.5rem;
  left: 0.5rem;
  border-radius: 0;
  border-color: green;
  border-top-color: transparent;
  border-left-color: transparent;
  transform: rotate(60deg);
}

.subtask_name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    border: hidden;
    width: 100%;
    height: 99%;
}

.wrap_subtask_name {
    display: inline-block;
    height: 2rem;
    text-align: center;
    width: 26rem;
    border: 0.1rem solid #ddd;
    border-radius: 0.3rem;
    background-color: white;
    overflow: hidden
}

.subtask_name_toolchip {
    max-width: 26rem;
    display: none;
    position: absolute;
    top: 3.5em;
    left: 5rem;
    z-index: 9999;
    padding: 0.3em 0.5em;
    color: #FFFFFF;
    background: rgb(124, 124, 124);
    border-radius: 0.5em;
}

.subtask_name_toolchip:after {
    width: 100%;
    content: "";
    display: block;
    position: absolute;
    left: 0.5em;
    top: -0.8rem;
    border-top:0.8rem solid transparent;
    border-left:0.8rem solid rgb(124, 124, 124);
}

#add_subtask {
    width: 10rem;
    margin: 0.3rem auto;
    display: block;
    text-align: center;
    border: 0.15rem solid grey;
    color: grey;
    font-weight: bold;
    transform: scale(0.9, 0.9);
    user-select: none;
}

#add_subtask:hover {
    border-color:  #bb6dbb;
    color: #bb6dbb;
    cursor : pointer;
    transition : .2s;
    transform: scale(1, 1);
}

#delete_subtask {
    width: 3rem;
    height: 1.5rem;
    color: grey;
    display: flex;
    justify-content: center;
    align-items: center;
    padding-left: 1rem;
    transform: scale(0.9, 0.9);
}
#delete_subtask:hover {
    transform: scale(1, 1) !important;
    color: red !important;
    cursor: pointer;
    font-weight: bold;
}

renderer.js
renderer.js
// Vars
let PROJECT_TEXT;
let PROJECT_ID = 0;
let PROJECT_ELEMENT = "";
let project = {};
let TASK_ID = 0;
let CARDS_SCROLL_TOP = 0;
let CARDS_SCROLL_LEFT = 0;
let top_wait;
let top_doing;
let top_done;

// Electronのモジュール
const electron = require("electron");
const ipcRenderer = electron.ipcRenderer;

// Rem取得
function convertRemToPx(rem) {
    const fontSize = getComputedStyle(document.documentElement).fontSize;
    return rem * parseFloat(fontSize);
}
// subtask function.
function ADD_SUBTASK(clone, task, subtask) {  
    var clone_subtask = $($('#subtask_template').html());
    clone_subtask.attr("id", subtask.id);
    // checkbox input 設定
    clone_subtask.find("input[type=checkbox]").each((index, element) => {
        element.id = `${element.id}_${task.id}_${subtask.id}`;
        if (subtask.checked) {
            $(element).prop('checked', true);
        }
    });
    // checkbox label 設定
    clone_subtask.find("label.checkbox_label").each((index, element) => {
        $(element).attr('for', `${$(element).attr('for')}_${task.id}_${subtask.id}`);
    });
    // checkbox content 設定
    clone_subtask.find(".subtask_name").val(subtask.name);
    clone_subtask.find(".checkbox_input").prop('checked', subtask.checked);
    if (clone_subtask.find(".checkbox_input").prop('checked')) {
        // 取り消し線追加
        clone_subtask.find(".checkbox_input").closest(".checkbox").find(".subtask_name").css("text-decoration", "line-through");
    }
    // サブタスク追加
    clone.find(".checklist").append(clone_subtask);
}
function LOAD_SUBTASKS(clone, task) {
    var subtasks = task.subtasks;
    // クリア
    clone.find(".checkbox").each((index, element) => {
        $(element).remove();
    });
    // 再描画
    for (let i = 0; i < subtasks.length; i++) {
        ADD_SUBTASK(clone, task, subtasks[i]);
    }
}

// task function.
function ADD_TASK(task) {
    var clone = $($('#task_template').html());
    // task_id 設定
    clone.attr("id", task.id);
    // task_name 設定
    clone.find(".task_name").val(task.name);
    // task_detail 設定
    clone.find(".task_detail").val(task.detail);
    // status input 設定
    clone.find("input[type=radio]").each((index, element) => {
        element.name = `${element.name}_${task.id}`;
        element.id = `${element.id}_${task.id}`;
        if ($(element).attr("value") == task.status) {
            $(element).prop('checked', true);
        }
        if (task.status === "Done") {
            clone.addClass("Done");
        }
    });
    // status label 設定
    clone.find("label.status_label").each((index, element) => {
        $(element).attr('for', `${$(element).attr('for')}_${task.id}`);
    });
    // date input 設定
    clone.find("input.date_input").each((index, element) => {
        element.id = `${element.id}_${task.id}`;
        if (element.id == `Start_${task.id}`) {
            $(element).attr("value", task.start_date); 
        }
        if (element.id == `Due_${task.id}`) {
            $(element).attr("value", task.due_date); 
        }
        if (element.id == `End_${task.id}`) {
            $(element).attr("value", task.end_date); 
        }
    });
    // date label 設定
    clone.find("label.date_label").each((index, element) => {
        $(element).attr('for', `${$(element).attr('for')}_${task.id}`);
    });
    // subtask 設定
    LOAD_SUBTASKS(clone, task);
    // タスク追加
    if (task.status === "Wait") {
        $('#cards_wait').append(clone);
    }else if (task.status === "Doing") {
        $('#cards_doing').append(clone);
    }else if (task.status === "Done") {
        $('#cards_done').append(clone);
    }
    // Callender. 追加時に設定が必要
    $(function() {
        $.datepicker.setDefaults($.datepicker.regional["ja"]);
        $(".date_input").datepicker();
    });
}
function SHOW_TASKS(tasks) {
    // スクロール位置一時保存
    $('.cards').each((index, element) => {
        switch ($(element).attr('id')) {
        case "cards_wait":
            top_wait = $(element).scrollTop();
            break;
        case "cards_doing":
            top_doing = $(element).scrollTop();
            break;
        case "cards_done":
            top_done = $(element).scrollTop();
            break;
        }
    });
    // クリア
    $('.card').each((index, element) => {
        $(element).remove();
    });
    // 再描画
    for (let i = 0; i < tasks.length; i++) {
        ADD_TASK(tasks[i]);
    }
}
function LOAD_TASKS() {
    // projects再取得
    projects = ipcRenderer.sendSync('data', 'projects');
    // activeなproject取得
    project_id = $('#left_menu div.project.active').attr("id"); 
    // tasks取得
    var index = -1;
    index = projects.findIndex((project) => {
        return project.id === project_id;
    })
    var tasks;
    if (index !== -1){
        tasks = projects[index].tasks;
    } else {
        tasks = [];
    }
    SHOW_TASKS(tasks);
    // タスク詳細の高さ調節。読み込み後でないとscrollHeightが使えない。
    $(".task_detail").each((index, element) => {
        const minHeight = convertRemToPx(5); // 5rem
        $(element).height(minHeight);
        if (element.scrollHeight > element.clientHeight){
            $(element).height(element.scrollHeight);
        }
    });
    // タスク詳細の高さ調節後にスクロールをリロード
    RELOAD_SCROLL()
}
// スクロール位置リストア
function RELOAD_SCROLL() {
    $('.cards').each((index, element) => {
        switch ($(element).attr('id')) {
        case "cards_wait":
            $(element).scrollTop(top_wait);
            break;
        case "cards_doing":
            $(element).scrollTop(top_doing);
            break;
        case "cards_done":
            $(element).scrollTop(top_done);
            break;
        }
    });
}

// project function.
function ADD_PROJECT(project) {
    // テンプレートからclone作成
    let clone = $($('#project_template').html());
    // cloneにid, textを設定
    clone.find("#project").each((index, element) => {
        element.id = project.id;
        element.textContent = project.name;
    });
    // cloneを一覧に追加
    $('#menu_list').append(clone);
}
function SHOW_PROJECTS(projects) {
    // クリア
    active_project_id = "";
    $('#left_menu div.project').each((index, element) => {
        if (element.classList.contains("active")){
            active_project_id = element.id;
        }
        $(element).parent().remove();
    });
    // 再描画
    for (let i = 0; i < projects.length; i++) {
        ADD_PROJECT(projects[i]);
        if (i==0){
            $('#left_menu div.project').addClass("active");
        }
        if (projects[i].id == active_project_id){
            $('#left_menu div.project').removeClass("active")
            $(`#left_menu div#${active_project_id}`).addClass("active");
        }
    }
}
function LOAD_PROJECTS() {
    // projects取得
    projects = ipcRenderer.sendSync('data', 'projects');
    SHOW_PROJECTS(projects);
    LOAD_TASKS();
}

// テキスト全選択
jQuery.fn.selectText = function(){
    var doc = document;
    var element = this[0];
    if (window.getSelection) {
        var selection = window.getSelection();        
        var range = document.createRange();
        range.selectNodeContents(element);
        selection.removeAllRanges();
        selection.addRange(range);
    }
 };


// ソート可能にする。ロード時に読み込みが必要なため、$(function(){})で定義。
$(function() {
    $('.cards').each((index, element) => {
        $(element).sortable({
            // オプション
            connectWith: '.cards',
            revert: 100,
            cursor: 'move',
            delay: 100,
            // ドロップした時のイベント(e.targetに受け取り側が、ui.itemにドラッグした要素がはいる)
            receive: (e, ui) => {
                // ステータスを受け取り側に合わせて変更
                var task_status;
                switch ($(e.target).attr("id")) {
                    case "cards_wait":
                        task_status = "Wait";
                        break;
                    case "cards_doing":
                        task_status = "Doing";
                        break;
                    case "cards_done":
                        task_status = "Done";
                        break;
                    default:
                        return false;
                }
                project_id = $('#left_menu div.project.active').attr("id");
                task_id = $(ui.item).attr("id");
                rc = ipcRenderer.sendSync("changeTaskStatus", {project_id: project_id, task_id: task_id, task_status:task_status});
                if (rc) {
                    // ステータストグルの変更
                    $(e.target).find(`input[type=radio].${task_status}`).prop('checked', true);
                    // Doneクラスのリセット後、ステータスがDoneなら追加(ユーザ入力でないためchangeで発火されない)
                    $(ui.item).removeClass("Done");
                    if (task_status === "Done") {
                        $(ui.item).addClass("Done");
                    }
                }
                return rc
            },
            update: (e, ui) => {
                let task_id = ui.item.attr("id");
                let task_array = $(element).sortable("toArray");
                let rc = true;
                if (task_array.includes(task_id)) {
                    project_id = $('#left_menu div.project.active').attr("id");
                    rc = ipcRenderer.sendSync("sortTask", {project_id: project_id, key: "array", task_array: task_array});
                }
                return rc
            }
        });
    });
});

left_menu.js
left_menu.js
'user strict'

// 読み込み操作
if (! $('#left_menu div.project').length) {
    LOAD_PROJECTS();
}

// イベント操作
// 対象:プロジェクト追加ボタン
// 動作:シングルクリック
// 内容:直前にプロジェクトを追加する。
$(document).on('click', '#add_project', function(){
    // project追加要求、returnされるproject取得
    rc = ipcRenderer.sendSync('addProject');
    // 画面に表示
    LOAD_PROJECTS();
});

// 対象:プロジェクト名
// 動作:シングルクリック
// 内容:プロジェクトを選択する。
$(document).on('click', '#left_menu div.project', function(){
    $('#left_menu div.project').removeClass("active")
    $('#left_menu div.project').each(function () {
        $(this).attr('contenteditable', 'false');
        if (! $(this).text()) {
            $(this).text(PROJECT_TEXT);
        }
    })
    $(this).addClass("active");
});

// 対象:プロジェクト名
// 動作:ダブルクリック
// 内容:プロジェクト名を編集可能にする。エンターキーまたはダブルクリックで終了する。
$(document).on('dblclick', '#left_menu div.project', function(){
    let contenteditable = $(this).attr('contenteditable');
    if (contenteditable == 'false') {
        PROJECT_TEXT = $(this).text();
        // すべて編集不可に
        $('#left_menu div.project').attr('contenteditable', 'false');
        // 対象だけ編集可に
        $(this).attr('contenteditable', 'true')
        $(this).focus();
        $(this).selectText();
        // エンター押下時にフォーカスアウト(Shift+EnterはOK)
        $(this).keypress(function(e){ 
            if(! event.shiftKey){
                if (e.keyCode == 13) {
                    $(this).focusout();
                }
            }
        });
        // フォーカスアウト時に編集不可にしてMainプロセスに変更要求
        $(this).focusout(()=>{
            $(this).attr('contenteditable', 'false');
            if (! $(this).text()) {
                $(this).text(PROJECT_TEXT);
            }else{
                project_id = $(this).attr('id');
                project_name = $(this).text();
                rc = ipcRenderer.sendSync('changeProjectName', {project_id: project_id, project_name: project_name});
                if (!rc) {
                    $(this).text(PROJECT_TEXT);
                }
            }
        })
    }
});

// 対象:プロジェクト名
// 動作:右クリック
// 内容:削除ポップアップを出す。
$(document).on('contextmenu', '#left_menu div.project', function(){
    // reset
    $('.delete').removeClass('delete');
    $('p.delete_target').remove();
    // 対象追加
    $(this).addClass('delete');
    $('<p class="delete_target">削除対象:' + $(this).text() + '</p>').insertBefore($('#delete_project'));
    $('#modalArea').fadeIn();
    return false
});
// 対象:モーダル
// 動作:右クリック
// 内容:モーダルを閉じる
$('#closeModal , #modalBg').click(function(){
    $('#modalArea').fadeOut();
    $('p.delete_target').remove();
});
// 対象:削除ボタン(モーダル)
// 動作:クリック
// 内容:プロジェクトを削除
$('#delete_project').click(function(){
    project_id = $('#left_menu div.project.delete').attr("id");
    rc = ipcRenderer.sendSync('deleteProject', project_id);
    $('#modalArea').fadeOut();
    LOAD_PROJECTS();
    $('p.delete_target').remove();
});

right_body.js
right_body.js
'user strict'

// 読み込み操作
// LOAD_PROJECTS中で読み込むため不要
//if (! $('#cards.card').length) {
//    LOAD_TASKS();
//}

// イベント操作
// 対象:タスク削除ボタン
// 動作:シングルクリック
// 内容:選択したタスクを削除する。
$(document).on('click', '#delete_task', (e) => {
    project_id = $('#left_menu div.project.active').attr("id");
    task_id = $(e.target).parents(".card").attr("id");
    rc = ipcRenderer.sendSync("confirm", {title: "タスク削除", message: "削除しますか?"});
    if (rc) {
        res = ipcRenderer.sendSync("deleteTask", {project_id: project_id, task_id: task_id});
        LOAD_TASKS();
    }
});

// イベント操作
// 対象:タスク追加ボタン
// 動作:シングルクリック
// 内容:最後尾に新規タスクを追加する。
$(document).on('click', '#add_task', () => {
    project_id = $('#left_menu div.project.active').attr("id");
    rc = ipcRenderer.sendSync('addTask', project_id);
    LOAD_TASKS();
    $('#cards_wait').animate({scrollTop: $('#cards_wait').get(0).scrollHeight}, 2000);
});

// イベント操作
// 対象:タスク状態ラジオボタン
// 動作:シングルクリック
// 内容:タスク状態を変更する。
$(document).on('click', '.status_input', (e) => {
    project_id = $('#left_menu div.project.active').attr("id");
    task_id = $(e.target).parents(".card").attr("id");
    if ($(e.target).prop('checked')) {
        task_status = $(e.target).attr("value");
        rc = ipcRenderer.sendSync("changeTaskStatus", {project_id: project_id, task_id: task_id, task_status:task_status});
        if (rc) {
            if (task_status !== "Done") {
                rc = ipcRenderer.sendSync('changeTaskDate', {project_id: project_id, task_id: task_id, task_end_date: ""});
            }
            if (rc) {
                LOAD_TASKS();
            }
        }
    }
})

// イベント操作
// 対象:タスク名
// 動作:フォーカス
// 内容:編集があった場合にタスク名を変更する。
$(document).on('focus', '.task_name', (e) => {
    $(e.target).select();
    // エンター押下時にフォーカスアウト(Shift+EnterはOK)
    $(e.target).keypress(function(e){ 
        if(! event.shiftKey){
            if (e.keyCode == 13) {
                $(e.target).blur();
            }
        }
    });
});
// フォーカスアウト時に変更反映
$(document).on('blur', '.task_name', (e) => {
    // 空なら変更せず再ロード。空でなければ変更を反映する。
    if ($(e.target).val()) {
        project_id = $('#left_menu div.project.active').attr("id");
        task_id = $(e.target).parents(".card").attr("id");
        task_name = $(e.target).val();
        rc = ipcRenderer.sendSync('changeTaskName', {project_id: project_id, task_id: task_id, task_name: task_name});
        if (rc) {
            $(e.target).scrollLeft(0); // はみ出た分表示がずれるので最初の位置に戻す
        }
    }
    LOAD_TASKS();
});

// イベント操作
// 対象:タスク詳細
// 動作:入力
// 内容:入力に合わせてサイズを調整する。
$(document).on('input', 'textarea', (e) => {
    const minHeight = convertRemToPx(5); // 5rem
    $(e.target).height(minHeight)
    if (e.target.scrollHeight > e.target.clientHeight){
        $(e.target).height(e.target.scrollHeight);
    }
});

// イベント操作
// 対象:タスク詳細
// 動作:内容が修正されたとき
// 内容:タスク詳細を変更する。
$(document).on('change', 'textarea', (e) => {
    project_id = $('#left_menu div.project.active').attr("id");
    task_id = $(e.target).parents(".card").attr("id");
    var task_detail = $(e.target).val();
    rc = ipcRenderer.sendSync('changeTaskDetail', {project_id: project_id, task_id: task_id, task_detail: task_detail});
    LOAD_TASKS();
});

// イベント操作
// 対象:タスク開始日
// 動作:内容が修正されたとき
// 内容:タスク開始日を変更する。
$(document).on('change', '.date_input.Start', (e) => {
    project_id = $('#left_menu div.project.active').attr("id");
    task_id = $(e.target).parents(".card").attr("id");
    var task_start_date = $(e.target).val();
    rc = ipcRenderer.sendSync('changeTaskDate', {project_id: project_id, task_id: task_id, task_start_date: task_start_date});
});
// イベント操作
// 対象:タスク期日
// 動作:内容が修正されたとき
// 内容:タスク期日を変更する。
$(document).on('change', '.date_input.Due', (e) => {
    project_id = $('#left_menu div.project.active').attr("id");
    task_id = $(e.target).parents(".card").attr("id");
    var task_due_date = $(e.target).val();
    rc = ipcRenderer.sendSync('changeTaskDate', {project_id: project_id, task_id: task_id, task_due_date: task_due_date});
});
// イベント操作
// 対象:タスク完了日
// 動作:内容が修正されたとき
// 内容:タスク完了日を変更する。
$(document).on('change', '.date_input.End', (e) => {
    project_id = $('#left_menu div.project.active').attr("id");
    task_id = $(e.target).parents(".card").attr("id");
    var task_end_date = $(e.target).val();
    rc = ipcRenderer.sendSync('changeTaskDate', {project_id: project_id, task_id: task_id, task_end_date: task_end_date});
});

// イベント操作
// 対象:サブタスク追加ボタン
// 動作:シングルクリック
// 内容:最後尾に新規サブタスクを追加する。
$(document).on('click', '#add_subtask', (e) => {
    project_id = $('#left_menu div.project.active').attr("id");
    task_id = $(e.target).parents(".card").attr("id");
    rc = ipcRenderer.sendSync('addSubtask', {project_id: project_id, task_id: task_id});
    LOAD_TASKS();
});

// イベント操作
// 対象:サブタスク削除ボタン
// 動作:シングルクリック
// 内容:選択したタスクを削除する。
$(document).on('click', '#delete_subtask', (e) => {
    project_id = $('#left_menu div.project.active').attr("id");
    task_id = $(e.target).parents(".card").attr("id");
    subtask_id = $(e.target).parents(".checkbox").attr("id");
    rc = ipcRenderer.sendSync("confirm", {title: "サブタスク削除", message: "削除しますか?"});
    if (rc) {
        res = ipcRenderer.sendSync("deleteSubtask", {project_id: project_id, task_id: task_id, subtask_id:subtask_id});
        LOAD_TASKS();
    }
});

// イベント操作
// 対象:サブタスク名
// 動作:フォーカス
// 内容:編集があった場合にタスク名を変更する。
$(document).on('focus', '.subtask_name', (e) => {
    $(e.target).select();
    // エンター押下時にフォーカスアウト(Shift+EnterはOK)
    $(e.target).keypress(function(e){ 
        if(! event.shiftKey){
            if (e.keyCode == 13) {
                $(e.target).blur();
            }
        }
    });
});
// フォーカスアウト時に変更反映
$(document).on('blur', '.subtask_name', (e) => {
    // 空なら変更せず再ロード。空でなければ変更を反映する。
    if ($(e.target).val()) {
        project_id = $('#left_menu div.project.active').attr("id");
        task_id = $(e.target).parents(".card").attr("id");
        subtask_id = $(e.target).parents(".checkbox").attr("id");
        subtask_name = $(e.target).val();
        rc = ipcRenderer.sendSync('changeSubtaskName', {project_id: project_id, task_id: task_id, subtask_id: subtask_id, subtask_name: subtask_name});
        if (rc) {
            $(e.target).scrollLeft(0); // はみ出た分表示がずれるので最初の位置に戻す
        }
    }
    LOAD_TASKS();
});


// イベント操作
// 対象:サブタスク状態チェックボックス
// 動作:シングルクリック
// 内容:タスク状態を変更する。
$(document).on('click', '.checkbox_input', (e) => {
    project_id = $('#left_menu div.project.active').attr("id");
    task_id = $(e.target).parents(".card").attr("id");
    subtask_id = $(e.target).parents(".checkbox").attr("id");
    subtask_checked = $(e.target).prop('checked');
    rc = ipcRenderer.sendSync("changeSubtaskChecked", {project_id: project_id, task_id: task_id, subtask_id: subtask_id, subtask_checked:subtask_checked});
    if (!rc) {
        $(e.target).prop('checked', !subtask_checked);
    } else {
        LOAD_TASKS();
    }
})

// イベント操作
// 対象:タスク名
// 動作:マウスオーバー
// 内容:ツールチップを表示する。
$(document).on('mouseover', '.task_name', (e) => {
    if ($(e.target).parent().width() < e.target.scrollWidth) {
        var task_name = $(e.target).val();
        $(e.target).closest(".card_title").find(".task_name_toolchip").text(task_name);
        $(e.target).closest(".card_title").find(".task_name_toolchip").css("display", "block");
        $(e.target).on('mouseleave', () => {
            $(e.target).closest(".card_title").find(".task_name_toolchip").css("display", "none");
        })
    }
});
// イベント操作
// 対象:サブタスク名
// 動作:マウスオーバー
// 内容:ツールチップを表示する。
$(document).on('mouseover', '.subtask_name', (e) => {
    if ($(e.target).parent().width() < e.target.scrollWidth) {
        var subtask_name = $(e.target).val();
        $(e.target).closest(".checkbox").find(".subtask_name_toolchip").text(subtask_name);
        $(e.target).closest(".checkbox").find(".subtask_name_toolchip").css("display", "block");
        $(e.target).on('mouseleave', () => {
            $(e.target).closest(".checkbox").find(".subtask_name_toolchip").css("display", "none");
        })
    }
});

// イベント操作
// 対象:プロジェクト名
// 動作:シングルクリック
// 内容:タスク一覧を再読み込みする。
$(document).on('click', '#left_menu div.project', () => {
    LOAD_TASKS();
});

8
7
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7