SPA(Single Page Application)のためのJavaScript入門 疑似フレームウインドウの作成


1.はじめに

 入門とはいっても、さすがにJavaScriptの基本文法やCSSの知識は必要となる。今回も以前の記事と同じようにフレームワークやライブラリは一切使用しない。そしてIE11でも動くレガシーな文法しか使用しない。

 前回の記事でSPAのアプリケーションを作るのに高度な技術はいらないという話を書いたが、あれは高度な技術がいらないだけで基本的な技術はいるのである。ちょっとした座標計算(足し算と引き算は)は高度な技術ではないというだけである。3Dの座標計算のように角度やベクトルの計算のために三角関数を使うこともなければ、ポリゴンの表裏や接触判定のために外積や内積を使う必要も無いのである。


2.何を作るのか

 入門記事である。ならば実用的で簡単なものがいい。WEBのUIで問題になるのはユーザ入力部分である。ユーザ入力は特定の場所に表示しっぱなしにしておくと、入力したいときにわざわざその場所まで移動しなければならない。これをポップアップでコンテンツの上に重ねて表示できればいい。しかしそうすると今度は入力中に下のコンテンツを見たいという、わがままボディをチラ見したいワガママさんの要望が上がってくる。ポップアップの位置やサイズをユーザが動かせるようにする必要が出てくるのだ。

 そこで必要になるのがウインドウシステムである。しかし以前に私が作ったものは機能をつけすぎて、複雑極まりないものになってしまった。これではいけない。初心に立ち戻って最小コードでなんとかしよう。入門のレベルに抑えるため、できる限りコードからは余計なものは省いた。


3.とりあえずこんなのを作る

https://mofon001.github.io/JavaScriptVirtualWindow/Test03.html

シーケンス+011536829364899.gif

 ブラウザの中に疑似ウインドウを表示する。一見難しそうに思えるが、必要なことを必要な分だけ記述するだけだ。重要なのは基本をしっかり押さえておくことであって、そこに高度な技術はいらないのだ。


4.実装の課程


4.1 はるばる来たよ箱だけ

 まずは箱を表示する。

image1536804145653.png


Test01.html

<!DOCTYPE html>

<html>

<head>
<meta charset="UTF-8" />
<title>JavaScriptTest</title>
<script type="text/javascript">
//ウインドウ表示用ライブラリ
(function(){
WIN={};
WIN.createWindow = function() {
//ウインドウ用ノードの作成
var node = document.createElement('DIV');
//位置を指定
node.style.position = 'absolute';
//境界の区別用に背景色の設定
node.style.backgroundColor = '#FFFF00';
//位置指定メソッド
node.setPos = function (x, y) {
this.style.left = parseInt(x) + 'px';
this.style.top = parseInt(y) + 'px';
}
//サイズ指定メソッド
node.setSize = function (width, height) {
this.style.width = parseInt(width) + 'px';
this.style.height = parseInt(height) + 'px';
}
//ノードを本文へ追加
document.body.appendChild(node);
//ノードを戻す
return node;
}
})();

//サンプルのメイン処理
addEventListener("DOMContentLoaded", Main);
function Main() {
//ウインドウの作成
var win = WIN.createWindow();
win.setSize(200, 100);
win.setPos(32, 64);
win.textContent = "ウインドウテスト";

var win2 = WIN.createWindow();
win2.setSize(100, 200);
win2.setPos(250, 130);
win2.textContent = "ウインドウテスト2";
}
</script>
</head>
<body>
</body>
</html>


 WIN.createWindowというメソッドを作り、DIVのノードに対し位置とサイズを指定する機能を追加している。色が黄色でとち狂ったセンスだが、そこは気にしてはいけない部分である。表示が確認できればいいのだ。node.style.positionがabsoluteになっているが、外的影響を遮断したいのならfixedを使うものありだ。absoluteは相対位置指定となるので、親子関係で位置を追従させたい場合はこちらの指定となる。

 ちなみにWEB上のabsoluteの解説は誤解を招くものが多い。それらはabsoluteとrelativeを並べて絶対位置と相対位置と書き分けている。しかし何を基準にするかによってこの定義が変わるのだ。

属性
クライアント領域
基準ボックス

fixed
絶対
-

absolute
相対
絶対

relative
相対
相対

 基準ボックスはノードのpositionにstatic以外を指定すると更新され、親子関係で一番近い上位ノードが基準ボックスとなる。どこが基準ボックスなのかはノードのoffsetParentプロパティで取得できる。


4-2 引っ張って移動する

2018.09.13-16.421536826483218.gif


Test02.html

<!DOCTYPE html>

<html>

<head>
<meta charset="UTF-8" />
<title>JavaScriptTest</title>
<script type="text/javascript">
//ウインドウ表示用ライブラリ
(function(){
addEventListener("mouseup", mouseUp);
addEventListener("mousemove", mouseMove);
WIN = {moveNode:null};
function mouseUp(e){
//移動の解除
WIN.moveNode = null;
}
function mouseMove(e){
//ノードを移動させる
if(WIN.moveNode){
var node = WIN.moveNode;
node.setPos(e.x + WIN.x,e.y+WIN.y);
if(e.preventDefault) e.preventDefault();else e.returnValue=false;
}
}
WIN.createWindow = function() {
//ウインドウ用ノードの作成
var node = document.createElement('DIV');
//位置を絶対位置指定
node.style.position = 'fixed';
//境界の区別用に背景色の設定
node.style.backgroundColor = '#FFFF00';
//位置指定メソッド
node.setPos = function (x, y) {
this.style.left = parseInt(x) + 'px';
this.style.top = parseInt(y) + 'px';
}
node.getPosX = function(){return parseInt(this.style.left);}
node.getPosY = function () {return parseInt(this.style.top);}
//サイズ指定メソッド
node.setSize = function (width, height) {
this.style.width = parseInt(width) + 'px';
this.style.height = parseInt(height) + 'px';
}
//移動に備えて、必要な情報を収集
function mouseDown(e) {
if (WIN.moveNode === null) {
WIN.moveNode = node;
WIN.x = node.getPosX() - e.x;
WIN.y = node.getPosY() - e.y;
}
}
node.addEventListener("mousedown", mouseDown);
//ノードを本文へ追加
document.body.appendChild(node);
//ノードを戻す
return node;
}
})();
//サンプルのメイン処理
addEventListener("DOMContentLoaded", Main);
function Main() {
//ウインドウの作成
var win = WIN.createWindow();
win.setSize(200, 100);
win.setPos(32, 64);
win.textContent = "ウインドウテスト";

var win2 = WIN.createWindow();
win2.setSize(100, 200);
win2.setPos(250, 130);
win2.textContent = "ウインドウテスト2";
}
</script>
</head>
<body>
</body>
</html>


 今回のソースは、最初のサンプルにマウスイベントの処理を加えたものだ。mousedownイベントが発生したら対象ノードの情報を記録。それを利用してmousemoveでウインドウの移動を行っている。最後にmouseupで移動処理を解除する。移動の処理は単純な足し算を基準点から行うだけだ。

 ちなみにmousemoveのところでイベントを止めればEDGE以外のブラウザはテキスト選択を止めることができる。EDGE対策は次のソースで行う。


4.3 フレームウインドウと重ね合わせと親子関係の処理

 項目を細かく分けて記事を書くのも面倒くさくなってきたのでまとめてやってしまう。そもそも呼吸すら面倒な性格なのだ。ヒデブ。


Test03.html

<!DOCTYPE html>

<html>

<head>
<meta charset="UTF-8" />
<title>JavaScriptTest</title>
<script type="text/javascript">
//ウインドウ表示用ライブラリ
(function(){
//イベント設定
addEventListener("mouseup", mouseUp,false);
addEventListener("touchend", mouseUp,{passive: false});
addEventListener("mousemove", mouseMove, false);
addEventListener("touchmove", mouseMove,{passive: false});
//ウインドウ制御用グローバルオブジェクト
WIN = { moveNode: null, frame: null};
//マウスとタッチイベントの座標取得処理
function getPos(e){
var p = {};
if (e.targetTouches && e.targetTouches.length) {
var touch = e.targetTouches[0];
p.x = touch.pageX;
p.y = touch.pageY;
} else {
p.x = e.x;
p.y = e.y;
}
return p;
}
//マウスが離された場合に選択をリセット
function mouseUp(e){
WIN.moveNode = null;
WIN.frame = null;
}
function mouseMove(e){
if(WIN.moveNode){
var p = getPos(e); //座標の取得
var node = WIN.moveNode; //移動中ノード
var x = node.getPosX();
var y = node.getPosY();
var width = node.getWidth();
var height = node.getHeight();
//選択されている場所によって挙動を変える
var frameIndex = parseInt(WIN.frame);
switch(frameIndex){
case 0://上
y = WIN.y + p.y - WIN.py;
height = WIN.height-(p.y - WIN.py);
break;
case 1://右
width = WIN.width + (p.x - WIN.px);
break;
case 2://下
height = WIN.height + (p.y - WIN.py);
break;
case 3://左
x = WIN.x + p.x - WIN.px;
width = WIN.width - (p.x - WIN.px);
break;
case 4://左上
x = WIN.x + p.x - WIN.px;
y = WIN.y + p.y - WIN.py;
width = WIN.width - (p.x - WIN.px);
height = WIN.height-(p.y - WIN.py);
break;
case 5://右上
width = WIN.width + (p.x - WIN.px);
y = WIN.y + p.y - WIN.py;
height = WIN.height - (p.y - WIN.py);
break;
case 6://左下
x = WIN.x + p.x - WIN.px;
width = WIN.width - (p.x - WIN.px);
height = WIN.height + (p.y - WIN.py);
break;
case 7://右下
width = WIN.width + (p.x - WIN.px);
height = WIN.height + (p.y - WIN.py);
break;
case 8://タイトル
//default: //クライアント領域(必要なければ削除可)
x = WIN.x + p.x - WIN.px;
y = WIN.y + p.y - WIN.py;
break;
}
//位置とサイズの設定
node.setPos(x,y);
node.setSize(width,height);
//移動フレーム処理時はイベントを止める
if(frameIndex < 9)
if (e.preventDefault) e.preventDefault(); else e.returnValue = false;
}
}
WIN.createWindow = function() {
//ウインドウ用ノードの作成
var node = document.createElement('DIV');
node.dataset.type = "window";
//位置を絶対位置指定
node.style.position = 'absolute'; //ここをfixedにするとオーバーラップする子ウインドウが作成できる
//境界の区別用に背景色の設定
node.style.backgroundColor = '#eeeeee';
//位置指定メソッド
node.setPos = function (x, y) {
this.style.left = parseInt(x) + 'px';
this.style.top = parseInt(y) + 'px';
}
//座標データを返す
node.getPosX = function(){return parseInt(this.style.left);}
node.getPosY = function () {return parseInt(this.style.top);}
node.getWidth = function () { return parseInt(this.style.width); }
node.getHeight = function () { return parseInt(this.style.height); }
//サイズ指定メソッド
node.setSize = function (width, height) {
this.style.width = parseInt(width) + 'px';
this.style.height = parseInt(height) + 'px';
}
//ウインドウの重ね合わせ順位の調整
node.foreground = function(){
var parent = node.parentNode;
if (!parent)
return;
//親をフォアグラウンドに設定
var p = parent;
while(p = p.parentNode){
if (p.dataset && p.dataset.type === "window")
p.foreground();
}

//兄弟ウインドウの列挙しソート
var nodes = [].slice.call(parent.querySelectorAll('*>[data-type="window"]'));
nodes.sort(function(a,b){
if(a === node)
return 1;
if (b === node)
return -1;
return parseInt(a.style.zIndex) - parseInt(b.style.zIndex);
});
//Zオーダーの再附番
for(var i=0;i<nodes.length;i++){
nodes[i].style.zIndex = i;
}
}
//ウインドウを閉じる
node.close = function(){
if(this.parentNode)
this.parentNode.removeChild(this);
}
//移動に備えて、必要な情報を収集
function mouseDown(e) {
if (WIN.moveNode == null) {
this.foreground();
WIN.moveNode = this;
var p = getPos(e);
WIN.px = p.x;
WIN.py = p.y;
WIN.x = this.getPosX();
WIN.y = this.getPosY();
WIN.width = this.getWidth();
WIN.height = this.getHeight();
}
}
node.addEventListener("touchstart", mouseDown, { passive: false });
node.addEventListener("mousedown", mouseDown);

//ノードを本文へ追加
document.body.appendChild(node);
//クライアントノードを返す
node.getClient = function(){
return this;
}
//子ノードの追加
node.addChild = function(child){
node.getClient().appendChild(child);
}
//タイトル設定
node.setTitle = function(value){
if(node.childNodes[8]){
node.childNodes[8].textContent = value;
}
}
//フレーム追加処理
node.addFrame = function(flag){
//各サイズ
const FRAME_SIZE = 10;
const TITLE_SIZE = 24;
//タイトルの設定
var titleSize = flag?TITLE_SIZE:0;
//ノードに枠を追加
node.style.border = "solid 1px";
node.style.boxSizing = "border-box";
node.style.boxShadow = "10px 10px 10px rgba(0, 0, 0, 0.4)";
//各パーツのスタイル設定
var frameStyles = [
"position:absolute;user-select:none;cursor:n-resize; left:0px;top:-{0}px;right:0px;height:{0}px;",//上
"position:absolute;user-select:none;cursor:e-resize; top:0px;right:-{0}px;bottom:0px;width:{0}px;",//右
"position:absolute;user-select:none;cursor:s-resize; left:0px;right:0px;height:{0}px;bottom:-{0}px;",//下
"position:absolute;user-select:none;cursor:w-resize; top:0px;left:-{0}px;bottom:0px;width:{0}px;",//左
"position:absolute;user-select:none;cursor:nw-resize;left:-{0}px;top:-{0}px;width:{0}px;height:{0}px;",//左上
"position:absolute;user-select:none;cursor:ne-resize;right:-{0}px;top:-{0}px;width:{0}px;height:{0}px;",//右上
"position:absolute;user-select:none;cursor:sw-resize;left:-{0}px;bottom:-{0}px;width:{0}px;height:{0}px;",//左下
"position:absolute;user-select:none;cursor:se-resize;right:-{0}px;bottom:-{0}px;width:{0}px;height:{0}px;",//右下
"position:absolute;cursor:move;left:0px;top:0px;right:0px;height:{1}px;background-color:#aabbee;overflow:hidden",//タイトル
"position:absolute;left:0px;top:{1}px;right:0px;bottom:0px;overflow:hidden",//クライアント領域
];
//フレームとタイトル、クライアント領域の作成
for (var i = 0; i < frameStyles.length; i++) {
var frame = document.createElement('div');
frame.style.cssText = frameStyles[i].replace(/\{0\}/g, FRAME_SIZE).replace(/\{1\}/g, titleSize);
frame.dataset.index = i;
node.appendChild(frame);
//フレームクリックイベントの処理
function onFrame(e){
if (WIN.frame == null)
WIN.frame = this.dataset.index;
//EDGEはここでイベントを止めないとテキスト選択が入る
if(WIN.frame<9)
if (e.preventDefault) e.preventDefault(); else e.returnValue = false;
}
frame.addEventListener("touchstart", onFrame, { passive: false });
frame.addEventListener("touchend", function () { WIN.frame = null; }, { passive: false });
frame.addEventListener("mousedown", onFrame, false);
frame.addEventListener("mouseup", function () { WIN.frame = null; }, false);
}
//クライアント領域の取得を書き換える
node.getClient = function () {
return this.childNodes[9];
}

}
//新規ウインドウをフォアグラウンドにする
node.foreground();
//ノードを戻す
return node;
}
})();

//ウインドウ機能を使うメイン処理
addEventListener("DOMContentLoaded", Main);
function Main() {
//フレームウインドウの作成(タイトルあり)
var win = WIN.createWindow();
win.setSize(400, 300);
win.setPos(32, 64);
win.addFrame(true);
win.setTitle("ウインドウ1");
win.getClient().textContent = "ウインドウテスト";
//フレームウインドウの作成(タイトルあり)
var win2 = WIN.createWindow();
win2.setSize(200, 200);
win2.setPos(50, 30);
win2.addFrame(true);
win2.setTitle("ウインドウ1の子");
win2.getClient().textContent = "ウインドウテスト2";
win.addChild(win2);
//フレーム無しのウインドウの作成
var win3 = WIN.createWindow();
win3.setSize(100, 200);
win3.setPos(350, 30);
win3.getClient().textContent = "ウインドウテスト3 フレーム無し";
//フレームウインドウの作成(タイトルあり)
var win = WIN.createWindow();
win.setSize(300, 200);
win.setPos(0, 0);
win.addFrame(true);
win.setTitle("ウインドウ");
win.getClient().textContent = "ウインドウテスト4";
}
</script>
</head>
<body>
</body>
</html>


 このソースは記事の最初に張り付いている実行結果のコードだ。少々長くなってしまったが、極力無駄は部分は削っている。

 まずは追加したイベントから紹介する。touchstart、touchend、touchmoveの三種類だ。タッチパネルの使用を考慮した場合、それらのイベントを処理する必要がある。それぞれmousedown、mouseup、mousemoveに置き換えて考えると簡単だが、座標の取得方法が異なる。タッチパネルにはマルチタッチというのが存在するので、このプログラムでは最初にタッチされた部分を座標として採用している。ということでスマートフォンなどでも仮想ウインドウの移動に対応できるのだが、操作しづらいことこの上ない。センスのある人は使いやすく効果的なジェスチャーを導入してほしい。

 そして今回の重要な部分、サイズ変更機能付きのフレーム作成だ。ここの動作はWindowsのウインドウのフレームの真似をして作っている。フレームは枠を1pxで作り、その周りに見えない10pxのオーラを展開している。透明のノードで囲っている状態だ。どのようなstyleが必要かはframeStylesのデータを見てほしい。この透明のオーラの力でマウスのイベントを取得したり、カーソルの形状を変えたりする。

 その他、z-indexを使ったウインドウの重ね合わせ処理も必要な項目となる。フォアグラウンドに回ったウインドウを前面に持ってくる処理だ。mousedownイベント発生時にz-indexを再計算すればいいだけの処理なのだが、常に最前面のウインドウを作ったり優先順位の条件を追加する場合は、ソートの条件に色々と手を入れる必要がある。

 ちなみにフレームを持たないウインドウをデフォルトにしているのは訳がある。このウインドウを元にしてツリービューやリストビュー等を実装する。そうやっていろいろな機能を持ったウインドウシステムを構築していくことができるのだ。

 また、今回はコードをシンプルにするために入れていないが、子ウインドウのレイアウト処理も必要になってくる。私の自作ライブラリだと.NETのWindows.Formsのようなドッキングスタイルを指定するレイアウト方式を採用している。これをやるためには親ウインドウのサイズ変更に連動して、子ウインドウの位置を再計算しなければならない。こうやって処理を増やしていくと、いつの間にかコードが肥大化していく。まあ、それはもう諦めるしかないだろう。


まとめ

 SPAでWebアプリを作成するのは、感覚的にはネイティブアプリを作成するのと同じなのだ。部分部分の動作を自分でガシガシ書いていくことになる。そして基本をしっかり押さえれば、大抵のことはなんとかなる。さあ、車輪の再発明を恐れず突っ走っていこう。