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
自分のページに↓を追記する
code:js
import {ScrapBubble} from '/api/code/programming-notes/ScrapBubble/script.js';
new ScrapBubble();
参加していないproject
TamperMonkeyに↓を貼り付ける
code:tampermonkey.js
// ==UserScript==
// @name ScrapBubble
// @version 0.1.6
// @description n hop先のテキストを表示する
// @author takker
// @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();
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 これでもまだエラーが出る時がある
対処方法がわからんtakker.icon
code: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を吐いてしまうので注意
/icons/fail.iconURLから素直に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 ネストしないところまではできた
/icons/done.iconリンクのない場所でbubbleが表示される
どこかで発動したthis._show()をcancelしきれていない?
14:19:10 非表示にしてからDOMに入れれば解決しそう
解決しなかった……
16:18:42 多分解決している
/icons/done.icon何故か変なタイミングでbubbleが一瞬消えたりする
13:56:11 ↓を直したら一緒に直った?
15:10:57 .page-linkではなく、.page-linkの子要素に対して発生するeventに対して実行していたのが原因
これで直った
code:diff
+if (!target.matches('.page-link')) return;
-const link = target.closest('.page-link');
-if (!link) return;
+const link = target;
/icons/done.iconcard bubbleの表示位置がずれているのを直したい
DOMを挿入した後に位置を計算する必要がある
既知の問題
/icons/done.icon今表示している大本のページの逆リンクを吹き出し表示できていない
タイトルをhoverしたら表示するようにしてみたい
/icons/done.icon右端のリンクをhoverすると、text bubbleが細長く表示されてしまう
空リンクを区別できない
カーソルを置いたのにbubbleが消えてしまうことがある
dependencies
code:js
(async () => {
const {ScrapBubble} = await import('/api/code/programming-notes/ScrapBubble/script.js');
new ScrapBubble();
})();
code: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._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;
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 cards = links1hop.flatMap(({
title: _title, titleLc, descriptions, 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._listindex = {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としている
code: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) {
.match(/^\/(\w\-+)\/(.+)/)?.slice(1) ?? ''; this._listindex-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;
}
}