Porterっぽい編集バーを生やすUserScript@1.0.0
変更点
破壊的変更:interfaceを完全に変更した
proxyかまして読めるようにした
scrapboxからコードを取得するときコケてる?
これだとinterfaceを解説するのが面倒だな
まあ各自でコードに書かれたJSDocを読んでください もしくは deno doc https://scrapbox.io https://scrapbox.io/api/code/takker/Porterっぽい編集バーを生やすUserScript@1.0.0/script.ts
をterminalで実行すると読めます
機能
カーソル及び選択範囲が変化するたびにボタンの表示を変更できる
カーソルと選択範囲を操作する函数をevent listenerで使える
使えるアイコンの数を増やした
アイコンを合成できるようにした
トップページやStreamでは
改善
CSSの修正
これで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 予想通り衝突した
スクロールできなくなった
これが有力候補
コードをそれなりに書き換える必要がある
検討事項
onClick
も書き換えられるようにするか?
そこまでするくらいなら、ボタンを削除して作り直したほうが早そう
2段にするやつ
うーん、簡単にCSSをカスタマイズできればいいんだけどなー
UserCSSで別途組み込む案もあるが、UserScriptだけで完結できないのがめんどい
CSSを差し替えるoptionをつけるか?
ボタンを押したことがわかるようなanimationをつけたい
肝心の色が思いつかない……
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ミスってた
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.tsimport { 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.tsimport 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.tsimport { 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]));
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.tsexport 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;
};