606 lines
20 KiB
JavaScript
606 lines
20 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
import { allow_debug } from "./js_shared.js";
|
|
import { BaseSmartWidget, BaseSmartWidgetManager } from "./makadi/BaseSmartWidget.js";
|
|
import { SmartButton } from "./makadi/SmartButton.js";
|
|
import { Shapes } from "./utils.js";
|
|
|
|
app.registerExtension({
|
|
name: "iTools.previewNode",
|
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
|
if (nodeData.name === "iToolsPreviewImage") {
|
|
}
|
|
},
|
|
async nodeCreated(node) {
|
|
if (node.comfyClass !== "iToolsPreviewImage") {
|
|
return;
|
|
}
|
|
|
|
// init size
|
|
node.size = [350, 350];
|
|
|
|
if (allow_debug) console.log("node", node);
|
|
|
|
// vars
|
|
let a = null;
|
|
let b = null;
|
|
let c = null;
|
|
let noteButton = null;
|
|
let drawNoteButton = () => {};
|
|
let compare = false;
|
|
let imgsBeforeCompare = null;
|
|
let imagesTracked = [];
|
|
const MAX_IMAGES = 8;
|
|
|
|
let mouse = {
|
|
mouseInNode: false,
|
|
x: 0,
|
|
y: 0,
|
|
};
|
|
|
|
// other vars
|
|
let toastShownCountH = 0;
|
|
let toastShownCountPC = 0;
|
|
let toastShownCountI = 0;
|
|
const MAX_TOAST_SHOWS = 2;
|
|
|
|
function pushToImgs(newImage) {
|
|
// Check if image is undefined or null
|
|
if (!newImage || !newImage.naturalWidth) {
|
|
if (allow_debug) console.log("Undefined or null image, skipping");
|
|
return imagesTracked;
|
|
}
|
|
|
|
const imageExists = imagesTracked.some((img) => {
|
|
// Extract filename from URLs by removing the random parameter
|
|
const getFilename = (url) => {
|
|
if (!url) return "";
|
|
const match = url.match(/filename=([^&]+)/);
|
|
return match ? match[1] : "";
|
|
};
|
|
|
|
const newImageFilename = getFilename(newImage.src);
|
|
const existingImageFilename = getFilename(img.src);
|
|
|
|
return newImageFilename === existingImageFilename;
|
|
});
|
|
|
|
if (imageExists) {
|
|
if (allow_debug) console.log("Image already exists, skipping");
|
|
return imagesTracked;
|
|
}
|
|
|
|
imagesTracked.push(newImage);
|
|
// If array exceeds MAX_IMAGES, remove oldest image(s)
|
|
if (imagesTracked.length > MAX_IMAGES) {
|
|
imagesTracked.shift(); // removes first (oldest) element
|
|
}
|
|
if (allow_debug) console.log("imagesTracked list updated, length:", imagesTracked.length);
|
|
return imagesTracked;
|
|
}
|
|
|
|
function cycleImgs() {
|
|
if (imagesTracked.length === 1) {
|
|
app.extensionManager.toast.add({
|
|
severity: "info",
|
|
summary: "iTools!",
|
|
detail: "Only this image exist in this node history",
|
|
life: 2000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Switch to previous imagesTracked if available
|
|
if (node.imgs && imagesTracked.length > 1) {
|
|
node.imgs = imagesTracked; // Show previous image
|
|
}
|
|
}
|
|
|
|
function togglingLastTwoImages() {
|
|
if (allow_debug) console.log("imagesTracked", imagesTracked);
|
|
// Only cycle between last two imagesTracked
|
|
if (imagesTracked.length > 1) {
|
|
node.imageIndex = 0;
|
|
|
|
// Get current image
|
|
const currentImg = node.imgs[0];
|
|
|
|
// Get last two imagesTracked from history
|
|
const lastTwo = imagesTracked.slice(-2);
|
|
|
|
// If current is last image, show second to last
|
|
// If current is second to last, show last
|
|
const nextImg = currentImg === lastTwo[1] ? lastTwo[0] : lastTwo[1];
|
|
|
|
// Update display
|
|
node.imgs = [nextImg];
|
|
|
|
// Update button text with underline using Unicode
|
|
const isShowingCurrent = nextImg === lastTwo[1];
|
|
b.text = isShowingCurrent ? "[Current] | Previous" : "Current | [Previous]";
|
|
|
|
if (allow_debug) console.log("Toggling between last two imagesTracked");
|
|
} else {
|
|
if (toastShownCountPC < MAX_TOAST_SHOWS) {
|
|
app.extensionManager.toast.add({
|
|
severity: "info",
|
|
summary: "iTools!",
|
|
detail: "You must execute this node at least twice",
|
|
life: 2000,
|
|
});
|
|
toastShownCountPC++;
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleButtonActivation() {
|
|
c.isActive = compare;
|
|
if (!compare) {
|
|
c.color = c.originalColor;
|
|
c.textColor = c.originalTextColor;
|
|
} else {
|
|
c.color = "#80a1c0";
|
|
c.textColor = "black";
|
|
}
|
|
}
|
|
|
|
function showButtons() {
|
|
a.isVisible = true;
|
|
b.isVisible = true;
|
|
// c.isVisible = true;
|
|
}
|
|
|
|
function createButtons(startVisible = true) {
|
|
a = new SmartButton(75, 8, 55, 20, node, "History");
|
|
a.allowVisualHover = true;
|
|
a.textYoffset = -0;
|
|
a.isVisible = startVisible;
|
|
a.shape = Shapes.ROUND_L;
|
|
a.roundRadius = 5;
|
|
a.outlineWidth = 1;
|
|
a.outlineColor = "#656565";
|
|
a.color = "#222222";
|
|
a.font = "12px Arial";
|
|
a.onClick = () => {
|
|
if (compare) {
|
|
// cancel compare
|
|
compare = !compare;
|
|
applyCompareState();
|
|
toggleButtonActivation(c, compare);
|
|
}
|
|
if (!node.imgs) {
|
|
if (toastShownCountH < MAX_TOAST_SHOWS) {
|
|
app.extensionManager.toast.add({
|
|
severity: "info",
|
|
summary: "iTools!",
|
|
detail: "No images in this node history",
|
|
life: 2000,
|
|
});
|
|
toastShownCountH++;
|
|
}
|
|
return;
|
|
}
|
|
if (node.imgs.length > 1) {
|
|
togglingLastTwoImages();
|
|
} else {
|
|
cycleImgs();
|
|
}
|
|
};
|
|
|
|
b = new SmartButton(75 + 55, 8, 120, 20, node, "[Current] | Previous");
|
|
b.allowVisualHover = true;
|
|
b.textYoffset = -0;
|
|
b.isVisible = startVisible;
|
|
b.shape = Shapes.ROUND_R;
|
|
b.roundRadius = 5;
|
|
b.outlineWidth = 1;
|
|
b.outlineColor = "#656565";
|
|
b.color = "#222222";
|
|
b.font = "12px Arial";
|
|
b.onClick = () => {
|
|
if (compare) {
|
|
// cancel compare
|
|
compare = !compare;
|
|
applyCompareState();
|
|
toggleButtonActivation(c, compare);
|
|
}
|
|
togglingLastTwoImages();
|
|
};
|
|
|
|
c = new SmartButton(75 + 55 + 125, 8 + 1, 18, 20, node, "|");
|
|
c.allowVisualHover = true;
|
|
c.textYoffset = -0.05;
|
|
c.isVisible = startVisible;
|
|
c.shape = Shapes.CIRCLE;
|
|
//c.roundRadius = 5;
|
|
c.outlineWidth = 1;
|
|
c.outlineColor = "#656565";
|
|
c.color = "#222222";
|
|
c.activeColor = c.font = "12px Arial";
|
|
c.onClick = () => {
|
|
// reset togglingLastTwoImages
|
|
if (b.text !== "[Current] | Previous") togglingLastTwoImages();
|
|
|
|
// start compare
|
|
if (imagesTracked.length <= 1) {
|
|
if (toastShownCountI < MAX_TOAST_SHOWS) {
|
|
app.extensionManager.toast.add({
|
|
severity: "info",
|
|
summary: "iTools!",
|
|
detail: "You must execute this node at least twice",
|
|
life: 2000,
|
|
});
|
|
toastShownCountI++;
|
|
}
|
|
return;
|
|
}
|
|
compare = !compare;
|
|
applyCompareState();
|
|
toggleButtonActivation(c, compare);
|
|
};
|
|
|
|
// Sits on the top left of the preview area
|
|
noteButton = new SmartButton(8, 40, 54, 17, node, "Add Note");
|
|
noteButton.allowVisualHover = true;
|
|
noteButton.textYoffset = -0;
|
|
noteButton.isVisible = false;
|
|
noteButton.shape = Shapes.ROUND;
|
|
noteButton.roundRadius = 5;
|
|
noteButton.outlineWidth = 1;
|
|
noteButton.outlineColor = "#656565";
|
|
noteButton.color = "#222222AA";
|
|
noteButton.font = "11px Arial";
|
|
noteButton.onClick = () => {
|
|
const img = noteTargetImage();
|
|
if (!img) return;
|
|
app.extensionManager.dialog
|
|
.prompt({
|
|
title: "iTools Preview Image",
|
|
message: "Note for this image (leave empty to remove)",
|
|
defaultValue: getNote(img), // key name differs between frontend versions
|
|
default: getNote(img),
|
|
})
|
|
.then((value) => {
|
|
if (value === null || value === undefined) return; // cancelled
|
|
setNote(img, value.trim());
|
|
node.setDirtyCanvas(true, true);
|
|
});
|
|
};
|
|
|
|
// The frontend paints the image from a deferred microtask, which lands on
|
|
// top of anything the widget pass drew, so this button is painted from that
|
|
// same deferred pass instead (see drawNoteLayer). Clicks still use isVisible.
|
|
const origButtonDraw = noteButton.draw;
|
|
noteButton.draw = () => {};
|
|
drawNoteButton = (ctx) => {
|
|
if (noteButton.isVisible) origButtonDraw.call(noteButton, ctx);
|
|
};
|
|
}
|
|
|
|
// Notes are stored per image filename in node.properties, so they are saved
|
|
// with the workflow and follow the image through history / compare instead of
|
|
// being baked into the image itself
|
|
function getNotes() {
|
|
if (!node.properties.iToolsImageNotes) node.properties.iToolsImageNotes = {};
|
|
return node.properties.iToolsImageNotes;
|
|
}
|
|
|
|
function getNote(img) {
|
|
const key = getImageKey(img);
|
|
return (key && getNotes()[key]) || "";
|
|
}
|
|
|
|
function setNote(img, text) {
|
|
const key = getImageKey(img);
|
|
if (!key) return;
|
|
if (text) getNotes()[key] = text;
|
|
else delete getNotes()[key];
|
|
}
|
|
|
|
// The image a new note applies to: the one currently on screen, or the
|
|
// current (right hand) image while comparing
|
|
function noteTargetImage() {
|
|
if (!node.imgs?.length) return null;
|
|
if (compare && node.imgs.length > 1) return node.imgs.at(-1);
|
|
// In the grid state overIndex is the image the mouse is over
|
|
if (node.imageIndex == null && node.imgs.length > 1) return node.imgs[node.overIndex ?? 0];
|
|
return node.imgs[node.imageIndex ?? 0] || node.imgs[0];
|
|
}
|
|
|
|
// Mirrors the frontend renderPreview geometry so the note sits exactly on the
|
|
// image in both preview states: single image and the multi image grid
|
|
function collectNoteTargets(widget_width, shiftY, computedHeight) {
|
|
if (!node.imgs?.length) return [];
|
|
|
|
// Grid state: the frontend fills node.imageRects with the cell of each image
|
|
if (node.imageIndex == null && node.imgs.length > 1) {
|
|
const cells = node.imageRects;
|
|
if (!cells?.length) return [];
|
|
return node.imgs
|
|
.map((img, i) => {
|
|
const cell = cells[i];
|
|
if (!cell || !img?.width) return null;
|
|
const [cellX, cellY, cellW, cellH] = cell;
|
|
const ratio = Math.min(cellW / img.width, cellH / img.height);
|
|
const w = img.width * ratio;
|
|
const h = img.height * ratio;
|
|
return { img, rect: { x: cellX + (cellW - w) / 2, y: cellY + (cellH - h) / 2, w, h } };
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// Single image state, scale is capped at 1 (no upscaling) like the frontend
|
|
const img = node.imgs[node.imageIndex ?? 0];
|
|
if (!img?.naturalWidth) return [];
|
|
const sizeTextHeight = app.extensionManager.setting.get("Comfy.Node.AllowImageSizeDraw") ? 15 : 0;
|
|
const dw = widget_width;
|
|
const dh = (computedHeight ?? node.size[1] - shiftY) - sizeTextHeight;
|
|
const scale = Math.min(dw / img.naturalWidth, dh / img.naturalHeight, 1);
|
|
const w = img.naturalWidth * scale;
|
|
const h = img.naturalHeight * scale;
|
|
return [{ img, rect: { x: (dw - w) / 2, y: (dh - h) / 2 + shiftY, w, h } }];
|
|
}
|
|
|
|
// The frontend defers its drawImage calls to a microtask, so queue the notes
|
|
// and the button right after to land on top of the images instead of under them
|
|
function drawNoteLayer(ctx, widget_width, shiftY, computedHeight) {
|
|
const targets = collectNoteTargets(widget_width, shiftY || 0, computedHeight).filter((t) =>
|
|
getNote(t.img)
|
|
);
|
|
|
|
const transform = ctx.getTransform();
|
|
queueMicrotask(() => {
|
|
ctx.save();
|
|
ctx.setTransform(transform);
|
|
for (const target of targets) drawNoteBar(ctx, target.rect, getNote(target.img));
|
|
drawNoteButton(ctx);
|
|
ctx.restore();
|
|
});
|
|
}
|
|
|
|
// iToolsPreviewImage gets one image per run, while iToolsCompareImage gets two.
|
|
// Feeding node.imgs the last two tracked images puts this node in the same
|
|
// two-image preview state compare_node draws from.
|
|
function applyCompareState() {
|
|
if (compare) {
|
|
const lastTwo = imagesTracked.slice(-2);
|
|
imgsBeforeCompare = node.imgs;
|
|
node.imgs = [lastTwo[0], lastTwo[1]]; // A = previous, B = current
|
|
node.imageIndex = null; // multi image view, same as compare node
|
|
} else {
|
|
node.imgs = imgsBeforeCompare || [imagesTracked.at(-1)];
|
|
node.imageIndex = 0;
|
|
imgsBeforeCompare = null;
|
|
}
|
|
patchPreviewWidget(); // widget may already be swapped by the assignment above
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
// Patches the image preview widget draw. Re-applied on demand because
|
|
// assigning node.imgs makes the frontend rebuild the widget, dropping the patch.
|
|
function patchPreviewWidget() {
|
|
// ImagePreviewWidget is added by the frontend under this name
|
|
const previewWidget =
|
|
node.widgets?.find((widget) => widget.name === "$$canvas-image-preview") ||
|
|
node.widgets?.find((widget) => !(widget instanceof BaseSmartWidget) && widget.drawWidget);
|
|
|
|
// Pinned to the node, top left of the preview area
|
|
if (noteButton) {
|
|
const targetImg = noteTargetImage();
|
|
// Hidden in the history grid, there is no single image to attach a note to
|
|
const inHistoryGrid = !compare && node.imageIndex == null && node.imgs?.length > 1;
|
|
noteButton.isVisible = !!previewWidget && !!node.imgs?.length && !inHistoryGrid;
|
|
if (typeof previewWidget?.y === "number") noteButton.myY = previewWidget.y + 6;
|
|
noteButton.text = getNote(targetImg) ? "Edit Note" : "Add Note";
|
|
}
|
|
|
|
if (!previewWidget) return;
|
|
|
|
const comparing = () => compare && node.imgs?.length > 1;
|
|
|
|
// The canvas uses widget.draw when it exists, otherwise widget.drawWidget,
|
|
// so both entry points need the override
|
|
// Only wrap draw if the widget already has one, adding it would change dispatch
|
|
if (typeof previewWidget.draw === "function" && previewWidget.draw !== previewWidget._itoolsDrawWrapper) {
|
|
const originalDraw = previewWidget.draw;
|
|
const drawWrapper = function (ctx, node, widget_width, y, widget_height, lowQuality) {
|
|
if (comparing()) {
|
|
drawImgOverlay(mouse, node, widget_width, y, ctx, compare, getNote);
|
|
drawNoteButton(ctx); // compare draws its images synchronously
|
|
} else {
|
|
originalDraw.call(this, ctx, node, widget_width, y, widget_height, lowQuality);
|
|
drawNoteLayer(ctx, widget_width, y, this.computedHeight ?? widget_height);
|
|
}
|
|
};
|
|
previewWidget._itoolsDrawWrapper = drawWrapper;
|
|
previewWidget.draw = drawWrapper;
|
|
}
|
|
|
|
if (previewWidget.drawWidget !== previewWidget._itoolsDrawWidgetWrapper) {
|
|
const originalDrawWidget = previewWidget.drawWidget;
|
|
const drawWidgetWrapper = function (ctx, options) {
|
|
const width = options?.width ?? node.size[0];
|
|
if (comparing()) {
|
|
drawImgOverlay(mouse, node, width, this.y, ctx, compare, getNote);
|
|
drawNoteButton(ctx); // compare draws its images synchronously
|
|
} else {
|
|
originalDrawWidget?.call(this, ctx, options);
|
|
drawNoteLayer(ctx, width, this.y, this.computedHeight);
|
|
}
|
|
};
|
|
previewWidget._itoolsDrawWidgetWrapper = drawWidgetWrapper;
|
|
previewWidget.drawWidget = drawWidgetWrapper;
|
|
}
|
|
}
|
|
|
|
// Canvas level hook, same approach as BaseSmartWidget: runs every frame so the
|
|
// patch is re-applied as soon as the frontend rebuilds the preview widget
|
|
const origCanvasDrawForeground = app.canvas.onDrawForeground;
|
|
app.canvas.onDrawForeground = (ctx) => {
|
|
if (origCanvasDrawForeground) origCanvasDrawForeground.call(app.canvas, ctx);
|
|
if (node.graph) patchPreviewWidget();
|
|
};
|
|
|
|
createButtons();
|
|
|
|
node.onExecuted = async function (message) {
|
|
// Wait for image to be loaded
|
|
for (let i = 0; i < 20 && !node.imgs; i++) {
|
|
if (allow_debug) console.log("wait..", i);
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
if (!node.imgs) return;
|
|
|
|
showButtons();
|
|
|
|
// Reset togglingLastTwoImages
|
|
if (b.text !== "[Current] | Previous") togglingLastTwoImages();
|
|
node.setDirtyCanvas(true, false);
|
|
|
|
setTimeout(() => {
|
|
// push last image
|
|
const lastImage = node.imgs?.at(-1);
|
|
if (!imagesTracked.some((img) => img === lastImage)) {
|
|
pushToImgs(lastImage);
|
|
}
|
|
|
|
// Keep comparing against the freshly generated image
|
|
if (compare) applyCompareState();
|
|
|
|
// Override draw function in ImagePreviewWidget
|
|
patchPreviewWidget();
|
|
}, 300);
|
|
};
|
|
|
|
node.onResize = function (newSize) {
|
|
// limit width size while resizing
|
|
node.size[0] = Math.max(285, newSize[0]);
|
|
// if (allow_debug) console.log("Node", node);
|
|
};
|
|
|
|
const m = new BaseSmartWidgetManager(node, "iToolsPreviewImage");
|
|
const origOnRemoved = node.onRemoved;
|
|
node.onRemoved = function () {
|
|
origOnRemoved?.apply(this, arguments);
|
|
m.destroy();
|
|
};
|
|
|
|
node.onMouseEnter = (e) => {
|
|
mouse.mouseInNode = true;
|
|
};
|
|
|
|
node.onMouseLeave = (e) => {
|
|
mouse.mouseInNode = false;
|
|
};
|
|
|
|
node.onMouseMove = (e, pos) => {
|
|
if (mouse.mouseInNode) {
|
|
const graphMouse = app.canvas.graph_mouse;
|
|
mouse.x = graphMouse[0] - node.pos[0];
|
|
mouse.y = graphMouse[1] - node.pos[1];
|
|
}
|
|
};
|
|
},
|
|
|
|
});
|
|
|
|
const compareWay = app.extensionManager.setting.get("iTools.Nodes.Compare Mode", "makadi");
|
|
|
|
// Notes key off the output filename so the same image keeps its note across
|
|
// runs, history cycling and compare
|
|
function getImageKey(img) {
|
|
if (!img?.src) return "";
|
|
const match = img.src.match(/filename=([^&]+)/);
|
|
return match ? decodeURIComponent(match[1]) : img.src;
|
|
}
|
|
|
|
// Note bar sitting on the bottom of the image, clipped to it
|
|
function drawNoteBar(ctx, rect, text) {
|
|
if (!text || !rect) return;
|
|
const fontSize = Math.max(8, Math.min(12, Math.round(rect.w / 16)));
|
|
const padding = Math.round(fontSize / 2);
|
|
const barHeight = fontSize + padding * 2;
|
|
const barY = rect.y + rect.h - barHeight;
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(rect.x, barY, rect.w, barHeight);
|
|
ctx.clip();
|
|
ctx.fillStyle = "#000000AA";
|
|
ctx.fillRect(rect.x, barY, rect.w, barHeight);
|
|
ctx.fillStyle = "white";
|
|
ctx.font = `${fontSize}px monospace`;
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(text, rect.x + padding, barY + barHeight / 2);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawImgOverlay(mouse, node, widget_width, y, ctx, compareMode = false, getNote = null) {
|
|
if (!compareMode || !node.imgs || node.imgs.length < 2) return;
|
|
y = y ? y : 0; // Ensure y is defined
|
|
|
|
const img1 = node.imgs[0]; // previous
|
|
const img2 = node.imgs[1]; // current
|
|
if (!img1 || !img2) {
|
|
if (allow_debug) console.log("No previous image to compare with");
|
|
return;
|
|
}
|
|
|
|
const dw = widget_width;
|
|
const dh = node.size[1] - y;
|
|
|
|
// Force both images to the same height (dh), scaling down if too wide
|
|
const getParams = (img) => {
|
|
const scale = dh / img.naturalHeight;
|
|
const w = img.naturalWidth * scale;
|
|
const finalScale = w > dw ? dw / w : 1;
|
|
|
|
const finalW = w * finalScale;
|
|
const finalH = dh * finalScale;
|
|
|
|
return {
|
|
x: (dw - finalW) / 2,
|
|
y: y + (dh - finalH) / 2,
|
|
w: finalW,
|
|
h: finalH,
|
|
};
|
|
};
|
|
|
|
const p1 = getParams(img1);
|
|
const p2 = getParams(img2);
|
|
|
|
// Shared interaction bounds (the container area)
|
|
const viewW = Math.max(p1.w, p2.w);
|
|
const imgX = (dw - viewW) / 2;
|
|
|
|
let mouseX;
|
|
if (compareWay === "makadi") {
|
|
const graphMouse = app.canvas.graph_mouse;
|
|
mouseX = graphMouse[0] - node.pos[0];
|
|
} else {
|
|
mouseX = mouse.mouseInNode ? mouse.x : dw / 2;
|
|
}
|
|
|
|
const splitX = Math.max(imgX, Math.min(mouseX, imgX + viewW));
|
|
|
|
const left = compareWay === "makadi" ? { img: img1, p: p1 } : { img: img2, p: p2 };
|
|
const right = compareWay === "makadi" ? { img: img2, p: p2 } : { img: img1, p: p1 };
|
|
|
|
// Draw Left Side
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(0, y, splitX, dh);
|
|
ctx.clip();
|
|
ctx.drawImage(left.img, left.p.x, left.p.y, left.p.w, left.p.h);
|
|
drawNoteBar(ctx, left.p, getNote?.(left.img)); // clipped to this half
|
|
ctx.restore();
|
|
|
|
// Draw Right Side
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(splitX, y, dw - splitX, dh);
|
|
ctx.clip();
|
|
ctx.drawImage(right.img, right.p.x, right.p.y, right.p.w, right.p.h);
|
|
drawNoteBar(ctx, right.p, getNote?.(right.img));
|
|
ctx.restore();
|
|
} |