動作GIF
ドロップダウンリストを選択すると自分の記事を新着
順で表示します。
記事を選択後、挿入したい場所をクリックすることで挿入されます。
記事の取得内容はURLとタイトルで、フォーマットを指定することで文字リンクの状態などで挿入できます。
* [${title}](${url})
の例
テキストフィールドでは検索画面と同様の入力方法ですべての記事から(最大100件)検索でき、user:qiita
やtag:qiita
など固定の入力条件をコードで保存できます。
(Qiitaの検索画面同様完全な部分一致にはなりません)
user:khsk title:${query}
の例(自分の記事からタイトルに入力内容を含む記事を検索)
アイデア元
必要なもの
Qiitaのread_qiita権限のアクセストークンが必要です。
https://qiita.com/settings/tokens/new
これは限定共有記事を取得するために必要なものです。公開記事しか投稿していない/使わない方はすべて/api/v2/items
検索にするとトークンを省略できます。
動作検証環境
- Firefox
- Tampermonkey
既知の問題
記事を選択後、エディタをクリックし挿入する前に再度記事を選択すると挿入内容が上書きされずすべての選択記事が挿入されます。
意図的ではありませんが、format
に改行文字を含めれば一括で複数記事を挿入できます。
場合によってはエディタ完成前にスクリプトが走り操作用タグが挿入されない可能性があります。
クエリ検索では次のページを読み込む選択肢がありません。
省いたこと
表示場所、表示方法はかなり手抜きです。
エディタメニューを(とくにtextが)圧迫しますので、可能ならば絵文字や検索機能のようにポップアップで表示できれば省スペースかつ可読性があがります。Qiitaや外部ライブラリを読み込んだりCSSを工夫してください。
パーツはこのままでも場所をヘッダーに移動するのもいいかもしれません。
挿入方法も絵文字のように選択即挿入の方が便利ですが、今回は挿入場所の記憶を逐一したくありませんでした。気にならない方は記憶するようにするとよいでしょう。(自分の知識不足でフォーカスが外れたときに取得できるかも)
自分の記事読み込みはクリックまで読み込みません。待てない方はスクリプトが走ったときに読み込んだり全件読み込みしてもいいでしょう。
コード
// ==UserScript==
// @name Qiita add my items list
// @namespace http://tampermonkey.net/
// @version 0.1
// @description 編集画面から記事を取得してURLとタイトルを挿入する
// @author khsk
// @match https://qiita.com/drafts/new
// @match https://qiita.com/drafts/*/edit
// @grant none
// ==/UserScript==
(function() {
'use strict';
// read権限 https://qiita.com/settings/tokens/new
const token = ''
// query版用
const username = 'khsk' // 分ける必要ないな
// const query = /* ?query= */ 'user:' + username + ' ${query}'
const query = /* ?query= */ 'user:' + username + ' title:${query}'
// const query = /* ?query= */ 'user:${query}'
// const query = /* ?query= */ 'tag:${query}'
// const query = /* ?query= */ '${query}'
// query版用まで
const headers= new Headers()
headers.set('Authorization', 'Bearer ' + token)
// const format = '* [${title}](${url})'
const format = '${url}'
// let insertCount = 0 // んー使わないかなぁ
// const format = '${icnt}. [${title}](${url})' // 未実装
const pg = '100'
let requestCount = 0
const editer = document.querySelector('div[contenteditable]')
const select = document.createElement('select')
// ブラウザデフォルトの↓△開くボタンを使う
select.style.setProperty('appearance', 'listbox', 'important');
// ドロップダウン目的なので開くボタンサイズあればいい
select.style.width = '2rem'
// TODO 動的なので不在のタイミングがある。MutationObserverで待ったほうがいいが、わりと失敗率が低いので雑にリロード対処で
const head = document.querySelector('main > div:nth-child(3) > div > div > div')
head.parentNode.insertBefore(select ,head)
// optionをクリックしたら、エディターをクリックしたときにクリック位置に挿入する予約を行う。
// 挿入位置はblurでは、はずしたときのカーソル位置を記録できずエディター操作ごとにキャレットを保存する必要がある。それは嫌なので手動選択にする。
function insertItem(optionNode) {
editer.addEventListener('click', event => {
const sel = window.getSelection()
const text = sel.anchorNode.textContent
const formattedItem = format.replace('${url}', optionNode.value).replace('${title}', optionNode.textContent)
sel.anchorNode.textContent = text.substring(0, sel.focusOffset) + formattedItem + text.substring(sel.focusOffset, text.length)
}, {once:true})
}
function appendOption(item) {
const option = document.createElement('option')
option.value = item.url
option.textContent = item.title
select.appendChild(option)
return option.addEventListener('click', event => {
// エディターへ挿入前に再度optionを選択したら2重に挿入されるので本来は予約済みの確認か上書きをしたほうがいいが、低問題なので捨て置く
insertItem(event.target)
// ドロップダウン目的で何を選択したかは不要なので消しておく
select.selectedIndex = -1
})
}
function createOptions() {
select.disabled = true
fetch('https://qiita.com/api/v2/authenticated_user/items?per_page=' + pg + '&page=' + ++requestCount, {headers: headers})
.then( responce => {
return responce.json()
}).then(json => {
for(const item of json) {
appendOption(item)
}
if(json.length == pg) {
// 次の記事を読み込む選択肢を作る
const option = document.createElement('option')
option.textContent = 'さらに読み込む'
select.appendChild(option)
option.addEventListener('click', event => {
select.selectedIndex = -1
select.removeChild(option)
createOptions()
// んーjsでselectを開くのはおそらく不可能。何か通知できればいいけど処理は高速に見えるのでいいか。
//const mouseEvent = new Event('click', {cancelable: true})
//select.dispatchEvent(mouseEvent);
})
}
select.selectedIndex = -1
select.disabled = false
})
}
// 最初の読み込み。 使わないときに無駄な処理しないように自動で読み込むようにはしてない
select.addEventListener('click', event => {
if(event.target.childElementCount != 0 || event.target.nodeName != 'SELECT'/* たぶん不要 */) { return }
createOptions()
}, {useCapture: true})
// blur時のselectionは外したクリック先のnodeになったりするので不向き
// document.querySelector('div[contenteditable]').addEventListener('blur', event => {
// console.log('blur',document.getSelection())
// })
///////////////////////////////////////////////////////////
// query版もオマケで作る
const form = document.createElement('form')
const input = document.createElement('input')
input.type = 'text'
input.style.lineHeight = 'unset'
form.style.marginTop = 'auto'
form.style.marginBottom = 'auto'
form.appendChild(input)
select.parentNode.insertBefore(form ,select.nextSibling)
form.addEventListener('submit', event => {
event.preventDefault()
select.disabled = true
select.innerHTML = ''
const formattedQuery = query.replace('${query}', encodeURIComponent(input.value))
fetch('https://qiita.com/api/v2/items?per_page=' + pg + '&query=' + formattedQuery,
// 自分の非公開記事を含めたいなら。不要ならトークンも不要(回数制限60/h)
{headers: headers}
)
.then( responce => {
return responce.json()
}).then(json => {
for(const item of json) {
appendOption(item)
}
// 0件であることの通知と、0件でクリック=authenticated_userの検索開始条件なので回避用optionを作る
// if(json.length == 0) {
const option = document.createElement('option')
option.textContent = '検索結果0件(リセット)'
select.appendChild(option)
// 0件用としたものの、個人的利用外だがquery検索後にauthenticated_user検索に戻れるようにoptionを空にするリセット機能ももたせる(ので0件でなくても作る) どんなのがいいかはお好きに
option.addEventListener('click', event => {
select.selectedIndex = -1
select.innerHTML = ''
requestCount = 0
})
// }
select.selectedIndex = -1
select.disabled = false
})
})
})();