import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; // Fabric.js local loader let fabricLoaded = false; function loadFabric() { if (fabricLoaded) return Promise.resolve(); return new Promise((resolve, reject) => { const script = document.createElement("script"); // Load from local extension directory (allowed by CSP 'self') script.src = new URL("./lib/fabric.min.js", import.meta.url).href; script.onload = () => { fabricLoaded = true; resolve(); }; script.onerror = () => reject(new Error("Failed to load Fabric.js")); document.head.appendChild(script); }); } // --- Styles --- const STYLES = ` .pb-overlay { position:fixed; inset:0; z-index:99999; background:rgba(0,0,0,0.85); display:flex; flex-direction:column; align-items:center; justify-content:center; } .pb-toolbar { display:flex; gap:6px; padding:10px 16px; background:#1e1e1e; border-radius:8px; margin-bottom:10px; align-items:center; flex-wrap:wrap; } .pb-toolbar button { background:#333; color:#eee; border:1px solid #555; border-radius:4px; padding:6px 12px; cursor:pointer; font-size:13px; transition:all .15s; } .pb-toolbar button:hover { background:#444; } .pb-toolbar button.active { background:#0066ff; border-color:#0066ff; color:#fff; } .pb-toolbar .pb-sep { width:1px; height:24px; background:#555; margin:0 4px; } .pb-toolbar input[type=color] { width:32px; height:28px; border:none; padding:0; cursor:pointer; border-radius:4px; } .pb-toolbar input[type=range] { width:80px; accent-color:#0066ff; } .pb-toolbar input.pb-mosaic-size { width:90px; } .pb-toolbar .pb-label { color:#aaa; font-size:12px; } .pb-canvas-wrap { border:2px solid #444; border-radius:4px; overflow:hidden; } .pb-actions { display:flex; gap:10px; margin-top:10px; } .pb-actions button { padding:8px 24px; border-radius:6px; font-size:14px; cursor:pointer; border:none; } .pb-actions .pb-cancel { background:#555; color:#eee; } .pb-actions .pb-confirm { background:#0066ff; color:#fff; } `; function injectStyles() { if (document.getElementById("pb-styles")) return; const el = document.createElement("style"); el.id = "pb-styles"; el.textContent = STYLES; document.head.appendChild(el); } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } const PB_EXPORT_PROPS = ["pbTool"]; function clampPointer(canvas, pointer) { return { x: clamp(pointer.x, 0, canvas.width), y: clamp(pointer.y, 0, canvas.height), }; } function makeCircleCursor(size) { const cursorSize = clamp(Math.round(size), 18, 80); const center = cursorSize / 2; const hotspot = Math.round(center); const radius = Math.max(3, center - 2); const svg = ` `.trim(); return `url("data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}") ${hotspot} ${hotspot}, crosshair`; } function applyMosaicCursor(canvas, state) { const cursor = makeCircleCursor(state.mosaicSize); canvas.defaultCursor = cursor; canvas.hoverCursor = cursor; } function configureMosaicObject(obj) { obj.set({ selectable: false, hasControls: false, hasBorders: false, lockMovementX: true, lockMovementY: true, lockScalingX: true, lockScalingY: true, lockRotation: true, perPixelTargetFind: true, objectCaching: false, }); obj.pbTool = "mosaic"; return obj; } function normalizeMosaicObjects(canvas) { canvas.getObjects().forEach((obj) => { if (obj.pbTool === "mosaic" || obj.type === "image") { configureMosaicObject(obj); } }); } // --- Get current image URL from node --- function getImageUrl(node) { if (node.imgs && node.imgs.length > 0) { return node.imgs[node.imageIndex ?? 0].src; } const widget = node.widgets?.find(w => w.name === "image"); if (!widget?.value) return null; const val = String(widget.value); const match = val.match(/^(.+?)(?:\s*\[(\w+)\])?$/); if (!match) return null; const filename = match[1]; const type = match[2] || "input"; const parts = filename.split("/"); const name = parts.pop(); const subfolder = parts.join("/"); return `/view?filename=${encodeURIComponent(name)}&type=${type}&subfolder=${encodeURIComponent(subfolder)}`; } // --- History Manager --- class HistoryManager { constructor(canvas) { this.canvas = canvas; this.stack = []; this.index = -1; this.locked = false; } save() { if (this.locked) return; this.index++; this.stack.length = this.index; this.stack.push(this.canvas.toJSON(PB_EXPORT_PROPS)); } undo() { if (this.index <= 0) return; this.index--; this._restore(); } redo() { if (this.index >= this.stack.length - 1) return; this.index++; this._restore(); } _restore() { this.locked = true; this.canvas.loadFromJSON(this.stack[this.index], () => { normalizeMosaicObjects(this.canvas); this.canvas.renderAll(); this.locked = false; }); } } // --- Shape drawing handler --- function setupShapeDrawing(canvas, state) { let startX, startY, shape; canvas.on("mouse:down", (opt) => { if (state.tool === "select" || state.tool === "brush" || state.tool === "eraser" || state.tool === "mosaic") return; const ptr = canvas.getPointer(opt.e); startX = ptr.x; startY = ptr.y; state.drawing = true; const opts = { left: startX, top: startY, fill: "transparent", stroke: state.color, strokeWidth: state.width, selectable: true }; if (state.tool === "rect") { shape = new fabric.Rect({ ...opts, width: 0, height: 0 }); } else if (state.tool === "circle") { shape = new fabric.Ellipse({ ...opts, rx: 0, ry: 0 }); } else if (state.tool === "line") { shape = new fabric.Line([startX, startY, startX, startY], { stroke: state.color, strokeWidth: state.width, selectable: true }); } if (shape) canvas.add(shape); }); canvas.on("mouse:move", (opt) => { if (!state.drawing || !shape) return; const ptr = canvas.getPointer(opt.e); const dx = ptr.x - startX; const dy = ptr.y - startY; if (state.tool === "rect") { shape.set({ left: dx > 0 ? startX : ptr.x, top: dy > 0 ? startY : ptr.y, width: Math.abs(dx), height: Math.abs(dy) }); } else if (state.tool === "circle") { shape.set({ left: dx > 0 ? startX : ptr.x, top: dy > 0 ? startY : ptr.y, rx: Math.abs(dx) / 2, ry: Math.abs(dy) / 2 }); } else if (state.tool === "line") { shape.set({ x2: ptr.x, y2: ptr.y }); } canvas.renderAll(); }); canvas.on("mouse:up", () => { if (!state.drawing || !shape) return; state.drawing = false; shape = null; }); } // --- Mosaic brush handler --- function createMosaicSource(sourceImg, canvas, blockSize) { const scaleX = canvas.width / sourceImg.width; const scaleY = canvas.height / sourceImg.height; const sourceBlockSize = Math.max(1, Math.round(blockSize / Math.min(scaleX, scaleY))); const smallW = Math.max(1, Math.ceil(sourceImg.width / sourceBlockSize)); const smallH = Math.max(1, Math.ceil(sourceImg.height / sourceBlockSize)); const smallCanvas = document.createElement("canvas"); smallCanvas.width = smallW; smallCanvas.height = smallH; const smallCtx = smallCanvas.getContext("2d"); smallCtx.imageSmoothingEnabled = true; smallCtx.drawImage(sourceImg, 0, 0, sourceImg.width, sourceImg.height, 0, 0, smallW, smallH); const pixelCanvas = document.createElement("canvas"); pixelCanvas.width = sourceImg.width; pixelCanvas.height = sourceImg.height; const pixelCtx = pixelCanvas.getContext("2d"); pixelCtx.imageSmoothingEnabled = false; pixelCtx.drawImage(smallCanvas, 0, 0, smallW, smallH, 0, 0, sourceImg.width, sourceImg.height); return pixelCanvas; } function getMosaicBrushBounds(sourceImg, canvas, centerX, centerY, size) { const scaleX = canvas.width / sourceImg.width; const scaleY = canvas.height / sourceImg.height; const sourceX = centerX / scaleX; const sourceY = centerY / scaleY; const radiusX = Math.max(1, (size / 2) / scaleX); const radiusY = Math.max(1, (size / 2) / scaleY); return { centerX: sourceX, centerY: sourceY, radius: Math.max(radiusX, radiusY), left: clamp(Math.floor(sourceX - radiusX), 0, sourceImg.width), top: clamp(Math.floor(sourceY - radiusY), 0, sourceImg.height), right: clamp(Math.ceil(sourceX + radiusX), 0, sourceImg.width), bottom: clamp(Math.ceil(sourceY + radiusY), 0, sourceImg.height), }; } function expandMosaicBounds(bounds, brushBounds) { if (!bounds.left && bounds.left !== 0) { bounds.left = brushBounds.left; bounds.top = brushBounds.top; bounds.right = brushBounds.right; bounds.bottom = brushBounds.bottom; return; } bounds.left = Math.min(bounds.left, brushBounds.left); bounds.top = Math.min(bounds.top, brushBounds.top); bounds.right = Math.max(bounds.right, brushBounds.right); bounds.bottom = Math.max(bounds.bottom, brushBounds.bottom); } function paintMosaicStamp(sourceImg, canvas, mosaicSource, strokeCtx, bounds, point) { const brushBounds = getMosaicBrushBounds(sourceImg, canvas, point.x, point.y, point.size); if (brushBounds.right <= brushBounds.left || brushBounds.bottom <= brushBounds.top) return false; strokeCtx.save(); strokeCtx.beginPath(); strokeCtx.arc(brushBounds.centerX, brushBounds.centerY, brushBounds.radius, 0, Math.PI * 2); strokeCtx.clip(); strokeCtx.drawImage( mosaicSource, brushBounds.left, brushBounds.top, brushBounds.right - brushBounds.left, brushBounds.bottom - brushBounds.top, brushBounds.left, brushBounds.top, brushBounds.right - brushBounds.left, brushBounds.bottom - brushBounds.top ); strokeCtx.restore(); expandMosaicBounds(bounds, brushBounds); return true; } function paintMosaicLine(sourceImg, canvas, mosaicSource, strokeCtx, bounds, from, to, size) { const dx = to.x - from.x; const dy = to.y - from.y; const distance = Math.hypot(dx, dy); const step = Math.max(2, size * 0.25); const steps = Math.max(1, Math.ceil(distance / step)); let painted = false; for (let i = 1; i <= steps; i++) { const t = i / steps; painted = paintMosaicStamp(sourceImg, canvas, mosaicSource, strokeCtx, bounds, { x: from.x + dx * t, y: from.y + dy * t, size, }) || painted; } return painted; } function createMosaicStrokeObject(strokeCanvas, bounds, canvas, sourceImg) { const width = bounds.right - bounds.left; const height = bounds.bottom - bounds.top; if (width < 1 || height < 1) return Promise.resolve(null); const cropCanvas = document.createElement("canvas"); cropCanvas.width = width; cropCanvas.height = height; cropCanvas.getContext("2d").drawImage( strokeCanvas, bounds.left, bounds.top, width, height, 0, 0, width, height ); const scaleX = canvas.width / sourceImg.width; const scaleY = canvas.height / sourceImg.height; const dataUrl = cropCanvas.toDataURL("image/png"); return new Promise(resolve => { fabric.Image.fromURL(dataUrl, (imgObj) => { configureMosaicObject(imgObj).set({ left: bounds.left * scaleX, top: bounds.top * scaleY, scaleX, scaleY, }); resolve(imgObj); }); }); } function setupMosaicDrawing(canvas, state, sourceImg, history) { let lastPoint, mosaicSource, strokeCanvas, strokeCtx, strokePreview, strokeBounds, strokePainted; const refreshPreview = () => { if (!strokePreview) return; strokePreview.dirty = true; canvas.requestRenderAll(); }; canvas.on("mouse:down", (opt) => { if (state.tool !== "mosaic") return; const ptr = clampPointer(canvas, canvas.getPointer(opt.e)); lastPoint = ptr; state.drawing = true; strokePainted = false; strokeBounds = {}; mosaicSource = createMosaicSource(sourceImg, canvas, state.mosaicSize); strokeCanvas = document.createElement("canvas"); strokeCanvas.width = sourceImg.width; strokeCanvas.height = sourceImg.height; strokeCtx = strokeCanvas.getContext("2d"); strokeCtx.imageSmoothingEnabled = false; strokePreview = new fabric.Image(strokeCanvas, { left: 0, top: 0, scaleX: canvas.width / sourceImg.width, scaleY: canvas.height / sourceImg.height, selectable: false, evented: false, excludeFromExport: true, objectCaching: false, }); history.locked = true; canvas.add(strokePreview); history.locked = false; strokePainted = paintMosaicStamp(sourceImg, canvas, mosaicSource, strokeCtx, strokeBounds, { x: ptr.x, y: ptr.y, size: state.mosaicSize, }) || strokePainted; refreshPreview(); }); canvas.on("mouse:move", (opt) => { if (state.tool !== "mosaic" || !state.drawing || !strokeCtx || !lastPoint) return; const ptr = clampPointer(canvas, canvas.getPointer(opt.e)); strokePainted = paintMosaicLine( sourceImg, canvas, mosaicSource, strokeCtx, strokeBounds, lastPoint, ptr, state.mosaicSize ) || strokePainted; lastPoint = ptr; refreshPreview(); }); canvas.on("mouse:up", async () => { if (!state.drawing || !strokePreview) return; history.locked = true; canvas.remove(strokePreview); history.locked = false; state.drawing = false; lastPoint = null; if (!strokePainted) { strokeCanvas = null; strokeCtx = null; strokePreview = null; mosaicSource = null; canvas.renderAll(); return; } const mosaicObj = await createMosaicStrokeObject(strokeCanvas, strokeBounds, canvas, sourceImg); mosaicSource = null; strokeCanvas = null; strokeCtx = null; strokePreview = null; if (mosaicObj) { canvas.add(mosaicObj); canvas.discardActiveObject(); } canvas.renderAll(); }); } // --- Open Paint Modal --- async function openPaintModal(node) { injectStyles(); await loadFabric(); if (!node.properties) node.properties = {}; // Detect if user switched to a different image — reset saved state const widget = node.widgets?.find(w => w.name === "image"); const currentVal = widget?.value ? String(widget.value) : ""; const originalImg = node.properties.paintBrushOriginal; if (originalImg && currentVal !== originalImg && currentVal !== "painted_" + originalImg) { // Image changed, clear old paint state delete node.properties.paintBrushCanvas; delete node.properties.paintBrushOriginal; } const savedState = node.properties.paintBrushCanvas; const storedOriginal = node.properties.paintBrushOriginal; // If re-editing, use the original image as background; otherwise use current let bgUrl; if (savedState && storedOriginal) { bgUrl = `/view?filename=${encodeURIComponent(storedOriginal)}&type=input&subfolder=`; } else { bgUrl = getImageUrl(node); } if (!bgUrl) { alert("请先加载一张图片"); return; } // Save original image name on first paint if (!storedOriginal) { node.properties.paintBrushOriginal = currentVal; } // Create overlay const overlay = document.createElement("div"); overlay.className = "pb-overlay"; const maxW = window.innerWidth * 0.8; const maxH = window.innerHeight * 0.75; // Load image to get dimensions const img = await new Promise((resolve, reject) => { const i = new Image(); i.crossOrigin = "anonymous"; i.onload = () => resolve(i); i.onerror = () => reject(new Error("图片加载失败")); i.src = bgUrl; }); const scale = Math.min(maxW / img.width, maxH / img.height, 1); const cw = Math.round(img.width * scale); const ch = Math.round(img.height * scale); const state = { tool: "brush", color: "#ff0000", width: 4, mosaicSize: 14, drawing: false }; const toolbar = buildToolbar(state); overlay.appendChild(toolbar); const wrap = document.createElement("div"); wrap.className = "pb-canvas-wrap"; const canvasEl = document.createElement("canvas"); canvasEl.width = cw; canvasEl.height = ch; wrap.appendChild(canvasEl); overlay.appendChild(wrap); document.body.appendChild(overlay); // Init Fabric canvas const canvas = new fabric.Canvas(canvasEl, { width: cw, height: ch, isDrawingMode: true }); canvas.freeDrawingBrush.color = state.color; canvas.freeDrawingBrush.width = state.width; // Set background image (always the original) await new Promise(resolve => { canvas.setBackgroundImage(bgUrl, () => { canvas.renderAll(); resolve(); }, { scaleX: cw / img.width, scaleY: ch / img.height, crossOrigin: "anonymous" }); }); // Restore previous drawing objects if re-editing if (savedState) { await new Promise(resolve => { canvas.loadFromJSON(savedState, () => { normalizeMosaicObjects(canvas); // Re-apply background since loadFromJSON may clear it canvas.setBackgroundImage(bgUrl, () => { canvas.renderAll(); resolve(); }, { scaleX: cw / img.width, scaleY: ch / img.height, crossOrigin: "anonymous" }); }); }); } // History const history = new HistoryManager(canvas); setTimeout(() => history.save(), 300); canvas.on("object:added", () => history.save()); canvas.on("object:modified", () => history.save()); // Shape drawing setupShapeDrawing(canvas, state); setupMosaicDrawing(canvas, state, img, history); wireToolbar(toolbar, canvas, state, history); // Actions buttons const actions = document.createElement("div"); actions.className = "pb-actions"; actions.innerHTML = ``; overlay.appendChild(actions); const close = () => { canvas.dispose(); overlay.remove(); }; actions.querySelector(".pb-cancel").onclick = close; overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }); overlay.tabIndex = 0; overlay.focus(); // Keyboard shortcuts overlay.addEventListener("keydown", (e) => { if (e.ctrlKey && e.key === "z" && !e.shiftKey) { e.preventDefault(); history.undo(); } if (e.ctrlKey && (e.key === "Z" || (e.key === "z" && e.shiftKey))) { e.preventDefault(); history.redo(); } if (e.key === "Delete" || e.key === "Backspace") { const active = canvas.getActiveObject(); if (active) { canvas.remove(active); canvas.discardActiveObject(); history.save(); } } }); // Confirm - save painted image and store canvas state for re-editing actions.querySelector(".pb-confirm").onclick = async () => { // Save canvas objects (without background) for future re-editing node.properties.paintBrushCanvas = canvas.toJSON(PB_EXPORT_PROPS); node.graph?.change?.(); await savePaintedImage(canvas, node, img.width, img.height); close(); }; } // --- Save painted image --- async function savePaintedImage(canvas, node, origW, origH) { // Export at original resolution const exportCanvas = document.createElement("canvas"); exportCanvas.width = origW; exportCanvas.height = origH; const ctx = exportCanvas.getContext("2d"); const dataUrl = canvas.toDataURL({ format: "png", multiplier: origW / canvas.width }); const exportImg = await new Promise((resolve) => { const i = new Image(); i.onload = () => resolve(i); i.src = dataUrl; }); ctx.drawImage(exportImg, 0, 0, origW, origH); // Get original filename for naming (use stored original, not current painted name) const origName = (node.properties.paintBrushOriginal || "").split("/").pop() || "image.png"; const paintedName = "painted_" + origName; const blob = await new Promise(r => exportCanvas.toBlob(r, "image/png")); const formData = new FormData(); formData.append("image", blob, paintedName); formData.append("type", "input"); formData.append("overwrite", "true"); const resp = await api.fetchApi("/upload/image", { method: "POST", body: formData }); const data = await resp.json(); // Add to combo options so the value persists across refresh const widget = node.widgets.find(w => w.name === "image"); if (widget) { if (Array.isArray(widget.options?.values) && !widget.options.values.includes(data.name)) { widget.options.values.push(data.name); } widget.value = data.name; if (widget.callback) { widget.callback(data.name); } } // Mark graph as changed to trigger workflow auto-save node.graph?.change?.(); app.graph.setDirtyCanvas(true, true); } // --- Build Toolbar --- function buildToolbar(state) { const toolbar = document.createElement("div"); toolbar.className = "pb-toolbar"; toolbar.innerHTML = ` 颜色 线宽 块大小 `; return toolbar; } // --- Wire Toolbar --- function wireToolbar(toolbar, canvas, state, history) { // Tool buttons toolbar.querySelectorAll("[data-tool]").forEach(btn => { btn.onclick = () => { toolbar.querySelectorAll("[data-tool]").forEach(b => b.classList.remove("active")); btn.classList.add("active"); state.tool = btn.dataset.tool; if (state.tool === "brush") { canvas.isDrawingMode = true; canvas.selection = false; canvas.defaultCursor = "default"; canvas.hoverCursor = "move"; canvas.freeDrawingBrush.color = state.color; canvas.freeDrawingBrush.width = state.width; } else if (state.tool === "eraser") { // Eraser: click on object to delete it canvas.isDrawingMode = false; canvas.selection = true; canvas.defaultCursor = "crosshair"; canvas.hoverCursor = "pointer"; } else if (state.tool === "mosaic") { canvas.isDrawingMode = false; canvas.selection = false; canvas.discardActiveObject(); applyMosaicCursor(canvas, state); } else { canvas.isDrawingMode = false; canvas.selection = false; canvas.defaultCursor = "default"; canvas.hoverCursor = "move"; } }; }); // Eraser: remove object on click canvas.on("mouse:down", (opt) => { if (state.tool !== "eraser") return; const target = canvas.findTarget(opt.e); if (target) { canvas.remove(target); canvas.discardActiveObject(); canvas.renderAll(); history.save(); } }); // Color picker toolbar.querySelector(".pb-color").oninput = (e) => { state.color = e.target.value; if (canvas.isDrawingMode) { canvas.freeDrawingBrush.color = state.color; } }; // Width slider toolbar.querySelector(".pb-width").oninput = (e) => { state.width = parseInt(e.target.value); if (canvas.isDrawingMode) { canvas.freeDrawingBrush.width = state.width; } }; toolbar.querySelector(".pb-mosaic-size").oninput = (e) => { state.mosaicSize = parseInt(e.target.value); if (state.tool === "mosaic") { applyMosaicCursor(canvas, state); } }; // Action buttons toolbar.querySelector("[data-action=undo]").onclick = () => history.undo(); toolbar.querySelector("[data-action=clear]").onclick = () => { canvas.getObjects().forEach(obj => canvas.remove(obj)); canvas.renderAll(); history.save(); }; } // --- Extension Registration --- app.registerExtension({ name: "o1key.paintBrush", // Register command for toolbar button commands: [ { id: "o1key.PaintBrush", icon: "pi pi-pencil", label: "画笔", tooltip: "画笔", function: () => { const selectedNodes = app.canvas.selected_nodes; const node = selectedNodes ? Object.values(selectedNodes)[0] : null; if (node) openPaintModal(node); } } ], // Add title attribute to our toolbar button for native tooltip // Hide "节点信息" button and reorder paint brush next to mask editor setup() { const observer = new MutationObserver(() => { const toolbox = document.querySelector('[class*="selection-toolbox"], [class*="SelectionToolbox"]'); if (!toolbox) return; // Hide "节点信息" button (matches by aria-label or title) toolbox.querySelectorAll("button").forEach(btn => { const label = btn.title || btn.getAttribute("aria-label") || btn.textContent || ""; if (label.includes("节点信息") || label.includes("Node Info") || label.includes("Info")) { btn.style.display = "none"; } }); // Find our paint brush button and move it next to mask editor const pencilIcon = toolbox.querySelector('[class*="pi-pencil"]'); if (pencilIcon) { const paintBtn = pencilIcon.closest("button"); if (paintBtn && !paintBtn.title) paintBtn.title = "画笔"; // Find mask editor button (has mask/pen-tool icon) const allBtns = Array.from(toolbox.querySelectorAll("button")); const maskBtn = allBtns.find(b => { const cls = b.innerHTML || ""; return cls.includes("mask") || cls.includes("pen-tool") || cls.includes("Mask"); }) || allBtns[0]; if (maskBtn && paintBtn && paintBtn.previousElementSibling !== maskBtn) { maskBtn.after(paintBtn); } } }); observer.observe(document.body, { childList: true, subtree: true }); }, // Show button in toolbar when LoadImage node is selected getSelectionToolboxCommands(item) { if (item?.comfyClass === "LoadImage" || item?.type === "LoadImage") { return ["o1key.PaintBrush"]; } return []; }, beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== "LoadImage") return; const origMenu = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function (canvasRef, options) { origMenu?.call(this, canvasRef, options); options.unshift({ content: "画笔 (Paint)", callback: () => openPaintModal(this) }); }; } });