您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
なんIにお絵かき機能を追加するユーザースクリプトです。
// ==UserScript== // @name なん愛ch お絵描き機能 // @namespace http://tampermonkey.net/ // @version 1.0.5 // @description なんIにお絵かき機能を追加するユーザースクリプトです。 // @author You // @match https://openlive2ch.pages.dev/* // @grant GM.addStyle // @grant GM.addElement // @license MIT // ==/UserScript== (async function() { 'use strict'; const [ mdi, oekaki ] = await Promise.all([ 'https://cdn.jsdelivr.net/npm/@mdi/[email protected]/mdi.js', 'https://cdn.jsdelivr.net/npm/@onjmin/oekaki/dist/index.min.mjs' ].map(v => import(v))); GM.addStyle(` .grid .upper-canvas { opacity: 0.4; background-image: linear-gradient(to right, gray 1px, transparent 1px), linear-gradient(to bottom, gray 1px, transparent 1px); background-size: var(--grid-cell-size) var(--grid-cell-size); } .tool-options { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } .size-display { width: 50px; text-align: center; } .slider-wrapper { flex-grow: 1; } .slider-input { width: 100%; } .palette { width: 30px; height: 30px; border-radius: 50%; border: 2px solid #fff; cursor: pointer; padding: 0; } `); const uploadImageBtn = document.getElementById('upload-image-btn'); if (!uploadImageBtn) { console.error('#upload-image-btn not found.'); return; } const parent = uploadImageBtn.parentElement; if (parent) { const oekakiButton = await GM.addElement(parent, 'button', { id: 'oekaki-btn', type: 'button', class: 'smallbtn', textContent: 'お絵描き' }); const imagePreview = document.getElementById('image-preview'); const imageThumbnail = document.getElementById('image-thumbnail'); if (!imagePreview || !imageThumbnail) { console.error('#image-preview or #image-thumbnail not found.'); return; } const oekakiUIWrapper = document.createElement('div'); oekakiUIWrapper.id = 'oekaki-ui-wrapper'; oekakiUIWrapper.style.display = 'none'; oekakiUIWrapper.style.position = 'fixed'; oekakiUIWrapper.style.top = '0'; oekakiUIWrapper.style.left = '0'; oekakiUIWrapper.style.width = '100vw'; oekakiUIWrapper.style.height = '100vh'; oekakiUIWrapper.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; oekakiUIWrapper.style.zIndex = '9999'; oekakiUIWrapper.style.flexDirection = 'column'; oekakiUIWrapper.style.justifyContent = 'center'; oekakiUIWrapper.style.alignItems = 'center'; oekakiUIWrapper.style.color = '#fff'; document.body.appendChild(oekakiUIWrapper); const toolPanel = document.createElement('div'); toolPanel.style.padding = '10px'; toolPanel.style.backgroundColor = '#333'; toolPanel.style.borderRadius = '5px'; toolPanel.style.display = 'flex'; toolPanel.style.flexWrap = 'wrap'; toolPanel.style.gap = '10px'; toolPanel.style.marginBottom = '10px'; oekakiUIWrapper.appendChild(toolPanel); const toolOptionsPanel = document.createElement('div'); toolOptionsPanel.className = 'tool-options'; oekakiUIWrapper.appendChild(toolOptionsPanel); const layerPanel = document.createElement('div'); layerPanel.style.padding = '10px'; layerPanel.style.backgroundColor = '#333'; layerPanel.style.borderRadius = '5px'; layerPanel.style.position = 'absolute'; layerPanel.style.top = '10px'; layerPanel.style.right = '10px'; layerPanel.style.zIndex = '10000'; layerPanel.style.display = 'flex'; layerPanel.style.flexDirection = 'column'; layerPanel.style.gap = '5px'; layerPanel.style.maxHeight = '80vh'; layerPanel.style.overflowY = 'auto'; oekakiUIWrapper.appendChild(layerPanel); const oekakiContainer = document.createElement('div'); oekakiContainer.style.border = '1px solid white'; oekakiContainer.style.boxSizing = 'content-box'; oekakiUIWrapper.appendChild(oekakiContainer); let activeLayer = null; let oekakiInitialized = false; let width, height; let choicedTool = 'ペン'; // 初期値をペンに設定 let isErasable = false; let isFlipped = false; let isGrid = false; let currentColor = '#000000'; let brushSize = 16; let penSize = 4; let eraserSize = 16; let dotPenScale = 1; oekaki.color.value = currentColor; oekaki.penSize.value = penSize; oekaki.brushSize.value = brushSize; oekaki.eraserSize.value = eraserSize; const recentColors = ['#000000', '#FFFFFF']; const addRecentColors = () => { if (choicedTool === tools.translate.label) return; const idx = recentColors.indexOf(currentColor); if (idx === 0) return; if (idx !== -1) recentColors.splice(idx, 1); recentColors.unshift(currentColor); if (recentColors.length > 8) recentColors.pop(); }; const updateToolOptions = () => { toolOptionsPanel.innerHTML = ''; const choiced = Object.values(tools).find(v => v.label === choicedTool); const colorPickerInput = document.createElement('input'); colorPickerInput.type = 'color'; colorPickerInput.value = currentColor; colorPickerInput.oninput = (e) => { currentColor = e.target.value; oekaki.color.value = currentColor; }; toolOptionsPanel.appendChild(colorPickerInput); recentColors.forEach(color => { const colorBtn = document.createElement('button'); colorBtn.className = 'palette'; colorBtn.style.backgroundColor = color; colorBtn.onclick = () => { currentColor = color; oekaki.color.value = currentColor; colorPickerInput.value = currentColor; }; toolOptionsPanel.appendChild(colorBtn); }); const sizeDisplay = document.createElement('span'); sizeDisplay.className = 'size-display'; toolOptionsPanel.appendChild(sizeDisplay); const sliderWrapper = document.createElement('div'); sliderWrapper.className = 'slider-wrapper'; if (choicedTool === tools.brush.label) { sizeDisplay.textContent = `${brushSize}px`; const slider = document.createElement('input'); slider.type = 'range'; slider.min = '1'; slider.max = '64'; slider.value = brushSize; slider.className = 'slider-input'; slider.oninput = (e) => { brushSize = parseInt(e.target.value); oekaki.brushSize.value = brushSize; sizeDisplay.textContent = `${brushSize}px`; }; sliderWrapper.appendChild(slider); } else if (isGrid) { sizeDisplay.textContent = `${dotPenScale}倍`; const slider = document.createElement('input'); slider.type = 'range'; slider.min = '1'; slider.max = '8'; slider.value = dotPenScale; slider.className = 'slider-input'; slider.oninput = (e) => { dotPenScale = parseInt(e.target.value); oekaki.setDotSize(dotPenScale); document.documentElement.style.setProperty("--grid-cell-size", `${oekaki.getDotSize()}px`); sizeDisplay.textContent = `${dotPenScale}倍`; }; sliderWrapper.appendChild(slider); } else if (choicedTool === tools.pen.label) { sizeDisplay.textContent = `${penSize}px`; const slider = document.createElement('input'); slider.type = 'range'; slider.min = '1'; slider.max = '64'; slider.value = penSize; slider.className = 'slider-input'; slider.oninput = (e) => { penSize = parseInt(e.target.value); oekaki.penSize.value = penSize; sizeDisplay.textContent = `${penSize}px`; }; sliderWrapper.appendChild(slider); } else if (choicedTool === tools.eraser.label) { sizeDisplay.textContent = `${eraserSize}px`; const slider = document.createElement('input'); slider.type = 'range'; slider.min = '1'; slider.max = '64'; slider.value = eraserSize; slider.className = 'slider-input'; slider.oninput = (e) => { eraserSize = parseInt(e.target.value); oekaki.eraserSize.value = eraserSize; sizeDisplay.textContent = `${eraserSize}px`; }; sliderWrapper.appendChild(slider); } if (choicedTool !== tools.dropper.label && choicedTool !== tools.fill.label) { toolOptionsPanel.appendChild(sliderWrapper); } }; const updateThumbnail = () => { const dataURL = oekaki.render().toDataURL(); if (dataURL) { imageThumbnail.src = dataURL; document.getElementById("image-status").innerText = "プレビュー"; } }; const updateLayerPanel = () => { layerPanel.innerHTML = ''; const layers = oekaki.getLayers(); layers.forEach((layer, index) => { const layerItem = document.createElement('div'); layerItem.style.display = 'flex'; layerItem.style.alignItems = 'center'; layerItem.style.color = '#fff'; layerItem.style.cursor = 'pointer'; layerItem.style.padding = '5px'; layerItem.style.backgroundColor = activeLayer === layer ? '#555' : 'transparent'; layerItem.style.gap = '10px'; // レイヤー名の表示 const layerNameSpan = document.createElement('span'); layerNameSpan.textContent = layer.name; layerNameSpan.onclick = () => { activeLayer = layer; updateLayerPanel(); }; layerItem.appendChild(layerNameSpan); // 表示/非表示ボタン const visibilityButton = document.createElement('button'); visibilityButton.style.cssText = ` display: flex; align-items: center; justify-content: center; padding: 8px; border: 1px solid #555; background-color: #444; color: #fff; cursor: pointer; border-radius: 4px; width: 40px; height: 40px; `; visibilityButton.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="${layer.visible ? mdi.mdiEye : mdi.mdiEyeOff}"/></svg>`; visibilityButton.onclick = (e) => { e.stopPropagation(); layer.visible = !layer.visible; updateThumbnail(); updateLayerPanel(); }; layerItem.appendChild(visibilityButton); // 削除ボタン const deleteButton = document.createElement('button'); deleteButton.style.cssText = ` display: flex; align-items: center; justify-content: center; padding: 8px; border: 1px solid #555; background-color: #444; color: #fff; cursor: pointer; border-radius: 4px; width: 40px; height: 40px; `; deleteButton.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="${mdi.mdiDelete}" /></svg>`; deleteButton.onclick = (e) => { e.stopPropagation(); // レイヤー削除の確認ロジック if (layer.locked || (layer.used && !confirm(`${layer.name}を削除しますか?`))) { return; } activeLayer.delete(); const { prev, next } = activeLayer; if (next) activeLayer = next; else if (prev) activeLayer = prev; else { // 全てのレイヤーが削除された場合の初期化 const newLayer = new oekaki.LayeredCanvas("レイヤー #1"); activeLayer = newLayer; } updateLayerPanel(); updateThumbnail(); }; layerItem.appendChild(deleteButton); layerPanel.appendChild(layerItem); }); const addLayerButton = document.createElement('button'); addLayerButton.textContent = 'レイヤー追加'; addLayerButton.style.cssText = 'margin-top: 10px; padding: 5px; cursor: pointer;'; addLayerButton.onclick = () => { const layers = oekaki.getLayers(); const newLayer = new oekaki.LayeredCanvas(`レイヤー #${layers.length + 1}`); activeLayer = newLayer; updateLayerPanel(); }; layerPanel.appendChild(addLayerButton); }; const mdi2DataUrl = (mdi) => { const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path d="${mdi}" fill="black" stroke="white" stroke-width="1"/></svg>`; const base64 = btoa(svg); return `data:image/svg+xml;base64,${base64}`; }; const createToolButton = (label, iconName, onClick) => { const button = document.createElement('button'); button.title = label; button.style.cssText = ` display: flex; align-items: center; justify-content: center; padding: 8px; border: 1px solid #555; background-color: #444; color: #fff; cursor: pointer; border-radius: 4px; width: 40px; height: 40px; `; const svgIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svgIcon.setAttribute('width', '24'); svgIcon.setAttribute('height', '24'); svgIcon.setAttribute('viewBox', '0 0 24 24'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('fill', 'currentColor'); path.setAttribute('d', iconName); svgIcon.appendChild(path); button.appendChild(svgIcon); button.onclick = onClick; return button; }; const updateToolButtons = () => { const buttons = toolPanel.querySelectorAll('button'); buttons.forEach(button => { const buttonLabel = button.title; if (buttonLabel === choicedTool || (buttonLabel === '常に消しゴム' && isErasable) || (buttonLabel === '左右反転' && isFlipped) || (buttonLabel === 'グリッド線' && isGrid)) { button.style.backgroundColor = '#6A1B9A'; } else { button.style.backgroundColor = '#444'; } }); updateToolOptions(); }; const tools = { brush: { label: "ブラシ", icon: mdi.mdiBrush }, pen: { label: "ペン", icon: mdi.mdiPen }, eraser: { label: "消しゴム", icon: mdi.mdiEraser }, dropper: { label: "カラーピッカー", icon: mdi.mdiEyedropper }, fill: { label: "塗りつぶし", icon: mdi.mdiFormatColorFill }, translate: { label: "ハンドツール", icon: mdi.mdiHandBackRight }, erasable: { label: "常に消しゴム", icon: mdi.mdiEraserVariant }, flip: { label: "左右反転", icon: mdi.mdiFlipHorizontal }, grid: { label: "グリッド線", icon: mdi.mdiGrid }, undo: { label: "戻る", icon: mdi.mdiUndo }, redo: { label: "進む", icon: mdi.mdiRedo }, save: { label: "画像を保存", icon: mdi.mdiContentSaveOutline }, clear: { label: "全消し", icon: mdi.mdiTrashCanOutline } }; const notDrawing = (e) => { const target = e.target; return ( !getSelection()?.isCollapsed || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ); }; const handleKeyDown = async (e) => { if (notDrawing(e)) return; if (!e.ctrlKey) return; let key = e.key.toLowerCase(); if (e.getModifierState("CapsLock")) { key = /[a-z]/.test(key) ? key.toUpperCase() : key.toLowerCase(); } switch (key) { case "1": { e.preventDefault(); choicedTool = tools.brush.label; break; } case "2": { e.preventDefault(); choicedTool = tools.pen.label; break; } case "3": { e.preventDefault(); choicedTool = tools.eraser.label; break; } case "4": { e.preventDefault(); choicedTool = tools.dropper.label; break; } case "5": { e.preventDefault(); choicedTool = tools.fill.label; break; } case "6": { e.preventDefault(); choicedTool = tools.translate.label; break; } case "e": { e.preventDefault(); isErasable = !isErasable; updateToolButtons(); break; } case "f": { e.preventDefault(); isFlipped = !isFlipped; oekaki.flipped.value = isFlipped; updateToolButtons(); break; } case "g": { e.preventDefault(); isGrid = !isGrid; oekakiContainer.classList.toggle('grid', isGrid); updateToolButtons(); break; } case "z": { e.preventDefault(); if (e.shiftKey) { if (activeLayer) activeLayer.redo(); } else { if (activeLayer) activeLayer.undo(); } updateThumbnail(); break; } case "s": { e.preventDefault(); const dataURL = oekaki.render().toDataURL("image/png"); const link = document.createElement("a"); link.href = dataURL; link.download = "drawing.png"; link.click(); break; } case "c": { e.preventDefault(); if (activeLayer) { const blob = await new Promise(resolve => activeLayer.canvas.toBlob(resolve)); if (blob) { await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); } } break; } } updateToolButtons(); }; window.addEventListener('paste', async (e) => { if (!activeLayer || activeLayer.locked) return; const imageItem = Array.from(e.clipboardData?.items || []).find(v => v.kind === "file" && v.type.startsWith("image/")); if (!imageItem) return; const blob = imageItem.getAsFile(); if (!blob) return; const bitmap = await createImageBitmap(blob); activeLayer.paste(bitmap); activeLayer.trace(); updateThumbnail(); }); const dropper = (x, y) => { if (!activeLayer) return; const result = oekaki.dropper(x, y); if (!result) return; const [r, g, b, a] = result; if (a > 0) { isErasable = false; const hex = `#${[r, g, b] .map((v) => v.toString(16).padStart(2, "0")) .join("")}`; currentColor = hex; oekaki.color.value = currentColor; } else { isErasable = true; } updateToolOptions(); }; const fill = async (x, y) => { if (!activeLayer) return; const rgb = currentColor .slice(1) .match(/.{2}/g) ?.map((v) => Number.parseInt(v, 16)); if (rgb?.length !== 3) return; const [r, g, b] = rgb; const data = oekaki.floodFill( activeLayer.data, width, height, x, y, isErasable ? [0, 0, 0, 0] : [r, g, b, 255], ); if (data) activeLayer.data = data; }; const initOekaki = async () => { if (oekakiInitialized) return; oekakiInitialized = true; width = Math.min(window.innerWidth * 0.8, 800); height = Math.min(window.innerHeight * 0.8, 600); oekaki.init(oekakiContainer, width, height); const upperCanvas = oekaki.upperLayer.value.canvas; if (upperCanvas) { upperCanvas.classList.add("upper-canvas"); } document.documentElement.style.setProperty("--grid-cell-size", `${oekaki.getDotSize()}px`); const bgLayer = new oekaki.LayeredCanvas("白背景"); bgLayer.fill("#FFF"); bgLayer.trace(); const firstLayer = new oekaki.LayeredCanvas("レイヤー #2"); activeLayer = firstLayer; updateLayerPanel(); const separator = "separator"; [ tools.brush, tools.pen, tools.eraser, tools.dropper, tools.fill, tools.translate, separator, tools.erasable, tools.flip, tools.grid, separator, tools.undo, tools.redo, tools.save, tools.clear ].forEach(t => { if (t === separator) { const separatorElement = document.createElement('div'); separatorElement.style.width = '1px'; separatorElement.style.height = '48px'; separatorElement.style.backgroundColor = '#555'; toolPanel.appendChild(separatorElement); return; } const button = createToolButton(t.label, t.icon, async () => { const toggleTools = [tools.erasable, tools.flip, tools.grid]; if (toggleTools.includes(t)) { switch (t.label) { case '常に消しゴム': isErasable = !isErasable; break; case '左右反転': isFlipped = !isFlipped; oekaki.flipped.value = isFlipped; break; case 'グリッド線': isGrid = !isGrid; oekakiContainer.classList.toggle('grid', isGrid); if (isGrid) { dotPenScale = 4; oekaki.setDotSize(dotPenScale); document.documentElement.style.setProperty("--grid-cell-size", `${oekaki.getDotSize()}px`); } break; } } else if (t.label === '戻る') { if (activeLayer) activeLayer.undo(); updateThumbnail(); } else if (t.label === '進む') { if (activeLayer) activeLayer.redo(); updateThumbnail(); } else if (t.label === '画像を保存') { const dataURL = oekaki.render().toDataURL("image/png"); const link = document.createElement("a"); link.href = dataURL; link.download = "drawing.png"; link.click(); } else if (t.label === '全消し') { if (activeLayer) activeLayer.clear(); updateThumbnail(); } else { choicedTool = t.label; const choiced = Object.values(tools).find(v => v.label === choicedTool); const xy = choiced.label === tools.fill.label ? "21 19" : "3 21"; if (oekaki.upperLayer.value.canvas) { oekaki.upperLayer.value.canvas.style.cursor = `url('${mdi2DataUrl(choiced.icon)}') ${xy}, auto`; } } updateToolButtons(); }); toolPanel.appendChild(button); }); const choiced = Object.values(tools).find(v => v.label === choicedTool); const xy = choiced.label === tools.fill.label ? "21 19" : "3 21"; oekaki.upperLayer.value.canvas.style.cursor = `url('${mdi2DataUrl(choiced.icon)}') ${xy}, auto`; updateToolButtons(); let prevX = null; let prevY = null; let dropping = false; oekaki.onDraw((x, y, buttons) => { if (!activeLayer || activeLayer.locked) return; if (prevX === null) prevX = x; if (prevY === null) prevY = y; if (choicedTool === tools.dropper.label || (buttons & 2) !== 0) { dropper(x, y); dropping = true; } else { if (choicedTool === tools.brush.label) { activeLayer.drawLine(x, y, prevX, prevY); } else if (choicedTool === tools.translate.label) { if (isGrid) { activeLayer.translateByDot(x - prevX, y - prevY); } else { activeLayer.translate(x - prevX, y - prevY); } } else { const lerps = oekaki.lerp(x, y, prevX, prevY); switch (choicedTool) { case tools.pen.label: for (const [lx, ly] of lerps) { const isEraseMode = isErasable; if (isGrid) { isEraseMode ? activeLayer.eraseByDot(lx, ly) : activeLayer.drawByDot(lx, ly); } else { isEraseMode ? activeLayer.erase(lx, ly) : activeLayer.draw(lx, ly); } } break; case tools.eraser.label: for (const [lx, ly] of lerps) { if (isGrid) { activeLayer.eraseByDot(lx, ly); } else { activeLayer.erase(lx, ly); } } break; } } } prevX = x; prevY = y; }); const fin = () => { if (activeLayer?.modified()) { activeLayer.trace(); addRecentColors(); updateToolOptions(); updateThumbnail(); } }; oekaki.onDrawn((x, y, buttons) => { prevX = null; prevY = null; if (activeLayer?.locked) return; if (choicedTool === tools.fill.label && !dropping) fill(x, y); dropping = false; fin(); }); oekaki.upperLayer.value.canvas.addEventListener('contextmenu', (e) => { e.preventDefault(); }); window.addEventListener('keydown', handleKeyDown); window.addEventListener('paste', handleKeyDown); }; oekakiButton.addEventListener('click', () => { imagePreview.style.display = 'block'; oekakiUIWrapper.style.display = 'flex'; initOekaki(); }); const closeButton = await GM.addElement(toolPanel, 'button', { textContent: '閉じる', style: 'padding: 5px 10px; cursor: pointer;' }); closeButton.addEventListener('click', async () => { oekakiUIWrapper.style.display = 'none'; const canvas = oekaki.render(); const blob = await (new Promise((resolve) => { canvas.toBlob(resolve, 'image/png'); })); if (!blob) return; const file = new File([blob], "image.png", { type: blob.type }); // DataTransferオブジェクトを利用してFileListを作成 const dataTransfer = new DataTransfer(); dataTransfer.items.add(file); // input要素にFileListを設定 const fileInput = document.getElementById("image-upload"); fileInput.files = dataTransfer.files; // changeイベントを発火させる const event = new Event('change', { bubbles: true }); fileInput.dispatchEvent(event); }); } })();