generated at
履歴スライダー

参考:

script.js
/* LineId --- 24文字のstring interface commit.changes { _update: LineId; // 更新する行 lines: { origText: string; // 元のテキスト text: string; // 新しいテキスト }; } | { _insert: LineId | "_end"; // 行を入れる場所の後ろ側の行 lines: { id: LineId; // 入れる行 text: string; // 入れるテキスト }; } | { _delete: LineId; // 消す行 lines: { origText: string; // 消す前にあったテキスト }; } | { title: string; // 新しいタイトル titleLc: string; // 新しいタイトル(小文字) } | { descriptions: string[]; // 新しいページ説明文 } | { links: string[]; // 新しいリンク } */ const CHANGE_TYPE_INSERT = '_insert'; const CHANGE_TYPE_UPDATE = '_update'; const CHANGE_TYPE_DELETE = '_delete'; const CHANGE_TYPE_TITLE = 'title'; const CHANGE_TYPE_DESCRIPTIONS = 'descriptions'; const CHANGE_TYPE_LINKS = 'links'; const getHrefInfo = href => { const a = document.createElement('a'); a.href = href; const [__ignore, project, page] = a.pathname.split('/') return { host: a.host, project, page, }; }; const fetchCommitDataOfHref = href => { const { host, project, page } = getHrefInfo(href); return fetch(`https://${host}/api/pages/${project}/${page}`) .then(res => res.json()) .then(pageData => { const pageId = pageData.id; const commitUrl = `https://${host}/api/commits/${project}/${pageId}`; return fetch(commitUrl); }) .then(res => res.json()); }; const makeChangeInfo = rawChange => { const change = {}; if (rawChange._insert) { change.type = CHANGE_TYPE_INSERT; change.targetId = rawChange[change.type]; } else if (rawChange._update) { change.type = CHANGE_TYPE_UPDATE; change.targetId = rawChange[change.type]; } else if (rawChange._delete) { change.type = CHANGE_TYPE_DELETE; change.targetId = rawChange[change.type]; } else if (rawChange.title) { change.type = CHANGE_TYPE_TITLE; } else if (rawChange.descriptions) { change.type = CHANGE_TYPE_DESCRIPTIONS; } else if (rawChange.links) { change.type = CHANGE_TYPE_LINKS; } else { console.info('unknown type. raw commit change: ', rawChange); } if (rawChange.lines) { change.lines = rawChange.lines; } return change; }; const onHistorySliderClick = () => { fetchCommitDataOfHref(location.href) .then(commitData => { // returns all changes of all commits return commitData.commits.reduce((all, rawCommit) => { return [ ...all, ...rawCommit.changes.map(makeChangeInfo), ]; }, []); }) .then(changes => { // 行の文字列のコレクション // - すべての履歴それぞれのtextを順に入れておく const texts = []; // 行ごとの履歴情報を配列にする // [{ id: LindeId, updatedAt: [idx1, ... ], deletedAt?: idx }] // - 行ごとに id, 更新された履歴連番, 削除された履歴連番を持つ // - 出現した行すべてが入るので, ページの行とは対応していないことに注意 // 1個目の履歴 let initHistory; const [initChange, ...restChanges] = changes; console.debug({ initChange, restChanges, changes }); // 最初のイベントは _insert か _update のはずなのでとにかく履歴をつくる if (initChange.type === CHANGE_TYPE_INSERT) { // タイトルの次の行に書くとき insert // TODO: このときタイトルは title という change に入るので取りこぼしている texts.push(initChange.lines.text); initHistory = { id: initChange.lines.id, updatedAt: [0], }; } else if (initChange.type === CHANGE_TYPE_UPDATE) { // タイトル行に書くとき texts.push(initChange.lines.text); initHistory = { id: initChange.targetId, updatedAt: [0], }; } else { console.log('init change neither _insert nor _update'); } const allHistory = restChanges.reduce((history, change, i) => { console.debug({ text: change.lines && change.lines.text, texts, history }); const lineIndex = history.findIndex(h => h.id === change.targetId); switch (change.type) { case CHANGE_TYPE_INSERT: texts.push(change.lines.text); const line = { id: change.lines.id, updatedAt: [texts.length - 1], }; if (change.targetId === '_end') { // 最後の行なら単に1行足す history.push(line); } else { // 最後の行じゃないなら入れるべき場所に差し込む if (lineIndex === -1) { console.info(`_insert: line index not found. commit change (${i}):`, change); return history; } history.splice(lineIndex, 0, line); } break; case CHANGE_TYPE_UPDATE: texts.push(change.lines.text); if (lineIndex === -1) { console.info(`_update: line index not found. commit change (${i}):`, change); return history; } history[lineIndex].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); return history; } history[lineIndex].deletedAt = texts.length - 1; break; } return history; }, [initHistory]); console.debug({ allHistory }); return { texts, allHistory }; }) .then(({ texts, allHistory }) => { // 履歴連番から履歴を得る // - その時点で存在した行の履歴 const getHistoryByTime = time => { return allHistory.reduce((all, history) => { // これまでの更新のうち最新の履歴連番 const updatedAt = history.updatedAt.filter(u => u <= time).pop(); // もう出現しているかどうか const appeared = updatedAt !== undefined; // もう消えてるかどうか const deleted = history.deletedAt !== undefined && history.deletedAt <= time; // まだ出現していない or もう消えてるなら, その行は返さない if (!appeared || deleted) { return all; } // 出現している(かつ消えてない)ならその行を返す return [...all, { id: history.id, textIndex: updatedAt }]; }, []); }; // 履歴連番に対応するテキストを得る const getTextByTime = time => getHistoryByTime(time).map(history => texts[history.textIndex]).join('\n'); // 操作系を表示してスライダーで操作できるようにする // - 指定された履歴連番からテキストを得て表示する const historyContainer = document.createElement('div'); historyContainer.classList.add('history-container'); Object.entries({ 'background-color': '#555555', position: 'fixed', 'z-index': '90000', top: '5px', left: '5px', width: 'calc( 100% - 10px )', 'max-height': '100%', border: '3px solid #22cc77', overflow: 'scroll', }).forEach(([key, value]) => historyContainer.style.setProperty(key, value)); document.querySelector('body').appendChild(historyContainer); const historyCloseButton = document.createElement('button'); historyCloseButton.classList.add('history-close-button'); Object.entries({ 'font-size': '30px', 'line-height': '1em', padding: '0', position: 'fixed', 'z-index': '90001', top: '10px', right: '10px', width: '30px', height: '30px', }).forEach(([key, value]) => historyCloseButton.style.setProperty(key, value)); historyContainer.appendChild(historyCloseButton); historyCloseButton.insertAdjacentText('beforeend', '×'); const historyBackground = document.createElement('div'); historyBackground.classList.add('history-background'); Object.entries({ position: 'fixed', 'z-index': '89999', top: '0', right: '0', width: '100%', height: '100%', 'background-color': 'rgba(255, 255, 255, 0.4)', }).forEach(([key, value]) => historyBackground.style.setProperty(key, value)); document.querySelector('body').appendChild(historyBackground); const historyPre = document.createElement('pre'); historyPre.classList.add('history-pre'); Object.entries({ width: '100%', 'max-height': '100%', }).forEach(([key, value]) => historyPre.style.setProperty(key, value)); historyPre.insertAdjacentHTML('beforeend', getTextByTime(texts.length)); historyContainer.appendChild(historyPre); const historySlider = document.createElement('input'); historySlider.classList.add('history-slider'); Object.entries({ position: 'fixed', 'z-index': '99999', top: '10px', width: '90%', }).forEach(([key, value]) => historySlider.style.setProperty(key, value)); historySlider.type = 'range'; historySlider.min = '0'; historySlider.max = `${texts.length}`; historySlider.value = `${texts.length}`; historySlider.step = 1; historyContainer.appendChild(historySlider); const removeHistoryContainer = () => { historyContainer.parentNode.removeChild(historyContainer); historyBackground.parentNode.removeChild(historyBackground); }; historyCloseButton.addEventListener('click', removeHistoryContainer); historyBackground.addEventListener('click', removeHistoryContainer); const updateHistory = e => { const time = parseInt(e.target.value, 10); historyPre.innerHTML = getTextByTime(time); console.debug({ time, html: historyPre.innerHTML }); }; historySlider.addEventListener('input', updateHistory); historySlider.addEventListener('change', updateHistory); }); }; scrapbox.PageMenu.addMenu({ title: '履歴スライダー', image: 'https://1.bp.blogspot.com/-UZtkSEX0wh4/U5l5_dNcEsI/AAAAAAAAhWs/UzJGVzyiX8Y/s800/kaichu_dokei.png', onClick: onHistorySliderClick, });

history_slider.js
// 旧名(互換用) import '/api/code/hogashi-pub/履歴スライダー/history_slider.js';