Webデスクトップの制作 #04 ウィンドウの配置と移動

2024/10/11

アプリケーション

t f B! P L
eyecatch ウィンドウが固定表示になっているので、ちゃんとデスクトップ・ウィンドウとして操作できるように、今回は、配置と移動処理を構築していきます。

デモ

アイコンをクリックして表示されるウィンドウが移動できるようになりました。 複数のウィンドウを起動する時に、座標を少しずらして表示するのと、選択したウィンドウが一番手前に来る処理を入れています。

ソースコード

index.html

※ index.htmlは前回と同じです。 <meta charset="utf-8"> <link rel="stylesheet" href="style.css"> <script type="module" src="main.js"></script> <div id="desktop"> <header> <a class='logo' href='./'> Logo </a> <ul class="menu"> <li> <a href='#news'> 最新情報 </a> </li> <li> <a href='#contact' data-logined> お問い合わせ </a> </li> <li> <label for="menu"> メニュー </label> <ul> <li> <a class='sort-icon'> アイコン整列 </a> </li> <li> <a href='#link'> Links </a> </li> </ul> </li> </ul> <div class='time'><span class='ymd'></span><span class='his'></span></div> <label for='menu_toggle'> <div class='hamburger'> <span></span> </div> </label> </header> <main class='desktop'> <div class="icon"><img src="img/set-up-svgrepo-com.png"><p class="name">Setting</p></div> <div class="icon"><img src="img/gift-svgrepo-com.png"><p class="name">Gift</p></div> <div class="icon"><img src="img/like-svgrepo-com.png"><p class="name">Heart</p></div> <div class="icon"><img src="img/the-internet-svgrepo-com.png"><p class="name">World</p></div> </main> </div>

style.css

※ ほんの少しだけ変更をしています。 #desktop{ --header-size : 50px; --main-size : calc(100% - var(--header-size)); --color-bg1 : #6bd8e5; --color-bg2 : #98cead; width : 100%; height:50vh; min-height: 300px; box-shadow:4px 4px 20px rgba(0,0,0,0.5); display:flex; flex-direction:column; gap:0; } #desktop, #desktop *{ white-space:normal; } #desktop, #desktop *, #desktop *::before, #desktop *::after{ -webkit-box-sizing : border-box; -moz-box-sizing : border-box; -o-box-sizing : border-box; -ms-box-sizing : border-box; box-sizing : border-box; -webkit-user-select : none; -moz-user-select : none; -ms-user-select : none; user-select : none; } #desktop main{ height : var(--main-size); position:relative; z-index:1; background: linear-gradient(-45deg, var(--color-bg1), var(--color-bg2)); overflow:hidden; } #desktop header{ width:100%; height : var(--header-size); display : flex; gap:20px; align-items : center; padding:0 10px; background-color:white; position:relative; z-index:100; } #desktop header .time{ padding-right:10px; } #desktop header .time *{ font-size:0.8em; display:block; height:30%; text-align:right; } #desktop header .time > *{ display:block; margin:0; } #desktop .menu{ --hover-color: #FDD; height:100%; margin-left:auto; display:flex; align-items:center; gap:10px; } #desktop .menu li{ height:100%; padding:5px; display:flex; gap:2px; align-items:center; justify-content:center; cursor:pointer; position:relative; } #desktop .menu *{ color : black; text-decoration:none; } #menu .icon{ width:20px; height: 20px; fill:black; vertical-align:middle; } #desktop label[for='menu_toggle']{ display:none; } /** * サブメニュー */ #desktop .menu > li > ul{ display:flex; flex-direction:column; background-color:white; min-width:100px; position:absolute; top:100%; left:50%; transform:translateX(-50%); margin:0; padding:0; } #desktop .menu > li:has(ul)::after{ content:""; display:inline-block; width:0.5em; height:0.5em; border-color:black; border-style:solid; border-width:0 1px 1px 0; transform:rotate(45deg); margin-left:4px; } #desktop .menu > li:hover{ background-color: var(--hover-color); } #desktop .menu > li:not(:hover) > ul{ display:none; } #desktop .menu > li > ul li{ height:var(--header-size); padding:5px 10px; cursor:pointer; justify-content:start; } #desktop .menu > li > ul li *{ white-space:nowrap; } #desktop .menu > li > ul li:hover{ background-color: var(--hover-color); } @media (max-width:500px){ #desktop label[for='menu_toggle']{ display:block; } #desktop header nav{ height:100%; margin:0; width:100%; } #desktop header .menu{ justify-content:flex-start; width:100%; height:100%; background-color:var(--color-body-bg); } #desktop header .menu > *{ padding:0; flex-grow:1; } } #desktop .logo{ height:100%; display:flex; align-items:center; text-decoration:none; background-color:var(--color-bg); } #desktop .logo img, #desktop .logo svg{ height:100%; fill:var(--color-01); } /** * Icon */ #desktop{ --icon-size : 50px; --icon-margin : 10px; --icon-font-size : 10px; } #desktop .icon{ --x : 0px; --y : 0px; --z : 1; --z-add : 2000; width : calc(var(--icon-size) + var(--icon-margin) * 2 ); display : flex; flex-direction: column; gap : 4px; border : 2px solid transparent; border-radius:5px; overflow : hidden; z-index : var(--z); } #desktop .icon:not([data-status="move"]){ transition-property: left,top; transition-duration: 0.3s; } #desktop .icon[data-status="move"]{ z-index : calc(var(--z) + var(--z-add)); } #desktop .icon[data-select]{ border-color:rgba(255,255,255,0.5); background-color:rgba(0,0,0,0.3); } #desktop .icon img{ display : block; margin : 0 var(--icon-margin); width : var(--icon-size); height : var(--icon-size); object-fit: contain; pointer-events:none; } #desktop .icon .name{ display : block; margin : 0; padding : 3px; width : 100%; font-size : var(--icon-font-size); text-align: center; line-height: 1.4em; word-break: break-all; overflow: hidden; display: -webkit-box; text-overflow: ellipsis; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } #desktop .icon[data-select] .name{ background-color:rgb(66, 86, 188); color:white; } /** * Window */ #desktop .window{ position:absolute; min-width: 200px; min-height:200px; display:flex; flex-direction:column; box-shadow:4px 4px 20px rgba(0,0,0,0.5); border-radius:10px; overflow:hidden; z-index:100; } #desktop .window .header{ height:30px; background-color:#DDD; cursor:move; display:flex; gap:8px; align-items:center; padding:10px; } #desktop .window .header .name{ white-space:nowrap; overflow: hidden; text-overflow: ellipsis; } #desktop .window .header .close{ margin-left:auto; width: 20px; height: 20px; cursor:pointer; background-color:white; border:1px solid black; position:relative; } #desktop .window .header .close::before, #desktop .window .header .close::after{ content:""; display:block; width:100%; height:1px; background-color:black; position:absolute; top:50%; left:50%; } #desktop .window .header .close::before{ transform:translate(-50%,-50%) rotate(45deg); } #desktop .window .header .close::after{ transform:translate(-50%,-50%) rotate(-45deg); } #desktop .window .body{ flex:1; background-color:white; overflow:auto; position:relative; z-index:1; } @media (max-width:500px){ #desktop header .time{ padding:0; } }

main.js

class Main{ window_default = { pos : { x: 80, y: 20, }, gap : { x: 30, y: 40, }, size : { w : 300, h : 200, }, z : 1000 } constructor(){ this.set_event() } get elm_main(){ return document.querySelector("main.desktop") } get window_rect(){ return this.elm_main.getBoundingClientRect() } set_event(){ window.addEventListener("click" , this.click.bind(this)) } click(e){ // アイコンをクリック const icon = e.target.closest(".icon") if(icon){ const name = icon.querySelector(".name").textContent this.view_window({ name : name }) } // windowのクローズボタンをクリック const close = e.target.closest(".window .header .close") if(close){ const elm_window = e.target.closest(".window") elm_window.parentNode.removeChild(elm_window) } // windiwをクリック const elm_win = e.target.closest(".window") if(elm_win){ this.window_sort(elm_win) } } view_window(options){ // same window don't view if(this.elm_main.querySelector(`.window[name="${options.name}"]`)){return} const elm_window = document.createElement("div") elm_window.className = "window" elm_window.name = options.name elm_window.innerHTML = `<div class="header"> <span class="name">${options.name}</span> <div class="close"></div> </div> <div class="body"></div> ` const rect = this.window_init_rect() elm_window.style.left = `${rect.x}px` elm_window.style.top = `${rect.y}px` elm_window.style.width = `${rect.w}px` elm_window.style.height = `${rect.h}px` elm_window.addEventListener("pointermove" , this.pointermove.bind(this)) this.elm_main.appendChild(elm_window) this.window_sort(elm_window) } // window移動 pointermove(e){ // マウスがクリックされていない場合は処理をしない if(!e.buttons){return} const elm_window = e.target.closest(".window") // 座標移動 const pos = this.window_move_pos({ x : elm_window.offsetLeft + e.movementX, y : elm_window.offsetTop + e.movementY, w : elm_window.offsetWidth, h : elm_window.offsetHeight, }) elm_window.style.left = `${pos.x}px` elm_window.style.top = `${pos.y}px` elm_window.style.position = 'absolute' elm_window.draggable = false elm_window.setAttribute("data-move", true) elm_window.setPointerCapture(e.pointerId) } window_init_rect(){ const windows = this.elm_main.querySelectorAll(".window:not([data-move])") const rect = { x : windows.length ? windows[windows.length-1].offsetLeft + this.window_default.gap.x : this.window_default.pos.x, y : windows.length ? windows[windows.length-1].offsetTop + this.window_default.gap.y : this.window_default.pos.y, w : this.window_default.size.w, h : this.window_default.size.h, } // 右下制御 rect.x = rect.x > this.window_rect.width - this.window_default.size.w ? this.window_rect.width - this.window_default.size.w : rect.x rect.y = rect.y > this.window_rect.width - this.window_default.size.w ? this.window_rect.width - this.window_default.size.w : rect.y return rect } window_move_pos(rect){ // 左上制限 rect.x = rect.x < 0 ? 0 : rect.x rect.y = rect.y < 0 ? 0 : rect.y // 右下制限 rect.x = rect.x > this.window_rect.width - rect.w ? this.window_rect.width - rect.w : rect.x rect.y = rect.y > this.window_rect.height - rect.h ? this.window_rect.height - rect.h : rect.y return rect } window_sort(active_window){ const windows = Array.from(this.elm_main.querySelectorAll(".window")) if(windows){ windows.sort((a,b)=>{ if(Number(a.style.getPropertyValue("z-index") || 0) < Number(b.style.getPropertyValue("z-index") || 0)){return -1} if(Number(a.style.getPropertyValue("z-index") || 0) > Number(b.style.getPropertyValue("z-index") || 0)){return +1} return 0 }) } let num = 0 for(const elm of windows){ if(elm === active_window){ elm.style.zIndex = windows.length + this.window_default.z } else{ elm.style.zIndex = num + 1 + this.window_default.z num++ } } } } switch(document.readyState){ case "complete": case "interactive": new Main() break default: window.addEventListener("DOMContentLoaded", (()=>new Main())) }

解説

まだ、Javascriptもさほど煩雑にはなっていません。 今のところかなりシンプルにかけていますね(自画自賛)。 今回のポイントは、要素のドラッグ移動と、z-indexです。 表示したウィンドウそれぞれのheader部分に、onpointermoveというイベントをセットして、このコールバックのみでウィンドウ移動を処理しています。 このシンプルな記述は、qiitaにかかれてあったものを参考にしました。 JavaScriptで要素をドラッグして移動する簡単な方法 解説などは、他のページで別の人がやっているようなので、わからない命令は、そちらを見たほうがいいかもです。

移動処理

とりあえず説明すると、次の関数がコールバック部分です。 // window移動 pointermove(e){ // マウスがクリックされていない場合は処理をしない if(!e.buttons){return} const elm_window = e.target.closest(".window") // 座標移動 const pos = this.window_move_pos({ x : elm_window.offsetLeft + e.movementX, y : elm_window.offsetTop + e.movementY, w : elm_window.offsetWidth, h : elm_window.offsetHeight, }) elm_window.style.left = `${pos.x}px` elm_window.style.top = `${pos.y}px` elm_window.style.position = 'absolute' elm_window.draggable = false elm_window.setAttribute("data-move", true) elm_window.setPointerCapture(e.pointerId) } クリックされたエレメントは、ヘッダ部分なので、まずはclosest()を使って、上位要素の.windowを選択しています。 次に座標を取得する関数を別に作って、そこに座標とサイズ情報を送って画面からはみ出さない座標を返すようにセットしました。 最後にwindowエレメントに対して、styleをセットするんですが、 elm_window.draggable = false この部分は、HTMLはもともと、AタグやIMGタグは、ドラッカブル要素と言って、画面内でドラッグすることができる要素なので、もし該当する場合を考慮して、falseをセットしています。 elm_window.setAttribute("data-move", true) この箇所では、移動したウィンドウは、複数ウィンドウを開く時に、座標取得する対象から外すようにしています。 elm_window.setPointerCapture(e.pointerId) 最後に、setPointerCaptureという関数はあまり見たことがなかったのですが、これは、イベントを継続してくれるための重要な処理で、この処理を書かないと、ウィンドウを素早く動かした時に、表示がついてこなっくなって、UIとして致命的な不具合っぽくなってしまいます。

z-index処理

アクティブなウィンドウを一番手前にする処理を書かないと、後で表示したウィンドウが永遠に手前に表示されて、使い物にならないデスクトップになってしまいます。 window_sort(active_window){ const windows = Array.from(this.elm_main.querySelectorAll(".window")) if(windows){ windows.sort((a,b)=>{ if(Number(a.style.getPropertyValue("z-index") || 0) < Number(b.style.getPropertyValue("z-index") || 0)){return -1} if(Number(a.style.getPropertyValue("z-index") || 0) > Number(b.style.getPropertyValue("z-index") || 0)){return +1} return 0 }) } let num = 0 for(const elm of windows){ if(elm === active_window){ elm.style.zIndex = windows.length + this.window_default.z } else{ elm.style.zIndex = num + 1 + this.window_default.z num++ } } } この箇所で、アクティブなウィンドウを送ると、それを一番手前に表示するようになります。 処理はそんなに複雑じゃないんですが、.windowクラスをリスト取得して、それを、z-index別にソートをします。 次にforループで、activeを除いた形でz-indexを書き直して、アクティブは、最終値をセットするようにして、1番手前に表示されるようになっています。 ちなみに、this.window_default.zは、アイコンとウィンドウの前後表示をするための、オフセット値です。

あとがき

ますますデスクトップっぽくなっていますが、実はまだまだです。 ウィンドウのサイズ変更もできていなければ、アイコンをドラッグできてもいません。 そもそも、アイコンをjson格納しているのを、動的に変更するにはどうすればいいかを検討する必要があります。 ユーザビリティとカスタマイザビリティは、もしかすると、不具合に繋がる可能性もあるので、仕様設計は非常に重要な開発ポイントですね。

人気の投稿

このブログを検索

ごあいさつ

このWebサイトは、独自思考で我が道を行くユゲタの少し尖った思考のTechブログです。 毎日興味がどんどん切り替わるので、テーマはマルチになっています。 もしかしたらアイデアに困っている人の助けになるかもしれません。

ブログ アーカイブ