generated at
beyondTheLink
scrapboxで他のページヘの遷移・他のページを参照するUserScript



仕様(mac)
文字列を選択時cmd+ctrl+s文字列のページに遷移する
リンク内にカーソルがあるcmd+ctrl+sリンクに遷移する
文字列を選択時cmd+ctrl+p文字列のページをポップアップで表示
リンク内にカーソルがあるcmd+ctrl+pリンクのページをポップアップで表示

仕様(win)
文字列を選択時alt+s文字列のページに遷移する
リンク内にカーソルがあるalt+sリンクに遷移する
文字列を選択時alt+p文字列のページをポップアップで表示
リンク内にカーソルがあるalt+pリンクのページをポップアップで表示


導入方法
script.js
import '/api/code/jyori112/beyondTheLink/script.js';

style.css
@import '/api/code/jyori112/beyondTheLink/style.css';
UserCSSの最初に記載してください


hr
hr


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; }


style.css
.beyondTheLink_popup { position: absolute; z-index: 0; min-width: 100px; max-width: 500px; min-height: 50px; background-color: #fff; border: solid 1px #ccc; 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; border-left: solid 4px #a0a0a0; 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 { color: #fd7373; } .beyondTheLink_popup.empty heading a:hover { color: #fd7373; text-decoration: none; } .beyondTheLink_popup.empty heading a:link { color: #fd7373; text-decoration: none; } .beyondTheLink_popup.empty heading a:visited { color: #fd7373; text-decoration: none; } .beyondTheLink_popup.empty heading a:active { color: #fd7373; text-decoration: none; }

| Popup.js
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(); } }

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(); popup.setTitle("Hello World", "https://example.com/"); const titleElm = popup.elm.getElementsByTagName("heading")[0]; ctx.assertEqual(titleElm.textContent, "Hello World"); }); it("set title link", (ctx) => { const popup = new Popup(); popup.setTitle("Hello World", "https://example.com/"); const linkElm = popup.elm.getElementsByTagName("heading")[0] .getElementsByTagName("a")[0]; ctx.assertEqual(linkElm.href, "https://example.com/"); }); 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.children[0], lineElm); });

| Line Parser
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" }[dec]); } 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(match[2], this.decorator2classes(match[1]))); cur += match[0].length; } lineElm.appendChild(this.createSpan(text.substring(cur))); return lineElm; } }
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
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(' '); if (tokens[tokens.length-1].match(/^https?:\/\//)) { return {text: tokens.slice(0, tokens.length-1).join(' '), url: tokens[tokens.length-1], 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; } } } }
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"}); ctx.assertEqual(builder.url(), "https://scrapbox.io/jyori112/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"); });

test.js
run();