beyondTheLink
https://gyazo.com/b5846e414fbf69edc4611d83f83da6f7
table:仕様(mac)
文字列を選択時 cmd+ctrl+s 文字列のページに遷移する
リンク内にカーソルがある cmd+ctrl+s リンクに遷移する
文字列を選択時 cmd+ctrl+p 文字列のページをポップアップで表示
リンク内にカーソルがある cmd+ctrl+p リンクのページをポップアップで表示
table:仕様(win)
文字列を選択時 alt+s 文字列のページに遷移する
リンク内にカーソルがある alt+s リンクに遷移する
文字列を選択時 alt+p 文字列のページをポップアップで表示
リンク内にカーソルがある alt+p リンクのページをポップアップで表示
導入方法
code:script.js
import '/api/code/jyori112/beyondTheLink/script.js';
code:style.css
@import '/api/code/jyori112/beyondTheLink/style.css';
hr.icon
hr.icon
code:script.js
import { getLinkAtCursor, loadPage } from '/api/code/jyori112/userscriptUtils/script.js';
import { register } from '/api/code/jyori112/shortcutManager/script.js';
import { Popup } from '/api/code/jyori112/beyondTheLink/Popup.js';
register("BeyondTheLink")
.for('mac').on('KeyP').with('meta').with('ctrl')
.for('win').on('KeyP').with('alt')
.do(async (event) => {
event.stopImmediatePropagation();
// Get URL to Show
const urlToOpen = getPageURLToOpen();
if (!urlToOpen) {
return;
}
// Create Popup
window.popup = new Popup()
window.popup.moveToCursor();
// Load Page data to show in popup
const data = await loadPage(urlToOpen);
// Show popup
window.popup.showPage(data);
});
document.addEventListener('keydown', (event) => {
if (window.popup !== undefined) {
window.popup.delete();
window.popup = undefined;
}
});
document.addEventListener('click', (e) => {
if (window.popup !== undefined) {
window.popup.delete();
window.popup = undefined;
}
});
function getPageURLToOpen() {
const selectedText = window.getSelection().toString();
if (selectedText) {
const normalizedText = selectedText.replaceAll(/\(.+?)\/g, "$1"); return /${scrapbox.Project.name}/${encodeURIComponent(normalizedText)};
}
const linkElem = getLinkAtCursor();
if (linkElem) {
return linkElem.pathname;
}
// タイトルとして選べるものがないので、何もしない
return;
}
code:style.css
.beyondTheLink_popup {
position: absolute;
z-index: 0;
min-width: 100px;
max-width: 500px;
min-height: 50px;
box-shadow: 2px 2px 2px #ccc; padding: 5px;
font-family: "Roboto",Helvetica,Arial,"Hiragino Sans",sans-serif;
}
.beyondTheLink_popup heading {
display: block;
font-size: 120%;
border-bottom: solid 1px #ccc; margin-bottom: 5px;
}
.beyondTheLink_popup p {
margin-top: 2px;
margin-bottom: 0px;
}
.beyondTheLink_popup span.bold {
font-weight: bold;
}
.beyondTheLink_popup span.underlined {
text-decoration: underline;
}
.beyondTheLink_popup span.deleted {
text-decoration: line-through;
}
.beyondTheLink_popup p.quote {
background-color: var(--quote-bg-color, rgba(0,0,0,0.05));
display: block;
padding-left: 4px;
}
.beyondTheLink_popup a.hashtag {
margin-right: 3px;
}
.beyondTheLink_popup img.icon {
height: 1.3em;
vertical-align: top;
max-width: 100%;
max-height: 300px;
display: inline-block;
}
.beyondTheLink_popup.empty heading a {
}
.beyondTheLink_popup.empty heading a:hover {
text-decoration: none;
}
.beyondTheLink_popup.empty heading a:link {
text-decoration: none;
}
.beyondTheLink_popup.empty heading a:visited {
text-decoration: none;
}
.beyondTheLink_popup.empty heading a:active {
text-decoration: none;
}
| Popup.js
code:Popup.js
export class Popup {
constructor() {
// elementの作成
this.elm = document.createElement('div');
this.elm.classList.add('beyondTheLink_popup');
// title element
this.titleElm = document.createElement('heading');
this.elm.appendChild(this.titleElm);
// body element
this.bodyElm = document.createElement('div');
this.bodyElm.classList.add("body");
this.elm.appendChild(this.bodyElm);
}
setTitle(title, url) {
const linkElm = document.createElement('a');
linkElm.href = url;
linkElm.innerText = title;
this.titleElm.appendChild(linkElm);
}
appendLine(lineElm) {
this.bodyElm.appendChild(lineElm)
}
moveToCursor(popupElement) {
const cursorElm = document.getElementsByClassName('cursor')0; const cursorRect = cursorElm.getBoundingClientRect();
const appContainer = document.getElementById("app-container");
const appContainerRect = appContainer.getBoundingClientRect();
this.elm.style.top = (cursorRect.bottom - appContainerRect.top) + 'px';
this.elm.style.left = (cursorRect.right - appContainerRect.left) + 'px';
}
mount() {
// app-containerの下に置く(スクロールについてくるようにするため)
const appContainer = document.getElementById("app-container");
appContainer.appendChild(this.elm);
}
unmount() {
this.elm.remove();
}
}
code:test.js
import { run, it } from '/api/code/jyori112/testUserscript/script.js';
import { Popup } from './Popup.js';
it("mounts popup", (ctx) => {
const popup = new Popup();
popup.mount();
const elems = document.getElementById("app-container")
.getElementsByClassName("beyondTheLink_popup");
ctx.assertEqual(elems.length, 1);
});
it("append title text", (ctx) => {
const popup = new Popup();
const titleElm = popup.elm.getElementsByTagName("heading")0; ctx.assertEqual(titleElm.textContent, "Hello World");
});
it("set title link", (ctx) => {
const popup = new Popup();
const linkElm = popup.elm.getElementsByTagName("heading")0 .getElementsByTagName("a")0; });
it("append line", (ctx) => {
const popup = new Popup();
const lineElm = document.createElement("p");
popup.appendLine(lineElm);
const bodyElm = popup.elm.getElementsByClassName("body")0; ctx.assertEqual(bodyElm.children0, lineElm); });
| Line Parser
code:LineParser.js
export class LineParser {
createSpan(text, classes) {
const spanElm = document.createElement('span');
spanElm.innerText = text;
if (classes) {
for (const cls of classes) {
spanElm.classList.add(cls);
}
}
return spanElm;
}
decorator2classes(decorator) {
const classes = [];
for (const dec of new Set(decorator)) {
classes.push({
"*": "bold",
"_": "underline",
"-": "deleted"
}
return classes;
}
parse(text) {
const lineElm = document.createElement('p');
var cur = 0;
for (const match of text.matchAll(/\[(\*_\-+) (.+?)\]/g)) { lineElm.appendChild(this.createSpan(text.substring(cur, match.index)));
lineElm.appendChild(this.createSpan(match2, this.decorator2classes(match1))); }
lineElm.appendChild(this.createSpan(text.substring(cur)));
return lineElm;
}
}
code:test.js
import { LineParser } from './LineParser.js';
it('parse bold line', (ctx) => {
const lineParser = new LineParser();
const lineElm = lineParser.parse("test bold test");
const bold = lineElm.getElementsByClassName("bold")0; ctx.assertEqual(bold.textContent, "bold");
});
it('parse boudle bold line', (ctx) => {
const lineParser = new LineParser();
const lineElm = lineParser.parse("test bold test");
const bold = lineElm.getElementsByClassName("bold")0; ctx.assertEqual(bold.textContent, "bold");
});
it('parse underline line', (ctx) => {
const lineParser = new LineParser();
const lineElm = lineParser.parse("test underline test");
const underline = lineElm.getElementsByClassName("underline")0; ctx.assertEqual(underline.textContent, "underline");
});
it('parse deleted line', (ctx) => {
const lineParser = new LineParser();
const lineElm = lineParser.parse("test deleted test");
const deleted = lineElm.getElementsByClassName("deleted")0; ctx.assertEqual(deleted.textContent, "deleted");
});
| PopupBuilder.js
code:PopupBuilder.js
import { LineParser } from './LineParser.js';
export class PopupBuilder {
constructor(data) {
this.data = data;
this.lineParser = new LineParser();
}
title() {
return this.data.title;
}
url() {
const normalizedTitle = encodeURIComponent(this.title().replaceAll(" ", "_"));
return https://scrapbox.io/${scrapbox.Project.name}/${normalizedTitle};
}
* lineElements() {
for (const line of this.data.lines.slice(1)) {
yield this.lineParser.parse(line.text);
}
}
createLine() {
const lineElem = document.createElement('p');
return lineElem;
}
generateShellScriptLine(text) {
lineElem = this.createLine();
const code = document.createElement('code');
code.innerText = text;
code.classList.add('shellscript_line');
lineElem.append(code);
return lineElem;
}
generateHelpfeelLine(text) {
lineElem = this.createLine();
const code = document.createElement('code');
code.innerText = text;
code.classList.add('helpfeel');
lineElem.append(code);
return lineElem;
}
generateQuoteLine(elem, text) {
lineElem = this.createLine();
const span = document.createElement('span');
this.parse(span, text.substring(1).trim());
span.classList.add('quote');
lineElem.append(span);
return lineElem;
}
getUrlFromLinkText(linkText) {
if (linkText.startsWith('/')) {
return {text: linkText,
url: https://scrapbox.io${linkText},
newTab: true};
}
// 外部リンクじゃないかをチェック
const tokens = linkText.split(' ');
return {text: tokens.slice(0, tokens.length-1).join(' '),
newTab: true};
}
// 普通のリンク
return {text: linkText,
url: https://scrapbox.io/${scrapbox.Project.name}/${linkText},
newTab: false};
}
generateLinkElement(linkText) {
if (linkText.endsWith('.icon')) {
const iconPageName = linkText.slice(0, -5)
const link = document.createElement('a');
link.href = /${scrapbox.Project.name}/${iconPageName};
const iconImage = document.createElement('img');
iconImage.classList.add('icon');
iconImage.alt = iconPageName;
iconImage.title = iconPageName;
iconImage.src = /api/pages/${scrapbox.Project.name}/${iconPageName}/icon;
link.append(iconImage);
return link;
} else {
const link = document.createElement('a');
const linkInfo = this.getUrlFromLinkText(linkText);
link.innerText = linkInfo.text;
link.href = linkInfo.url;
if (linkInfo.newTab) {
link.target = "_blank";
}
return link;
}
}
fillDecoratedContent(elem, decoratorText, tokens) {
// 装飾タグの中身をパースして、elemに追加する
// decoratorTextが装飾の種類を指定する
// tokensは装飾の終了タグ(])以降も含めて良い
// - 終了タグ以降のtokenが返される
const span = document.createElement('span');
// 太字などの装飾
const decorator = new Set(decoratorText);
if (decorator.has("*")) {
span.classList.add("bold");
}
if (decorator.has("-")) {
span.classList.add("deleted");
}
if (decorator.has("_")) {
span.classList.add("underlined");
}
elem.append(span);
return this.fillLineContentsFromTokens(span, tokens, false);
}
fillLineContentsFromNormalText(elem, text) {
// 生身のテキストをパースし、elemに追加する
// 返り値はなし
for (let token of text.split(/(\s+)/)) {
if (token.startsWith('#')) {
// ハッシュタグ
const linkText = token.substring(1);
const link = document.createElement('a');
link.innerText = token;
link.href = https://scrapbox.io/${scrapbox.Project.name}/${linkText};
link.classList.add('hashtag');
elem.append(link);
} else if (token.match(/^https?:\/\//)) {
// 生のURL
const link = document.createElement('a');
link.innerText = token;
link.href = token;
link.target = '_blank';
elem.append(link);
} else {
// 普通のテキスト
const span = document.createElement('span');
span.innerText = token;
elem.append(span);
}
}
}
fillLineContentsFromTokens(elem, tokens, root) {
// scrapboxをトークン化したものをパースし、elemの中身に追加する
// 余ったトークンを返す
// root=trueなら、余ったトークンもどうにかelemの中身に入れ込む
if (tokens.length == 0) {
return [];
}
// get the first token
let token = tokens.shift();
if (token == "") {
return this.fillLineContentsFromTokens(elem, tokens, root);
}
if (token === '`') {
// look for end index
const endIndex = tokens.indexOf('`');
// get inner code
const innerCode = tokens.slice(0, endIndex).join("");
tokens = tokens.slice(endIndex + 1);
// create code element
const inlineCodeSpan = document.createElement('span');
const inlineCodeSpanCode = document.createElement('code');
inlineCodeSpanCode.innerText = innerCode;
inlineCodeSpanCode.classList.add('code');
inlineCodeSpan.append(inlineCodeSpanCode);
elem.append(inlineCodeSpan);
} else if (token === '[') {
// リンク
// リンク内に、装飾は入れないので、次の']'をリンク終わりとして認識する
const endLink = tokens.indexOf(']');
const linkText = tokens.slice(0, endLink).join('');
elem.append(this.generateLinkElement(linkText));
tokens = tokens.slice(endLink+1);
} else if (token.startsWith('[')) {
// 装飾
tokens = this.fillDecoratedContent(elem, token.slice(1), tokens);
} else if (token === ']' && !root) {
// 装飾の終わりなはずなので、return
return tokens;
} else {
// 普通のテキスト(生リンクも含む)
const span = document.createElement('span');
this.fillLineContentsFromNormalText(span, token);
elem.append(span);
}
return this.fillLineContentsFromTokens(elem, tokens, root);
}
fillLineContents(elem, text) {
// scrapbox記法をパースし、elemの中身を埋める
let tokens = text.split(/(\[\*\-_+|\|\|`)/g); return this.fillLineContentsFromTokens(elem, tokens, true);
}
generateLine(text) {
// 行の要素を作成し返す
// あとは、親要素にappendするだけ
if (text == "") {
return null;
}
// 最初の文字で、決まる系の処理
if (text.startsWith('$')) {
return this.generateShellScriptLine(text);
} else if (text.startsWith('?')) {
return this.generateHelpfeelLine(text);
} else if (text.startsWith('>')) {
return this.generateQuoteLine(text);
} else {
const elem = this.createLine();
this.fillLineContents(elem, text);
if (elem.firstChild) {
return elem;
} else {
return null;
}
}
}
* generateLines(lines) {
// すべての行のHTML要素を作成し返す
// あとは、DOMにappendするだけ
let isCodeBlock = false;
lines.shift();
for (let line of lines) {
// 最初の文字から、CodeBlockの終わりを判定する
const t0 = line.text.charAt(0);
const text = line.text.trim();
if (text.startsWith('code:')) {
isCodeBlock = true
continue;
}
if (isCodeBlock) {
if (t0 === ' ' || t0 == '\t') {
// コードブロックが続く
continue;
}
// コードブロックの終わり
isCodeBlock = false
}
yield this.generateLine(text);
}
}
build() {
// ポップアップの中身を作成する
// クリックできるように
// 他の場所をクリックしたら、ポップアップを消すようになっている
// それを止める
this.elem.onclick = (event) => {
event.stopPropagation();
};
// 存在するページなのかを確認
if (this.data.persistent) {
this.elem.classList.remove('empty');
} else {
console.log("Empty Page");
// 存在しないページ
this.elem.classList.add('empty');
}
// タイトルを作成
this.elem.append(this.generateTitle());
let count = 0;
for (let lineElem of this.generateLines(this.data.lines)) {
if (lineElem === null) {
continue;
}
this.elem.append(lineElem);
count += 1;
if (count >= 5) {
break;
}
}
}
}
code:test.js
import { PopupBuilder } from './PopupBuilder.js';
it("get title", (ctx) => {
const builder = new PopupBuilder({title: "hello world"});
ctx.assertEqual(builder.title(), "hello world");
});
it("get link", (ctx) => {
const builder = new PopupBuilder({title: "hello world"});
});
it('get plain line', (ctx) => {
const builder = new PopupBuilder({title: "hello world",
lines: text: "hello world" }, { text: "test" }});
const lineElm = builder.lineElements().next().value;
ctx.assertEqual(lineElm.textContent, "test");
});
code:test.js
run();