scrapbox-suggest-container
実装したいこと
変えるところ
UI
--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
内部はclassで構成されているので、好きなmethod/propertyを生やせる
CSSファイルで設定できるようにした
しかしその代わりに、styleの適用にラグが発生するようになってしまった
要素が読み込まれた直後にstylesheetが読み込まれるみたい
<suggest-container>
これ
style
container.jsexport 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.jsimport {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なので、妥当だと思う

jssuggestContainer.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)
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を閉じる
任意のアイテムを削除する
script.js pop(...indices) {
indices.forEach(index => this.items[index]?.remove?.());
if (this.items.length === 0) this.hide();
}
無効なindexは無視する
全てのアイテムを削除する
というか、一つ飛びにアイテムが消える……

じゃななんで↓のコードはエラーが出ないんだ……?

謎が深まったが、非標準の関数を用いたことでバグったということはわかった
仕方ない。Arrayに変換して消そう。
13:34:08 成功した。
this._box.textContent = ''
でも同じ効果がある
どっちのほうがいいかな?

script.js clear() {
[...this.items].forEach(item => item.remove());
this.hide();
}
アイテムを取得する
script.js get items() {
return this._box.childNodes;
}
get firstItem() {
return this.items[0];
}
get lastItem() {
return this.items[this.items.length - 1];
}
フォーカスのあたっているアイテムを取得する
もっとわかり易い名前にした
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の監視をしないといけないのが面倒だな

アイテムは <suggest-container-item>
として取得できるようにする
フォーカスを当てたりクリックしたりするのは <suggest-container-item>
に任せる
Shadow DOM中のDOMを外部に渡せるのかな?

できなかったら、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.jsfunction _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を制御できるようになるので便利
これで行こう
だめだったらShadow DOM無しで使おう
擬似要素を追加指定できるみたい
↑何もしなくても、勝手にLight DOMの親のCSSが子に受け継がれるみたい
<li>
を継承して作ってもいいかも
DOMが1段減る
独自に追加したmethodが使えなくなるみたい
やめよう
style
font-size
を ul
の方で決めなくてもいいみたい
item.jsexport 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;
}
`;
script.jsimport {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;
}
代わりに #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.jsdocument.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.jsexport 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.jsexport const editor = document.getElementById('editor');
export const cursor = document.getElementsByClassName('cursor')[0];