ScrapBubble
普通にAPIを1リンクづつfetchすればいい
実装
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を直ちに消す
bubbleの表示方法
targetのDOM渡す必要ないな
表示したい場所、リンクに被らないように開けておく空白の幅、リンクのpath (projectとtitle)さえわかればいい
お試し
参加しているproject
自分のページに↓を追記する
jsimport {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/
のときに誤認識してしまう
scrapbox
が使えるようになるまでbuzy loopする
2021-05-03 15:45:40 これでもまだエラーが出る時がある
対処方法がわからん

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を吐いてしまうので注意

URLから素直に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 空ページ判定にミスがあったのを直した
16:06:00 n hop先リンクを全て表示できるようにした
2つまではいく
3つめのリンクをhoverしても、bubbleが表示されない
16:09:44 document.elementFromPoint()
が null
のときの対処を入れてconsole errorを抑制したら直った
13:50:22 ネストしないところまではできた

リンクのない場所でbubbleが表示される
どこかで発動した this._show()
をcancelしきれていない?
14:19:10 非表示にしてからDOMに入れれば解決しそう
解決しなかった……
16:18:42 多分解決している

何故か変なタイミングで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;

card bubbleの表示位置がずれているのを直したい
DOMを挿入した後に位置を計算する必要がある
既知の問題
今表示している大本のページの逆リンクを吹き出し表示できていないタイトルをhoverしたら表示するようにしてみたい
右端のリンクをhoverすると、text bubbleが細長く表示されてしまう空リンクを区別できない
カーソルを置いたのにbubbleが消えてしまうことがある
dependencies
js(async () => {
const {ScrapBubble} = await import('/api/code/programming-notes/ScrapBubble/script.js');
new ScrapBubble();
})();
script.jsimport {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;
}
}