generated at
ScrapBubble
外部projectを透過的に扱う必要がなければ、もっと簡単にscrapbox-card-bubbleとかを作れることに気づいたtakker
普通にAPIを1リンクづつfetchすればいい

2021-06-11 13:24:21 scrapbox-card-bubbleの吹き出しが死にました
次期ver.のScrapBubble@0.2.0に意識が向かっているので、こっちは直さないかも

実装
text-bubble / card-container / .page-link からカーソルを離した
その階層とそれより下の階層のbubblesを全て消すtimerを発動する
text-bubble / card-container / .page-link にカーソルを移した
一旦timerを消去する
card-container a .page-link 上にカーソルがある場合は、新しいtext bubbleとcard bubbleを表示させるtimerを発動する
text-bubble / card-container をクリックした
クリックした階層より下の階層にある全てのbubblesを直ちに消す
text-bubble / card-container 以外をクリックした
全てのbubblesを直ちに消す
scrapbox-text-bubble-2のコードをベースに作る
bubbleの表示方法
targetのDOM渡す必要ないな
表示したい場所、リンクに被らないように開けておく空白の幅、リンクのpath (projectとtitle)さえわかればいい

お試し
参加しているproject
自分のページに↓を追記する
js
import {ScrapBubble} from '/api/code/programming-notes/ScrapBubble/script.js'; new ScrapBubble();
参加していないproject
TamperMonkeyに↓を貼り付ける
tampermonkey.js
// ==UserScript== // @name ScrapBubble // @namespace https://scrapbox.io // @version 0.1.6 // @description n hop先のテキストを表示する // @author takker // @downloadURL https://scrapbox.io/api/code/programming-notes/ScrapBubble/tampermonkey.js // @match https://scrapbox.io/* // @exclude https://scrapbox.io/settings // @license MIT // @copyright Copyright (c) 2021 takker // ==/UserScript== "use strict"; window.addEventListener('load', async () => { // 自分が所属しているprojectは除く const res = await fetch('/api/projects'); const {projects} = await res.json(); // #editorが読み込まれるまで待機 // cf. https://scrapbox.io/scrapbox-drinkup/puppeteerでscrapboxを自動化してイテレーション資料を宣言的に完成させる_(2019%2F9%2F5) await new Promise(resolve => { let timer = null; timer = setInterval(() => { if (!document.getElementById('editor')) return; clearInterval(timer); resolve(); }, 1000); }); // 念の為1秒くらい待っとく await new Promise((resolve) => setTimeout(() => resolve(), 1000)); if (projects.map(({name}) => name).includes(scrapbox.Project.name)) { console.info(`You belong to "/${scrapbox.Project.name}".`); return; } console.info(`You don't belong to "/${scrapbox.Project.name}".`); const {ScrapBubble} = await import('/api/code/programming-notes/ScrapBubble/script.js'); new ScrapBubble(); window.onload = undefined; });
URLからproject nameを取得するのは難しい
location.pathname.match(/^\/([\w\-]+)/)[1] だと、 https://scrapbox.io/stream/programming-notes/ のときに誤認識してしまう
fail素直にscrapbox.Project.nameから取得したほうが無難
scrapbox が使えるようになるまでbuzy loopする
2021-05-03 15:45:40 これでもまだエラーが出る時がある
対処方法がわからんtakker
js
// scrapboxが有効になるまで待つ const callback = (resolve) => scrapbox ? (scrapbox?.Project ? resolve() : setTimeout(() => callback(resolve), 1000)) : setTimeout(() => callback(resolve), 1000); await new Promise(resolve => callback(resolve));
scrapbox?.Project?.name でbuzy loopするとReactがerrorを吐いてしまうので注意
failURLから素直にparseすることにしよう
import() のタイミングが早すぎて起動に失敗することがある
2021-05-18 14:30:07 #editor が読み込まれるまで待機することにした

2021-05-18
2021-05-05
URLからproject nameを取得できるようにした
2021-05-03
15:06:52
window.onload を使うのをやめた
scrapbox をうまく読み込めなくなったのでやめた
https://scrapbox.io/stream/:project などの特殊なページにいたときに、自分の参加していないprojectだと誤認識していた
2021-05-02
14:51:49 右端のカードの表示位置を調節して、細長くならないようにした
2021-05-01
19:08:32 1 hopが1つで2 hopが0の中身のあるページを無視していた
17:31:23 scroll中はcursorが離れていてもカードを消さないようにした
17:19:04 空ページ判定にミスがあったのを直した
17:03:10 ScrapBubble#608d015e1280f00000acea1eを実装した
16:06:00 n hop先リンクを全て表示できるようにした
donescrapbox-card-container中のリンクのhoverがなんかうまく行かない
2つまではいく
3つめのリンクをhoverしても、bubbleが表示されない
16:09:44 document.elementFromPoint() null のときの対処を入れてconsole errorを抑制したら直った
13:50:22 ネストしないところまではできた
doneリンクのない場所でbubbleが表示される
どこかで発動した this._show() をcancelしきれていない?
ScrapBubble#608cde2e1280f00000f6b2b0の修正で発生するようになった
14:19:10 非表示にしてからDOMに入れれば解決しそう
解決しなかった……
16:18:42 多分解決している
done何故か変なタイミングでbubbleが一瞬消えたりする
13:56:11 ↓を直したら一緒に直った?
15:10:57 .page-link ではなく、 .page-link の子要素に対して発生するeventに対して実行していたのが原因
これで直った
diff
+if (!target.matches('.page-link')) return; -const link = target.closest('.page-link'); -if (!link) return; +const link = target;
donecard bubbleの表示位置がずれているのを直したい
DOMを挿入した後に位置を計算する必要がある

既知の問題
done今表示している大本のページの逆リンクを吹き出し表示できていない
タイトルをhoverしたら表示するようにしてみたい
done右端のリンクをhoverすると、text bubbleが細長く表示されてしまう
空リンクを区別できない
カーソルを置いたのにbubbleが消えてしまうことがある

dependencies
js
(async () => { const {ScrapBubble} = await import('/api/code/programming-notes/ScrapBubble/script.js'); new ScrapBubble(); })();
script.js
import {textBubble} from '../text-bubble-component/script.js'; import {cardContainer} from '../scrapbox-card-container/script.js'; import {scrapboxDOM} from '../scrapbox-dom-accessor/script.js'; export class ScrapBubble { constructor({delay = 650} = {}) { this._list = []; this._timer = null; this._acceptProjects = [scrapbox.Project.name]; this._delay = delay; // rootのevent設定 // リンクにcursorを乗せると吹き出しを開く scrapboxDOM.editor.parentElement.addEventListener('pointerenter', ({target}) => { if (target.matches('.line-title .text')) { this._onenterLink(0, target, {project: scrapbox.Project.name, title: scrapbox.Page.title}, {text: true}, ); return; } if (!target.matches('.page-link')) return; this._onenterLink(0, target, {project: scrapbox.Project.name, title: scrapbox.Page.title}); }, {capture: true}); // リンクから離れたら全てを消すトリガーを発動する scrapboxDOM.editor.parentElement.addEventListener('pointerleave', ({target}) => { if (!target.matches('.page-link, .line-title .text')) return; this._onleave(0); }, {capture: true}); // 対象カード以外を押したら、そのカードを消す addEventListener('click', ({target}) => { if (target.matches('text-bubble, card-container')) { this._hide(parseInt(target.dataset.index) + 1); return; } this._hide(0); }, {capture: true}); addEventListener('wheel', () => this._cancel()); // 次ページに遷移するときには全部消す const observer = new MutationObserver(() => { this._cancel(); this._remove(0); }); observer.observe(document.getElementsByTagName('title')[0], {childList: true}); } _cancel() { clearTimeout(this._timer); } // 特定の位置にtext bubbleとcard bubbleを生成して表示する // リンク先が存在しなければfalseを返す async _bubble(index, {top, left, right, bottom, href, base, disable}) { if (index < 0 || this._list.length < index) return; // 新しく追加するので、最大要素番号 + 1まで許容する const path = new URL(href).pathname; const [project, title] = path.match(/^\/([\w\-]+)\/(.+)/)?.slice(1) ?? ['', '']; if (!this._acceptProjects.includes(project)) return true; this._cancel(); const now = new Date().getTime(); // 吹き出し表示する情報を取得する const res = await fetch(`/api/pages/${project}/${title}`); if (!res.ok) return false; const json = await res.json(); const {name, lines, links, relatedPages} = json; // 空ページなら何もしない if (json.name || (relatedPages?.links1hop?.length < 2 && relatedPages?.links2hop.length === 0 && lines.length < 2) ) return false; const {links1hop} = relatedPages; // 逆リンクのみを取得する const linksLc = [base.title, ...links].map(link => link.toLowerCase().replace(/ /g, '_')); const cards = links1hop.flatMap(({ title: _title, titleLc, descriptions, linked, updated, image }) => !linksLc.includes(titleLc) ? [{project, title: _title, description: descriptions.join('\n'), linked, updated, image}] : [] ); // text bubbleとcar bubbleを作る const textBubble = !disable?.text ? this._createTextBubble({ path, lines: JSON.stringify(lines), index, }) : undefined; const cardBubble = this._createCardBubble({ cards: JSON.stringify(cards), index, }); // 要素を付け替える this._remove(index); this._list[index] = {text: textBubble, card: cardBubble}; if (textBubble) scrapboxDOM.editor.append(textBubble); if (cardBubble) scrapboxDOM.editor.append(cardBubble); // 位置合わせする const root = scrapboxDOM.editor.getBoundingClientRect(); // 親要素が基準になる if (textBubble) { textBubble.style.top = `${bottom - root.top}px`; // leftが左端に寄りすぎの場合は、rightで合わせる if ((left - root.left)/ root.width > 0.5) { textBubble.style.right = `${root.right - right}px`; } else { textBubble.style.left = `${left - root.left}px`; } } if (cardBubble) { cardBubble.style.bottom = `${root.bottom - top}px`; // leftが左端に寄りすぎの場合は、rightで合わせる if ((left - root.left)/ root.width > 0.5) { cardBubble.style.right = `${root.right - right}px`; } else { cardBubble.style.left = `${left - root.left}px`; }
right, bottomはCSSにおいて座標の基準が端からになる
座標軸の向きが反転する
なので、 right - root.right ではなく root.right - right としている
script.js
} // bubbleを開く this._timer = setTimeout(() => { this._show(index); // fetchとDOMの組み立てにかかった時間を除いておく }, Math.max(0, this._delay - (new Date().getTime() - now))); return true; } _onenterBubble(index) { this._onleave(index + 1); this._show(index); } async _onenterLink(index, target, base, disable = {}) { const {top, left, right, bottom} = target.getBoundingClientRect(); const isEmptyLink = !(await this._bubble(index, { top, left, right, bottom, href: target.href ?? location.href, base, disable, })); if (target.matches('.page-link') && isEmptyLink) { const [project, title] = new URL(target.href).pathname .match(/^\/([\w\-]+)\/(.+)/)?.slice(1) ?? ['', '']; this._list[index-1]?.text?.changeToEmptyLink?.(project, title); } } _onleave(index) { this._cancel(); this._timer = setTimeout(() => this._hide(index), this._delay); } // indexまで表示する _show(index) { if (index < 0 || this._list.length <= index) return; this._list.slice(0, index + 1).forEach(({text, card}) => {text?.show?.();card?.show?.();}); } // index以下を非表示にする _hide(index) { if (index < 0 || this._list.length <= index) return; this._list.slice(index).forEach(({text, card}) => {text?.hide?.();card?.hide?.()}); } // index以下を全て消す _remove(index) { if (index < 0 || this._list.length <= index) return; const deleteList = this._list.slice(index); this._list = this._list.slice(0, index); deleteList.forEach(({card, text}) => {card?.remove?.();text?.remove?.();}); } // bubbleの作成 _createCardBubble({top, left, cards, index}) { const container = cardContainer({ cards, css: {top, left}, 'data-index': index, hidden: true, }); // eventの設定 container.on('pointerenter', 'related-page-card', ({target}) => this._onenterLink(index + 1, target, {project: target.project, title: target.title})); container.on('pointerleave', 'related-page-card', ({target}) => this._onleave(index + 1)); container.addEventListener('pointerenter', () => this._onenterBubble(index), {capture: true}); container.addEventListener('pointerleave', ({screenX, screenY}) => { // 同階層と子孫要素のbubbleにいたときは無視 const element = document.elementFromPoint(screenX, screenY); if (element && element.matches('text-bubble, card-container') && element.dataset.index !== undefined && parseInt(element.dataset.index) >= index) return; console.log(`[scrapbox-text-bubble-2]Leave No. ${index}`); this._onleave(index); }, {capture: true}); return container; } _createTextBubble({top, left, path, lines, index}) { const bubble = textBubble({ path, lines, css: {top, left}, hidden: true, 'data-index': index, }); // eventの設定 bubble.on('pointerenter', '.page-link', ({target}) => this._onenterLink(index + 1, target, {project: bubble.project, title: bubble.title})); bubble.on('pointerleave', '.page-link', ({target}) => this._onleave(index + 1)); bubble.addEventListener('pointerenter', () => this._onenterBubble(index), {capture: true}); bubble.addEventListener('pointerleave', ({screenX, screenY}) => { // 同階層と子孫要素のbubbleにいたときは無視 const element = document.elementFromPoint(screenX, screenY); if (element && element.matches('text-bubble, card-container') && element.dataset.index !== undefined && parseInt(element.dataset.index) >= index) return; console.log(`[scrapbox-text-bubble-2]Leave No. ${index}`); this._onleave(index); }, {capture: true}); return bubble; } }