scrapbox-suggest-container-3
変えるところ
アイテムのDOMを決め打ちにした
代わりに少し設定項目を増やした
アイテムを追加するmethodsを減らした
使いそうになかったので
2021-04-08
01:53:09 DOMを生成する補助関数を追加した
2021-03-16
14:30:55 CSSを調節した
説明書きを表示するときに、少し文字を詰めるようにした
2021-03-15
UI
--completion-bg
--completion-item-text-color
--completion-item-hover-text-color
--completion-item-hover-bg
focusがあたったときのアイテムの背景色
--completion-border
枠線のスタイル
--completion-shadow
影のスタイル
Interface
container-css.jsexport 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.jsimport {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
アイテムの追加
tspublic 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)
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を閉じる
任意のアイテムを削除する
script.js pop(...indices) {
indices.forEach(index => this.items[index]?.remove?.());
if (this.items.length === 0) this.hide();
}
無効なindexは無視する
全てのアイテムを削除する
this._box.textContent = ''
でも同じ効果がある
どっちのほうがいいかな?
script.js clear() {
this._box.textContent = '';
this.hide();
}
アイテムを取得する
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);
}
フォーカスのあたっているアイテムを取得する
もっとわかり易い名前にした
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の監視をしないといけないのが面倒だな
アイテムは <suggest-container-item>
として取得できるようにする
フォーカスを当てたりクリックしたりするのは <suggest-container-item>
に任せる
Shadow DOM中のDOMを外部に渡せるのかな?
できなかったら、classでwrapするなど他の方法を考えよう
フォーカスの移動
.select
というクラスをつけて擬似的に再現している
スクロールも再現している
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.jsimport {h} from '../easyDOMgenerator/script.js';
export const suggestContainer = (...params) => h('suggest-container', ...params);
containerの要素
<suggest-container-item>
#description
にテキストがあるときとないときとでCSSを切り替えられるようにするとよさそう
item-css.jsexport 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.jsimport {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.jsexport const create = (props) => {
const item = document.createElement('suggest-container-item');
item.set(props);
return item;
}
test code
.cursor
に連動して表示するだけ
jsimport('/api/code/programming-notes/scrapbox-suggest-container-3/test1.js');
test1.jsimport {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.jsexport 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!');
},
},
];