文字列を選択時 | cmd+ctrl+s | 文字列のページに遷移する |
リンク内にカーソルがある | cmd+ctrl+s | リンクに遷移する |
文字列を選択時 | cmd+ctrl+p | 文字列のページをポップアップで表示 |
リンク内にカーソルがある | cmd+ctrl+p | リンクのページをポップアップで表示 |
文字列を選択時 | alt+s | 文字列のページに遷移する |
リンク内にカーソルがある | alt+s | リンクに遷移する |
文字列を選択時 | alt+p | 文字列のページをポップアップで表示 |
リンク内にカーソルがある | alt+p | リンクのページをポップアップで表示 |
script.jsimport '/api/code/jyori112/beyondTheLink/script.js';
style.css@import '/api/code/jyori112/beyondTheLink/style.css';
script.jsimport { 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.jsexport 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.jsimport { 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);
});
LineParser.jsexport 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.jsimport { 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.jsimport { 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.jsimport { 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();