// ==UserScript==
// @name NovelAI Mod
// @namespace https://www6.notion.site/dc99953d5f04405c893fba95dace0722
// @version 21.1
// @description Extention for NovelAI Image Generator. Fast Suggestion, Chant, Save in JPEG, Get aspect ratio.
// @author SenY
// @match https://novelai.net/image
// @match https://novelai.github.io/image
// @icon https://www.google.com/s2/favicons?sz=64&domain=novelai.net
// @grant none
// @license MIT
// ==/UserScript==
// 定数と設定
const SUGGESTION_LIMIT = 500;
const colors = {
"-1": ["red", "maroon"],
"0": ["lightblue", "dodgerblue"],
"1": ["gold", "goldenrod"],
"3": ["violet", "darkorchid"],
"4": ["lightgreen", "darkgreen"],
"5": ["tomato", "darksalmon"],
"6": ["red", "maroon"],
"7": ["whitesmoke", "black"],
"8": ["seagreen", "darkseagreen"]
};
// グローバル変数
let lastTyped = new Date();
let suggested_at = new Date();
let chants = [];
let allTags = [];
let chantURL;
let tagNameIndex = new Map();
let tagTermsIndex = new Map();
// ユーティリティ関数
const gcd = (a, b) => b == 0 ? a : gcd(b, a % b);
const getAspect = whArray => whArray.map(x => x / gcd(whArray[0], whArray[1]));
const aspectList = (wa, ha, maxpixel = 1024 * 1024) => {
let aspect = wa / ha;
let limit = 16384;
let steps = 64;
let ws = Array.from({ length: (limit - steps) / steps + 1 }, (_, i) => (i + 1) * steps);
let hs = ws.slice();
return ws.flatMap(w => hs.map(h => w / h === aspect && w * h <= maxpixel ? [w, h] : null)).filter(Boolean);
};
const getChantURL = force => {
if (force === true) {
localStorage.removeItem("chantURL");
}
let url = localStorage.getItem("chantURL") || prompt("Input your chants json url.\nThe URL must be a Cors-enabled server (e.g., gist.github.com).", "https://gist.githubusercontent.com/vkff5833/989808aadebf8648831955cdf2a7b3e3/raw/yuuri.json");
if (url) localStorage.setItem("chantURL", url);
return url;
};
/**
* 画像のDataURLを取得する
* @param {string} mode - 'current'または'uploaded'
* @returns {Promise<string|null>} 画像のDataURL
*/
const getImageDataURL = async (mode) => {
console.debug(`Getting image DataURL for mode: ${mode}`);
try {
if (mode === "current") {
const imgs = Array.from(document.querySelectorAll('img[src]')).filter(x => x.offsetParent);
const url = imgs.reduce((max, img) => img.height > max.height ? img : max).src || document.querySelector('img[src]')?.src;
if (!url) {
console.debug("No image URL found");
return null;
}
console.debug("Found image URL:", url.substring(0, 50) + "...");
const response = await fetch(url);
const blob = await response.blob();
return await new Promise(resolve => {
let reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
} else if (mode === "uploaded") {
const uploadInput = document.getElementById('naimodUploadedImage');
const previewImage = document.getElementById('naimodPreviewImage');
if (uploadInput && uploadInput.files[0]) {
return await new Promise(resolve => {
let reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.readAsDataURL(uploadInput.files[0]);
});
} else if (previewImage && previewImage.src) {
return previewImage.src;
}
return null;
}
} catch (error) {
console.error("Error getting image DataURL:", error);
return null;
}
};
/**
* タグの改善提案をAIに要求する
* @param {string} tags - 現在のタグリスト
* @param {string} imageDataURL - 画像のDataURL
* @returns {Promise<Object|false>} 改善提案の結果
*/
async function ImprovementTags(tags, imageDataURL) {
console.debug("Starting tag improvement process");
console.debug("ImprovementTags function called with tags:", tags);
// タグの_を半角スペースに置換
tags = tags.replace(/_/g, " ");
const apiKey = document.getElementById('geminiApiKey').value;
if (!apiKey) {
alert("Gemini API Keyが設定されていません。設定画面でAPIキーを入力してください。");
return false;
}
console.debug("API Key found:", apiKey.substring(0, 5) + "...");
const model = document.getElementById('geminiModel').value;
const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
console.debug("ENDPOINT:", ENDPOINT);
let prompt = `以下に示すのはdanbooruのタグを用いて画像を生成するNovelAIの機能で、添付の画像を生成する為に用意したタグの一覧です。
${tags}
- 実際の画像の内容と列挙されたタグ一覧の、比較検討とブラッシュアップを行ってください。
- 不要と判断したタグを列挙してください。例えば顔しか映っていない画像なのにスカートや靴下に関する言及がある場合や、顔が映らない構図なのに瞳の色や表情に言及がある場合、黒髪のキャラしか居ないのにblonde hairと記述されるなど明らかに的外れなタグが含まれている場合などは、それらを除去する必要があります。(should remove)
- それとは逆に、新たに付与するべきであると考えられるタグがある場合は列挙してください。(should add)
- また、客観的に画像から読み取って付与するべきと判断したタグ以外に、追加することでより表現が豊かになると考えられるタグがあれば、それも提案してください。例えば文脈上付与した方がよりリアリティが増すと思われるアクセサリや小物を示すタグ、より効果的な表現を得る為に付与するのが好ましいと思われるエフェクトのタグなどです。
これらの内容は元々の画像には含まれていない要素であるべきです。(may add)
- 固有名詞と思われる認識不能なタグに関しては無視してください。
- また、NovelAI系ではない自然文のプロンプトで画像生成をする場合に用いるプロンプトも別途提案してください。実際の画像や上記のshouldRemove,shouldAdd,mayAddの内容を踏襲し、2000文字程度の自然言語の英文で表現してください。
返信はJSON形式で以下のスキーマに従ってください。
{
"shouldRemove": [string]
"shouldAdd": [string]
"mayAdd": [string],
"naturalLanguagePrompt": [string]
}
`;
console.debug("Prompt prepared:", prompt);
const payload = {
method: 'POST',
headers: {},
body: JSON.stringify({
contents: [
{
parts: [
{ text: prompt },
{ inline_data: { mime_type: "image/jpeg", data: imageDataURL.split(',')[1] } }
]
}
],
"generationConfig": {
"temperature": 1.0,
"max_output_tokens": 4096
},
safetySettings: [
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_HARASSMENT",
"threshold": "OFF"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "OFF"
}
]
}),
mode: 'cors'
};
console.debug("Payload prepared:", payload);
let result;
try {
console.debug("Sending fetch request to Gemini API");
const response = await fetch(ENDPOINT, payload);
console.debug("Response received:", response);
const data = await response.json();
console.debug("Response data:", data);
result = data.candidates[0].content.parts[0].text;
result = result.replace('```json\n', '').replace('\n```', '');
result = JSON.parse(result);
// 結果のタグも_を半角スペースに置換
result.shouldRemove = result.shouldRemove.map(tag => tag.replace(/_/g, " "));
result.shouldAdd = result.shouldAdd.map(tag => tag.replace(/_/g, " "));
result.mayAdd = result.mayAdd.map(tag => tag.replace(/_/g, " "));
console.debug("Parsed result:", result);
} catch (error) {
console.error('エラー:', error);
alert("AI改善の処理中にエラーが発生しました。");
return false;
}
return result || false;
}
/**
* 現在の画像をJPEG形式で保存する
*/
const saveJpeg = async () => {
console.debug("Starting JPEG save process");
// シードボタンの検索
const seedButton = Array.from(document.querySelectorAll('span[style]')).find(x => x.textContent.trim().match(/^[0-9]*(seed|シード)/i))?.closest("button");
console.debug("Found seed button:", seedButton);
// シード値の取得
let seed = Array.from(seedButton.querySelectorAll("span")).find(x => x.textContent.match(/^[0-9]*$/))?.textContent;
console.debug("Extracted seed:", seed);
let filename = `${seed}.jpg`;
console.debug("Generated filename:", filename);
// 画像データの取得
console.debug("Getting image DataURL...");
const dataURI = await getImageDataURL("current");
if (!dataURI) {
throw new Error("Failed to get image DataURL");
}
console.debug("Got DataURL, length:", dataURI.length);
// 画像オブジェクトの作成
console.debug("Creating Image object...");
let image = new Image();
image.src = dataURI;
console.debug("Waiting for image to load...");
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error("Failed to load image"));
});
console.debug("Image loaded, dimensions:", image.width, "x", image.height);
// キャンバスの作成と描画
console.debug("Creating canvas...");
let canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
console.debug("Drawing image to canvas...");
let ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
// JPEG形式への変換
console.debug("Converting to JPEG...");
let quality = document.getElementById("jpegQuality")?.value || 0.85;
console.debug("Using JPEG quality:", quality);
let JPEG = canvas.toDataURL("image/jpeg", quality);
console.debug("JPEG conversion complete, data length:", JPEG.length);
// ダウンロードリンクの作成と実行
console.debug("Creating download link...");
let link = document.createElement('a');
link.href = JPEG;
link.download = filename;
console.debug("Triggering download...");
link.click();
console.debug("JPEG save process completed successfully");
};
const updateImageDimensions = (w, h) => {
document.querySelectorAll('input[type="number"][step="64"]').forEach((x, i) => {
x.value = [w, h][i];
x._valueTracker = '';
x.dispatchEvent(new Event('input', { bubbles: true }));
});
};
/**
* エディタのコンテンツを設定する
* @param {string} content - 設定するコンテンツ
* @param {Element} editor - 対象のエディタ要素
* @param {Object} options - カーソル位置などのオプション
*/
const setEditorContent = (content, editor, options = {}) => {
console.debug("Setting editor content", { contentLength: content.length });
if (!editor) {
console.error("Editor is required for setEditorContent");
return;
}
const { cursorPosition } = options;
editor.innerHTML = "";
const p = document.createElement("p");
p.textContent = content;
editor.appendChild(p);
editor.dispatchEvent(new Event('input', { bubbles: true }));
// カーソル位置の復元
if (typeof cursorPosition === 'number') {
const range = document.createRange();
const sel = window.getSelection();
range.setStart(p.firstChild, Math.min(cursorPosition, content.length));
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
};
/**
* エディタ内の絶対カーソル位置を計算する
* @param {Element} editor - 対象のエディタ要素
* @returns {number} 絶対カーソル位置
*/
const getAbsoluteCursorPosition = (editor) => {
console.debug("Calculating absolute cursor position");
const selection = window.getSelection();
const range = selection.getRangeAt(0);
// カーソルが含まれているp要素を特定
const currentP = range.startContainer.nodeType === 3 ?
range.startContainer.parentNode :
range.startContainer;
// 全てのp要素を取得
const allPs = Array.from(editor.querySelectorAll("p"));
let absoluteCursorPosition = 0;
// カーソル位置までの文字数を計算
for (let p of allPs) {
if (p === currentP) {
absoluteCursorPosition += range.startOffset;
break;
}
absoluteCursorPosition += p.textContent.length + 1; // +1 は改行文字の分
}
return absoluteCursorPosition + 2;
};
/**
* 現在のカーソル位置のタグを取得する
* @param {Element} editor - 対象のエディタ要素
* @param {number} position - カーソル位置
* @returns {string|undefined} カーソル位置のタグ
*/
const getCurrentTargetTag = (editor, position) => {
console.time("getCurrentTargetTag");
console.debug("Getting current target tag at position:", position);
console.time("get content");
const content = Array.from(editor.querySelectorAll("p"))
.map(p => p.textContent)
.join("\n");
console.timeEnd("get content");
// カンマまたは改行で分割(連続する区切り文字は1つとして扱う)
console.time("split tags");
const splitTags = (text) =>
text.split(/[,\n]+/).map(x => x.trim()).filter(Boolean);
const oldTags = splitTags(content);
const beforeCursor = content.slice(0, position);
const beforeTags = splitTags(beforeCursor);
console.timeEnd("split tags");
console.time("find target tag");
const targetTag = beforeTags[beforeTags.length - 2];
const result = oldTags[oldTags.indexOf(targetTag) + 1]?.trim();
console.timeEnd("find target tag");
console.timeEnd("getCurrentTargetTag");
return result;
};
// タグを削除する関数を修正
const removeTagsFromPrompt = (tagsToRemove, editor, options = {}) => {
if (!editor || !tagsToRemove?.length) return;
const { cursorPosition } = options;
const content = editor.textContent;
const oldTags = content.split(/[,\n]+/).map(x => x.trim()).filter(Boolean);
// 削除対象のタグを除外
const tags = oldTags.filter(tag => !tagsToRemove.includes(tag));
setEditorContent(tags.join(", ") + ", ", editor, { cursorPosition });
};
// appendTagsToPromptを修正
const appendTagsToPrompt = (tagsToAdd, editor, options = {}) => {
if (!editor) {
console.error("Editor is required for appendTagsToPrompt");
return;
}
if (!tagsToAdd?.length) return;
const position = window.getLastCursorPosition();
const { removeIncompleteTag } = options;
const content = editor.textContent;
const oldTags = content.split(/[,\n]+/).map(x => x.trim()).filter(Boolean);
const targetTag = getCurrentTargetTag(editor, position);
let tags = oldTags.flatMap(tag =>
tag === targetTag ? [tag, ...tagsToAdd] : [tag]
);
if (!targetTag) {
tags = [...tags, ...tagsToAdd];
}
tags = [...new Set(tags.filter(Boolean))];
if (removeIncompleteTag) {
tags = tags.filter(tag => tag !== removeIncompleteTag);
}
setEditorContent(tags.join(", ") + ", ", editor, { cursorPosition: position });
};
/**
* タグの強調度を調整する
* @param {number} value - 調整値(正: 強調、負: 抑制)
* @param {Element} editor - 対象のエディタ要素
* @param {Object} options - オプション
*/
const adjustTagEmphasis = (value, editor, options = {}) => {
console.debug("Adjusting tag emphasis", { value });
if (!editor) {
console.error("Editor is required for adjustTagEmphasis");
return;
}
const position = window.getLastCursorPosition();
const targetTag = getCurrentTargetTag(editor, position);
if (targetTag) {
const getTagEmphasisLevel = tag =>
tag.split("").filter(x => x === "{").length -
tag.split("").filter(x => x === "[").length;
const content = editor.textContent;
const oldTags = content.split(/[,\n]+/).map(x => x.trim()).filter(Boolean);
let tags = oldTags.map(tag => {
if (tag === targetTag) {
let emphasisLevel = getTagEmphasisLevel(targetTag) + value;
tag = tag.replace(/[\{\}\[\]]/g, "");
return emphasisLevel > 0 ? '{'.repeat(emphasisLevel) + tag + '}'.repeat(emphasisLevel) :
emphasisLevel < 0 ? '['.repeat(-emphasisLevel) + tag + ']'.repeat(-emphasisLevel) : tag;
}
return tag;
}).filter(Boolean);
setEditorContent(tags.join(", ") + ", ", editor, { cursorPosition: position });
}
};
// 初期化時にインデックスを構築
const buildTagIndices = () => {
console.time("build indices");
allTags.forEach(tag => {
// 名前のインデックス
const normalizedName = tag.name.toLowerCase().replace(/_/g, " ");
tagNameIndex.set(normalizedName, tag);
// 別名のインデックス
tag.terms.forEach(term => {
const normalizedTerm = term.toLowerCase().replace(/_/g, " ");
if (!tagTermsIndex.has(normalizedTerm)) {
tagTermsIndex.set(normalizedTerm, new Set());
}
tagTermsIndex.get(normalizedTerm).add(tag);
});
});
console.timeEnd("build indices");
};
// showTagSuggestionsの中のフィルタリング部分を修正
const getTagSuggestions = (targetTag, limit = 20) => {
console.time("get tag suggestions");
const normalizedTarget = targetTag.toLowerCase()
.replace(/_/g, " ")
.replace(/[\{\}\[\]\\]/g, "");
const results = new Set();
// 名前での検索
for (const [name, tag] of tagNameIndex) {
if (name.includes(normalizedTarget)) {
results.add(tag);
if (results.size >= limit) break;
}
}
// 結果が不足している場合は別名も検索
if (results.size < limit) {
for (const [term, tags] of tagTermsIndex) {
if (term.includes(normalizedTarget)) {
for (const tag of tags) {
results.add(tag);
if (results.size >= limit) break;
}
}
if (results.size >= limit) break;
}
}
console.timeEnd("get tag suggestions");
return Array.from(results).slice(0, limit);
};
/**
* タグの提案を表示する
* @param {Element} targetEditor - 対象のエディタ要素
* @param {number} position - カーソル位置
*/
const showTagSuggestions = (targetEditor = null, position = null) => {
console.debug("=== Start showTagSuggestions ===");
console.time("showTagSuggestions total");
const editor = targetEditor || window.getLastFocusedEditor();
if (editor) {
console.time("get cursor and target tag");
const cursorPosition = position ?? window.getLastCursorPosition();
let targetTag = getCurrentTargetTag(editor, cursorPosition)?.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
console.timeEnd("get cursor and target tag");
console.time("get suggestion field");
let suggestionField = editor.closest(".relative")?.querySelector("#suggestionField") ||
document.getElementById("suggestionField");
console.timeEnd("get suggestion field");
if (suggestionField) {
console.time("clear suggestion field");
suggestionField.textContent = "";
console.timeEnd("clear suggestion field");
if (targetTag) {
console.time("filter suggestions");
let suggestions = getTagSuggestions(targetTag);
console.timeEnd("filter suggestions");
console.debug(`Found ${suggestions.length} suggestions for tag: ${targetTag}`);
console.time("create suggestion buttons");
let done = new Set();
suggestions.forEach(tag => {
if (!done.has(tag.name)) {
const incompleteTag = targetTag;
let button = createButton(
`${tag.name} (${tag.coumt > 1000 ? `${(Math.round(tag.coumt / 100) / 10)}k` : tag.coumt})`,
colors[tag.category][1],
() => appendTagsToPrompt([tag.name], editor, {
removeIncompleteTag: incompleteTag
})
);
button.title = tag.terms.filter(Boolean).join(", ");
suggestionField.appendChild(button);
if (editor.textContent.split(",").map(y => y.trim().replace(/_/g, " ").replace(/[\{\}\[\]\\]/g, "")).includes(tag.name.replace(/_/g, " "))) {
button.style.opacity = 0.5;
}
done.add(tag.name);
}
});
console.timeEnd("create suggestion buttons");
}
}
}
console.timeEnd("showTagSuggestions total");
console.debug("=== End showTagSuggestions ===");
};
// UIコンポーネントの作成関数
const createSettingsModal = () => {
let modal = document.getElementById("naimod-modal");
if (!modal) {
modal = document.createElement("div");
modal.id = "naimod-modal";
modal.className = "naimod-modal";
modal.addEventListener("click", (event) => {
if (event.target === modal) {
modal.style.display = "none";
}
});
let settingsContent = document.createElement("div");
settingsContent.className = "naimod-settings-content";
// 2ペインコンテナ
const twoPane = document.createElement("div");
twoPane.className = "naimod-modal-two-pane";
// 左ペイン(Settings)
const settingsPane = document.createElement("div");
settingsPane.className = "naimod-modal-pane settings-pane";
// Settings タイトル
const settingsTitle = document.createElement("h2");
settingsTitle.textContent = "Settings";
settingsTitle.className = "naimod-section-title";
settingsPane.appendChild(settingsTitle);
// Reset Chants URLセクション
settingsPane.appendChild(createResetChantsSection());
// API Settings セクション
settingsPane.appendChild(createAPIKeySection());
// 画像ソース選択セクション
settingsPane.appendChild(createImageSourceSection());
// 右ペイン(Operation)
const operationPane = document.createElement("div");
operationPane.className = "naimod-modal-pane operation-pane";
// Operation タイトル
const operationTitle = document.createElement("h2");
operationTitle.textContent = "AI Improvements";
operationTitle.className = "naimod-section-title";
operationPane.appendChild(operationTitle);
// Suggest AI Improvementsボタンを操作セクション直下に配置
const initialButtonContainer = document.createElement("div");
initialButtonContainer.className = "naimod-button-container";
const suggestButton = createButton("Suggest AI Improvements", "blue", async () => {
console.debug("Suggest AI Improvements clicked");
const rightPane = document.querySelector(".naimod-pane.right-pane");
if (!rightPane) {
console.error("Right pane not found");
return;
}
// 自然言語プロンプトの状態をリセット
const naturalLanguagePrompt = document.getElementById("naturalLanguagePrompt");
const copyButton = naturalLanguagePrompt?.parentElement?.querySelector("button");
if (naturalLanguagePrompt) {
naturalLanguagePrompt.textContent = "Waiting for AI suggestions...";
if (copyButton) copyButton.style.display = "none";
}
// lastFocusedEditorからタグを取得
const editor = window.getLastFocusedEditor();
if (!editor) {
console.error("No editor is focused");
rightPane.innerHTML = "Please focus on a tag editor first";
naturalLanguagePrompt.textContent = "";
if (copyButton) copyButton.style.display = "none";
return;
}
console.debug("Getting tags from editor");
const tags = editor.textContent.trim();
console.debug("Current tags:", tags);
if (tags) {
rightPane.innerHTML = "Waiting for AI suggestions...";
suggestButton.disabled = true;
suggestButton.textContent = "Processing...";
console.debug("Getting image source");
let imageSource = document.getElementById("imageSource").value;
console.debug("Image source:", imageSource);
console.debug("Getting image DataURL");
let imageDataURL = await getImageDataURL(imageSource);
if (!imageDataURL) {
console.error("Failed to get image DataURL");
rightPane.innerHTML = "";
naturalLanguagePrompt.textContent = "";
if (copyButton) copyButton.style.display = "none";
suggestButton.disabled = false;
suggestButton.textContent = "Suggest AI Improvements";
return;
}
console.debug("Got image DataURL, length:", imageDataURL.length);
console.debug("Calling ImprovementTags");
let result = await ImprovementTags(tags, imageDataURL);
console.debug("ImprovementTags result:", result);
if (result) {
displaySuggestions(result, rightPane);
}
suggestButton.disabled = false;
suggestButton.textContent = "Suggest AI Improvements";
} else {
console.error("No tags found in editor");
rightPane.innerHTML = "No tags found in editor";
naturalLanguagePrompt.textContent = "";
if (copyButton) copyButton.style.display = "none";
}
});
suggestButton.className = "naimod-operation-button";
initialButtonContainer.appendChild(suggestButton);
operationPane.appendChild(initialButtonContainer);
// AIサジェスト関連セクション
const suggestSection = document.createElement("div");
suggestSection.className = "naimod-operation-section";
// Danbooru Tags セクションタイトル
const danbooruTitle = document.createElement("h3");
danbooruTitle.textContent = "Danbooru Tags";
danbooruTitle.className = "naimod-section-title";
suggestSection.appendChild(danbooruTitle);
// タグ提案表示域
const rightPane = document.createElement("div");
rightPane.className = "naimod-pane right-pane";
suggestSection.appendChild(rightPane);
operationPane.appendChild(suggestSection);
// 自然言語プロンプトセクションを修正
const createNaturalLanguageSection = () => {
const container = document.createElement("div");
container.className = "naimod-natural-language-container";
const title = document.createElement("h3");
title.textContent = "Natural Language Prompt";
title.className = "naimod-section-title";
container.appendChild(title);
const content = document.createElement("div");
content.id = "naturalLanguagePrompt";
content.className = "naimod-natural-language-content";
content.textContent = "";
container.appendChild(content);
return container;
};
operationPane.appendChild(createNaturalLanguageSection());
// ペインを追加
twoPane.appendChild(settingsPane);
twoPane.appendChild(operationPane);
settingsContent.appendChild(twoPane);
// 閉じるボタンセクション
settingsContent.appendChild(createCloseButtonSection(modal));
modal.appendChild(settingsContent);
document.body.appendChild(modal);
}
return modal;
};
const createAPIKeySection = () => {
const section = document.createElement("div");
section.className = "naimod-section";
const sectionTitle = document.createElement("h3");
sectionTitle.textContent = "Gemini Settings";
sectionTitle.className = "naimod-section-title";
section.appendChild(sectionTitle);
// APIキー入力フィールド
let apiKeyInput = document.createElement("input");
apiKeyInput.type = "text";
apiKeyInput.id = "geminiApiKey";
apiKeyInput.className = "naimod-input";
apiKeyInput.placeholder = "Enter Gemini API Key";
apiKeyInput.value = localStorage.getItem("geminiApiKey") || "";
apiKeyInput.style.width = "100%";
apiKeyInput.style.padding = "5px";
apiKeyInput.style.marginBottom = "10px";
apiKeyInput.addEventListener("change", () => {
localStorage.setItem("geminiApiKey", apiKeyInput.value);
});
// モデル選択セレクトボックス
const modelSelect = document.createElement("select");
modelSelect.id = "geminiModel";
modelSelect.className = "naimod-input";
modelSelect.style.width = "100%";
modelSelect.style.padding = "5px";
modelSelect.style.marginBottom = "10px";
const models = [
"gemini-2.0-flash-lite-preview",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-thinking-exp",
"gemini-2.0-pro-exp"
];
models.forEach(model => {
const option = document.createElement("option");
option.value = model;
option.textContent = model;
modelSelect.appendChild(option);
});
// 保存された値があれば復元
modelSelect.value = localStorage.getItem("geminiModel") || "gemini-2.0-flash-thinking-exp";
modelSelect.addEventListener("change", () => {
localStorage.setItem("geminiModel", modelSelect.value);
});
section.appendChild(apiKeyInput);
section.appendChild(modelSelect);
return section;
};
const createResetChantsSection = () => {
const section = document.createElement("div");
section.className = "naimod-section";
section.style.marginBottom = "20px";
const sectionTitle = document.createElement("h3");
sectionTitle.textContent = "Chant Settings";
sectionTitle.className = "naimod-section-title";
section.appendChild(sectionTitle);
// リセットボタン
let resetButton = createButton("Reset Chants URL", "red", () => {
chantURL = getChantURL(true);
initializeApplication();
});
resetButton.className = "naimod-button";
section.appendChild(resetButton);
return section;
};
const createImageSourceSection = () => {
const section = document.createElement("div");
section.className = "naimod-image-source-container";
const sectionTitle = document.createElement("h3");
sectionTitle.textContent = "Image Source";
sectionTitle.className = "naimod-section-title";
section.appendChild(sectionTitle);
// 画像ソース選択UI
const { imageSourceSelect, uploadContainer } = createImageSourceUI();
section.appendChild(imageSourceSelect);
section.appendChild(uploadContainer);
return section;
};
const createTwoPaneSection = () => {
const section = document.createElement("div");
section.className = "naimod-operation-section";
// Danbooru Promptセクション
const danbooruTitle = document.createElement("h3");
danbooruTitle.textContent = "Danbooru Prompt";
danbooruTitle.className = "naimod-section-title";
section.appendChild(danbooruTitle);
// ボタンコンテナ
const buttonContainer = document.createElement("div");
buttonContainer.className = "naimod-button-container";
buttonContainer.style.display = "flex";
buttonContainer.style.gap = "10px";
buttonContainer.style.marginBottom = "15px";
// Suggest AI Improvementsボタン
let suggestButton = createButton("Suggest AI Improvements", "blue", async () => {
console.debug("Suggest AI Improvements clicked");
const rightPane = document.querySelector(".naimod-pane.right-pane");
if (!rightPane) {
console.error("Right pane not found");
return;
}
// 自然言語プロンプトの状態をリセット
const naturalLanguagePrompt = document.getElementById("naturalLanguagePrompt");
const copyButton = naturalLanguagePrompt?.parentElement?.querySelector("button");
if (naturalLanguagePrompt) {
naturalLanguagePrompt.textContent = "Waiting for AI suggestions...";
if (copyButton) copyButton.style.display = "none";
}
// lastFocusedEditorからタグを取得
const editor = window.getLastFocusedEditor();
if (!editor) {
console.error("No editor is focused");
rightPane.innerHTML = "Please focus on a tag editor first";
naturalLanguagePrompt.textContent = "";
if (copyButton) copyButton.style.display = "none";
return;
}
console.debug("Getting tags from editor");
const tags = editor.textContent.trim();
console.debug("Current tags:", tags);
if (tags) {
rightPane.innerHTML = "Waiting for AI suggestions...";
suggestButton.disabled = true;
suggestButton.textContent = "Processing...";
console.debug("Getting image source");
let imageSource = document.getElementById("imageSource").value;
console.debug("Image source:", imageSource);
console.debug("Getting image DataURL");
let imageDataURL = await getImageDataURL(imageSource);
if (!imageDataURL) {
console.error("Failed to get image DataURL");
rightPane.innerHTML = "";
naturalLanguagePrompt.textContent = "";
if (copyButton) copyButton.style.display = "none";
suggestButton.disabled = false;
suggestButton.textContent = "Suggest AI Improvements";
return;
}
console.debug("Got image DataURL, length:", imageDataURL.length);
console.debug("Calling ImprovementTags");
let result = await ImprovementTags(tags, imageDataURL);
console.debug("ImprovementTags result:", result);
if (result) {
displaySuggestions(result, rightPane);
}
suggestButton.disabled = false;
suggestButton.textContent = "Suggest AI Improvements";
} else {
console.error("No tags found in editor");
rightPane.innerHTML = "No tags found in editor";
naturalLanguagePrompt.textContent = "";
if (copyButton) copyButton.style.display = "none";
}
});
suggestButton.className = "naimod-operation-button";
// Apply Suggestionsボタン
let applyButton = createButton("Apply Suggestions", "green", () => {
console.debug("Apply Suggestions clicked");
const editor = window.getLastFocusedEditor();
if (!editor) {
console.error("No editor is focused");
return;
}
const rightPane = document.querySelector(".naimod-pane.right-pane");
if (!rightPane) {
console.error("Right pane not found");
return;
}
console.debug("Getting enabled tags");
let enabledTags = Array.from(rightPane.querySelectorAll('button[data-enabled="true"]'))
.map(button => button.textContent.replace(/_/g, " "));
console.debug("Enabled tags:", enabledTags);
if (enabledTags.length > 0) {
setEditorContent(enabledTags.join(", ") + ", ", editor);
console.debug("Applied tags to editor");
// モーダルを閉じる
const modal = document.getElementById("naimod-modal");
if (modal) {
modal.style.display = "none";
console.debug("Closed modal");
}
} else {
console.debug("No enabled tags found");
}
});
applyButton.className = "naimod-operation-button";
buttonContainer.appendChild(suggestButton);
buttonContainer.appendChild(applyButton);
section.appendChild(buttonContainer);
// タグ提案表示域
const suggestionPane = document.createElement("div");
suggestionPane.className = "naimod-pane right-pane";
section.appendChild(suggestionPane);
return section;
};
// ポップアップの位置を調整する関数を修正
const adjustPopupPosition = (popup) => {
const rect = popup.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
// 現在の位置を取得(getBoundingClientRectを使用)
const currentTop = rect.top;
const currentLeft = rect.left;
// 新しい位置を設定(window.scrollY/Xを考慮)
const newTop = Math.min(Math.max(0, currentTop + window.scrollY), maxY + window.scrollY);
const newLeft = Math.min(Math.max(0, currentLeft + window.scrollX), maxX + window.scrollX);
// 位置を設定(pxを付与)
popup.style.top = `${newTop}px`;
popup.style.left = `${newLeft}px`;
// 位置を保存
savePopupPosition(popup);
};
// MutationObserverでポップアップの高さ変更を監視
const observePopupSize = (popup) => {
const observer = new MutationObserver((mutations) => {
adjustPopupPosition(popup);
});
observer.observe(popup, {
attributes: true,
childList: true,
subtree: true
});
};
// ポップアップの位置を保存(廃止してsavePopupStateに統合)
const savePopupPosition = (popup) => {
savePopupState(popup);
};
// ポップアップの状態を復元を修正
const restorePopupState = (popup) => {
const savedState = localStorage.getItem('naimodPopupState');
if (savedState) {
const state = JSON.parse(savedState);
// 位置の復元(初期値を設定)
popup.style.top = '20px';
popup.style.left = '20px';
popup.style.width = '300px'; // デフォルト幅
// 保存された位置とサイズがあれば適用
if (state.position.top && state.position.left) {
popup.style.top = state.position.top;
popup.style.left = state.position.left;
}
if (state.size?.width) {
popup.style.width = state.size.width;
}
// 位置の調整を即時実行
requestAnimationFrame(() => adjustPopupPosition(popup));
// 開閉状態の復元
const content = popup.querySelector('.naimod-popup-content');
const toggleButton = popup.querySelector('.naimod-toggle-button');
if (state.isCollapsed) {
content.style.display = 'none';
toggleButton.innerHTML = '+';
} else {
content.style.display = 'block';
toggleButton.innerHTML = '−';
}
}
};
// ドラッグ機能の実装を修正(位置保存を追加)
const makeDraggable = (element, handle) => {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
let isDragging = false;
// マウスイベントハンドラー
const dragMouseDown = (e) => {
if (e.target === handle || e.target.parentElement === handle) {
e.preventDefault();
startDragging(e.clientX, e.clientY);
document.addEventListener('mouseup', closeDragElement);
document.addEventListener('mousemove', elementDrag);
}
};
// タッチイベントハンドラー
const dragTouchStart = (e) => {
if (e.target === handle || e.target.parentElement === handle) {
e.preventDefault();
const touch = e.touches[0];
startDragging(touch.clientX, touch.clientY);
document.addEventListener('touchend', closeDragElement);
document.addEventListener('touchmove', elementDragTouch);
}
};
// ドラッグ開始時の共通処理
const startDragging = (clientX, clientY) => {
isDragging = true;
pos3 = clientX;
pos4 = clientY;
};
// マウスでのドラッグ
const elementDrag = (e) => {
if (!isDragging) return;
e.preventDefault();
updateElementPosition(e.clientX, e.clientY);
};
// タッチでのドラッグ
const elementDragTouch = (e) => {
if (!isDragging) return;
e.preventDefault();
const touch = e.touches[0];
updateElementPosition(touch.clientX, touch.clientY);
};
// 位置更新の共通処理
const updateElementPosition = (clientX, clientY) => {
pos1 = pos3 - clientX;
pos2 = pos4 - clientY;
pos3 = clientX;
pos4 = clientY;
const newTop = element.offsetTop - pos2;
const newLeft = element.offsetLeft - pos1;
const maxX = window.innerWidth - element.offsetWidth;
const maxY = window.innerHeight - element.offsetHeight;
element.style.top = `${Math.min(Math.max(0, newTop), maxY)}px`;
element.style.left = `${Math.min(Math.max(0, newLeft), maxX)}px`;
};
// ドラッグ終了時の共通処理
const closeDragElement = () => {
isDragging = false;
document.removeEventListener('mouseup', closeDragElement);
document.removeEventListener('mousemove', elementDrag);
document.removeEventListener('touchend', closeDragElement);
document.removeEventListener('touchmove', elementDragTouch);
savePopupPosition(element);
};
// イベントリスナーの設定
handle.addEventListener('mousedown', dragMouseDown);
handle.addEventListener('touchstart', dragTouchStart, { passive: false });
};
/**
* メインUIを作成する
* @returns {Element} 作成されたUIのルート要素
*/
const createMainUI = () => {
console.debug("Creating main UI");
// 既存のポップアップがあれば削除
const existingPopup = document.getElementById("naimod-popup");
if (existingPopup) {
existingPopup.remove();
}
// メインのポップアップコンテナ
const popup = document.createElement("div");
popup.id = "naimod-popup";
popup.className = "naimod-popup";
// ヘッダー部分
const header = document.createElement("div");
header.className = "naimod-popup-header";
// タイトル
const title = document.createElement("h2");
title.textContent = "NovelAI Helper";
header.appendChild(title);
// 最小化/最大化ボタン
const toggleButton = createToggleButton(popup);
header.appendChild(toggleButton);
// コンテンツ部分
const content = document.createElement("div");
content.className = "naimod-popup-content";
// メインコントロール
const controls = document.createElement("div");
controls.className = "naimod-controls";
// 設定ボタン
const settingsButton = createButton("Settings", "blue", () => {
const modal = document.getElementById("naimod-modal");
if (modal) modal.style.display = "block";
});
controls.appendChild(settingsButton);
const jpegButton = createButton("JPEG", "green", saveJpeg);
controls.appendChild(jpegButton);
// サジェストクリアボタン
/*
const clearButton = createButton("Clear Suggestion", "red", () => {
const suggestionField = document.getElementById("suggestionField");
if (suggestionField) suggestionField.textContent = "";
});
controls.appendChild(clearButton);
*/
// Chantsフィールド
const chantsField = document.createElement("div");
chantsField.id = "chantsField";
chantsField.className = "naimod-chants-field";
addChantsButtons(chantsField);
// サジェストフィールド
const suggestionField = document.createElement("div");
suggestionField.id = "suggestionField";
suggestionField.className = "naimod-suggestion-field";
// 要素の組み立て
content.appendChild(controls);
content.appendChild(chantsField);
content.appendChild(suggestionField);
popup.appendChild(header);
popup.appendChild(content);
// スタイルの追加
addStyles();
// 状態を復元(位置と開閉状態)
restorePopupState(popup);
// ドラッグ可能にする
makeDraggable(popup, header);
// サイズ変更の監視を開始
observePopupSize(popup);
// リサイズハンドルを設定
setupResizeHandles(popup);
return popup;
};
// スタイルの追加を修正
const addStyles = () => {
if (document.getElementById('naimod-styles')) return;
const style = document.createElement('style');
style.id = 'naimod-styles';
style.textContent = `
/* ==========================================================================
基本設定
========================================================================== */
#imageSource {
color: black;
}
/* ==========================================================================
モーダル基本レイアウト
========================================================================== */
.naimod-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.naimod-settings-content {
width: 90%;
max-width: 1200px;
height: 90vh;
padding: 20px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
background-color: rgba(0, 0, 64, 0.9);
color: #fff;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
/* ==========================================================================
ポップアップ関連
========================================================================== */
.naimod-popup {
position: fixed;
top: 20px;
right: 20px;
background-color: rgba(0, 0, 64, 0.9);
border: 1px solid #ccc;
border-radius: 5px;
z-index: 10000;
width: 300px;
max-height: 90vh;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
color: white;
user-select: none;
display: flex;
flex-direction: column;
opacity: 0.85;
position: relative;
min-width: 200px;
max-width: 800px;
}
.naimod-popup-header {
padding: 0px 10px;
background-color: rgba(0, 0, 80, 0.95);
border-bottom: 1px solid #ccc;
cursor: move;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
flex-shrink: 0;
}
.naimod-popup-header h2 {
margin: 0;
font-size: 0.9em;
pointer-events: none;
}
.naimod-popup-content {
padding: 5px;
overflow-y: auto;
width: 100%;
box-sizing: border-box;
flex: 1;
min-height: 0;
}
/* ==========================================================================
2ペインレイアウト
========================================================================== */
.naimod-modal-two-pane {
display: flex;
gap: 20px;
flex: 1;
overflow: hidden;
padding-bottom: 70px;
}
.naimod-modal-pane {
padding: 20px;
background-color: rgba(0, 0, 32, 0.5);
border-radius: 8px;
overflow-y: auto;
max-height: 100%;
}
.settings-pane {
width: 300px;
flex-shrink: 0;
}
.operation-pane {
flex: 1;
min-width: 600px;
}
/* ==========================================================================
コントロール要素
========================================================================== */
/* ボタン */
.naimod-button {
background: none;
border: 1px solid currentColor;
padding: 5px 10px;
margin: 5px;
border-radius: 3px;
cursor: pointer;
color: white;
transition: opacity 0.3s;
}
.naimod-button:hover {
opacity: 0.8;
}
.naimod-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.naimod-operation-button {
flex: 1;
padding: 8px 15px;
font-size: 1em;
border: 1px solid rgba(255, 255, 255, 0.3);
background-color: rgba(0, 0, 0, 0.2);
color: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.naimod-operation-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.naimod-operation-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.naimod-operation-button[data-color="blue"] {
border-color: rgba(52, 152, 219, 0.5);
}
.naimod-operation-button[data-color="green"] {
border-color: rgba(46, 204, 113, 0.5);
}
/* 入力フィールド */
.naimod-input {
width: 100%;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.2);
color: white;
font-size: 14px;
}
.naimod-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.4);
background-color: rgba(0, 0, 0, 0.3);
}
.naimod-toggle-button {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1rem;
padding: 0 5px;
z-index: 1;
}
/* ==========================================================================
タグ関連
========================================================================== */
.naimod-tag-button {
background-color: white;
border: 1px solid currentColor;
padding: 3px 8px;
margin: 3px;
border-radius: 3px;
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s;
}
.naimod-tag-button:hover {
opacity: 0.8;
}
.naimod-tag {
display: inline-block;
padding: 2px 8px;
margin: 2px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 3px;
color: white;
}
/* ==========================================================================
画像アップロード関連
========================================================================== */
.naimod-image-source-container {
background-color: rgba(0, 0, 32, 0.5);
border-radius: 5px;
padding: 15px;
margin-bottom: 20px;
}
.naimod-upload-container {
width: 100%;
height: 200px;
border: 2px dashed rgba(255, 255, 255, 0.3);
display: flex;
justify-content: center;
align-items: center;
margin: 10px 0;
cursor: pointer;
position: relative;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 5px;
}
.naimod-preview-image {
max-width: 100%;
max-height: 200px;
object-fit: contain;
border-radius: 5px;
}
/* ==========================================================================
スクロールバー
========================================================================== */
.naimod-natural-language-content::-webkit-scrollbar,
.naimod-pane.right-pane::-webkit-scrollbar {
width: 8px;
}
.naimod-natural-language-content::-webkit-scrollbar-track,
.naimod-pane.right-pane::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.naimod-natural-language-content::-webkit-scrollbar-thumb,
.naimod-pane.right-pane::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.naimod-natural-language-content::-webkit-scrollbar-thumb:hover,
.naimod-pane.right-pane::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* ==========================================================================
レスポンシブ対応
========================================================================== */
@media screen and (max-width: 1400px) {
.naimod-settings-content {
max-width: 1000px;
}
}
@media screen and (max-width: 1200px) {
.naimod-settings-content {
max-width: 900px;
}
}
@media screen and (max-width: 1000px) {
.naimod-settings-content {
max-width: 800px;
}
}
@media screen and (max-width: 800px) {
.naimod-settings-content {
width: 95%;
height: 95vh;
margin: 0;
padding: 10px;
}
.naimod-modal-two-pane {
flex-direction: column;
gap: 10px;
min-height: auto;
}
.settings-pane,
.operation-pane {
width: 100%;
min-width: 0;
}
.naimod-operation-section {
height: auto;
min-height: 400px;
}
}
/* ==========================================================================
セクションとコンテンツ
========================================================================== */
.naimod-section-title {
color: white;
font-size: 1.2em;
margin-bottom: 15px;
padding-bottom: 5px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.naimod-section {
margin-bottom: 20px;
}
.naimod-controls {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.naimod-chants-field {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 10px;
}
.naimod-suggestion-field {
max-height: calc(90vh - 200px);
overflow-y: auto;
border-top: 1px solid #ccc;
padding-top: 10px;
}
/* ==========================================================================
ナチュラルランゲージコンテナ
========================================================================== */
.naimod-natural-language-container {
background: none;
padding: 0;
margin-top: 20px;
}
.naimod-natural-language-content {
max-height: 300px;
overflow-y: auto;
padding: 15px;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 5px;
white-space: pre-wrap;
word-break: break-word;
color: white;
font-size: 0.9em;
line-height: 1.4;
margin: 10px 0;
}
/* ==========================================================================
ボタンコンテナと操作セクション
========================================================================== */
.naimod-button-container {
display: flex;
gap: 10px;
margin: 10px 0 20px;
flex-shrink: 0;
}
.naimod-close-button-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 64, 0.9);
padding: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
text-align: center;
}
.naimod-operation-section {
background-color: rgba(0, 0, 32, 0.5);
border-radius: 8px;
padding: 15px;
flex-direction: column;
margin-bottom: 20px;
}
/* ==========================================================================
タグセクション
========================================================================== */
.tag-section {
margin-bottom: 15px;
}
.tag-section h3 {
color: white;
font-size: 1.1em;
margin: 10px 0;
padding-bottom: 5px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
/* ==========================================================================
右ペイン
========================================================================== */
.naimod-pane.right-pane {
flex: 1;
overflow-y: auto;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 5px;
padding: 15px;
margin-top: 10px;
margin-bottom: 10px;
min-height: 200px;
}
/* ==========================================================================
画像ソース選択
========================================================================== */
.naimod-image-source-select {
width: 100%;
padding: 8px;
margin-bottom: 10px;
background-color: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
border-radius: 4px;
}
.naimod-image-source-select:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.4);
}
`;
document.head.appendChild(style);
};
// Build関数の名前を変更
const initializeApplication = async () => {
console.debug("Initializing application");
console.debug("Application initialization started");
// スタイルの適用(applyStylesから変更)
addStyles();
// モーダルの作成
createSettingsModal();
// メインUIの作成と配置
const ui = createMainUI();
document.body.appendChild(ui);
// アスペクト比の更新
updateAspectRatio();
};
const createDisplayAspect = () => {
let displayAspect = document.createElement("div");
displayAspect.id = "displayAspect";
let container = document.createElement("div");
container.style.maxWidth = "130px";
container.style.display = "flex";
let [widthAspect, heightAspect] = ['width', 'height'].map(type => {
let input = document.createElement("input");
input.type = "number";
return input;
});
let largeCheck = document.createElement("input");
largeCheck.type = "checkbox";
largeCheck.title = "Check to large size.";
let submitButton = document.createElement("input");
submitButton.type = "submit";
submitButton.value = "Aspect => Pixel";
[widthAspect, heightAspect].forEach(input => {
const baseInput = document.querySelector('input[type="number"][step="64"]');
if (baseInput) {
baseInput.classList.forEach(x => input.classList.add(x));
}
});
[widthAspect, heightAspect, largeCheck].forEach(el => container.appendChild(el));
[container, submitButton].forEach(el => displayAspect.appendChild(el));
submitButton.addEventListener("click", () => {
let wa = widthAspect.value;
let ha = heightAspect.value;
let maxpixel = largeCheck.checked ? 1664 * 1664 : 1024 * 1024;
let as = aspectList(wa, ha, maxpixel);
if (as.length) {
updateImageDimensions(...as[as.length - 1]);
}
});
return displayAspect;
};
const updateAspectInputs = () => {
let displayAspect = document.querySelector("#displayAspect");
if (displayAspect) {
let [widthAspect, heightAspect] = displayAspect.querySelectorAll("input");
let Aspect = getAspect(Array.from(document.querySelectorAll('input[type="number"][step="64"]')).map(x => x.value));
[widthAspect.value, heightAspect.value] = Aspect;
}
};
// ヘルパー関数
const createButton = (text, color, onClick) => {
let button = document.createElement("button");
button.textContent = text;
button.style.color = color;
button.addEventListener("click", onClick);
return button;
};
// 画像ソース選択UIの作成
const createImageSourceUI = () => {
let imageSourceLabel = document.createElement("label");
imageSourceLabel.textContent = "Image Source: ";
let imageSourceSelect = document.createElement("select");
imageSourceSelect.id = "imageSource";
["current", "uploaded"].forEach(source => {
let option = document.createElement("option");
option.value = source;
option.textContent = source.charAt(0).toUpperCase() + source.slice(1);
imageSourceSelect.appendChild(option);
});
let uploadContainer = document.createElement("div");
uploadContainer.className = "naimod-upload-container";
uploadContainer.textContent = "Drag & Drop or Click to Upload";
let uploadInput = document.createElement("input");
uploadInput.type = "file";
uploadInput.id = "naimodUploadedImage";
uploadInput.accept = "image/*";
uploadInput.style.display = "none";
// 画像アップロードハンドラーの設定
setupImageUploadHandlers(uploadContainer, uploadInput);
// 初期状態を設定
imageSourceSelect.value = "current";
uploadContainer.style.display = "none";
imageSourceSelect.addEventListener("change", () => {
uploadContainer.style.display = imageSourceSelect.value === "uploaded" ? "flex" : "none";
});
return { imageSourceSelect, uploadContainer };
};
// 画像アップロードハンドラーの設定
const setupImageUploadHandlers = (uploadContainer, uploadInput) => {
// ファイル選択時の処理
uploadInput.addEventListener("change", (e) => {
if (e.target.files && e.target.files[0]) {
handleImageUpload(e.target.files[0], uploadContainer);
} else {
console.error("No file selected");
alert("ファイルが選択されていません。");
}
});
// クリックでファイル選択
uploadContainer.addEventListener("click", () => {
uploadInput.click();
});
// ドラッグ&ドロップ処理
uploadContainer.addEventListener("dragover", (e) => {
e.preventDefault();
uploadContainer.style.borderColor = "#000";
});
uploadContainer.addEventListener("dragleave", () => {
uploadContainer.style.borderColor = "#ccc";
});
uploadContainer.addEventListener("drop", (e) => {
e.preventDefault();
uploadContainer.style.borderColor = "#ccc";
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleImageUpload(e.dataTransfer.files[0], uploadContainer);
} else {
console.error("No file dropped");
alert("ファイルのドロップに失敗しました。");
}
});
// クリップボードからの画像貼り付け
document.addEventListener("paste", (e) => {
if (document.getElementById("imageSource").value === "uploaded") {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
const blob = items[i].getAsFile();
handleImageUpload(blob, uploadContainer);
break;
}
}
}
});
uploadContainer.appendChild(uploadInput);
};
// 画像アップロード処理
const handleImageUpload = (file, uploadContainer) => {
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
let previewImage = document.getElementById('naimodPreviewImage');
if (!previewImage) {
previewImage = document.createElement('img');
previewImage.id = 'naimodPreviewImage';
previewImage.className = 'naimod-preview-image';
}
previewImage.src = e.target.result;
previewImage.style.display = "block";
uploadContainer.textContent = "";
uploadContainer.appendChild(previewImage);
};
reader.onerror = (error) => {
console.error("Error reading file:", error);
alert("画像の読み込み中にエラーが発生しました。");
};
reader.readAsDataURL(file);
} else {
console.error("Invalid file type:", file ? file.type : "No file");
alert("有効な画像ファイルを選択してください。");
}
};
// 左ペインの作成を修正
const createLeftPane = () => {
const container = document.createElement("div");
container.className = "naimod-pane-container";
// Suggest AI Improvementsボタン
let suggestButton = createButton("Suggest AI Improvements", "blue", async () => {
console.debug("Suggest AI Improvements clicked");
const rightPane = document.querySelector(".naimod-pane.right-pane");
if (!rightPane) {
console.error("Right pane not found");
return;
}
// 自然言語プロンプトの状態をリセット
const naturalLanguagePrompt = document.getElementById("naturalLanguagePrompt");
const copyButton = naturalLanguagePrompt?.parentElement?.querySelector("button");
if (naturalLanguagePrompt) {
naturalLanguagePrompt.textContent = "Waiting for AI suggestions...";
if (copyButton) copyButton.style.display = "none";
}
// lastFocusedEditorからタグを取得
const editor = window.getLastFocusedEditor();
if (!editor) {
console.error("No editor is focused");
return;
}
console.debug("Getting tags from editor");
const tags = editor.textContent.trim();
console.debug("Current tags:", tags);
if (tags) {
rightPane.innerHTML = "<div>Loading AI suggestions...</div>";
suggestButton.disabled = true;
suggestButton.textContent = "Processing...";
console.debug("Getting image source");
let imageSource = document.getElementById("imageSource").value;
console.debug("Image source:", imageSource);
console.debug("Getting image DataURL");
let imageDataURL = await getImageDataURL(imageSource);
if (!imageDataURL) {
console.error("Failed to get image DataURL");
rightPane.innerHTML = "<div>Failed to get image. Please check your image source.</div>";
suggestButton.disabled = false;
suggestButton.textContent = "Suggest AI Improvements";
return;
}
console.debug("Got image DataURL, length:", imageDataURL.length);
console.debug("Calling ImprovementTags");
let result = await ImprovementTags(tags, imageDataURL);
console.debug("ImprovementTags result:", result);
if (result) {
displaySuggestions(result, rightPane);
}
suggestButton.disabled = false;
suggestButton.textContent = "Suggest AI Improvements";
} else {
console.error("No tags found in editor");
rightPane.innerHTML = "<div>No tags found in editor</div>";
}
});
suggestButton.style.marginBottom = "10px";
container.appendChild(suggestButton);
return container;
};
// 右ペインの作成を修正
const createRightPane = () => {
const container = document.createElement("div");
container.className = "naimod-pane-container";
// Apply Suggestionsボタン
let applyButton = createButton("Apply Suggestions", "green", () => {
console.debug("Apply Suggestions clicked");
const editor = window.getLastFocusedEditor();
if (!editor) {
console.error("No editor is focused");
return;
}
const rightPane = document.querySelector(".naimod-pane.right-pane");
if (!rightPane) {
console.error("Right pane not found");
return;
}
console.debug("Getting enabled tags");
let enabledTags = Array.from(rightPane.querySelectorAll('button[data-enabled="true"]'))
.map(button => button.textContent.replace(/_/g, " "));
console.debug("Enabled tags:", enabledTags);
if (enabledTags.length > 0) {
setEditorContent(enabledTags.join(", ") + ", ", editor);
console.debug("Applied tags to editor");
// モーダルを閉じる
const modal = document.getElementById("naimod-modal");
if (modal) {
modal.style.display = "none";
console.debug("Closed modal");
}
} else {
console.debug("No enabled tags found");
}
});
applyButton.style.marginBottom = "10px";
container.appendChild(applyButton);
// 提案タグ表示域
const rightPane = document.createElement("div");
rightPane.className = "naimod-pane right-pane";
rightPane.style.minHeight = "200px";
rightPane.style.backgroundColor = "rgba(0, 0, 64, 0.8)";
rightPane.style.padding = "10px";
rightPane.style.borderRadius = "5px";
rightPane.style.marginTop = "10px";
rightPane.style.overflowY = "auto";
container.appendChild(rightPane);
return container;
};
// displaySuggestionsを修正して自然言語プロンプトを表示
const displaySuggestions = (result, pane) => {
console.debug("Displaying suggestions", result);
if (!result) {
pane.innerHTML = "";
return;
}
pane.innerHTML = "";
// Danbooru Tags セクション
const danbooruSection = document.createElement("div");
danbooruSection.className = "tag-section";
// Apply Suggestionsボタンを作成
const buttonContainer = document.createElement("div");
buttonContainer.className = "naimod-button-container";
const applyButton = createButton("Apply Suggestions", "green", () => {
console.debug("Apply Suggestions clicked");
const editor = window.getLastFocusedEditor();
if (!editor) {
console.error("No editor is focused");
return;
}
const rightPane = document.querySelector(".naimod-pane.right-pane");
if (!rightPane) {
console.error("Right pane not found");
return;
}
console.debug("Getting enabled tags");
let enabledTags = Array.from(rightPane.querySelectorAll('button[data-enabled="true"]'))
.map(button => button.textContent.replace(/_/g, " "));
console.debug("Enabled tags:", enabledTags);
if (enabledTags.length > 0) {
setEditorContent(enabledTags.join(", ") + ", ", editor);
console.debug("Applied tags to editor");
// モーダルを閉じる
const modal = document.getElementById("naimod-modal");
if (modal) {
modal.style.display = "none";
console.debug("Closed modal");
}
} else {
console.debug("No enabled tags found");
}
});
applyButton.className = "naimod-operation-button";
buttonContainer.appendChild(applyButton);
danbooruSection.appendChild(buttonContainer);
// 既存のタグを表示
const editor = window.getLastFocusedEditor();
if (editor) {
const currentTags = editor.textContent.split(/[,\n]+/).map(t => t.trim()).filter(Boolean);
const shouldRemoveSet = new Set(result.shouldRemove || []);
currentTags.forEach(tag => {
const tagButton = createButton(tag, shouldRemoveSet.has(tag) ? "red" : "#3498db", () => { });
tagButton.className = "naimod-tag-button";
tagButton.dataset.enabled = shouldRemoveSet.has(tag) ? "false" : "true";
tagButton.textContent = tag;
if (shouldRemoveSet.has(tag)) {
tagButton.title = "This tag is recommended to be removed";
}
danbooruSection.appendChild(tagButton);
});
}
pane.appendChild(danbooruSection);
// 追加すべきタグ
if (result.shouldAdd?.length) {
const addSection = document.createElement("div");
addSection.className = "tag-section";
addSection.innerHTML = "<h3 style='color: green;'>Suggested New Tags:</h3>";
result.shouldAdd.forEach(tag => {
const tagButton = createButton(tag, "green", () => { });
tagButton.className = "naimod-tag-button";
tagButton.dataset.enabled = "true";
tagButton.textContent = tag;
addSection.appendChild(tagButton);
});
pane.appendChild(addSection);
}
// 追加を提案するタグ
if (result.mayAdd?.length) {
const mayAddSection = document.createElement("div");
mayAddSection.className = "tag-section";
mayAddSection.innerHTML = "<h3 style='color: blue;'>Optional Tags:</h3>";
result.mayAdd.forEach(tag => {
const tagButton = createButton(tag, "blue", () => { });
tagButton.className = "naimod-tag-button";
tagButton.dataset.enabled = "false";
tagButton.textContent = tag;
mayAddSection.appendChild(tagButton);
});
pane.appendChild(mayAddSection);
}
// 各ボタンにトグル機能を追加
pane.querySelectorAll('.naimod-tag-button').forEach(button => {
button.onclick = () => {
button.dataset.enabled = button.dataset.enabled === "true" ? "false" : "true";
button.style.opacity = button.dataset.enabled === "true" ? "1" : "0.5";
console.debug(`Tag ${button.textContent} toggled:`, button.dataset.enabled);
};
// 初期状態を反映
button.style.opacity = button.dataset.enabled === "true" ? "1" : "0.5";
});
// 自然言語プロンプトの更新
const naturalLanguagePrompt = document.getElementById("naturalLanguagePrompt");
if (naturalLanguagePrompt && result.naturalLanguagePrompt) {
naturalLanguagePrompt.textContent = result.naturalLanguagePrompt;
// コピーボタンを動的に作成
const container = naturalLanguagePrompt.parentElement;
const buttonContainer = document.createElement("div");
buttonContainer.className = "naimod-button-container";
const copyButton = createButton("Copy", "blue", () => {
console.debug("Copy button clicked");
const naturalLanguagePrompt = document.getElementById("naturalLanguagePrompt");
if (!naturalLanguagePrompt) {
console.error("Natural language prompt element not found");
return;
}
const promptText = naturalLanguagePrompt.textContent;
if (!promptText) {
console.debug("No prompt text to copy");
return;
}
// クリップボードへコピー
navigator.clipboard.writeText(promptText)
.then(() => {
console.debug("Text copied to clipboard");
// 一時的なフィードバックとしてボタンのテキストを変更
copyButton.textContent = "Copied!";
setTimeout(() => {
copyButton.textContent = "Copy";
}, 2000);
})
.catch(err => {
console.error("Failed to copy text:", err);
alert("Failed to copy text to clipboard");
});
});
copyButton.className = "naimod-operation-button";
buttonContainer.appendChild(copyButton);
// タイトルの直後にボタンを挿入
const title = container.querySelector(".naimod-section-title");
title.insertAdjacentElement('afterend', buttonContainer);
}
};
// 自然言語プロンプトセクションの作成を修正
const createNaturalLanguageSection = () => {
const container = document.createElement("div");
container.className = "naimod-natural-language-container";
const title = document.createElement("h3");
title.textContent = "Natural Language Prompt";
title.className = "naimod-section-title";
container.appendChild(title);
const content = document.createElement("div");
content.id = "naturalLanguagePrompt";
content.className = "naimod-natural-language-content";
content.textContent = "";
container.appendChild(content);
return container;
};
// 閉じるボタンセクションの作成を修正
const createCloseButtonSection = (modal) => {
const container = document.createElement("div");
container.className = "naimod-close-button-container";
container.style.textAlign = "center";
container.style.marginTop = "20px";
let closeButton = createButton("Close", "grey", () => modal.style.display = "none");
closeButton.style.padding = "5px 20px";
container.appendChild(closeButton);
return container;
};
// アスペクト比の更新
const updateAspectRatio = () => {
let widthInput = document.querySelector('input[type="number"][step="64"]');
if (widthInput) {
let displayAspect = document.querySelector("#displayAspect") || createDisplayAspect();
widthInput.parentNode.appendChild(displayAspect);
updateAspectInputs();
}
};
// Chantsボタンの追加
const addChantsButtons = async (chantsField) => {
try {
// chantURLが未定義の場合は取得
if (!chantURL) {
chantURL = getChantURL(null);
}
// Chantsデータのフェッチ
const response = await fetch(chantURL);
chants = await response.json();
// 各Chantに対応するボタンを作成
chants.forEach(chant => {
let button = createButton(
chant.name,
colors[chant.color][1],
() => handleChantClick(chant.name)
);
chantsField.appendChild(button);
});
} catch (error) {
console.error("Error fetching chants:", error);
// エラー時のフォールバック処理
let errorButton = createButton(
"Chants Load Error",
"red",
() => {
chantURL = getChantURL(true);
initializeApplication();
}
);
chantsField.appendChild(errorButton);
}
};
// Chantボタンのクリックハンドラー
const handleChantClick = (chantName) => {
const editor = window.getLastFocusedEditor();
if (!editor) {
console.error("No editor is focused");
return;
}
const chant = chants.find(x => x.name === chantName.trim());
if (chant) {
const tags = chant.content.split(",").map(x => x.trim());
appendTagsToPrompt(tags, editor);
}
};
// ウィンドウリサイズ時の処理を追加
window.addEventListener('resize', () => {
const popup = document.getElementById('naimod-popup');
if (popup) {
adjustPopupPosition(popup);
}
});
// キーボードショートカットハンドラー
document.addEventListener("keydown", e => {
const editor = window.getLastFocusedEditor();
if (!editor) return;
if (e.ctrlKey && e.code === "ArrowUp") {
e.preventDefault();
adjustTagEmphasis(1, editor);
}
if (e.ctrlKey && e.code === "ArrowDown") {
e.preventDefault();
adjustTagEmphasis(-1, editor);
}
});
// ポップアップの状態を保存
const savePopupState = (popup) => {
const state = {
position: {
top: popup.style.top,
left: popup.style.left
},
size: {
width: popup.style.width
},
isCollapsed: popup.querySelector('.naimod-popup-content').style.display === 'none'
};
localStorage.setItem('naimodPopupState', JSON.stringify(state));
};
// トグルボタンのクリックハンドラーを更新
const createToggleButton = (popup) => {
const toggleButton = document.createElement("button");
toggleButton.className = "naimod-toggle-button";
toggleButton.innerHTML = "−";
// トグル処理の共通関数
const toggleContent = (e) => {
e.preventDefault();
const content = popup.querySelector(".naimod-popup-content");
if (content.style.display === "none") {
content.style.display = "block";
toggleButton.innerHTML = "−";
} else {
content.style.display = "none";
toggleButton.innerHTML = "+";
}
savePopupState(popup);
};
// マウスとタッチの両方のイベントを設定
toggleButton.addEventListener('click', toggleContent);
toggleButton.addEventListener('touchend', toggleContent, { passive: false });
return toggleButton;
};
// リサイズハンドルの作成と設定を修正
const setupResizeHandles = (popup) => {
// スタイルの追加(既存のコード)
const style = document.createElement('style');
style.textContent = `
.naimod-resize-handle {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 1000;
}
.naimod-resize-handle.left {
left: -3px;
}
.naimod-resize-handle.right {
right: -3px;
}
`;
document.head.appendChild(style);
const leftHandle = document.createElement('div');
leftHandle.className = 'naimod-resize-handle left';
const rightHandle = document.createElement('div');
rightHandle.className = 'naimod-resize-handle right';
// ポップアップのposition: fixedを確保
if (getComputedStyle(popup).position !== 'fixed') {
popup.style.position = 'fixed';
}
popup.appendChild(leftHandle);
popup.appendChild(rightHandle);
// リサイズ処理
const startResize = (clientX, handle, isTouch = false) => {
const isLeft = handle.classList.contains('left');
const startX = clientX;
const startWidth = popup.offsetWidth;
const startLeft = popup.offsetLeft;
const resize = (moveEvent) => {
moveEvent.preventDefault();
const currentX = isTouch ? moveEvent.touches[0].clientX : moveEvent.clientX;
const delta = currentX - startX;
let newWidth;
if (isLeft) {
newWidth = startWidth - delta;
if (newWidth >= 200 && newWidth <= 800) {
popup.style.width = `${newWidth}px`;
popup.style.left = `${startLeft + delta}px`;
}
} else {
newWidth = startWidth + delta;
if (newWidth >= 200 && newWidth <= 800) {
popup.style.width = `${newWidth}px`;
}
}
savePopupState(popup);
};
const stopResize = () => {
if (isTouch) {
document.removeEventListener('touchmove', resize);
document.removeEventListener('touchend', stopResize);
} else {
document.removeEventListener('mousemove', resize);
document.removeEventListener('mouseup', stopResize);
}
};
if (isTouch) {
document.addEventListener('touchmove', resize, { passive: false });
document.addEventListener('touchend', stopResize);
} else {
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', stopResize);
}
};
// マウスイベントハンドラー
const mouseDownHandler = (e) => {
e.preventDefault();
startResize(e.clientX, e.target);
};
// タッチイベントハンドラー
const touchStartHandler = (e) => {
e.preventDefault();
const touch = e.touches[0];
startResize(touch.clientX, e.target, true);
};
// イベントリスナーの設定
[leftHandle, rightHandle].forEach(handle => {
handle.addEventListener('mousedown', mouseDownHandler);
handle.addEventListener('touchstart', touchStartHandler, { passive: false });
});
};
// イベントリスナー
document.addEventListener("blur", event => {
if (event.target.tagName.toLowerCase() === 'button') {
setTimeout(() => {
initializeApplication();
}, 1000);
}
}, true);
document.addEventListener("keydown", e => {
if (e.ctrlKey && e.code === "ArrowUp") {
e.preventDefault();
adjustTagEmphasis(1);
}
if (e.ctrlKey && e.code === "ArrowDown") {
e.preventDefault();
adjustTagEmphasis(-1);
}
});
// 最後にフォーカスされたエディタとカーソル位置を記録
let lastFocusedEditor = null;
let lastCursorPosition = 0;
// カーソル位置を更新する関数
const updateCursorPosition = (editor) => {
if (editor) {
lastCursorPosition = getAbsoluteCursorPosition(editor);
}
};
// ProseMirrorエディタの監視
const observeEditor = (editor) => {
if (editor) {
/*
editor.addEventListener("focus", () => {
lastFocusedEditor = editor;
updateCursorPosition(editor);
});
*/
editor.addEventListener("input", e => {
lastFocusedEditor = editor;
const now = new Date();
lastTyped = now;
if (now - suggested_at > SUGGESTION_LIMIT) {
showTagSuggestions(editor, lastCursorPosition);
suggested_at = now;
updateCursorPosition(editor);
} else {
setTimeout(() => {
if (lastTyped === now) {
showTagSuggestions(editor, lastCursorPosition);
suggested_at = new Date();
updateCursorPosition(editor);
}
}, 1000);
}
});
editor.addEventListener("click", e => {
lastFocusedEditor = editor;
updateCursorPosition(editor);
const now = new Date();
if (now - suggested_at > SUGGESTION_LIMIT / 2) {
showTagSuggestions(editor, lastCursorPosition);
suggested_at = now;
} else {
setTimeout(() => {
if (lastTyped === now) {
showTagSuggestions(editor, lastCursorPosition);
suggested_at = new Date();
}
}, 1000);
}
});
}
};
// DOM変更の監視
const observeDOM = () => {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) { // ELEMENT_NODE
const editors = node.getElementsByClassName("ProseMirror");
Array.from(editors).forEach(editor => {
if (!editor.hasAttribute('data-naimod-observed')) {
observeEditor(editor);
editor.setAttribute('data-naimod-observed', 'true');
}
});
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
};
// タグデータの取得と処理を最適化
fetch("https://gist.githubusercontent.com/vkff5833/275ccf8fa51c2c4ba767e2fb9c653f9a/raw/danbooru.json?" + Date.now())
.then(response => response.json())
.then(data => {
allTags = data;
return fetch("https://gist.githubusercontent.com/vkff5833/275ccf8fa51c2c4ba767e2fb9c653f9a/raw/danbooru_wiki.slim.json?" + Date.now());
})
.then(response => response.json())
.then(wikiPages => {
// wikiPagesをマップに変換して検索を高速化
const wikiMap = new Map(wikiPages.map(page => [page.name, page]));
allTags = allTags.map(x => {
const wikiPage = wikiMap.get(x.name);
if (wikiPage) {
// Set操作を1回だけ実行
const allTerms = new Set(x.terms);
wikiPage.otherNames.forEach(name => allTerms.add(name));
x.terms = Array.from(allTerms);
}
return x;
});
buildTagIndices();
});
// 初期化処理を最適化
let init = setInterval(() => {
const editors = document.getElementsByClassName("ProseMirror");
if (editors.length > 0) {
clearInterval(init);
chantURL = getChantURL(null);
// エディタの監視を一括で設定
const observedEditors = new Set();
Array.from(editors).forEach(editor => {
if (!observedEditors.has(editor)) {
observeEditor(editor);
editor.setAttribute('data-naimod-observed', 'true');
observedEditors.add(editor);
}
});
initializeApplication();
observeDOM();
}
}, 100);
// lastFocusedEditorをグローバルに公開
window.getLastFocusedEditor = () => lastFocusedEditor;
// lastCursorPositionをグローバルに公開
window.getLastCursorPosition = () => lastCursorPosition;