popup-menu@0.1.0
dependencies
code:script.js
import {html} from '../htm@3.0.4%2Fpreact/script.js';
import {useState, useMemo, useEffect, useCallback} from '../preact@10.5.13/hooks.js';
import useResizeObserver from '../use-resize-observer@7.0.0/script.js';
import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js';
export function PopupMenu({
open,
emptyMessage,
cursorPosition,
items,
onSelect,
onSelectNonexistent,
onClose,
}) {
const { ref, width: buttonContainerWidth = 0 } = useResizeObserver();
const isEmpty = useMemo(() => items.length === 0, items.length); const { width: editorWidth = 0 } = useResizeObserver({ ref: scrapboxDOM.editor });
// items が変わったら選択位置を 0 番目に戻す。ただし空なら null にセットする。
useEffect(() => {
setSelectedIndex(isEmpty ? null : 0);
const handleKeydown = useCallback(
e => {
// 閉じている時は何もしない
if (!open) return;
// IMEによる変換中は何もしない
if (e.isComposing) return;
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;
if (isTab || isShiftTab || isEnter || isEscape) {
e.preventDefault();
e.stopPropagation();
}
if (isEmpty || selectedIndex === null) {
if (isEnter) onSelectNonexistent?.();
if (isEscape) onClose?.();
} else {
if (isTab) setSelectedIndex((selectedIndex + 1) % items.length);
if (isShiftTab) setSelectedIndex((selectedIndex - 1 + items.length) % items.length);
if (isEscape) onClose?.();
}
},
);
useDocumentEventListener('keydown', handleKeydown, { capture: true });
const popupMenuStyle = calcPopupMenuStyle(cursorPosition);
const triangleStyle = calcTriangleStyle(cursorPosition, isEmpty);
const buttonContainerStyle = calcButtonContainerStyle(editorWidth, buttonContainerWidth, cursorPosition, isEmpty);
return html`
${open && html`
<div class="popup-menu" style="${popupMenuStyle}">
<div ref="${ref}" class="button-container" style="${buttonContainerStyle}">
${items.length === 0 ? emptyMessage ?? 'アイテムは空です' : items.map((item, i) =>
html<${PopupMenuButton} key="${i}" selected="${selectedIndex === i}">${item}<//>
)}
</div>
<div class="triangle" style="${triangleStyle}" />
</div>`
}`;
}
button component
code:script.js
const PopupMenuButton = ({ children, selected }) =>
html<div class="${selected ? 'button selected' : 'button'}">${children}</div>;
座標計算
code:script.js
/** .popup-menu のスタイルを計算する */
const calcPopupMenuStyle = (cursorPosition) => ({ top: cursorPosition.styleTop });
/** .triangle のスタイルを計算する */
const calcTriangleStyle = (cursorPosition, isEmpty) => ({
left: cursorPosition.styleLeft,
...(isEmpty
? {
borderTopColor: '#555',
}
: {}),
});
/** .button-container のスタイルを計算する */
function calcButtonContainerStyle(
editorWidth,
buttonContainerWidth,
cursorPosition,
isEmpty,
) {
const translateX = (cursorPosition.styleLeft / editorWidth) * 100;
// 端に寄り過ぎないように、translateX の上限・下限を設定しておく。
// 値はフィーリングで決めており、何かに裏打ちされたものではないので、変えたかったら適当に変える。
const minTranslateX = (20 / buttonContainerWidth) * 100;
const maxTranslateX = 100 - minTranslateX;
return {
left: cursorPosition.styleLeft,
transform: translateX(-${Math.max(minTranslateX, Math.min(translateX, maxTranslateX))}%),
...(isEmpty
? {
color: '#eee',
fontSize: '11px',
display: 'inline-block',
padding: '0 5px',
cursor: 'not-allowed',
backgroundColor: '#555',
}
: {}),
};
}
css
code:script.js
export const CSS = () => html`<style>
.popup-menu {
position:absolute;
left:0;
width:100%;
z-index:300;
transform:translateY(calc(-100% - 14px));
-webkit-user-select:none;
user-select:none;
font-family:"Open Sans",Helvetica,Arial,"Hiragino Sans",sans-serif;
pointer-events:none
}
.popup-menu .button-container {
position:relative;
display:inline-block;
max-width:70vw;
min-width:80px;
text-align:center;
background-color:#111;
padding:0 1px;
border-radius:4px;
pointer-events:auto
}
max-width:90vw
}
max-width:90vw
}
.popup-menu .triangle {
position:absolute;
transform:translateX(-50%);
width:0;
height:0;
border-top:6px solid #111; border-left:8px solid transparent;
border-right:8px solid transparent
}
max-width:80vw;
text-align:left
}
.button {
font-size:11px;
color:#eee;
cursor:pointer;
display:inline-block;
padding:0 5px
}
.button:not(:first-of-type) {
border:0;
border-left:1px solid #eee }
.button.selected {
background-color:#222;
text-decoration:underline
}
font-size:13px;
padding:6px;
min-width:12vw
}
font-size:13px;
padding:6px;
min-width:12vw
}
.button div.icon {
height:2em;
max-width:10em;
display:inline-block;
overflow:hidden;
margin-left:1px;
vertical-align:top
}
.button div.icon img {
max-height:100%;
vertical-align:unset
}
font-size:11px;
display:block;
line-height:1.2em;
padding:12px 10px;
min-width:40px;
border-left:0
}
border:0;
border-bottom:1px solid #eee }
max-width:80vw;
text-align:left
}
font-size:11px;
display:block;
line-height:1.2em;
padding:12px 10px;
min-width:40px;
border-left:0
}
htmldata-os*='ios' .popup-menu.vertical .button-container .button:not(:last-of-type), .popup-menudata-os*='ios'.vertical .button-container .button:not(:last-of-type) { border:0;
border-bottom:1px solid #eee }
</style>`;
custom hooks
code:script.js
const useDocumentEventListener = (type, listener, options = {}) => useEffect(() => {
document.addEventListener(type, listener, options);
return () => document.removeEventListener(type, listener, options);
test code
2021-05-20 00:12:46 custom elementをやめたが、今度はタブがクラッシュするようになってしまった
何が原因なんだ?
2021-05-09 04:24:03 うまくいかない
属性にobjectが入るようなComponentを下手にcustom element化するとおかしなことになるっぽい
DOMの挿入位置の影響か、popup menuの表示位置がずれてる
2021-05-20 20:57:41 直った
beforebeginをafterbeginに変えて、#editorの中に<userscript-app>が生成されるようにした
DOMの挿入位置が悪かったようだ
code:js
import('/api/code/programming-notes/popup-menu@0.1.0/test0.js');
code:test0.js
import {PopupMenu, CSS} from './script.js';
import {html} from '../htm@3.0.4%2Fpreact/script.js';
import {useState} from '../preact@10.5.13/hooks.js';
import register from '../preact-custom-element@4.2.1/script.js';
import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js';
import {useMutationObserver} from '../useMutationObserver/script.js';
import {throttle} from '../custom-throttle/script.js';
const App = () => {
const items = [];
styleTop: 0,
styleLeft: 0,
});
const callback = throttle((mutation) => {
const cursor = mutation.target;
setCursorPosition({
styleTop: +cursor.style.top.slice(0, -2),
styleLeft: +cursor.style.left.slice(0, -2),
});
}, 500);
return html<${CSS} /><${PopupMenu} items="${items}" cursorPosition="${cursorPosition}" open/>;
}
register(App, 'userscript-app', [], {shadow: true});
document.getElementById('editor').insertAdjacentHTML('afterbegin', '<userscript-app></userscript-app>');