generated at
Porterっぽい編集バーを生やすUserScript
2024/5/25 しばらく前から動いていない
todo(使うなら)

from

TODO
Porterっぽい編集バーを生やすUserScript#62e02349e5172d000064afbcを範囲選択せずに実行できるようにする


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";
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", "slash"] } : { type: "i-cursor" }, onClick: ({ cursor }) => toggleCaret(cursor), }); */

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

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
mod.ts
// カーソル位置と選択範囲の変化で描画し直す const update = () => { for (const { update } of buttons) { update(); } }; cursor.addChangeListener(() => update()); selection.addChangeListener(() => update()); scrapbox.addListener("layout:changed", update);

ボタン
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 && items[0] === "") { 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
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; };

ボタンの中に表示するやつ
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: Icon | [Icon, Icon]; } | { type: "text"; text: string } | ItemGroup; export const makeGroup = (...items: Item[]): HTMLSpanElement | undefined => { const nodes = items.flatMap((item) => { if (typeof item === "string") { return [makeItem(item)]; } if (Array.isArray(item.type)) { return [makeIcon(item.type)]; } switch (item.type) { case "text": return [makeItem(item.text)]; case "group": { const group = makeGroup(...item.items); return group ? [group] : []; } default: return [makeIcon(item.type)]; } }); if (nodes.length === 0) return; if (nodes.length === 1) return nodes[0]; 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(icon[0]); icon1.classList.add("fa-stack-1x"); const icon2 = makeIconBase(icon[1]); 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; };