generated at
popup-menu

重要なお知らせ (2021/8/2)
この UserScript はもうメンテナンスされていません。UserScript のコードは パブリック・ドメイン・マーク 1.0 として配布しておりますので、もしお使いになりたい方がいましたら、ライセンスの範囲内でご自由にお使い下さい。

hr

任意のタイミングでポップアップ ( [...] と書くと出てくるアレ) を召喚するUserScript向けのライブラリ.


導入方法
/help-jp/自分のページとアイコンに以下のスクリプトを追加する.
install.js
import { PopupMenu } from '/api/code/mizdra/popup-menu/script.js';

使い方
popupMenu.show を呼び出すとカーソルのある場所にポップアップを出すことができる
引数を使ってポップアップ内に表示されるアイテムをカスタマイズできる
itementer イベントで選択されたアイテムのインデックスを取得できる
usage.js
import { PopupMenu } from '/api/code/mizdra/popup-menu/script.js'; // `popupMenu.show` に渡すアイテムリスト const items = ['mizdra', 'done', 'scrapbox'].map(text => { 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);

ユースケース



ソースコード
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() { this._onItemEnter(this.items[this._getSelectedItemIndex()]); } _getSelectedItemIndex() { const buttons = this.buttonContainer.children; for (let i = 0; i < buttons.length; i++) { if (buttons[i].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.children[index].classList.add('selected'); } _onItemEnter(item) { this.dispatchEvent(new CustomEvent('itementer', { detail: item })); } } // cssを挿入 const style = createElementFromHTML(` <style> .custom-popup-menu.empty .button-container { color: #eee; font-size: 11px; display: inline-block; padding: 0 5px; cursor: not-allowed; background-color: #555; } .custom-popup-menu.empty .triangle { border-top-color: #555; } </style> `); document.head.appendChild(style);

ソースコードのライセンス