generated at
箇条書きを折り畳むUserScript (takker)
他にも作る人がいそうなので、suffixを付けて区別しておいた
めっちゃしっかりした出来でびっくりしたMijinko_SD
ありがとうございます!Mijinko_SDMijinko_SDMijinko_SDMijinko_SDMijinko_SD
コードブロックの中まで対応しているとは思わなかったMijinko_SD
よかったtakker
Gyazo GIFのFPSがひどく小さかったので、ffmpegでGIFを作り直した
すごいyosidercFQ2f7LRuLYPtetsuya-kkuuote増井俊之stakidooom
息をするようにコードを書いていてすごい


使い方
script.js
import { setup } from "./mod.js"; // 起動 const { cleanup } = setup(); // 終了したいときはcleanupを呼ぶ // cleanup();
戻り値で後始末関数を呼ぶやり方、今回は相性悪いな
disable() / enable() みたいな関数を呼び出して何度も切り替えられるようにしたほうが便利
たたむと下線が引かれます
色は --folding-line-color で設定してください

バグ
たまに判定がおかしくなる?
キー入力が遅くなる
setTImeout()requestAnimationFrame()でいくらかは逃してあるのだが……
別のUserScriptのせいかも

実装したいこと
すべて折り畳む
すべて展開する
page menuで有効化/無効化切り替え
青い矢印を表示せず、箇条書きのbulletをクリックしてtoggleすることはできる?yosider
アウトライナーはそういうものが多いと思う
トップレベルのインデントとかコードブロックだとbulletが表示されてないから困るか
bulletはない場合もあるので使わなかったtakker
Hide dotsを押した場合
数式行
その他UserCSSで消した

実装
大変だったtakker
大体やり方わかるしすぐ実装できるだろ、とたかをくくったらこのざまだよ
CSSの調節と折りたたみ部分の計算に特に時間がかかった
お疲れさまですcFQ2f7LRuLYP
ありがとうございますtakker
本体
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) {
前回のボタンを消して、新しく作り直す
子箇条書きがなければ消したままにする
流用してもいいんじゃない?takker
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.js
function 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(); } }
型定義ファイル
実装とは関係ない
linter errorを消すために入れたのだが、動いていなさそう……?
// @ts-check が認識されていないっぽい?
deps.d.ts
import type { Scrapbox } from "https://raw.githubusercontent.com/scrapbox-jp/types0.0.8/mod.ts"; declare const scrapbox: Scrapbox;

折り畳み箇所を計算するやつ
scrapbox.Page.linesを一回ループするだけで計算できるようなalgorithmにしてある
こうしないと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.ts
import { 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.ts
export { assertEquals } from "https://deno.land/std@0.125.0/testing/asserts.ts";


UserScript