popup-menu
重要なお知らせ (2021/8/2)
この UserScript はもうメンテナンスされていません。UserScript のコードは パブリック・ドメイン・マーク 1.0 として配布しておりますので、もしお使いになりたい方がいましたら、ライセンスの範囲内でご自由にお使い下さい。
hr.icon
任意のタイミングでポップアップ ([...]と書くと出てくるアレ) を召喚するUserScript向けのライブラリ.
https://gyazo.com/3ee1e49912f708267aeb969642a092d6
導入方法
code:install.js
import { PopupMenu } from '/api/code/mizdra/popup-menu/script.js';
使い方
popupMenu.showを呼び出すとカーソルのある場所にポップアップを出すことができる
引数を使ってポップアップ内に表示されるアイテムをカスタマイズできる
itementerイベントで選択されたアイテムのインデックスを取得できる
code:usage.js
import { PopupMenu } from '/api/code/mizdra/popup-menu/script.js';
// popupMenu.show に渡すアイテムリスト
return {
// 必須プロパティ。このノードがポップアップにマウントされる。
node: document.createTextNode(text),
// オプションで value プロパティを指定できる。value プロパティはポップアップの挙動に影響を与えないが、
// itementer イベントのリスナに追加で情報を渡す時に利用できる。ここではアイテムが選択された時に
// ページに挿入したい文字列を格納している。
value: text,
};
});
const popupMenu = new PopupMenu({
// アイテムが空の時のメッセージを指定できる。デフォルトは アイテムは空です。
emptyMessage: 'マッチするアイテムがありません',
});
popupMenu.addEventListener('itementer', (e) => {
const enteredItem = e.detail;
popupMenu.hide();
// value プロパティから文字列を取り出してページに挿入する
document.execCommand('insertText', null, enteredItem.value);
});
document.addEventListener('keydown', (e) => {
const isCtrlSpace = e.key === ' ' && e.ctrlKey && !e.shiftKey && !e.altKey;
const isTab = e.key === 'Tab' && !e.ctrlKey && !e.shiftKey && !e.altKey;
const isShiftTab = e.key === 'Tab' && !e.ctrlKey && e.shiftKey && !e.altKey;
const isEnter = e.key === 'Enter' && !e.ctrlKey && !e.shiftKey && !e.altKey;
const isEscape = e.key === 'Escape' && !e.ctrlKey && !e.shiftKey && !e.altKey;
// IMEによる変換中は何もしない
if (e.isComposing) return;
if (popupMenu.isHidden()) {
// ctrl+Spaceでポップアップを表示
if (isCtrlSpace) {
e.preventDefault();
e.stopPropagation();
popupMenu.show(items);
}
} else {
if (isTab || isShiftTab || isEnter || isEscape) {
e.preventDefault();
e.stopPropagation();
}
if (isTab) popupMenu.selectNextItem();
if (isShiftTab) popupMenu.selectPrevItem();
if (isEnter) popupMenu.enterItem();
if (isEscape) {
popupMenu.hide();
}
}
}, true);
ユースケース
ソースコード
code:script.js
const DEFAULT_OPTIONS = { emptyMessage: 'アイテムは空です' };
function createElementFromHTML(html) {
const el = document.createElement('div');
el.innerHTML = html;
return el.firstElementChild;
}
function getCursor() {
const cursorNode = document.querySelector('.cursor');
return { top: cursorNode.style.top, left: cursorNode.style.left };
}
function createPopupMenuTemplate() {
const html = `
<div class="popup-menu custom-popup-menu" style="left: 0px">
<div class="button-container" style="transform: translateX(-50%);"></div>
<div class="triangle"></div>
</div>
`;
return createElementFromHTML(html);
}
export class PopupMenu extends EventTarget {
constructor(options) {
super();
const popupMenu = createPopupMenuTemplate();
this.popupMenu = popupMenu;
this.buttonContainer = popupMenu.querySelector('.button-container');
this.triangle = popupMenu.querySelector('.triangle');
this.editor = document.querySelector('.editor');
this.editor.appendChild(popupMenu);
this.options = { ...DEFAULT_OPTIONS, ...options };
this.popupMenu.style.display = 'none';
this.items = null;
}
isHidden() {
return this.popupMenu.style.display === 'none';
}
show(items) {
// update items
this.updateItems(items);
}
hide() {
this.popupMenu.style.display = 'none';
this.buttonContainer.innerHTML = ''; // remove all items
this.items = null;
}
updateItems(items) {
// update items
this.items = items;
this.buttonContainer.innerHTML = ''; // remove all items
if (items.length === 0) {
// 表示すべきアイテムが無ければ空を表すメッセージを表示する
const emptyMessage = document.createTextNode(this.options.emptyMessage);
this.buttonContainer.appendChild(emptyMessage);
this.popupMenu.classList.add('empty');
} else {
// 表示すべきアイテムがあればアイテムを表示する
items.forEach((item) => {
const button = document.createElement('div');
button.classList.add('button');
button.addEventListener('click', () => {
this._onItemEnter(item);
});
// MEMO: removeEventListener してなくてメモリリークしていそう
button.appendChild(item.node);
this.buttonContainer.appendChild(button);
});
this.popupMenu.classList.remove('empty');
// 最初は 0 番のアイテムを選択状態にしておく
this._selecteItem(0);
}
// set style
const cursor = getCursor();
this.popupMenu.style.display = 'block';
this.popupMenu.style.top = cursor.top;
this.buttonContainer.style.left = cursor.left;
const cursorLeftByEditorWidth = +(cursor.left.slice(0, -2)) / this.editor.clientWidth * 100;
// 左側に寄り過ぎたり, 右側に寄り過ぎないように調整
// 最低でも20px相当のゆとりは持たせる
const minPercentage = 20 / this.buttonContainer.clientWidth * 100;
const maxPercentage = 100 - minPercentage;
this.buttonContainer.style.transform
= translateX(-${Math.max(minPercentage, Math.min(cursorLeftByEditorWidth, maxPercentage))}%);
this.triangle.style.left = cursor.left;
}
selectNextItem() {
const selectedItemIndex = this._getSelectedItemIndex();
this._selecteItem((selectedItemIndex + 1) % this.items.length);
}
selectPrevItem() {
const selectedItemIndex = this._getSelectedItemIndex();
this._selecteItem(((selectedItemIndex - 1) + this.items.length) % this.items.length);
}
enterItem() {
}
_getSelectedItemIndex() {
const buttons = this.buttonContainer.children;
for (let i = 0; i < buttons.length; i++) {
if (buttonsi.classList.contains('selected')) return i; }
return null;
}
_getItemLength() {
return this.buttonContainer.children.length;
}
_selecteItem(index) {
this.buttonContainer.children.forEach(button => {
button.classList.remove('selected');
});
this.buttonContainer.childrenindex.classList.add('selected'); }
_onItemEnter(item) {
this.dispatchEvent(new CustomEvent('itementer', { detail: item }));
}
}
// cssを挿入
const style = createElementFromHTML(`
<style>
.custom-popup-menu.empty .button-container {
font-size: 11px;
display: inline-block;
padding: 0 5px;
cursor: not-allowed;
}
.custom-popup-menu.empty .triangle {
}
</style>
`);
document.head.appendChild(style);
ソースコードのライセンス