generated at
popup-container@0.1.2
popup-container@0.1.1のバグ修正版
子要素を<slot>で挿入するようにしたことで、外部のCSSが適用されるようにした
属性を全て文字列化した
.button に対するCSSを全部削った
このComponentの責務じゃない

dependencies
script.js
import {html, toChildArray} from '../htm@3.0.4%2Fpreact/script.js'; import {useState, useMemo, useEffect, useCallback, useRef} 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'; import register from '../preact-custom-element@4.2.1/script.js'; function PopupContainer(props) { const { cursorTop = 0, cursorLeft = 0, } = props ?? {}; const open = props?.open === 'true' ? true : false; const cursorPosition = useMemo( () => ({styleTop: cursorTop, styleLeft: cursorLeft}), [cursorTop, cursorLeft] ); const slotRef = useRef(null); const { ref, width: buttonContainerWidth = 0 } = useResizeObserver(); const { width: editorWidth = 0 } = useResizeObserver({ ref: scrapboxDOM.editor }); const isEmpty = useMemo(() => slotRef?.current?.assignedNodes?.()?.length === 0, [slotRef]); const popupMenuStyle = calcPopupMenuStyle(cursorPosition); const triangleStyle = calcTriangleStyle(cursorPosition, isEmpty); const buttonContainerStyle = calcButtonContainerStyle(editorWidth, buttonContainerWidth, cursorPosition, isEmpty); return 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 } html[data-os*='android'] .popup-menu .button-container, .popup-menu .button-container[data-os*='android'] { max-width:90vw } html[data-os*='ios'] .popup-menu .button-container, .popup-menu .button-container[data-os*='ios'] { 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 } html[data-os*='android'] .popup-menu.vertical .button-container, .popup-menu[data-os*='android'].vertical .button-container { max-width:80vw; text-align:left } </style> <div class="popup-menu" style="${popupMenuStyle}" hidden="${!open}"> <div ref="${ref}" class="button-container" style="${buttonContainerStyle}"> <slot ref="${slotRef}"/> </div> <div class="triangle" style="${triangleStyle}" /> </div>`; } // Custom Elementにする export const tag = 'x-userscript-popup-container'; register(PopupContainer, tag, ['open', 'cursor-top', 'cursor-left'], {shadow: true});

座標計算
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', } : {}), }; }

test code
js
import('/api/code/programming-notes/popup-container@0.1.2/test0.js') .then(({mount}) => mount());
test0.js
import {tag} from './script.js'; import {html, render} from '../htm@3.0.4%2Fpreact/script.js'; import {useState} from '../preact@10.5.13/hooks.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 [cursorPosition, setCursorPosition] = useState({ top: 0, left: 0, }); const callback = throttle((mutation) => { const cursor = mutation.target; setCursorPosition({ top: +cursor.style.top.slice(0, -2), left: +cursor.style.left.slice(0, -2), }); }, 100); useMutationObserver([{current: scrapboxDOM.cursor}], ([mutation]) => callback(mutation), {attributes: true}); return html`<${tag} cursor-top="${cursorPosition.top}" cursor-left="${cursorPosition.left}" open>No item found</${tag}>`; } export function mount() { const app = document.createElement('div'); app.dataset.userscriptName= 'popup-test'; document.getElementById('editor').append(app); render(html`<${App} />`, app); }