generated at
scrapbox-suggest-container-3
拡張Scrapbox入力補完に使う補完windowのComponent
scrapbox-suggest-container-2をversion upした

変えるところ
アイテムのDOMを決め打ちにした
代わりに少し設定項目を増やした
Scrapboxの全文検索結果のように、説明文を入れられるようにした
アイテムを追加するmethodsを減らした
使いそうになかったので
2021-04-08
01:53:09 DOMを生成する補助関数を追加した
easyDOMgeneratorを使っている
2021-03-16
14:30:55 CSSを調節した
説明書きを表示するときに、少し文字を詰めるようにした
2021-03-15

UI
CSS custom propertyを使って、外部から色などを変更できるようにする
--completion-bg
--completion-item-text-color
--completion-item-hover-text-color
--completion-item-hover-bg
focusがあたったときのアイテムの背景色
--completion-border
枠線のスタイル
--completion-shadow
影のスタイル
Interface
container-css.js
export const css = ` ul { position: absolute; top: 100%; left: 0; z-index: 1000; flex-direction: column; min-width: 160px; max-width: 80%; max-height: 50vh; padding: 5px 0; margin: 2px 0 0; list-style: none; font-size: 14px; font-weight: normal; line-height: 28px; 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; white-space: nowrap; overflow-x: hidden; overflow-y: auto; text-overflow: ellipsis; } `;
Custom Elementの本体
script.js
import {css} from './container-css.js'; import {create} from './item.js'; customElements.define('suggest-container', class extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.innerHTML = `<style>${css}</style> <ul id="box"></ul>`; } connectedCallback() { this.hide(); }
methods/properties
アイテムの追加
ts
public push(Props: { title: string; link?: string; image?: string; description?: string; onClick: () => {}, });
onClick undefined を渡すと、選択不能フォーカス不可のアイテムになる
アイテムの追加方法をいくつか用意しておく
末尾に挿入
script.js
push(...items) { this._box.append(...items.map(props => create(props))); 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(...items.map(props => create(props))); this._box.insertBefore(fragment, this.firstItem); if (index !== -1) this.items[index + items.length].select(); if (this.mode === 'auto') this.show(); }
アイテムの置換
選択されていたらそれも維持する
使い回せるDOMは使い回す
script.js
replace(...items) { this._box.textContent = ''; this.push(...items); }
script.js_disabled(js)
replace(...items) { if (items.length === 0) { this.clear(); return; } const index = this.selecteItemIndex; if (items.length > this.items.length) { this.items.forEach((item, i) => item.set(items[i])); this._box.append(...items.slice(this.items.length).map(props => create(props))); } else { items.forEach((props, i) => this.items[i].set(props)); [...this.items].slice(items.length) .forEach(item => item.remove()); } if (index !== -1) (this.items[index] ?? this.firstItem).select(); }
アイテムの削除
空っぽになったらwindowを閉じる
ChildNode.remove()で何故か削除できなかったので、代わりにNode.removeChlid()を使っている
任意のアイテムを削除する
script.js
pop(...indices) { indices.forEach(index => this.items[index]?.remove?.()); if (this.items.length === 0) this.hide(); }
無効なindexは無視する
全てのアイテムを削除する
this._box.textContent = '' でも同じ効果がある
どっちのほうがいいかな?takker
script.js
clear() { this._box.textContent = ''; this.hide(); }
アイテムを取得する
13:29:54 NodeListを返してくれるNode.childNodesを使うようにした
script.js
get items() { return this._box.childNodes; } get selectableItems() { return [...this.items].filter(item => !item.disabled); } get firstItem() { return this._box.firstElementChild; } get lastItem() { return this._box.lastElementChild; } get firstSelectableItem() { return [...this.items].find(item => !item.disabled); } get lastSelectableItem() { return [...this.items].reverse().find(item => !item.disabled); }
フォーカスのあたっているアイテムを取得する
property nameはdocument.activeElementにあやかったtakker.icon
もっとわかり易い名前にした
script.js
get selectedItem() { return [...this.items].find(item => item.isSelected); }
script.js
get selectedItemIndex() { return [...this.items].findIndex(item => item.isSelected); }
get lastSelectedItem()
最後にフォーカスのあたっていたアイテムを取得する
いずれかのアイテムにフォーカスが当たっているときは get selectedItem() と同じ
focus eventの監視をしないといけないのが面倒だなtakker
アイテムは <suggest-container-item> として取得できるようにする
フォーカスを当てたりクリックしたりするのは <suggest-container-item> に任せる
Shadow DOM中のDOMを外部に渡せるのかな?takker
できなかったら、classでwrapするなど他の方法を考えよう
フォーカスの移動
.select というクラスをつけて擬似的に再現している
スクロールも再現している
ページの表示領域外だったらElement.scrollIntoVIew()で一気にscrollする
script.js
selectNext({wrap}) { if (!this.firstSelectableItem) return; const selectedItem = this.selectedItem; this.selectedItem?.deselect?.(); if (!selectedItem || (wrap && this.lastItem === selectedItem)) { this.firstSelectableItem?.select?.(); this._box.scroll(0, 0); } else{ selectedItem.nextSibling?.select?.(); } // アイテムが隠れていたらスクロールさせる const {top, bottom} = this.selectedItem.getBoundingClientRect(); const boxRect = this._box.getBoundingClientRect(); if (top < 0) { this.selectedItem.scrollIntoView({block: 'start'}); } else if (bottom > window.innerHeight) { this.selectedItem.scrollIntoView({block: 'end'}); } else if (top < boxRect.top) { this._box.scrollBy(0, top - boxRect.top); } else if (bottom > boxRect.bottom) { this._box.scrollBy(0, bottom - boxRect.bottom); } if (bottom > window.innerHeight) { this.selectedItem.scrollIntoView({block: 'bottom'}); } else if (bottom > this._box.getBoundingClientRect().bottom) { this._box.scrollBy(0, bottom - this._box.getBoundingClientRect().bottom); } if (this.selectedItem.disabled) this.selectNext({wrap}); }
script.js
selectPrevious({wrap}) { if (!this.firstSelectableItem) return; const selectedItem = this.selectedItem; this.selectedItem?.deselect?.(); if (!selectedItem) { this.firstSelectableItem?.select?.(); } else if (wrap && this.firstItem === selectedItem) { this.lastSelectableItem?.select?.(); this._box.scroll(0, this._box.scrollHeight); } else { selectedItem.previousSibling?.select?.(); } // アイテムが隠れていたらスクロールさせる const {top, bottom} = this.selectedItem.getBoundingClientRect(); const boxRect = this._box.getBoundingClientRect(); if (top < 0) { this.selectedItem.scrollIntoView({block: 'start'}); } else if (bottom > window.innerHeight) { this.selectedItem.scrollIntoView({block: 'end'}); } else if (top < boxRect.top) { this._box.scrollBy(0, top - boxRect.top); } else if (bottom > boxRect.bottom) { this._box.scrollBy(0, bottom - boxRect.bottom); } if (this.selectedItem.disabled) this.selectPrevious({wrap}); }
フォーカスがないときは、最後にフォーカスのあたっていたアイテムか、先頭の選択可能なアイテムにフォーカスを当てる最初の要素にフォーカスを当てる
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
import {h} from '../easyDOMgenerator/script.js'; export const suggestContainer = (...params) => h('suggest-container', ...params);
containerの要素
<suggest-container-item>
#description にテキストがあるときとないときとでCSSを切り替えられるようにするとよさそう
item-css.js
export const css = ` :host(.disabled) { cursor: not-allowed; } a { display: flex; padding: 0px 20px; clear: both; color: var(--completion-item-text-color, #333); align-items: center; -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); } :host(.selected) a{ 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; } #content { width: 100%; } :host([description]) #content { min-height: 28px; } :host([description]) #title { line-height: 100%; } #description { opacity: 0.5; font-size: 50%; line-height: 100%; overflow: hidden; text-overflow: ellipsis; } `;
item.js
import {css} from './item-css.js'; customElements.define('suggest-container-item', class extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.innerHTML = `<style>${css}</style> <li> <a id="body" tabindex="-1"> <img id="icon" hidden></img> <div id="content"> <div id="title"></div> <div id="description"></div> </div> </a> </li>`; this._body = shadow.getElementById('body'); this._icon = shadow.getElementById('icon'); this._title = shadow.getElementById('title'); this._description = shadow.getElementById('description'); this._body.addEventListener('click', e =>{ if (e.metaKey && this._body.href !== '') return; if (!this._onClick) return; e.preventDefault(); e.stopPropagation(); this.click(e); }); } set({title, link, image, description, onClick}) { title ? this.setAttribute('title', title) : this.removeAttribute('title'); link ? this.setAttribute('link', link) : this.removeAttribute('link'); image ? this.setAttribute('image', image) : this.removeAttribute('image'); description ? this.setAttribute('description', description) : this.removeAttribute('description'); if (!onClick) { this.classList.add('disabled'); return; } if (typeof onClick !== 'function') throw Error('onClick is not a function.'); this._onClick = onClick; } get disabled() { return this.classList.contains('disabled'); } get isSelected() { return !this.disabled && this.classList.contains('selected'); } select() { if (!this.disabled) this.classList.add('selected'); const element = document.activeElement; this.focus(); element.focus(); } deselect() { this.classList.remove('selected'); this.blur(); } click(eventHandler) { this._onClick?.(eventHandler ?? {}); } static get observedAttributes() { return ['title', 'link', 'image', 'description',]; } attributeChangedCallback(name, oldValue, newValue) { switch(name) { case 'title': this._title.textContent = newValue; return; case 'image': const img = this._icon; if (newValue) { img.src = newValue; img.hidden = false; } else { img.src = ''; img.hidden = true; } return; case 'description': this._description.textContent = newValue ?? ''; return; case 'link': this._body.href = newValue ?? ''; return; } } });
作成用関数
item.js
export const create = (props) => { const item = document.createElement('suggest-container-item'); item.set(props); return item; }

test code
.cursor に連動して表示するだけ
js
import('/api/code/programming-notes/scrapbox-suggest-container-3/test1.js');
test1.js
import {list} from './test-sample-list.js'; import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js'; import {suggestContainer} from './script.js'; import {js2vim} from '../JSのkeyをVim_key_codeに変換するscript/script.js'; const suggestBox = suggestContainer(); scrapboxDOM.editor.append(suggestBox); // 選択候補の追加 suggestBox.push(...list); // keyboard操作 scrapboxDOM.editor.addEventListener('keydown', e => { if (suggestBox.hidden) return; if (!e.isTrusted) return; // programで生成したkeyboard eventは無視する switch(js2vim(e)) { case '<C-i>': e.preventDefault(); e.stopPropagation(); (suggestBox.selectedItem ?? suggestBox.firstItem).click({ctrlKey: true}); return; case '<Tab>': e.preventDefault(); e.stopPropagation(); suggestBox.selectNext({wrap: true}); return; case '<S-Tab>': e.preventDefault(); e.stopPropagation(); suggestBox.selectPrevious({wrap: true}); return; case '<CR>': e.preventDefault(); e.stopPropagation(); (suggestBox.selectedItem ?? suggestBox.firstItem).click(); default: return; } }); // windowの表示 const observer = new MutationObserver(() =>{ suggestBox.position({ top: parseInt(scrapboxDOM.cursor.style.top) + 14, left: parseInt(scrapboxDOM.cursor.style.left), }); }); observer.observe(scrapboxDOM.cursor, {attributes: true});

サンプルデータ
test-sample-list.js
export const list = [ { title: 'Loading...', image: 'https://img.icons8.com/ios/180/loading.png', }, { title: 'scrapbox', image: 'https://i.gyazo.com/7057219f5b20ca8afd122945b72453d3.png', link: 'https://scrapbox.io', onClick: (e) => { if (e.ctrlKey) { window.open('https://scrapbox.io'); return; } alert('Hello, Scrapbox!'); }, }, { title: 'scrapbox', image: 'https://i.gyazo.com/7057219f5b20ca8afd122945b72453d3.png', description: 'UserScriptの危険性open project(誰でも参加できるプロジェクト)の場合、自分のページに悪意のあるUserScriptの書き込み・改変をされる恐れがあるこれが出たときに、すぐにLoad new UserScriptを押さずに、まず自分のページに行って内容を確認するまた、このprojectで公開しているUserScript, UserCSSは破壊的変更を行う場合があります', link: 'https://scrapbox.io', onClick: (e) => { if (e.ctrlKey) { window.open('https://scrapbox.io'); return; } alert('Hello, Scrapbox!'); }, }, ];

#2021-03-08 06:52:55