scrapbox-suggest-container-2
変えるところ
CSS周りの挙動をしりたい
任意のcomponentを選択候補に指定できるようにする
HTMLElementを受け取れるようにする
役割分担を変える
<suggest-container>にアイテムの作成も入れる
以下以外をすべて<suggest-container>で賄うようにする
アイテム内部のDOM構造
外部からHTMLElementを注入する
onclick
2つ設計案がある
1. <suggest-container>→<suggest-container-item-box>→ユーザー定義のDOM
selectとdeselectとclickを<suggest-container-item-box>で受け持つ
欠点
Shadow DOMがたくさんネストする
<suggest-container-item-box>を作るのが面倒
どのみちユーザー定義のDOMでCustom Elementを作ることになる
2. <suggest-container>→ユーザー定義のDOM
ユーザー定義のDOMで選択/選択解除とクリック処理を作る必要がある
実際に使うことを想定しているCustom Elementはたったの2つだし、気にしなくてもいいかもしれない
2.を使うか。
UI
--completion-bg
--completion-item-text-color
--completion-item-hover-text-color
--completion-item-hover-bg
focusがあたったときのアイテムの背景色
--completion-border
枠線のスタイル
--completion-shadow
影のスタイル
html構造
code:html
<suggest-container>
<ul>
<li>
${foreignElement}
</li>
</ul>
</suggest-container>
foreignElementには外部からDOMを指定する
もちろん、毎回DOMを用意するのは面倒なので、{text:'...',image:'...'}形式で簡単に指定できる補助関数も用意するつもり
defaultはこれ
code:html
<a tabindex="0">
<img></img>
</a>
Interface
code: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%;
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;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`;
Custom Elementの本体
code:script.js
import {css} from './container-css.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
アイテムの追加
code:js
suggestContainer.push({
body: () => {},
onClick: () => {},
});
bodyにHTMLElementを渡す
onClickにundefinedを渡すと、選択不能フォーカス不可のアイテムになる
読み込み中のメッセージなどを表示するときに使う
<slot>を通じてbodyを使わないと行けないみたい
class外部でdocument.createElementで作成したDOMはShadow DOMに注入できないようだ
[object Object]という文字列に変換されてしまう
アイテムの追加方法をいくつか用意しておく
末尾に挿入
code:script.js
push(...items) {
this._box.append(...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);
const itemNodes = 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(...items);
this._box.insertBefore(fragment, this.firstItem);
if (this.mode === 'auto') this.show();
}
アイテムの置換
選択されていたらそれも維持する
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 ?? this.firstItem).select(); }
アイテムの削除
空っぽになったらwindowを閉じる
任意のアイテムを削除する
code:script.js
pop(...indices) {
indices.forEach(index => this.itemsindex?.remove?.()); if (this.items.length === 0) this.hide();
}
無効なindexは無視する
全てのアイテムを削除する
this._box.textContent = ''でも同じ効果がある
どっちのほうがいいかな?takker.icon
code:script.js
clear() {
this.hide();
}
アイテムを取得する
code:script.js
get items() {
return this._box.childNodes;
}
get selectableItems() {
}
get firstItem() {
return this._box.firstElementChild;
}
get lastItem() {
return this._box.lastElementChild;
}
get firstSelectableItem() {
}
get lastSelectableItem() {
}
フォーカスのあたっているアイテムを取得する
もっとわかり易い名前にした
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}) {
if (!this.firstSelectableItem) return;
const selectedItem = this.selectedItem;
this.selectedItem?.deselect?.();
if (!selectedItem || (wrap && this.lastItem === selectedItem)) {
this.firstSelectableItem?.select?.();
} else{
selectedItem.nextSibling?.select?.();
}
if (this.selectedItem.disabled) this.selectNext({wrap});
}
code: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?.();
} else {
selectedItem.previousSibling?.select?.();
}
if (this.selectedItem.disabled) this.selectPrevious({wrap});
}
フォーカスがないときは、最後にフォーカスのあたっていたアイテムか、先頭の選択可能なアイテムにフォーカスを当てる最初の要素にフォーカスを当てる
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
});
containerの要素
Custom Elementにはしない
Custom Elementにしたほうが便利なのでCutom Elementにした
Custome Elementあり
DOMとclassが一体化しているので、<ul>に放り込むだけで管理できる
Custome Elementなし
DOMとDOMを操作するclass objectとで別々にリストを作って管理する必要がある
Shadow DOMを使った
やめた
Shadow DOMがネストしまくって気持ち悪い
https://gyazo.com/d4560b513b9266e47fc34af82328829d
無理だ。
HTMLLiElementに格納できないcallback関数を保持する必要がある
Custome Elementを使うしかないか。
ユーザが各自で定義する
defaultのCustom Elementは予め提供しておく
defaultのitem
suggest-container-default-item
code:item-css.js
export const css = `
:host(.disabled) {
cursor: not-allowed;
}
a {
display: flex;
padding: 0px 20px;
clear: both;
font-weight: normal;
line-height: 28px;
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);
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: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 id="frame">
<a id="body" tabindex="-1">
<img id="icon" hidden></img>
<div id="text"></div>
</a>
</li>`;
this._frame = shadow.getElementById('frame');
this._body = shadow.getElementById('body');
this._icon = shadow.getElementById('icon');
this._text = shadow.getElementById('text');
this._body.addEventListener('click', e =>{
if (!this._onClick) return;
e.preventDefault();
e.stopPropagation();
this.click(e);
});
}
set({text, image, link, onClick}) {
if (text) this.setAttribute('text', text);
if (image) this.setAttribute('image', image);
if (link) this.setAttribute('link', link);
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');
}
deselect() {
this.classList.remove('selected');
}
click(eventHandler) {
this._onClick?.(eventHandler ?? {});
}
static get observedAttributes() {
}
attributeChangedCallback(name, oldValue, newValue) {
switch(name) {
case 'text':
this._text.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 'link':
this._body.href = newValue ?? '';
return;
}
}
});
作成用関数
code:item.js
export const create = (props) => {
const item = document.createElement('suggest-container-item');
item.set(props);
return item;
}
test code
.cursorに連動して表示するだけ
code:js
(async () => import('/api/code/programming-notes/scrapbox-suggest-container-2/test1.js'))();
code:test1.js
import {list} from './test-sample-list.js';
import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js';
import './script.js';
import {create} from './item.js';
import {js2vim} from '../JSのkeyをVim_key_codeに変換するscript/script.js';
const suggestBox = document.createElement('suggest-container');
scrapboxDOM.editor.append(suggestBox);
// 選択候補の追加
suggestBox.push(...list.map(props => create(props)));
// 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});
サンプルデータ
code:test-sample-list.js
export const list = [
{
text: 'Loading...',
},
{
text: 'scrapbox',
onClick: (e) => {
if (e.ctrlKey) {
return;
}
alert('Hello, Scrapbox!');
},
},
];