generated at
scrapbox-suggest-container
外部プロジェクトのリンクを入力補完出来るようにするUserScriptとかで使っている補完候補を表示するwindowを作り直したやつ

実装したいこと

変えるところ
UI
CSS custom propertyを使って、外部から色などを変更できるようにする
--completion-bg
--completion-item-text-color
--completion-item-hover-text-color
--completion-item-hover-bg
focusがあたったときのアイテムの背景色
--completion-border
枠線のスタイル
--completion-shadow
影のスタイル
基本的なスタイルとDOM構造は dropdown-menu を参考にする
html
<ul class="dropdown-menu"> <li class="dropdown-item"> <a href="..."> <img src="..."></img> ... </a> </li> </ul>
Interface
Shadow DOMで閉じ込める
Custom Elementとして定義する
内部はclassで構成されているので、好きなmethod/propertyを生やせる
<link rel="stylesheet" href="を使ってstyleをつける
CSSファイルで設定できるようにした
しかしその代わりに、styleの適用にラグが発生するようになってしまった
要素が読み込まれた直後にstylesheetが読み込まれるみたい
<suggest-container>
これ
style
container.js
export const css = ` ul { position: absolute; top: 100%; left: 0; z-index: 1000; flex-direction: column; min-width: 160px; padding: 5px 0; margin: 2px 0 0; list-style: none; font-size: 14px; text-align: left; background-color: var(--completion-bg, #fff); border: var(--completion-border, 1px solid rgba(0,0,0,0.15)); border-radius: 4px; box-shadow: var(--completion-shadow, 0 6px 12px rgba(0,0,0,0.175)); background-clip: padding-box; } `;
Custom Elementの本体
script.js
import {css as containerCSS} from './container.js'; customElements.define('suggest-container', class extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({mode: 'open', delegatesFocus: true}); shadow.innerHTML = `<style>${containerCSS}</style> <ul id="box"></ul>`; this.rendered = true; } connectedCallback() { this.hide(); }
methods/properties
アイテムの追加
ほぼ同じUIなので、妥当だと思うtakker
js
suggestContainer.push({ text: () => {}, image: () => {}, onClick: () => {}, });
text image も関数を使えるようにしてみたい
ただ引数に渡すものが思いつかない……
全部実装しても思いつかなければ止めにする
onClick undefined を渡すと、選択不能フォーカス不可のアイテムになる
読み込み中のメッセージなどを表示するときに使う
アイテムの追加方法をいくつか用意しておく
末尾に挿入
script.js
push(...items) { this._box.append(..._createItems(...items)); if (this.mode === 'auto') this.show(); }
任意の場所に挿入: insert(index, ...items)
script.js
insert(index, ...items) { if (index > this.items.length - 1) throw Error(`An index is out of range.`); const itemNodes = _createItems(...items); if (index === this.items.length - 1) { this._box.append(...itemNodes); } else { const fragment = new DocumentFragment(); fragment.append(...itemNodes); this.items[index].insertAdjacentElement('afterend', fragment); } if (this.mode === 'auto') this.show(); }
先頭に挿入: pushFirst(...items)
Element.insertAdjacentElement()が何故か使えなかった
script.js
pushFirst(...items) { if (this.items.length === 0) { this.push(...items); return; } const index = this.selectedItemIndex; const fragment = new DocumentFragment(); fragment.append(..._createItems(...items)); this._box.insertBefore(fragment, this.firstItem); if (index !== -1) this.items[index + items.length].focus(); if (this.mode === 'auto') this.show(); }
アイテムの置換
focusがあたっていたらそれを維持する
script.js
replace(...items) { if (item.length === 0) throw Error('No item to be replaced found.'); const index = this.selecteItemIndex; this.clear(); if (index !== -1) this.items[index]?.focus?.() ?? this.firstItem.focus(); }
アイテムの削除
空っぽになったらwindowを閉じる
ChildNode.remove()で何故か削除できなかったので、代わりにNode.removeChlid()を使っている
任意のアイテムを削除する
script.js
pop(...indices) { indices.forEach(index => this.items[index]?.remove?.()); if (this.items.length === 0) this.hide(); }
無効なindexは無視する
全てのアイテムを削除する
13:25:25 Node.removeChlid()でも消えない?
というか、一つ飛びにアイテムが消える……takker
13:28:23 どうやらHTMLCollectionにはforEach()がないようだ
じゃななんで↓のコードはエラーが出ないんだ……?takker
謎が深まったが、非標準の関数を用いたことでバグったということはわかった
13:31:31 標準のNode.childNodesを使っても一つ飛びになる……?
仕方ない。Arrayに変換して消そう。
13:34:08 成功した。
this._box.textContent = '' でも同じ効果がある
どっちのほうがいいかな?takker
script.js
clear() { [...this.items].forEach(item => item.remove()); this.hide(); }
アイテムを取得する
13:29:54 NodeListを返してくれるNode.childNodesを使うようにした
script.js
get items() { return this._box.childNodes; } get firstItem() { return this.items[0]; } get lastItem() { return this.items[this.items.length - 1]; }
フォーカスのあたっているアイテムを取得する
property nameはdocument.activeElementにあやかったtakker.icon
もっとわかり易い名前にした
script.js
get selectedItem() { return [...this.items].find(item => item.hasFocus); }
script.js
get selectedItemIndex() { return [...this.items].findIndex(item => item.hasFocus); }
get lastSelectedItem()
最後にフォーカスのあたっていたアイテムを取得する
いずれかのアイテムにフォーカスが当たっているときは get selectedItem() と同じ
focus eventの監視をしないといけないのが面倒だなtakker
アイテムは <suggest-container-item> として取得できるようにする
フォーカスを当てたりクリックしたりするのは <suggest-container-item> に任せる
Shadow DOM中のDOMを外部に渡せるのかな?takker
できなかったら、classでwrapするなど他の方法を考えよう
フォーカスの移動
script.js
selectNext({wrap}) { const selectedItem = this.selectedItem; if (!selectedItem || (wrap && this.lastItem === selectedItem)) { this.firstItem?.select?.(); return this.selectedItem; } selectedItem.nextSibling?.select?.(); return this.selectedItem; }
script.js
selectPrevious({wrap}) { const selectedItem = this.selectedItem; if (!selectedItem) { this.firstItem?.select?.(); return this.selectedItem; } if (wrap && this.firstItem === selectedItem) { this.lastItem?.select?.(); return this.selectedItem; } selectedItem.previousSibling?.select?.(); return this.selectedItem; }
フォーカスがないときは、最後にフォーカスのあたっていたアイテムか、先頭の選択可能なアイテムにフォーカスを当てる最初の要素にフォーカスを当てる
get lastActiveItem() を実装するのが面倒そうなので
wrap: true を渡すと、アイテムの先頭/末尾まで選択していたときに、末尾/先頭に戻るようになる
defaultでは、先頭/末尾を選択しているときは何もしない
返り値として、移動先のアイテムを返す
windowの表示/非表示
アイテムが一つもないときは、自動的に非表示になる
表示位置を指定する
どういう書式で指定するかは考え中
style="top:...; left:...;" でいいや
script.js
position({top, left}) { this._box.style.top = `${top}px`; this._box.style.left = `${left}px`; this.show(); }
script.js
show() { if (this.items.length === 0) { this.hide(); return; } this.hidden = false; } hide() { this.hidden = true; }
内部実装
<suggest-container-item> にidを振って管理する
途中に新しいアイテムを挿入しても、focusが外れたりしないようにする
focus id は関係ないや
script.js
get _box() { return this.shadowRoot.getElementById('box'); }
script.js
function _createItems(...params) { return params.map(({text, image, link, onClick}) => { const item = document.createElement('suggest-container-item'); item.set({text, image, link, onClick}); return item; }); }
<suggest-container-item>
<suggest-container> のアイテム
これはCustom Elementにせず、普通に <a> でつくるだけで十分かも
<suggest-container> からCSSを制御できるようになるので便利
::part()を使えば、外部からcssを適用できる
これで行こう
:active() などを追加で指定できるのかどうかが不安……takker.icon
だめだったらShadow DOM無しで使おう
擬似要素を追加指定できるみたい
↑何もしなくても、勝手にLight DOMの親のCSSが子に受け継がれるみたい
<li> を継承して作ってもいいかも
DOMが1段減る
独自に追加したmethodが使えなくなるみたい
やめよう
style
font-size ul の方で決めなくてもいいみたい
item.js
export const css = ` a { display: flex; padding: 0px 20px; clear: both; font-size: 14px; font-weight: normal; line-height: 28px; align-items: center; color: var(--completion-item-text-color, #333); white-space: nowrap; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none; text-decoration: none; } a:hover { color: var(--completion-item-hover-text-color, #262626); background-color: var(--completion-item-hover-bg, #f5f5f5); } a:active, a:focus { color: var(--completion-item-hover-text-color, #262626); background-color: var(--completion-item-hover-bg, #f5f5f5); outline: 0; box-shadow: 0 0px 0px 3px rgba(102,175,233,0.6); border-color: #66afe9; transition: border-color ease-in-out 0.15s,box-shadow ease-in-out 0.15s; } img { height: 1.3em; margin-right: 3px; display: inline-block; } `;
attributeChangedCallback()connectedCallback()より前に呼び出される都合上、constructorでShadow DOMを初期化する必要がある
script.js
import {css as itemCSS} from './item.js'; customElements.define('suggest-container-item', class extends HTMLElement { constructor() { super(); this.rendered = false; this.configured = false; const shadow = this.attachShadow({mode: 'open', delegatesFocus: true}); shadow.innerHTML = `<style>${itemCSS}</style> <li><a id="a" tabindex="0"> <img id="icon" hidden></img> </a></li>`;
<img> <div> の間に空白を入れると、text nodeがDOMに混じってしまう
click eventは、 this.set() から登録したやつを優先させる
script.js
shadow.getElementById('a').addEventListener('click', (e) => { if (!this.onClick) return true; e.preventDefault(); e.stopPropagation(); this.click(e); }); this.rendered = true; } set({text, image, link, onClick}) { if (this.configured) return; if (!text) throw Error('text is empty.'); this.setAttribute('text', text); if (image) { if (typeof image !== 'string') throw Error('image is not a string'); this.setAttribute('src', image); } if (!onClick) { this.setAttribute('disabled', true); } else { if (typeof onClick !== 'function') throw Error('onClick is not a function.'); this.onClick = onClick; if (link) { if (typeof link !== 'string') throw Error('link is not a string'); if (!link.startsWith('https://scrapbox.io')) throw Error('link is required to be a url in scrapbox.io if needed.'); this.setAttribute('href', link); } } this.configured = true; }
Shadow DOM内部でfocus eventが発生すると、Shadow DOM自体にも :focus が付与されるが、 this.matches(':focus') では取得できないみたい
代わりに #a でチェックしてる
script.js
get hasFocus() { return this.shadowRoot.getElementById('a').matches(':focus'); } select() { this.shadowRoot.getElementById('a').focus(); } click(eventHandler, parameter) { this.onClick?.(eventHandler, parameter); } static get observedAttributes() { return ['text', 'src', 'href', 'disabled']; } attributeChangedCallback(name, oldValue, newValue) { switch(name) { case 'text': let textNode = this.shadowRoot.getElementById('a').lastChild; if (textNode.nodeName !== '#text') { textNode = document.createTextNode(newValue); this.shadowRoot.getElementById('a').appendChild(textNode); } else { textNode.textContent = newValue; } return; case 'src': const img = this.shadowRoot.getElementById('icon'); if (newValue) { img.src = newValue; img.hidden = false; } else { img.src = ''; img.hidden = true; } return; case 'href': this.shadowRoot.getElementById('a').href = newValue; return; case 'disabled': this.shadowRoot.getElementById('a').style.pointerEvents= newValue ? 'none' : ''; return; } } });
text / image / onClick の設定
set({text, image, link, onClick})
内部で属性に渡す
link scrapbox.io のリンクを渡せるようにもする
drag&dropでeditorにリンクを貼れる
フォーカスを当てる
focus()
click eventを発火させる
click()
複数のactionを登録させたいな
<C-i> を押すとアイコン記法で入力するとか
click(e, param) みたいにできるようにするのが手っ取り早いか
param に応じてやることを切り替えればいい

test code
.cursor に連動して表示するだけ
test1.js
(async () => { const promises = [ import('./test-sample-list.js'), import('./test-dom.js'), import('./test-dark-theme.js'), import('./script.js'), ]; const [{list}, {editor, cursor},] = await Promise.all(promises); const suggestBox = document.createElement('suggest-container'); suggestBox.push(...list); editor.append(suggestBox); const observer = new MutationObserver(() =>{ suggestBox.show({ top: parseInt(cursor.style.top) + 14, left: parseInt(cursor.style.left), }); }); observer.observe(cursor, {attributes: true}); })();

test codeで使うutilities
色付け
test-dark-theme.js
document.head.insertAdjacentHTML('beforeend', `<style> suggest-container { --completion-bg: #373b44; --completion-item-text-color: var(--page-text-color); --completion-item-hover-text-color: var(--page-text-color); --completion-item-hover-bg: #373b44; --completion-border: 1px solid #8888882d; } </style>`);
サンプルデータ
test-sample-list.js
export const list = [ { text: 'Loading...', image: 'https://img.icons8.com/ios/180/loading.png', }, { text: 'scrapbox', image: 'https://i.gyazo.com/7057219f5b20ca8afd122945b72453d3.png', link: 'https://scrapbox.io', onClick: () => alert('Hello, Scrapbox!'), }, ];
DOMのailias
test-dom.js
export const editor = document.getElementById('editor'); export const cursor = document.getElementsByClassName('cursor')[0];


MDN

#2021-02-01 04:13:04