generated at
scrapbox-history-slider@0.2.1
履歴スライダーをPreactで実装したもの


使い方
お試し
js
import('/api/code/programming-notes/scrapbox-history-slider@0.2.1/script.js');
bundle用
preactを既に自分のprojectに入れている人向け
sh
curl -o script.js https://scrapbox.io/api/code/programming-notes/scrapbox-history-slider@0.2.1/script.js && esbuild script.js --bundle --minify --format=esm --external:../htm@3.0.4%2Fpreact/script.js --external:../preact@10.5.13/hooks.js --outfile=script.min.js

履歴データをscrapbox-snapshot@0.1.0で変換する
履歴の日時を付与できるようにした
表示は不完全
UIの配色の調節
page snapshotからも履歴を生成できるようにした

実装したいこと
具体的な更新日時を表示したい
<input> title 属性をもたせる
脇に更新日時を表示する
sliderに目盛りをつける
1時間前
1日前
1週間前
etc.
読み込みをweb workerに委託する
巨大なページの差分を計算していると時間がかかる
読み込み中表示を詳細に出したい
以下の3項目には最低限分けたい
web workerの用意
頻繁に使う機能ではないので、開くたびに生成・破棄してもperformance上の問題はないだろう
データのfetch
履歴の変換
進捗も出す
page snapshotを細かくしたい
行の更新日時ごとに細かく差分を表示できるようにする
これはscrapbox-snapshot@0.1.0の機能として作ったほうがよさそう

2021-07-04
20:21:40 枠のかどを少し丸めて、厚みを増やした
20:10:11 page snapshotも表示できるようにした
19:43:19 rangeが変更されたときだけsliderの位置をresetする
19:32:19 sliderと履歴の間に区切り線を入れた
空白が空行なのかcomponentの余白なのかがわかりにくかった
19:23:54 枠の色と厚みを変えた
19:19:54 文字色と背景色を直した
19:18:11 行が生成される前の時刻を getSnapshot() に渡すとエラーになっていた
19:11:05 scrapbox-snapshot@0.1.0を使ったcodeに書き換えた

dependencies
script.js
import {html, render} from '../htm@3.0.4%2Fpreact/script.js'; import {useState, useRef, useEffect} from '../preact@10.5.13/hooks.js'; import {useLoader} from '../use-loader/script.js'; import {convert} from '../scrapbox-snapshot@0.1.0/script.js'; const App = () => { const [open, setOpen] = useState(false); const [max, setMax] = useState(0); const [index, setIndex] = useState(0); const [range, setRange] = useState([]); const [snapshot, setSnapshot] = useState(() => () => ''); const {loading} = useLoader(async () => { if (!open) return; const {range, getSnapshot} = await createSnapshot(); setRange(old => JSON.stringify(old) === JSON.stringify(range) ? old : range); setSnapshot(() => getSnapshot); }, {delay: 1000}, [open]); // rangeが変更されたときだけsliderの位置をresetする useEffect(() => { setMax(range.length - 1); setIndex(range.length - 1); }, [range]); useEffect(() => { scrapbox.PageMenu.addMenu({ title: '履歴スライダー', image: 'https://1.bp.blogspot.com/-UZtkSEX0wh4/U5l5_dNcEsI/AAAAAAAAhWs/UzJGVzyiX8Y/s800/kaichu_dokei.png', onClick: () => setOpen(true), }); }, []); const close = () => setOpen(false); const onSliderChange = ({target: {value}}) => setIndex(parseInt(value)); return html` <style> :host { color: var(--page-text-color, #4a4a4a); } .background { position: fixed; top: 0; right: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.4); z-index: 89999; } .container { position: fixed; top: 5px; left: 5px; width: calc(100% - 10px); max-height: 80vh; background-color: var(--page-bg, #fefefe); border-radius: 4px; border: 2px solid var(--body-bg, #dcdde0); z-index: 90000; } .close-button { font-size: 30px; line-height: 1em; padding: 0; width: 30px; height: 30px; } pre { width: 100%; max-height: 70vh; /* なぜか100%だとはみ出てしまった */ overflow-y: auto; font-family: var(--history-slider-pre-font, Menlo,Monaco,Consolas,"Courier New",monospace); word-break: break-all; word-wrap: break-word; white-space: pre-wrap; } input { width: 90%; } </style> ${open && html` <div class="background" onClick="${close}"/> <div class="container"> <div style="display: inline"> <input type="range" max="${max}" min="0" step="1" value="${index}" title="${new Date(range[index] * 1000)}" onInput="${onSliderChange}" /> <button class="close-button" onClick="${close}">x</button> </div> <hr /> ${loading && 'Loading...'} <pre>${snapshot(range[index])}</pre> </div> `} `; }; const app = document.createElement('div'); app.dataset.userscriptName= 'history-slider'; app.attachShadow({mode: 'open'}); document.body.append(app); render(html`<${App} />`, app.shadowRoot);

履歴を作成する
script.js
async function createSnapshot() { const [commitHistory, pageHistory] = await Promise.all([getCommitsHistory(), getPageSnapshots()]); const getSnapshot = time => { // 範囲外ならpageHistoryから取得する if (!commitHistory.range.includes(time)) { if (!pageHistory.range.includes(time)) return ''; return pageHistory.pages[time].map(line => line.text).join('\n'); } return commitHistory.history.flatMap(({id, snapshots}) => { // timeの時の文字列を取得する const times = Object.keys(snapshots).map(key => parseInt(key)); const key = times.includes(time) ? time : times.filter(key => key <= time).pop(); // timeの時まだ行が生成されていなければundefinedになる if (key === undefined) return []; const line = snapshots[key]; return line.text !== null ? [line.text] : []; }).join('\n'); } return { range: [...pageHistory.range, ...commitHistory.range], getSnapshot, }; } async function getPageSnapshots() { const res = await fetch(`/api/page-snapshots/${scrapbox.Project.name}/${scrapbox.Page.id}`); const {snapshots} = await res.json(); const pages = Object.fromEntries(snapshots.map(({lines, created}) => [created, lines])); const range = Object.keys(pages).map(created => parseInt(created)).sort(); return {range, pages}; } async function getCommitsHistory() { const res = await fetch(`/api/commits/${scrapbox.Project.name}/${scrapbox.Page.id}`); const {commits} = await res.json(); return convert(commits); }