Porterっぽい編集バーを生やすUserScript
元のソースコードが載っている
ここにあるソースコードは多少改変してある
code:script.js
var R=()=>{let e=document.createElement("style");e.textContent=`.status-bar.left {
left: 0;
right: unset;
}
.status-bar > div:first-of-type {
border-top-left-radius: unset;
}
.status-bar > div:last-of-type {
border-top-right-radius: 3px;
},document.head.append(e);let t=document.createElement("div");return t.classList.add("status-bar","left"),document.getElementsByClassName("app")[0].append(t),t},_=R(),w=()=>{let e=document.createElement("div");_.append(e);let t;return{render:(n,r)=>{e.textContent="",t&&e.removeEventListener("touchstart",t),t=r;let c=b(...n);c&&(t&&e.addEventListener("touchstart",t),e.append(c))},dispose:()=>e.remove()}},b=(...e)=>{let t=e.flatMap(r=>{switch(r.type){case"spinner":return[m("fa","fa-spinner")];case"check-circle":return[m("kamon","kamon-check-circle")];case"exclamation-triangle":case"caret-up":case"caret-down":case"caret-left":case"caret-right":case"cut":case"expand":case"i-cursor":case"undo":case"redo":return[m("fas",fa-${r.type})];case"copy":case"clipboard":return[m("far",fa-${r.type})];case"text":return[T(r.text)];case"group":{let c=b(...r.items);return c?[c]:[]}}});if(t.length===0)return;if(t.length===1)return t[0];let n=document.createElement("span");return n.classList.add("item-group"),n.append(...t),n},T=e=>{let t=document.createElement("span");return t.classList.add("item"),t.append(e),t},m=(...e)=>{let t=document.createElement("i");return t.classList.add(...e),T(t)};var l=e=>e==null,d=e=>typeof e=="string",p=e=>typeof e=="number";var L=(e,t)=>{if(!Array.isArray(e))throw new TypeError("${t}" must be an array but actual is "${e}")};var M=(e,t)=>{if(!(e instanceof HTMLTextAreaElement))throw new TypeError("${t}" must be HTMLTextAreaElement but actual is "${e}")};var a=()=>{let e=document.getElementById("text-input");if(!!e)return M(e,"textarea#text-input"),e};function P(e){if(l(e))return;if(p(e))return f(e)?.id;if(d(e))return e.startsWith("L")?e.slice(1):e;if(e.classList.contains("line"))return e.id.slice(1);let t=e.closest(".line");if(t)return t.id.slice(1)}function f(e){if(l(e))return;if(p(e))return y()[e];let t=P(e);return t?y().find(n=>n.id===t):void 0}function F(e){return e instanceof HTMLDivElement&&e.classList.contains("line")}function y(){return L(scrapbox.Page.lines,"scrapbox.Page.lines"),scrapbox.Page.lines}function g(e){if(l(e))return;if(p(e)||d(e))return f(e)?.text;if(!(e instanceof HTMLElement))return;if(F(e))return f(e)?.text;if(e.classList.contains("char-index"))return e.textContent??void 0;if(e.classList.contains("line")||e.getElementsByClassName("lines")?.[0])return y().map(({text:r})=>r).join(
);let t=[],n=f(e);if(!l(n)){for(let r of U(e))t.push(Y(r));return n.text.slice(Math.min(...t),Math.max(...t)+1)}}function*U(e){let t=e.getElementsByClassName("char-index");for(let n=0;n<t.length;n++)yield t[0]}function $(e){return e instanceof HTMLSpanElement&&e.classList.contains("char-index")}function Y(e){if(!$(e))throw Error("A char DOM is required.");let t=e.className.match(/c-(\d+)/)?.[1];if(l(t))throw Error('.char-index must have ".c-{\\d}"');return parseInt(t)}function o(e,t){let{noModifiedKeys:n=!1,...r}=t??{},c={bubbles:!0,cancelable:!0,keyCode:X[e],...n?{}:{...r}},E=a();if(!E)throw Error("#text-input must exist.");E.dispatchEvent(new KeyboardEvent("keydown",c)),E.dispatchEvent(new KeyboardEvent("keyup",c))}var X={Backspace:8,Tab:9,Enter:13,Delete:46,Escape:27," ":32,PageUp:33,PageDown:34,End:35,Home:36,ArrowLeft:37,ArrowUp:38,ArrowRight:39,ArrowDown:40,a:65,A:65,b:66,B:66,c:67,C:67,d:68,D:68,e:69,E:69,f:70,F:70,g:71,G:71,h:72,H:72,i:73,I:73,j:74,J:74,k:75,K:75,l:76,L:76,m:77,M:77,n:78,N:78,o:79,O:79,p:80,P:80,q:81,Q:81,r:82,R:82,s:83,S:83,t:84,T:84,u:85,U:85,v:86,V:86,w:87,W:87,x:88,X:88,y:89,Y:89,z:90,Z:90,0:48,1:49,2:50,3:51,4:52,5:53,6:54,7:55,8:56,9:57,F1:113,F2:114,F3:115,F4:116,F5:117,F6:118,F7:119,F8:120,F9:121,F10:122,F11:123,F12:124,":":186,"*":186,";":187,"+":187,"-":189,"=":189,".":190,">":190,"/":191,"?":191,"@":192,"":192,"":219,"{":219,"\\":220,"|":220,"":221,"}":221,"^":222,"~":222,_:226};function u(){let e=a();if(!e)throw Error("#text-input is not found.");let t=Object.keys(e).find(n=>n.startsWith("__reactFiber"));if(!t)throw Error('div.cursor must has the property whose name starts with "__reactFiber"');return et.return.return.stateNode.props}function*s(e,t){for(let n=e;n<t;n++)yield n}function k(e=1){for(let t of s(0,e))o("z",{ctrlKey:!0})}function H(e=1){for(let t of s(0,e))o("z",{shiftKey:!0,ctrlKey:!0})}function C(e=1){for(let t of s(0,e))o("ArrowRight",{ctrlKey:!0})}function v(e=1){for(let t of s(0,e))o("ArrowLeft",{ctrlKey:!0})}function D(e=1){for(let t of s(0,e))o("ArrowUp",{ctrlKey:!0})}function B(e=1){for(let t of s(0,e))o("ArrowDown",{ctrlKey:!0})}function A(e=1){for(let t of s(0,e))o("ArrowRight",{altKey:!0})}function I(e=1){for(let t of s(0,e))o("ArrowLeft",{altKey:!0})}function S(e=1){for(let t of s(0,e))o("ArrowUp",{altKey:!0})}function K(e=1){for(let t of s(0,e))o("ArrowDown",{altKey:!0})}var x=()=>{let e=a();if(!e)throw Error("#text-input is not found.");let t=Object.keys(e).find(n=>n.startsWith("__reactFiber"));if(!t)throw Error('#text-input must has the property whose name starts with "__reactFiber"');return et.return.return.stateNode._stores};var N=()=>{for(let e of x())if("goByAction"in e)return e;throw Error('#text-input must has a "Cursor" store.')};var O=()=>{for(let e of x())if("hasSelection"in e)return e;throw Error('#text-input must has a "Selection" store.')};var h=O(),i=N(),W=[{type:"caret-left",onClick:()=>{i.focus(),u().selectedText===""?I():v()}},{type:"caret-right",onClick:()=>{i.focus(),u().selectedText===""?A():C()}},{type:"caret-up",onClick:()=>{i.focus(),u().selectedText===""?S():D()}},{type:"caret-down",onClick:()=>{i.focus(),u().selectedText===""?K():B()}},{type:"copy",onClick:async()=>{try{let{position:e,selectedText:t}=u(),n=t||g(e.line);if(!n)return;await navigator.clipboard.writeText(n)}catch(e){console.error(e),alert(`Faild to copy: ${JSON.stringify(e)})}}},{type:"cut",onClick:async()=>{try{let e=h.hasSelection(),t=h.getRange().start.line,n=e?h.getSelectedText():g(t);if(!n)return;await navigator.clipboard.writeText(n),e||h.setRange({start:{line:t,char:0},end:{line:t,char:n.length}}),i.focus(),o("Delete")}catch(e){console.error(e),alert(Faild to cut:
${JSON.stringify(e)}`)}}},{type:"undo",onClick:()=>k()},{type:"redo",onClick:()=>H()},{type:"i-cursor",onClick:()=>{i.getVisible()?i.hide():(i.focus(),i.showEditPopupMenu())}}];if(/mobile/i.test(navigator.userAgent))for(let{type:e,onClick:t}of W){let{render:n,dispose:r}=w();n({type:e},t)} ソース
縦画面だと画面外にはみ出してしまうのでペーストボタンを削除する
code:script.ts
import { Icon, useStatusBar } from "./statusBar.ts";
import {
caret,
downBlocks,
downLines,
getText,
indentBlocks,
indentLines,
insertText,
outdentBlocks,
outdentLines,
press,
redo,
takeCursor,
takeSelection,
undo,
upBlocks,
upLines,
} from "../../takker/scrapbox-userscript-std/dom.ts";
const selection = takeSelection();
const cursor = takeCursor();
const data: { type: Icon; onClick: () => void }[] = [
{
type: "caret-left",
onClick: () => {cursor.focus();caret().selectedText === "" ? outdentBlocks() : outdentLines()},
},
{
type: "caret-right",
onClick: () => {cursor.focus();caret().selectedText === "" ? indentBlocks() : indentLines()},
},
{
type: "caret-up",
onClick: () => {cursor.focus();caret().selectedText === "" ? upBlocks() : upLines()},
},
{
type: "caret-down",
onClick: () => {cursor.focus();caret().selectedText === "" ? downBlocks() : downLines()},
},
{
type: "copy",
onClick: async () => {
try {
const { position, selectedText } = caret();
const text = selectedText || getText(position.line);
if (!text) return;
await navigator.clipboard.writeText(text);
} catch (e: unknown) {
console.error(e);
alert(Faild to copy:\n${JSON.stringify(e)});
}
},
},
{
type: "cut",
onClick: async () => {
try {
const hasSelection = selection.hasSelection();
const start = selection.getRange().start.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)});
}
},
},
{
type: "clipboard",
onClick: async () => {
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)});
}
},
},
{
type: "undo",
onClick: () => undo(),
},
{
type: "redo",
onClick: () => redo(),
},
{
type: "i-cursor",
onClick: () => {
if (cursor.getVisible()) {
cursor.hide();
} else {
cursor.focus();
cursor.showEditPopupMenu();
}
},
},
];
if (/mobile/i.test(navigator.userAgent)) {
for (const { type, onClick } of data) {
const { render, dispose } = useStatusBar();
render(type }, onClick);
}
}
code:statusBar.ts
/// <reference lib="esnext" />
/// <reference lib="dom" />
const makeLeftStatusBar = (): HTMLDivElement => {
const style = document.createElement("style");
style.textContent = `.status-bar.left {
left: 0;
right: unset;
}
.status-bar > div:first-of-type {
border-top-left-radius: unset;
}
.status-bar > 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 app = document.getElementsByClassName("app")0!; app.append(statusBar);
return statusBar;
};
const bar = makeLeftStatusBar();
export interface UseStatusBarResult {
/** 取得した.status-barの領域に情報を表示する */
render: (items: Item[], onClick?: (e: TouchEvent) => void) => void;
/** 取得した.statusb-barの領域を削除する */
dispose: () => void;
}
/** .status-barの一区画を取得し、各種操作函数を返す */
export const useStatusBar = (): UseStatusBarResult => {
const status = document.createElement("div");
bar.append(status);
let listener: ((e: TouchEvent) => void) | undefined;
return {
render: (items, onClick) => {
status.textContent = "";
if (listener) status.removeEventListener("touchstart", listener);
listener = onClick;
const child = makeGroup(...items);
if (child) {
if (listener) status.addEventListener("touchstart", listener);
status.append(child);
}
},
dispose: () => status.remove(),
};
};
export interface ItemGroup {
type: "group";
items: Item[];
}
export type Icon =
| "spinner"
| "check-circle"
| "exclamation-triangle"
| caret-${"up" | "down" | "left" | "right"}
| "copy"
| "cut"
| "clipboard"
| "expand"
| "i-cursor"
| "undo"
| "redo";
export type Item =
| {
type: Icon;
}
| { type: "text"; text: string }
| ItemGroup;
const makeGroup = (...items: Item[]): HTMLSpanElement | undefined => {
const nodes = items.flatMap((item) => {
switch (item.type) {
case "spinner":
case "check-circle":
case "exclamation-triangle":
case "caret-up":
case "caret-down":
case "caret-left":
case "caret-right":
case "cut":
case "expand":
case "i-cursor":
case "undo":
case "redo":
return [makeIcon("fas", fa-${item.type})];
case "copy":
case "clipboard":
return [makeIcon("far", fa-${item.type})];
case "text":
case "group": {
const group = makeGroup(...item.items);
return group ? group : []; }
}
});
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 = (...classNames: string[]): HTMLElement => {
const i = document.createElement("i");
i.classList.add(...classNames);
return makeItem(i);
};