script.jsimport { setup } from "./mod.js";
// 起動
const { cleanup } = setup();
// 終了したいときはcleanupを呼ぶ
// cleanup();
disable()
/ enable()
みたいな関数を呼び出して何度も切り替えられるようにしたほうが便利 --folding-line-color
で設定してくださいmod.js// @ts-check
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
/// <reference lib="./deps.d.ts" />
import { findFoldings } from "./folding.js";
/** エントリポイント
*
* @return {{ cleanup: () => void }} 後始末函数など
*/
export const setup = () => {
render();
scrapbox.addListener("layout:changed", render);
/** @type {number | undefined} */
let timer;
const callback = () => {
clearTimeout(timer);
timer = setTimeout(render, 500);
};
scrapbox.addListener("lines:changed", callback);
return {
cleanup: () => {
clearButtons();
removeCSS("folding-lines");
scrapbox.removeListener("layout:changed", render);
scrapbox.removeListener("lines:changed", callback);
},
};
};
function render() {
if (scrapbox.Layout !== "page") return;
/** 非表示にする行のID list
* @type {string[]}
*/
let hiddenIds = [];
/** @type {number | undefined} */
let animationId;
/** @type {(updater: (ids: string[]) => string[]) => void} */
const setIds = (updater) => {
hiddenIds = updater(hiddenIds);
// 再描画するときだけCSSを更新する
cancelAnimationFrame(animationId);
animationId = requestAnimationFrame(
() => {
writeCSS(
"folding-lines",
`div:is(${hiddenIds.map((id) => `#${id}`).join(", ")}) { display: none; }`,
)
},
);
};
for (const { id, indent, children } of findFoldings(scrapbox.Page.lines)) {
const line = document.getElementById(`L${id}`);
renderButton(line, indent, children.map((lineId) => `L${lineId}`), setIds);
}
}
/**
* @param {string} id
* @param {string} css
*/
function writeCSS(id, css) {
/** @type {HTMLStyleElement | null} */
let style = document.querySelector(`head style[data-style-name="${id}"]`);
if (!style) {
style = document.createElement("style");
style.dataset.styleName = id;
document.head.append(style);
}
style.textContent = css;
}
function removeCSS(id) {
document.querySelector(`head style[data-style-name="${id}"]`)?.remove?.();
}
/** 折り畳みボタンを描画する
*
* @param {HTMLDivElement} line 描画先の行のDOM
* @param {number} indent 描画先の行のインデントの深さ
* @param {string[]} childIds 子箇条書きのID list
* @param {(updater: (ids: string[]) => string[]) => void} setIds 非表示にする行のIDを更新する函数
* @return {void}
*/
function renderButton(line, indent, childIds, setIds) {
mod.js const oldButton = line.getElementsByClassName("button folding")[0];
let open = oldButton?.classList?.contains?.("open") ?? true;
oldButton?.remove?.();
if (childIds.length === 0) return;
mod.js const i = document.createElement("i");
i.className = `fas fa-caret-${open ? "down" : "right"}`;
i.style.minWidth = "0.5em";
const button = document.createElement("a");
button.type = "button";
button.classList.add("button", "folding", "open");
button.style.position = "absolute";
const head = line.getElementsByClassName("indent")[0]
?? line.getElementsByClassName("char-index")[0];
const offset = line.getBoundingClientRect().left;
const left = indent === 0 ? offset : head.getBoundingClientRect().left;
button.style.left = `calc(${Math.round(left - offset)}px - ${
line.getElementsByClassName("code-body").length > 0 ? 2.0 : 1.0
}em)`;
button.style.fontSize = "20px";
button.style.zIndex = "1000";
button.append(i);
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (open) {
setIds(
(hiddenIds) => {
hiddenIds.push(...childIds);
return hiddenIds;
},
);
i.className = `fas fa-caret-right`;
line.style.textDecoration = "solid underline var(--folding-line-color, orange) 2px";
open = false;
} else {
setIds(
(hiddenIds) =>
hiddenIds.filter(
(id) => !childIds.includes(id),
),
);
i.className = `fas fa-caret-down`;
line.style.textDecoration = "";
open = true;
}
});
line.insertAdjacentElement("afterbegin", button);
}
mod.jsfunction clearButtons() {
for (const button of Array.from(document.querySelectorAll("div.line > .button.folding"))) {
const line = button.closest("div.line");
if (line) {
line.style.textDecoration = "";
}
button.remove();
}
}
// @ts-check
が認識されていないっぽい?deps.d.tsimport type { Scrapbox } from "https://raw.githubusercontent.com/scrapbox-jp/types0.0.8/mod.ts";
declare const scrapbox: Scrapbox;
folding.js/** 行を走査して、折り畳める部分を返す
*
* @param {{ text: string; id: string }[]} lines 行のリスト
* @return {Generator<{ id: string; indent: number; children: string[] }, unknown, void>}
*/
export function* findFoldings(lines) {
/** 1番目が行番号、2番目がindentの数
*
* @type {[number, number]}
*/
const parentPos = [];
let index = -1;
for (const line of lines) {
index++;
const indent = getIndentCount(line.text);
for (const [parentIndex, parentIndent] of [...parentPos]) {
if (parentIndent < indent) break;
yield {
id: lines[parentIndex].id,
indent: parentIndent,
children: lines.slice(parentIndex + 1, index).map(({ id }) => id),
};
parentPos.shift();
}
parentPos.unshift([index, indent]);
}
/** @type {string[]} */
const children = [];
// 余りは必ず階層構造になっているはず
for (const [index, indent] of parentPos) {
const id = lines[index].id;
yield { id, indent, children: [...children] };
children.unshift(id);
}
}
/** 行のindentを返す
*
* @param {string} text
* @return {number}
*/
const getIndentCount = (text) => text.match(/^(\s*)/)?.[1]?.length ?? 0;
$ deno test https://scrapbox.io/api/code/villagepump/箇条書きを折り畳むUserScript_(takker)/folding_test.ts
folding_test.tsimport { findFoldings } from "./folding.js";
import { assertEquals } from "./deps_test.ts";
Deno.test("findFoldings()", async (t) => {
await t.step("general", () => {
const lines = [
" aa",
"a",
"a",
"",
"ccc",
" d",
" d",
" d",
" d",
" d",
" d",
" d",
" d",
"d",
" d",
"",
" d",
"",
"a",
" b",
" c",
].map((text, i) => ({ text, id: `${i}` }));
assertEquals([...findFoldings(lines)], [
{ id: "0", indent: 1, children: [] },
{ id: "1", indent: 0, children: [] },
{ id: "2", indent: 0, children: [] },
{ id: "3", indent: 0, children: [] },
{ id: "5", indent: 1, children: [] },
{ id: "8", indent: 4, children: [] },
{ id: "7", indent: 2, children: ["8"] },
{ id: "9", indent: 2, children: [] },
{ id: "10", indent: 2, children: [] },
{ id: "6", indent: 1, children: ["7", "8", "9", "10"] },
{ id: "12", indent: 2, children: [] },
{ id: "11", indent: 1, children: ["12"] },
{ id: "4", indent: 0, children: ["5", "6", "7", "8", "9", "10", "11", "12"] },
{ id: "14", indent: 2, children: [] },
{ id: "13", indent: 0, children: ["14"] },
{ id: "16", indent: 2, children: [] },
{ id: "15", indent: 0, children: ["16"] },
{ id: "17", indent: 0, children: [] },
{ id: "20", indent: 2, children: [] },
{ id: "19", indent: 1, children: ["20"] },
{ id: "18", indent: 0, children: ["19", "20" ] },
]);
});
await t.step("no indent", () => {
const lines = [
"a",
"a",
"a",
].map((text, i) => ({ text, id: `${i}` }));
assertEquals([...findFoldings(lines)], [
{ id: "0", indent: 0, children: [] },
{ id: "1", indent: 0, children: [] },
{ id: "2", indent: 0, children: [] },
]);
})
});
deps_test.tsexport { assertEquals } from "https://deno.land/std@0.125.0/testing/asserts.ts";