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を参考にする
code: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>
これ
https://gyazo.com/f660225a67e7d66f44af710209072a4d
style
code: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の本体
code: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.icon
code:js
suggestContainer.push({
text: () => {},
image: () => {},
onClick: () => {},
});
textとimageも関数を使えるようにしてみたい
ただ引数に渡すものが思いつかない……
全部実装しても思いつかなければ止めにする
onClickにundefinedを渡すと、選択不能フォーカス不可のアイテムになる
読み込み中のメッセージなどを表示するときに使う
アイテムの追加方法をいくつか用意しておく
末尾に挿入
code:script.js
push(...items) {
this._box.append(..._createItems(...items));
if (this.mode === 'auto') this.show();
}
任意の場所に挿入:insert(index, ...items)
code: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.itemsindex.insertAdjacentElement('afterend', fragment); }
if (this.mode === 'auto') this.show();
}
先頭に挿入:pushFirst(...items)
code: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 (this.mode === 'auto') this.show();
}
アイテムの置換
focusがあたっていたらそれを維持する
code: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.itemsindex?.focus?.() ?? this.firstItem.focus(); }
アイテムの削除
空っぽになったらwindowを閉じる
任意のアイテムを削除する
code:script.js
pop(...indices) {
indices.forEach(index => this.itemsindex?.remove?.()); if (this.items.length === 0) this.hide();
}
無効なindexは無視する
全てのアイテムを削除する
というか、一つ飛びにアイテムが消える……takker.icon
じゃななんで↓のコードはエラーが出ないんだ……?takker.icon
謎が深まったが、非標準の関数を用いたことでバグったということはわかった
仕方ない。Arrayに変換して消そう。
13:34:08 成功した。
this._box.textContent = ''でも同じ効果がある
どっちのほうがいいかな?takker.icon
code:script.js
clear() {
this.hide();
}
アイテムを取得する
code:script.js
get items() {
return this._box.childNodes;
}
get firstItem() {
}
get lastItem() {
}
フォーカスのあたっているアイテムを取得する
もっとわかり易い名前にした
code:script.js
get selectedItem() {
}
code:script.js
get selectedItemIndex() {
}
get lastSelectedItem()
最後にフォーカスのあたっていたアイテムを取得する
いずれかのアイテムにフォーカスが当たっているときはget selectedItem()と同じ
focus eventの監視をしないといけないのが面倒だなtakker.icon
アイテムは<suggest-container-item>として取得できるようにする
フォーカスを当てたりクリックしたりするのは<suggest-container-item>に任せる
Shadow DOM中のDOMを外部に渡せるのかな?takker.icon
できなかったら、classでwrapするなど他の方法を考えよう
フォーカスの移動
code: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;
}
code: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:...;"でいいや
code:script.js
position({top, left}) {
this._box.style.top = ${top}px;
this._box.style.left = ${left}px;
this.show();
}
code: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は関係ないや
code:script.js
get _box() {
return this.shadowRoot.getElementById('box');
}
code:script.js
});
code: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>のアイテム
https://gyazo.com/0debc6ce0b562f052441c368dd810ad8
これはCustom Elementにせず、普通に<a>でつくるだけで十分かも
<suggest-container>からCSSを制御できるようになるので便利
これで行こう
:active()などを追加で指定できるのかどうかが不安……takker.icon
だめだったらShadow DOM無しで使おう
擬似要素を追加指定できるみたい
↑何もしなくても、勝手にLight DOMの親のCSSが子に受け継がれるみたい
<li>を継承して作ってもいいかも
DOMが1段減る
独自に追加したmethodが使えなくなるみたい
やめよう
style
font-sizeをulの方で決めなくてもいいみたい
code: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);
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;
}
`;
code: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()から登録したやつを優先させる
code: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でチェックしてる
code: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() {
}
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に連動して表示するだけ
https://gyazo.com/0b8117c975415243e382cd44adf6e521.mp4
code:test1.js
(async () => {
const promises = [
import('./test-sample-list.js'),
import('./test-dom.js'),
import('./test-dark-theme.js'),
import('./script.js'),
];
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
色付け
code:test-dark-theme.js
document.head.insertAdjacentHTML('beforeend',
`<style>
suggest-container {
--completion-item-text-color: var(--page-text-color);
--completion-item-hover-text-color: var(--page-text-color);
}
</style>`);
サンプルデータ
code:test-sample-list.js
export const list = [
{
text: 'Loading...',
},
{
text: 'scrapbox',
onClick: () => alert('Hello, Scrapbox!'),
},
];
DOMのailias
code:test-dom.js
export const editor = document.getElementById('editor');
export const cursor = document.getElementsByClassName('cursor')0; MDN.icon