scrapbox-history-slider@0.2.0
使い方
お試し
code:js
import('/api/code/programming-notes/scrapbox-history-slider@0.2.0/script.js');
bundle用
preactを既に自分のprojectに入れている人向け
code:sh
その他UIの微調整
実装したいこと
具体的な更新日時を表示したい
<input>にtitle属性をもたせる
脇に更新日時を表示する
sliderに目盛りをつける
1時間前
1日前
1週間前
etc.
読み込みをweb workerに委託する
巨大なページの差分を計算していると時間がかかる
読み込み中表示を詳細に出したい
以下の3項目には最低限分けたい
web workerの用意
頻繁に使う機能ではないので、開くたびに生成・破棄してもperformance上の問題はないだろう
データのfetch
履歴の変換
進捗も出す
既知の問題
履歴がないとバグる
mobileだと文字選択できない
リンク扱いになるのか、何故か新しいタブを開くmodalが出てきてしまう
いや選択できた
\`とかを押すとmodalが出てくるのか?
わからん
light themeなのに背景色が黒になる
2021-07-03
09:33:51 modalの背景色を暗めにした
23:00:23 背景色をscrapboxのテーマに合わせた
2021-06-29
01:01:18 Loading...を表示するときにsliderと閉じるボタンを隠さない
2021-06-10
12:55:18 Appの識別をidからdata-userscript-nameに変えた
12:31:00 ちょっとだけrefactoring
commitの変更日時を保存するようにした
2021-06-08
21:39:38 マウスホイールでsliderを動かせるようにしようとしたが断念
preventDefault()とstopPropagation()を入れると何故か動かなくなってしまう
入れないと動くが、focusが当たっていないと動かない
2021-05-25
15:19:14 スタイルを少し直した
14:29:21 Custom Elementを使うのをやめた
2021-05-23
07:35:24 読込中表示を出すようにした
07:10:58
DOMを使い回せるように工夫しようとしているがうまく行かない
うまいこと属性openの変更がuseState()に反映されない
そもそもなぜかopenがundefinedになる
普通にtrueがちゃんと入った
07:31:22 成功した!
一応動く
起動するたびにDOMが残っちゃうのが行儀悪い
後スタイルがちょいおかしい
code: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';
const App = () => {
const {loading} = useLoader(async () => {
if (!open) return;
const {length, getSnapshot} = await createSnapshot();
setMax(length - 1);
setTime(length - 1);
setSnapshot(() => getSnapshot);
useEffect(() => {
scrapbox.PageMenu.addMenu({
title: '履歴スライダー',
onClick: () => setOpen(true),
});
}, []);
const close = () => setOpen(false);
const onSliderChange = ({target: {value}}) => setTime(value);
return html`
<style>
:host {
background-color: var(--page-bg);
}
.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;
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="${time}"
onInput="${onSliderChange}" />
<button class="close-button" onClick="${close}">x</button>
</div>
${loading ? 'Loading...' : html<pre>${snapshot(parseInt(time, 10))}</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);
履歴を作成する
/icons/TODO.icon履歴連番と更新日時を紐付ける
code:script.js
const CHANGE_TYPE_INSERT = '_insert';
const CHANGE_TYPE_UPDATE = '_update';
const CHANGE_TYPE_DELETE = '_delete';
async function createSnapshot() {
const res = await fetch(/api/commits/${scrapbox.Project.name}/${scrapbox.Page.id});
const {commits} = await res.json();
// all changes of all commits
// {type: string; targetId: string; lines: Object; created: number;}に変換する
const changes = commits.flatMap(
({changes, created}) => changes.flatMap(change => {
const changeInfo = makeChangeInfo(change, created);
})
);
// 行の文字列のコレクション
// - すべての履歴それぞれのtextを順に入れておく
const texts = [];
// 行ごとの履歴情報を配列にする
// {id: string; /*行ID*/ updatedAt: number[]; deletedAt?: number;}[]
// - 行ごとに id, 更新された履歴連番, 削除された履歴連番を持つ
// - 出現した行すべてが入るので, ページの行とは対応していないことに注意
// 1個目の履歴
let initHistory;
console.debug({ initChange, restChanges, changes });
最初のイベントは _insert か _update のはずなのでとにかく履歴をつくる
これ本当かな?
古い順にcommit dataは削除されるから、最初のイベントは任意になるはず
2021-07-04 06:41:16 やはり最初のイベントは任意だった
ていうかなんで最初のイベントを特別扱いしているんだ?
2021-07-04 10:22:22 テキストを格納するリストと、履歴を格納するリストとを別にしているのか
一緒にしちゃえばいいのに
一緒にするとしたらこうする
code:ts
type LineSnapShot = {
id: string;
};
行の順番は作成時に計算する
code:script.js
switch(initChange.type) {
case CHANGE_TYPE_INSERT:
// タイトルの次の行に書くとき insert
// TODO: このときタイトルは title という change に入るので取りこぼしている
texts.push(initChange.lines.text);
initHistory = {
id: initChange.lines.id,
};
break;
case CHANGE_TYPE_UPDATE:
// タイトル行に書くとき
texts.push(initChange.lines.text);
initHistory = {
id: initChange.targetId,
};
break;
default:
console.error('init change neither _insert nor _update');
}
const allHistory = restChanges.reduce((history, change, i) => {
//console.debug({ text: change.lines && change.lines.text, texts, history });
// 同じidのところに履歴を追加する
const lineIndex = history.findIndex(({id}) => id === change.targetId);
switch (change.type) {
case CHANGE_TYPE_INSERT:
texts.push(change.lines.text);
const line = {
id: change.lines.id,
};
// 最後の行なら単に1行足す
if (change.targetId === '_end') {
history.push(line);
break;
}
// 最後の行じゃないなら入れるべき場所に差し込む
if (lineIndex === -1) {
//console.info(_insert: line index not found. commit change (${i}):, change);
break;
}
history.splice(lineIndex, 0, line); // lineIndex番目の要素の後ろに挿入する
break;
case CHANGE_TYPE_UPDATE:
texts.push(change.lines.text);
if (lineIndex === -1) {
//console.info(_update: line index not found. commit change (${i}):, change);
break;
}
historylineIndex.updatedAt.push(texts.length - 1); break;
case CHANGE_TYPE_DELETE:
texts.push(null);
if (lineIndex === -1) {
//console.info(_delete: line index not found. commit change (${i}):, change);
break;
}
historylineIndex.deletedAt = texts.length - 1; break;
// 上記以外のchangesは全て無視する
default:
break;
}
return history;
console.debug({ allHistory });
履歴連番から履歴を得る
その時点で存在した行の履歴
code:script.js
const getHistoryByTime = time => allHistory.flatMap(({updatedAt, deletedAt, id}) => {
const lastUpdatedAt = updatedAt.filter(u => u <= time).pop(); // これまでの更新のうち最新の履歴連番
// まだ出現していない or もう消えてるなら, その行は返さない
if (lastUpdatedAt === undefined // まだ出現していない
|| (deletedAt !== undefined && deletedAt <= time) // もう消えている
) return [];
// 出現している(かつ消えてない)ならその行を返す
return id, textIndex: lastUpdatedAt };
});
return {
// 履歴連番の長さ
length: texts.length,
// 履歴連番に対応するテキストを得る関数
getSnapshot: time => getHistoryByTime(time)
};
}
changesのデータを変換する関数
{_update: 'xxx', lines,}を{type: '_update', targetId: 'xxx', lines, created}に変える
_update, _insert, _delete以外は除外する
code:script.js
function makeChangeInfo(rawChange, created) {
const change = {created};
if (rawChange.lines) change.lines = rawChange.lines;
if (rawChange._insert) {
change.type = CHANGE_TYPE_INSERT;
} else if (rawChange._update) {
change.type = CHANGE_TYPE_UPDATE;
} else if (rawChange._delete) {
change.type = CHANGE_TYPE_DELETE;
} else {
return;
}
return change;
}