generated at
Porterっぽい編集バーを生やすUserScript@1.0.0
Porterっぽい編集バーを生やすUserScript@0.1.0を受けて出てきた不満点を受けて浮かんだ新しいinterfaceで実装した

変更点
破壊的変更:interfaceを完全に変更した
proxyかまして読めるようにした
なんでか分からないけど、doc.deno.landでdocumentを出せないな……
scrapboxからコードを取得するときコケてる?
これだとinterfaceを解説するのが面倒だな
まあ各自でコードに書かれたJSDocを読んでくださいtakker
もしくは deno doc https://scrapbox.io https://scrapbox.io/api/code/takker/Porterっぽい編集バーを生やすUserScript@1.0.0/script.ts をterminalで実行すると読めます
機能
カーソル及び選択範囲が変化するたびにボタンの表示を変更できる
カーソルと選択範囲を操作する函数をevent listenerで使える
使えるアイコンの数を増やした
アイコンを合成できるようにした
トップページやStreamでは
改善
CSSの修正
ボタンを押したときにEvent.stopPropagation()Event.preventDefault()を実行するようにした
これでsafariとかでもうまく動くかな?

実装
ボタンが一つもなかったら .status-bar を非表示にする
Item string も指定できるようにする
onClick に各種操作objectsを渡す
setDisplay()
アイコンの表示を書き換えるやつ
Cursor
Selection

実装したいこと
layoutが "page" 以外のときは ClickProps をなくしたほうがよさそう
✅caretを消すのは、popupが表示されていて、かつ文字入力できる時のみにする
popupが消えたときtoggleCaretを実行するとpopupを復活させる
2023-05-20 17:22:03 これ以外に、ボタンを2回押さないといけないパターンが有る
textinputから一旦focusが外れてからでないと、ボタンを押せない

バグ
対策候補
touchstart を使う
仮に解決しても、今度はスクロール操作と衝突してしまうかも
2022-05-21 17:08:27 予想通り衝突した
スクロールできなくなった
capture phaseでlistenする
これが有力候補
コードをそれなりに書き換える必要がある

検討事項
onClick も書き換えられるようにするか?
そこまでするくらいなら、ボタンを削除して作り直したほうが早そう
2段にするやつ
うーん、簡単にCSSをカスタマイズできればいいんだけどなー
UserCSSで別途組み込む案もあるが、UserScriptだけで完結できないのがめんどい
CSSを差し替えるoptionをつけるか?
ボタンを押したことがわかるようなanimationをつけたい
:activeで色を変えればいいようだ
肝心の色が思いつかない……

2024-04-11
09:23:44 calendar アイコンを追加
2022-10-05
05:28:42 ScrapboxのDOM変更に対応
遅くとも assets-20221004-060430 以降で変更された
.status-bar .app から .app > .footer に移動した
この影響で、 .status-bar.left が表示されなくなってしまった
.app > .footer に移動し、更に position: absolute を明示することで対応する
2022-06-19
17:35:43 対応アイコンを増やした
default を使うことで、コード変更箇所を少なくできた
2022-05-21
17:10:15 touchstartをclickに戻した
2022-05-13
18:20:45 CSSミスってた
Scrapboxの通信ステータスの方にまで適用されてしまっていた
20:27:55 削除処理で buttons からアイテムを削除するのを忘れていた
配列からSetに変えて、途中の要素を簡単に削除できるようにした
20:08:22 長めのコマンドを別のmoduleに切り出した
20:00:29 問題なさそうなのでrelease
19:51:51 縦方向のスクロールバーを出さない
19:50:02 border-left border-right に変更
18:37:21 型チェック通した

使用例
$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/Porterっぽい編集バーを生やすUserScript@1.0.0/script.ts
script.ts
import { addButton } from "./mod.ts"; import { porterCopy, porterCut, porterPaste, toggleCaret } from "./commands.ts"; import { downBlocks, downLines, indentBlocks, indentLines, outdentBlocks, outdentLines, upBlocks, upLines, redo, undo, } from "../scrapbox-userscript-std/dom.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: "copy" }, onClick: async ({ cursor, selection }) => await porterCopy(cursor, selection), }); addButton({ display: { type: "cut" }, onClick: async ({ cursor, selection }) => await porterCut(cursor, selection), }); addButton({ display: { type: "clipboard" }, onClick: async ({ cursor }) => await porterPaste(cursor), }); addButton({ display: { type: "undo" }, onClick: () => undo(), }); addButton({ display: { type: "redo" }, onClick: () => redo(), }); addButton({ display: ({ cursor }) => cursor.getVisible() && cursor.hasFocus() // @ts-ignore private扱いだけど、これしかアクセス方法がないため使わせていただく && cursor.visiblePopupMenu ? { type: ["i-cursor", "slash"] } : { type: "i-cursor" }, onClick: ({ cursor }) => toggleCaret(cursor), });

command例
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.hasFocus // @ts-ignore private扱いだけど、これしかアクセス方法がないため使わせていただく && cursor.visiblePopupMenu) { cursor.hide(); } else { cursor.focus(); cursor.showEditPopupMenu(); } };

$ deno check --remote -r=https://scrapbox.io https://scrapbox.io/api/code/takker/Porterっぽい編集バーを生やすUserScript@1.0.0/mod.ts
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
ScrapboxのcursorScrapboxの選択範囲が変化するたびに再描画する
scrapbox.Layoutが変化したときも再描画する
これは表示非表示さえ変わればいいので、ボタンの中身の再描画は余計
そのうち直すかも
mod.ts
// カーソル位置と選択範囲の変化で描画し直す const update = () => { for (const { update } of buttons) { update(); } }; cursor.addChangeListener(() => update()); selection.addChangeListener(() => update()); scrapbox.addListener("layout:changed", update);

描画処理
ボタン
useStatusBar()との違い
文字列を直接指定できる

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])); status.addEventListener("click", (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; 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" | "calendar" | "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 "calendar": 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; };

#2024-04-11 09:23:37
#2023-05-20 17:22:58
#2023-05-19 19:10:31
#2022-10-05 05:33:22
#2022-06-19 16:34:20
#2022-05-21 17:09:59
#2022-05-15 18:21:14
#2022-05-13 16:52:51