Porterっぽい編集バーを生やすUserScript
2024/5/25 しばらく前から動いていない
todo.icon(使うなら)
from
TODO
code:script.ts
import { addButton } from "./mod.ts";
import { porterCopy, porterCut, porterPaste, toggleCaret } from "./commands.ts";
import {
downBlocks,
downLines,
indentBlocks,
indentLines,
insertText,
outdentBlocks,
outdentLines,
upBlocks,
upLines,
redo,
undo,
} from "../scrapbox-userscript-std/dom.ts";
code:script.ts
import {
convert as convertURL,
hasURL,
} from "../URLを外部リンク記法に変換するUserScript_(TamperMonkeyなし)/mod.ts";
addButton({
display: { type: "caret-left" },
onClick: ({ cursor, selection }) => {
cursor.focus();
selection.getSelectedText() === "" ? outdentBlocks() : outdentLines();
},
});
addButton({
display: { type: "caret-right" },
onClick: ({ cursor, selection }) => {
cursor.focus();
selection.getSelectedText() === "" ? indentBlocks() : indentLines();
},
});
addButton({
display: { type: "caret-up" },
onClick: ({ cursor, selection }) => {
cursor.focus();
selection.getSelectedText() === "" ? upBlocks() : upLines();
},
});
addButton({
display: { type: "caret-down" },
onClick: ({ cursor, selection }) => {
cursor.focus();
selection.getSelectedText() === "" ? downBlocks() : downLines()
},
});
addButton({
display: { type: "cut" },
onClick: async ({ cursor, selection }) => await porterCut(cursor, selection),
});
/*addButton({
display: { type: "copy" },
onClick: async ({ cursor, selection }) => await porterCopy(cursor, selection),
});*/
/*addButton({
display: { type: "clipboard" },
onClick: async ({ cursor }) => await porterPaste(cursor),
});*/
addButton({
display: ({ selection }) => hasURL(selection.getSelectedText()) ? "URL" : "",
onClick: async ({ selection }) => {
const text = selection.getSelectedText();
const converted = await convertURL(text);
if (text === converted) return;
await insertText(converted);
},
});
addButton({
display: { type: "undo" },
onClick: () => undo(),
});
addButton({
display: { type: "redo" },
onClick: () => redo(),
});
/*
addButton({
display: ({ cursor }) => cursor.getVisible() ?
{ type: "i-cursor" },
onClick: ({ cursor }) => toggleCaret(cursor),
});
*/
code:commands.ts
import type { Cursor, Selection } from "../scrapbox-userscript-std/dom.ts";
import {
getText,
insertText,
press,
} from "../scrapbox-userscript-std/dom.ts";
/** porterの挙動に合わせたcopy command */
export const porterCopy = async (cursor: Cursor, selection: Selection): Promise<void> => {
try {
const text = selection.getSelectedText() || getText(cursor.getPosition().line);
if (!text) return;
await navigator.clipboard.writeText(text);
} catch (e: unknown) {
console.error(e);
alert(Faild to copy:\n${JSON.stringify(e)});
}
};
/** porterの挙動に合わせたcut command */
export const porterCut = async (cursor: Cursor, selection: Selection): Promise<void> => {
try {
const hasSelection = selection.hasSelection();
const start = hasSelection ?
selection.getRange().start.line :
cursor.getPosition().line;
const text = hasSelection
? selection.getSelectedText()
: getText(start);
if (!text) return;
await navigator.clipboard.writeText(text);
if (!hasSelection) {
selection.setRange({
start: { line: start, char: 0 },
end: { line: start, char: text.length },
});
}
cursor.focus();
press("Delete");
} catch (e: unknown) {
console.error(e);
alert(Faild to cut:\n${JSON.stringify(e)});
}
};
/** porterの挙動に合わせたpaste command */
export const porterPaste = async (cursor: Cursor): Promise<void> => {
try {
const text = await navigator.clipboard.readText();
if (!text) return;
cursor.focus();
await insertText(text);
} catch (e: unknown) {
console.error(e);
alert(Faild to paste:\n${JSON.stringify(e)});
}
};
/** cursorを表示を切り替える
*
* mobileだとpopup menuを表示するcommandとしても使える
*/
export const toggleCaret = (cursor: Cursor) => {
if (cursor.getVisible()) {
cursor.hide();
} else {
cursor.focus();
cursor.showEditPopupMenu();
}
};
code:mod.ts
import { Button, ButtonComponent, Context, makeButton } from "./button.ts";
import { makeLeftStatusBar } from "./statusBar.ts";
import { takeStores } from "../scrapbox-userscript-std/dom.ts";
import type { Scrapbox } from "../scrapbox-jp%2Ftypes/userscript.ts";
declare const scrapbox: Scrapbox;
export type { Button, Context };
let animationId: number | undefined;
/** 現在登録されているボタンの描画情報 */
const buttons = new Set<ButtonComponent>();
const { cursor, selection } = takeStores();
const statusBar = makeLeftStatusBar();
/** 全てのボタンを削除する
*
* @param context ここに指定されたページの種類で表示するボタンのみ削除する。何も指定しないときは全てのボタンを削除する */
export const removeAllButtons = (context?: Context): void => {
if (!context) {
statusBar.textContent = "";
buttons.clear();
return;
}
for (const button of buttons) {
if (button.context !== context) continue;
button.status.remove();
buttons.delete(button);
}
};
/** ボタンを追加する
*
* @param init ボタンの設定
* @return 削除用函数
*/
export const addButton = (init: Button): () => void => {
const button = makeButton(init);
buttons.add(button);
statusBar.append(button.status);
return () => {
button.status.remove();
buttons.delete(button);
};
};
event loop
code:mod.ts
// カーソル位置と選択範囲の変化で描画し直す
const update = () => {
for (const { update } of buttons) {
update();
}
};
cursor.addChangeListener(() => update());
selection.addChangeListener(() => update());
scrapbox.addListener("layout:changed", update);
ボタン
code:button.ts
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
import { Item, makeGroup } from "./item.ts";
import { Cursor, Selection, takeStores } from "../scrapbox-userscript-std/dom.ts";
import type { Scrapbox } from "../scrapbox-jp%2Ftypes/userscript.ts";
declare const scrapbox: Scrapbox;
const { cursor, selection } = takeStores();
/** ボタンの設定 */
export interface Button {
/** .status-bar > div につけるclass name */
className?: string;
/** ボタンに表示するアイテム
*
* 空文字を渡すと非表示になる
*
* 函数系は、カーソルが動くたびに呼び出される
*/
display: Item | Item[] | ((props: Omit<ClickProps, "setDisplay">) => Item | Item[]);
onClick: (props: ClickProps) => void;
/** ボタンを表示するページの種類
*
* トップページやstreamなど、特殊なページで使いたいボタンがあれば指定する
*
* @default "page"
*/
context?: Context;
};
export interface ClickProps {
cursor: Cursor;
selection: Selection;
setDisplay: (...items: Item[]) => void;
}
export type Context = "page" | "stream" | "list";
/** ボタンの描画情報 */
export interface ButtonComponent {
status: HTMLDivElement;
context: Context;
update: () => void;
}
export const makeButton = (init: Button): ButtonComponent => {
const { className, display, onClick, context = "page" } = init;
const status = document.createElement("div");
if (className) status.classList.add(className);
if (!isContext(context)) status.style.display = "none";
const setDisplay = (...items: Item[]) => {
// 空文字の場合は非表示にする
if (items.length === 1 && items0 === "") { status.style.display = "none";
return;
}
status.textContent = "";
const group = makeGroup(...items);
if (group) status.append(group);
};
const itemList = typeof display === "function" ? display({ cursor, selection }) : display;
setDisplay(...(Array.isArray(itemList) ? itemList : itemList)); //tatus.addEventListener("click", (e) => {
status.addEventListener("touchstart", (e) => {
e.preventDefault();
e.stopPropagation();
onClick({ cursor, selection, setDisplay });
});
const update = () => {
if (isContext(context)) {
status.removeAttribute("style");
} else {
status.style.display = "none";
}
if (typeof display === "function") {
const itemList = display({ cursor, selection });
setDisplay(...(Array.isArray(itemList) ? itemList : itemList)); }
};
return { status, context, update };
};
const isContext = (context: Context): boolean =>
context !== "stream" ?
scrapbox.Layout === context :
scrapbox.Layout === "list" && location.pathname.startsWith("/stream");
status bar
code:statusBar.ts
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
export const makeLeftStatusBar = (): HTMLDivElement => {
const style = document.createElement("style");
style.textContent = `.status-bar.left {
position: absolute;
top: 0;
left: 0;
right: unset;
// max-width: 80vw; // なぜか左端が切れてしまうので無効化
overflow-x: auto;
overflow-y: hidden;
}
.status-bar.left:empty {
display: none;
}
.status-bar.left > div {
border-left: unset;
}
.status-bar.left > div {
border-right: 1px solid var(--tool-light-color, #a9aaaf); }
.status-bar.left > div:first-of-type {
border-top-left-radius: unset;
}
.status-bar.left > div:last-of-type {
border-top-right-radius: 3px;
}`;
document.head.append(style);
const statusBar = document.createElement("div");
statusBar.classList.add("status-bar", "left");
const footer = document.getElementsByClassName("footer")0!; footer.append(statusBar);
return statusBar;
};
ボタンの中に表示するやつ
code:item.ts
export interface ItemGroup {
type: "group";
items: Item[];
}
export type Icon =
| "spinner"
| "check-circle"
| "gyazo"
| "ocr"
| "trim"
| "exclamation-triangle"
| caret-${"up" | "down" | "left" | "right"}
| align-${"left" | "center" | "justify" | "right"}
| "copy"
| "cut"
| "clipboard"
| "expand"
| "strikethrough"
| "i-cursor"
| "undo"
| "redo"
| "times"
| "slash"
| "ban"
| "markdown"
| "link"
| "unlink"
| "bold"
| "strikethrough"
| "highlighter"
| "remove-format"
| "italic"
| "marker"
| "google"
| "code"
| "search"
| "language"
| "underline";
export type Item =
| string
| {
}
| { type: "text"; text: string }
| ItemGroup;
export const makeGroup = (...items: Item[]): HTMLSpanElement | undefined => {
const nodes = items.flatMap((item) => {
if (typeof item === "string") {
}
if (Array.isArray(item.type)) {
}
switch (item.type) {
case "text":
case "group": {
const group = makeGroup(...item.items);
return group ? group : []; }
default:
}
});
if (nodes.length === 0) return;
if (nodes.length === 1) return nodes0; const span = document.createElement("span");
span.classList.add("item-group");
span.append(...nodes);
return span;
};
const makeItem = (child: string | Node): HTMLSpanElement => {
const span = document.createElement("span");
span.classList.add("item");
span.append(child);
return span;
};
const makeIcon = (icon: Icon | Icon, Icon): HTMLSpanElement => { if (Array.isArray(icon)) {
const stack = document.createElement("span");
stack.classList.add("fa-stack");
const icon1 = makeIconBase(icon0); icon1.classList.add("fa-stack-1x");
const icon2 = makeIconBase(icon1); icon2.classList.add("fa-stack-1x");
stack.append(icon1, icon2);
return makeItem(stack);
}
return makeItem(makeIconBase(icon));
};
const makeIconBase = (icon: Icon): HTMLElement => {
const i = document.createElement("i");
switch (icon) {
case "spinner":
i.classList.add("fa", "fa-spinner");
break;
case "check-circle":
case "gyazo":
case "ocr":
case "trim":
i.classList.add("kamon", kamon-${icon});
break;
case "markdown":
case "google":
i.classList.add("fab", fa-${icon});
break;
case "copy":
case "clipboard":
i.classList.add("far", fa-${icon});
break;
default:
i.classList.add("fas", fa-${icon});
break;
}
return i;
};
https://gyazo.com/6d51e917def8d659f1c7dbab8ca2517e https://apps.apple.com/jp/app/porter-for-scrapbox/id1305805708