ODD CODES 野地 剛のwebデザインとか音楽とか

imgタグを量産してWeb上お絵かきソフトを作ってみたらブラウザが爆発した話

トップイメージ

皆様、いかがお過ごしであろうか? お久しぶりの野地である。

1月は本業が忙してブログどころではなかったのだが、ようやく暇ができたのでまたブログを再開することにした。いつも読んでくれる方々、申し訳ない&お待たせしました。

さて、そんな間に今まで書いたブログを見直していたのだが、やはり、文章が長すぎて文章を読むのが辛い! と書いた本人ですら思ったので、今回からはもっと短い文章でブログをまとめたいと思う。

基本、モノはあればあるほど良いってワケではないので、内容濃度はそのままに、新しい2016年版ODD CODESを楽しんでいただければ幸いである。

あ、それと、あけましておめでとうごさいます(激遅)。

目次

新年一発目の記事は、ブラウザを爆発させてしまうことが発覚し、泣く泣く開発を終了した自作ツールを紹介しよう。

今の時代、お絵かきツールをWebブラウザ上で作るならばCanvas一択なのは言うまでもない。

が、imgタグをボボボボボと画面に量産すれば案外お絵かきツールぐらいいけんじゃね? と思って作り始めたこのツール。

実際に作って遊んでみたら見事に愛用しているFirefox君が呼吸を止めた(別に壊れるわけではなく、応答を停止するだけなのでご安心を)ので、開発を断念した。

とはいえ、せっかく作ったのなら、誰かに見せたいもの。ということでブログの記事にしてみた。実際に遊んで笑ってやって欲しい。

  1. とりあえず、ツールを見てくれ
  2. で、動いた?
  3. カーソルを任意の画像(サイズ制限無し)に変える方法
  4. CSS3のfilterについてのアレコレ
  5. レイヤー機能をjQueryで実装
  6. ブラウザ上でCtrl+z系ショートカットを実装
  7. まとめ

とりあえず、ツールを見てくれ

失敗作ながらもGitHubにも載せてみた。そのリポジトリがコチラ。

https://github.com/Go-Noji/oekaki

サイトとして見られるURLがコチラ。

http://go-noji.github.io/oekaki/

左側が操作盤で、右側が描画領域である。

まず、Photoshopで言うところのブラシだが、一番左上にあるブラシ画像をクリックするとその画像のブラシに持ち替えることができる。

初期状態では自分が作った適当な画像しかないが、ボックスの下にあるurl入力フォームに画像のURLを打ち込んでAddボタンを押せば、Web上にある画像をいくらでも追加できる。画像サイズもアイコンサイズから巨大なものまで制限はない。常時ブラシボックスの最後にあるrndブラシを選択すれば、ブラシが素早くランダムに切り替わるランダムモードに変化。

ブラシを選んだら右の描画領域に絵を描いていくのだが描き方には二種類あり、クリックでスタンプのように画像を単発で置く方法と、Shiftキーを押している間だけカーソルの軌跡をなぞるように画像を配置する方法がある。

なお、デフォルトではカーソルは普通の矢印だが、操作盤にあるCursor modeのラジオボタンをdefからGhostに変えると半透明のブラシ画像となるので好みに応じて使い分けて欲しい。

しかし、このままだとまともに絵が描けないので、操作盤のsize
blur
opacity
saturate
brightness
hue-rotate
を弄ってブラシの性質を変えていく。それぞれ、大きさ、ぼかし、透明度、彩度、明るさ、色相に対応しているので、これらで画像をカスタマイズして絵を描こう(特にblurを設定するとブラシっぽくなる)。背景色はBack colorで変更可能だ。

さらに、このツールは普通デスクトップアプリによくある戻る、進む機能も搭載しているので、操作盤のUndo,RedoボタンやCtrl+zや、Ctrl+Shift+zで作業を巻き戻したり、やっぱり進めたりすることができる。

操作盤の一番下にはレイヤーの操作エリアも設けてある。操作エリアの右上にある+ボタンで新規レイヤーを作成し、ボタンで選択中のレイヤーを削除。レイヤーに描画した内容は各レイヤーパネルの左にあるVisibleボタンで表示・非表示切り替えができ、レイヤーの名前もName部分をクリックすることで変更可能。Numにはそのレイヤーに描画したimgタグの数が表示され、一番右のup,downで重なり順も変えられる。

以上がこのツールの機能なので、絵心のあるひとは是非遊んでみて欲しい。

で、動いた?

途中から重くてお絵かきどころじゃなくなったのではないだろうか。

WebGLをも動かす処理能力を持つ現代のブラウザですら、何千何万という画像の処理はキツかったようだ。無念。

しかし、いかに作ったモノがボツになろうと、作った分だけ新しい発見があるというもの。

代わりに今回得たものを以下に書いていこうと思う。

以下のコードは実際にアプリに使われているコードを説明のために大分省略+簡略化している。実際のコードが欲しい方は、上記のGitHubを参照してほしい。

カーソルを任意の画像(サイズ制限無し)に変える方法

今回作ったツールでは、ghostモードでブラシとなる画像がカーソルの代わりに半透明で表示されるが、これはCSSにおけるcursorプロパティによるモノではない。

手軽にやるならカーソル用の画像を作成してCSSのみで実装したほうが早い。

CSS
div{
    cursor: url(images/hoge.png),auto;
}
/*
これでこのサイトのdiv上でカーソルがhoge.pngになる。
ただし画像urlを指定する場合、カンマ区切りで一つ以上のブラウザが用意しているデフォルト値(autoとかpointerとか)を指定しないと動かない
詳細→ https://developer.mozilla.org/ja/docs/Using_URL_values_for_the_cursor_property
*/

が、それだと後述するCSS3のフィルターが効かない点、ブラウザによって対応がまちまちで最悪表示されない点、サイズの大きすぎる画像は無視される点でデメリットがある。

なので、このツールではあらかじめ用意しておいたghostPointというIDを付けたimgタグを一つ用意し、それをカーソルが描画領域内で動いた時だけカーソルの位置に移動させるという処理をさせている。

CSS
#field{
    position: relative;
    cursor: none;/*カーソルを表示させない*/
}
#ghostPoint{
    position: absolute;
    z-index: 99;
}
JS
//jQuery使用
fieldX = $('#field').offset().left;
fieldY = $('#field').offset().top;

//#fieldが画面の左上ピッタリにない時用の値
$('#field').mousemove(function(e){
    var pl = e.clientX - brushSize/2 -fieldX;
    var pt = e.clientY - brushSize/2 -fieldY;
    $('#ghostPoint').css({
        'left':pl,
        'top':pt
    });
});

ってな感じでCSS、jsを書けば#ghostPointがあたかもカーソルのようにふるまってくれる。

CSS3のfilterについてのアレコレ

詳しくは以下参照

パッと見分かり易い
http://www.webcyou.com

サンプル付き
http://qiita.com

このCSS filterは機能ごとにプロパティが分かれておらず、filterというプロパティに対して複数の値を設定することでそれぞれの効果を得る。

CSSのみで実装するなら問題にならないが、今回はjsから動的に組み込む必要があったのでappendでimgタグを挿入する際にstyle属性へ無理矢理変数をぶち込むという手荒なことをしている。

JS
//jQuery使用
function afterImage(e){
    //超手荒
    if(e.ShiftKey){
        $('#layer' + nowLayer).append('<img src="'+ brush +'" class="afterImage action'+ actionCount +'" style="left: '+ pl +'px;top: '+ pt +'px;-webkit-filter: blur(' + brushBlur + 'px) saturate(' + brushSaturate +'%) brightness(' + brushBrightness + ') hue-rotate(' + brushHue + 'deg);-moz-filter: blur(' + brushBlur + 'px) saturate(' + brushSaturate +'%) brightness(' + brushBrightness + ') hue-rotate(' + brushHue + 'deg);-o-filter: blur(' + brushBlur + 'px) saturate(' + brushSaturate +'%) brightness(' + brushBrightness + ') hue-rotate(' + brushHue + 'deg);-ms-filter: blur(' + brushBlur + 'px) saturate(' + brushSaturate +'%) brightness(' + brushBrightness + ') hue-rotate(' + brushHue + 'deg);filter: blur(' + brushBlur + 'px) saturate(' + brushSaturate +'%) brightness(' + brushBrightness + ') hue-rotate(' + brushHue + 'deg);"  width=" ' + brushSize + ' "  height=" ' + brushSize + '" />');
    }
}

$(function(){
    $('#field').mousemove(function(e){
        afterImage(e);
    });
})

もちろん対象が単体or同一のセレクタを持っていれば、jQueryのcssメソッドで変更することも可能だ。

ただしこのツールのように、このCSS filterを乱用(特に、blurを設定した画像を重ねまくる)するとブラウザのパフォーマンスを著しく損なう。

動的に変更することのない画像などに使うべきではないし、映像的な仕掛けが欲しい場合はそれこそCanvasの機能をフル活用するほうが良い。

レイヤー機能をjQueryで実装

Photoshopとかでさんざんお世話になっているレイヤー機能だが、Web上でもCSSのpositionz-indexをjsで動的に駆使すれば実現可能だ。

ただ、そんなWebアプリ的機能を作るのであればAngularJS等のフレームワークを使用するのが良作だろう。

だが自分はまだそれらフレームワークの勉強をほぼしていないのもあり、今回は無理矢理Jqueryで作成してみた。もし、jQueryでレイヤー機能が欲しい! という奇特な方がいれば、是非役立ててほしい。

HTML
<!--操作パネル-->
<div class="layerPanel">
    <h2>Layer</h2><input id="layerPlus" class="layerPlusMinus" type="button" value="+" /><input id="layerMinus" class="layerPlusMinus" type="button" value="-" />
    <ul id="layerBox" class="clearfix">
        <li id="lHead" class="clearfix">
            <p class="layerVisibilityH">Visible</p><p class="layerNameH">Name</p><p class="imgNumH">Img Num</p><p class="upDownH">UpDown</p>
        </li>
        <li id="l1" class="activeLayer layerList clearfix">
            <p class="layerVisibility on"></p><p class="layerName">layer1</p><input class="inputLayerName" type="text" /><p class="imgNum">0</p><p class="upDown"><input class="up" type="button" value="up" /><input class="down" type="button" value="down"></p>
        </li>
    </ul>
</div>
<!--実際のレイヤー領域-->
<div id="field">
    <img id="ghostPoint" />
    <div id="layer1" class="layer" style="z-index:1;"></div>
</div>
JS
var nowLayer = 1; //現在選択中のレイヤー番号
var highLayer = 1; //一番上のレイヤー番号

function layerVisibility(target){//レイヤーの表示・非表示用関数
//ターゲットとなるレイヤーのon,offクラスでオンオフを判断
if(target.hasClass('on')){
    target.removeClass('on');
    target.addClass('off');

    //クリックされた要素の親要素である.layerListに設定されているidから対象となるレイヤーのid番号を入手
    var num = target.parents('.layerList').attr('id').replace(/l/g,'');
    $('#layer' + num).css({
        'visibility':'hidden'
    });
}
else{
    target.removeClass('off');
    target.addClass('on');
    var num = target.parents('.layerList').attr('id').replace(/l/g,'');
    $('#layer' + num).css({
        'visibility':''
    });
    }
}

$(function(){
    //新規レイヤーを追加
    $('#layerPlus').click(function(){
        highLayer++;

        //選択中のレイヤーからactiveLayerクラスを外し、新規レイヤーを選択中にする。
        nowLayer = highLayer;
        $('#layerBox').find('.layerList').each(function(){
            $(this).removeClass('activeLayer');
        });
        $('#lHead').after('<li id="l' + nowLayer + '" class="activeLayer layerList clearfix"><p class="layerVisibility on"></p><p class="layerName">layer' + nowLayer + '</p><input class="inputLayerName" type="text" /><p class="imgNum">0</p><p class="upDown"><input class="up" type="button" value="up" /><input class="down" type="button" value="down"></p></li>');
    });

    //選択中のレイヤーを削除
    $('#layerMinus').click(function(){
        var nothing = nowLayer;
        $('#layer' + nowLayer).remove();
        $('#l' + nowLayer).remove();

        //一番上のレイヤーを削除した場合
        if(highLayer==nowLayer){
            highLayer--;
        }

        //一番上のレイヤーを選択状態にする
        $('#layerBox').find('.layerList').each(function(){
            nowLayer = $(this).attr('id').replace(/l/g,'');
            $(this).addClass('activeLayer');
            return false;
        });

        //削除したレイヤーが最後の一つだった場合、レイヤーを一つ追加する
        if(nothing==nowLayer){
            highLayer = 1;
            $('#lHead').after('<li id="l1" class="activeLayer layerList clearfix"><p class="layerVisibility on"></p><p class="layerName">layer1</p><p class="imgNum">0</p><p class="upDown"><input class="up" type="button" value="up" /><input class="down" type="button" value="down"></p></li>');
        }
    });

    //レイヤーを選択する
    $('#layerBox').on('click','.layerList',function(){
        nowLayer = $(this).attr('id').replace(/l/g,'');
        $('#layerBox').find('.layerList').each(function(){
            $(this).removeClass('activeLayer');
        });
        $(this).addClass('activeLayer');
    });

    //レイヤーを一つ手前に持ってくる
    $('#layerBox').on('click','.up',function(){
        var list = $(this).parents('.layerList');

        //もし一番上のレイヤーだったら処理を中断
        if(list.prev().attr('id')=='lHead'){
            return false;
        }
        var num = list.attr('id').replace(/l/g,'');
        var nextNum = list.prev().attr('id').replace(/l/g,'');
        var thisStyle = $('#layer' + num).attr('style');
        var targetStyle = $('#layer' + nextNum).attr('style');
        list.prev().clone().insertAfter(list);
        list.prev().remove();
        $('#layer' + nextNum).attr('style',thisStyle);
        $('#layer' + num).attr('style',targetStyle);
    });

    //レイヤーを一つ後ろに持ってくる
    $('#layerBox').on('click','.down',function(){
        var list = $(this).parents('.layerList');

        //もし一番下のレイヤーだったら処理を中断
        if(list.next().length==0){
            return false;
        }
        var num = list.attr('id').replace(/l/g,'');
        var prevNum = list.next().attr('id').replace(/l/g,'');
        var thisStyle = $('#layer' + num).attr('style');
        var targetStyle = $('#layer' + prevNum).attr('style');
        list.next().clone().insertBefore(list);
        list.next().remove();
        $('#layer' + prevNum).attr('style',thisStyle);
        $('#layer' + num).attr('style',targetStyle);
    });

    //レイヤーの表示・非表示を切り替える
    $('#layerBox').on('click','.layerVisibility',function(){
        var target = $(this);
        layerVisibility(target);
    });

    //レイヤーの名前を変更する
    $('#layerBox').on('click','.layerName',function(){
        var beforeName = $(this).text();

        //レイヤー名表示用のpタグを非表示にし、入力用のinputタグを表示
        $(this).next().css({
            'display':'block'
        });
        $(this).css({
            'display':'none'
        });
        $(this).next().val(beforeName);
    });

    //レイヤーの名前を確定する
    $('#layerBox').on('blur','.inputLayerName',function(){
        表示を元通りにし、入力された文字をpタグへ反映
        $(this).css({
            'display':''
        });
        $(this).prev().css({
            'display':''
        });
        var newText = $(this).val();
        $(this).prev().text(newText);
    });
});

ブラウザ上でCtrl+z系ショートカットを実装

パソコンのアプリケーションでお馴染みのCtrl+zとかのショートカット。

文字入力などに関してはブラウザが勝手にやってくれるのだが、それをjsのイベントとして取れればWebアプリの制作に役立つだろう。

今回のツールではCtrl+zと、Ctrl+Shift+zによるUndo,Redoしか実装していないが、zの部分を変えてやれば様々なショートカットに対応できるはずだ。

なお、かなり情報が古いが、chromeを除く主要ブラウザのキーコードの一覧はhttp://www.programming-magic.comに載っている(多分、今も変わっていない)ので、参考までに。

JS
$(function(){
    $('body').keydown(function(e){
        if(e.ctrlKey){//Ctrlキー(Macのコマンドキー)が押されているときのみ発動。
            if(e.keyCode=='90'){//ほぼ全てのブラウザでzのキーコードは90
                if(e.shiftKey){//同時にShiftキーが押されていたか否か
                    redo();//「やりなおし」関数実行
                    return false;
                }
                else{
                    undo();//「戻る」関数実行
                    return false;
                }
            }
        }
    });
});

まとめ

ユーザーに絵を描かすならCanvasを使おう(確定)。

とはいえ、上記で解説した以外にも他のプロジェクトに使いまわせそうな部分はあるので、作って損ではなかったかな……と思うことにする。

コードは全然流用してもらって構わないので、なんかこの部分使えそうだな、と思ったらバンバン使ってほしい。

次は、もっとマトモなアプリを完成させて記事にしたいとオモイマス……。その時まで一旦さらば。