/** @jsx h */ /** @jsxFrag Fragment */ /// /// /// import { TextBubble } from "./TextBubble.tsx"; import { CardBubble } from "./CardBubble.tsx"; import { CSS } from "./app.min.css.ts"; import { Fragment, h, render, useEffect } from "./deps/preact.tsx"; import { useBubbles } from "./hooks/useBubbles.ts"; import { useEventListener } from "./hooks/useEventListener.ts"; import { isLiteralStrings, toId } from "./utils.ts"; import { useProjectTheme } from "./hooks/useProjectTheme.ts"; import { sleep } from "./sleep.ts"; import { usePromiseSettledAnytimes } from "./hooks/usePromiseSettledAnytimes.ts"; import { getEditor } from "./dom.ts"; import { parseLink } from "./parseLink.ts"; import type { LinkType } from "./types.ts"; import type { Scrapbox } from "./deps/scrapbox.ts"; declare const scrapbox: Scrapbox; const userscriptName = "scrap-bubble"; export interface AppProps { /** hoverしてからbubbleを表示するまでのタイムラグ */ delay: number; /** cacheの有効期間 */ expired: number; /** 透過的に扱うprojectのリスト */ whiteList: string[]; /** リンク先へスクロールする機能を有効にする対象 * * `link`: []で囲まれたリンク * `hashtag`: ハッシュタグ * `lineId`: 行リンク */ scrollTargets: ("title" | "link" | "hashtag" | "lineId")[]; } const App = ( { delay, expired, whiteList, scrollTargets }: AppProps, ) => { const { bubbles, cache, show, hide } = useBubbles({ expired, whiteList }); const getTheme = useProjectTheme(); const [waitPointerEnter, handlePointerEnter] = usePromiseSettledAnytimes< PointerEvent >(); useEffect(() => { /** trueになったらevent loopを終了する */ let finished = false; (async () => { while (!finished) { const event = await waitPointerEnter(); if (!(event.currentTarget instanceof HTMLDivElement)) { throw TypeError(`event.currentTarget must be HTMLDivElement`); } const base = event.currentTarget; const depth = parseInt(base.dataset.index ?? "0"); const link = event.target as HTMLElement; // 処理をか.line-titleのときに限定する if (!isPageLink(link) && !isTitle(link)) continue; const { project = scrapbox.Project.name, title: encodedTitle, hash = "", } = isPageLink(link) ? parseLink({ pathType: "root", href: `${new URL(link.href).pathname}${new URL(link.href).hash}`, }) : { project: scrapbox.Project.name, title: scrapbox.Page.title }; // [/project]の形のリンクは何もしない if (project === "") return; const title = decodeURIComponent(encodedTitle ?? ""); cache(project, title); // delay以内にカーソルが離れるかクリックしたら何もしない try { const waited = sleep(delay); link.addEventListener("pointerleave", () => waited.cancel(), { once: true, }); link.addEventListener("click", () => waited.cancel(), { once: true }); await waited; } catch (e) { if (e === "cancelled") continue; throw e; } // スクロール先を設定する const scrollTo = hash !== "" && scrollTargets.includes("lineId") ? { type: "id", value: hash } as const : link.dataset.linkedTo && isLiteralStrings( link.dataset.linkedType, "link", "hashtag", "title", ) && scrollTargets.includes(link.dataset.linkedType) ? { type: "link", value: link.dataset.linkedTo } as const : undefined; // 表示位置を計算する const { top, right, left, bottom } = link.getBoundingClientRect(); const root = getEditor().getBoundingClientRect(); // linkが画面の右寄りにあったら、bubbleを左側に出す const adjustRight = (left - root.left) / root.width > 0.5; show(depth, project, title, { scrollTo, position: { top: Math.round(bottom - root.top), bottom: Math.round(root.bottom - top), ...(adjustRight ? { right: Math.round(root.right - right) } : { left: Math.round(left - root.left) }), maxWidth: adjustRight ? right - 10 : document.documentElement.clientWidth - left - 10, }, type: getLinkType(link), }); } })(); return () => finished = true; }, [delay, cache, show]); const editor = getEditor(); useEventListener(editor, "pointerenter", handlePointerEnter, { capture: true, }); useEventListener(document, "click", (e) => { const target = e.target as HTMLElement; if (target.dataset.userscriptName === userscriptName) return; hide(0); }, { capture: true }); useEffect(() => { const callback = () => hide(0); scrapbox.addListener("page:changed", callback); return () => scrapbox.removeListener("page:changed", callback); }, []); return ( <> {bubbles.map(({ project, title, lines, emptyLinks, position, scrollTo, type, linked, }, index) => ( hide(index + 1)} hasChildCards={bubbles.length > index + 1} /> ({ project, linkedTo: title, linkedType: type, theme: getTheme(project), ...rest, }), )} index={index + 1} onPointerEnterCapture={handlePointerEnter} onClick={() => hide(index + 1)} /> ))} ); }; export function mount( { delay = 500, expired = 60, whiteList = [], scrollTargets = ["link", "hashtag", "lineId", "title"], }: Partial = {}, ) { const app = document.createElement("div"); app.dataset.userscriptName = userscriptName; getEditor().append(app); const shadowRoot = app.attachShadow({ mode: "open" }); render( , shadowRoot, ); } function isTitle( element: HTMLElement, ): element is HTMLSpanElement { return element instanceof HTMLSpanElement && element.matches(".line-title .text"); } function isPageLink( element: HTMLElement, ): element is HTMLAnchorElement { return element instanceof HTMLAnchorElement && element.classList.contains("page-link"); } function getLinkType(element: HTMLSpanElement | HTMLAnchorElement): LinkType { return isPageLink(element) ? (element.type === "link" ? "link" : "hashtag") : "title"; }