From 52d006cadee084476c27f8f99233a41138ce5e57 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Wed, 5 Aug 2026 09:55:03 +0000 Subject: [PATCH] Fix compare node layout rendering --- js/compare/index.js | 232 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 187 insertions(+), 45 deletions(-) diff --git a/js/compare/index.js b/js/compare/index.js index fa7a2b1..8ad70d7 100644 --- a/js/compare/index.js +++ b/js/compare/index.js @@ -8,20 +8,26 @@ const PANEL = "#2a2c2e"; const BORDER = "#444"; const TEXT = "#d8d8d8"; const MUTED = "#9a9a9a"; -const TITLE = "#f3f3f3"; const MODE_LABELS = ["Show 1", "Show 2", "Left Right", "Up Down", "Overlay", "Difference"]; -const INIT_W = 420; -const INIT_H = 474; -const MIN_W = 360; -const MIN_H = 250; -const BUTTON_GAP = 4; -const BUTTON_H = 22; +const MODE_HINTS = ["", "", "Hover to slide left / right", "Hover to slide up / down", "", "Shows pixel differences"]; +const INIT_W = 440; +const IMAGE_TOP = 54; +const INIT_H = INIT_W + IMAGE_TOP; +const BUTTON_GAP = 3; +const BUTTON_H = 18; const BUTTON_TOP = 10; -const INFO_TOP = 38; -const IMAGE_TOP = 64; -const OUTER_PAD = 12; +const INFO_TOP = 30; +const BTN_X = 80; +const MIN_W = BTN_X + 6 * 56 + BUTTON_GAP * 5 + 6; +const MIN_H = IMAGE_TOP + 100; const TOP_TRIM = 8; +const SIZE_FONT = "12px 'Segoe UI',sans-serif"; +const BADGE_FONT = "10px 'Segoe UI',sans-serif"; +const BADGE_R = 8; +const SIZE_RESERVE = 86; +const SIZE_GAP = 6; +const SLIDER_PAD = 72; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); @@ -42,46 +48,63 @@ function repaintNode(node) { node._cmpDomRender?.(); } +function rowLayout(width) { + const leftPad = isVueNodes() ? 12 : BTN_X; + const rightPad = isVueNodes() ? 12 : 6; + const buttonWidth = Math.max( + 30, + Math.floor((width - leftPad - rightPad - BUTTON_GAP * (MODE_LABELS.length - 1)) / MODE_LABELS.length), + ); + return { leftPad, rightPad, buttonWidth }; +} + function getButtons(node, width) { - const availableWidth = width - OUTER_PAD * 2 - BUTTON_GAP * (MODE_LABELS.length - 1); - const buttonWidth = Math.max(44, Math.floor(availableWidth / MODE_LABELS.length)); + const layout = rowLayout(width); return MODE_LABELS.map((label, index) => ({ label, index, - x: OUTER_PAD + index * (buttonWidth + BUTTON_GAP), + x: layout.leftPad + index * (layout.buttonWidth + BUTTON_GAP), y: BUTTON_TOP, - w: buttonWidth, + w: layout.buttonWidth, h: BUTTON_H, active: node._cmpMode === index, })); } -function getInfoRect(width) { - return { - x: OUTER_PAD, +function getInfoRect(node, width) { + const layout = rowLayout(width); + const full = { + x: layout.leftPad, y: INFO_TOP, - w: width - OUTER_PAD * 2, + w: layout.buttonWidth * MODE_LABELS.length + BUTTON_GAP * (MODE_LABELS.length - 1), h: BUTTON_H, }; + const reserveLeft = node?._cmpImg1 ? SIZE_RESERVE + SIZE_GAP : 0; + const reserveRight = node?._cmpImg2 ? SIZE_RESERVE + SIZE_GAP : 0; + return { + x: full.x + reserveLeft, + y: full.y, + w: Math.max(40, full.w - reserveLeft - reserveRight), + h: full.h, + }; } -function getSliderRect(width) { - const info = getInfoRect(width); - const trackPad = 88; +function getSliderRect(node, width) { + const info = getInfoRect(node, width); return { - x: info.x + trackPad, - y: info.y + 8, - w: Math.max(40, info.w - trackPad - 8), + x: info.x + SLIDER_PAD, + y: info.y + info.h / 2 - 3, + w: Math.max(20, info.w - SLIDER_PAD - 24), h: 6, }; } function getImageRect(width, height) { return { - x: OUTER_PAD, + x: 6, y: IMAGE_TOP, - w: Math.max(40, width - OUTER_PAD * 2), - h: Math.max(80, height - IMAGE_TOP - OUTER_PAD), + w: Math.max(40, width - 12), + h: Math.max(80, height - IMAGE_TOP - 6), }; } @@ -128,15 +151,101 @@ function drawButton(ctx, button, hovered) { ctx.fill(); ctx.stroke(); ctx.fillStyle = button.active ? "#ffffff" : hovered ? "#ebebeb" : MUTED; - ctx.font = "10px sans-serif"; + let fontSize = 9; + ctx.font = `${fontSize}px 'Segoe UI',sans-serif`; + while (fontSize > 7 && ctx.measureText(button.label).width > button.w - 8) { + fontSize -= 0.5; + ctx.font = `${fontSize}px 'Segoe UI',sans-serif`; + } ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(button.label, button.x + button.w / 2, button.y + button.h / 2); ctx.restore(); } +function dimsText(image) { + return image ? `${image.naturalWidth}x${image.naturalHeight}` : ""; +} + +function sizesDiffer(node) { + return !!( + node?._cmpImg1 + && node?._cmpImg2 + && (node._cmpImg1.naturalWidth !== node._cmpImg2.naturalWidth + || node._cmpImg1.naturalHeight !== node._cmpImg2.naturalHeight) + ); +} + +function fillTextVCenter(ctx, text, x, yMid) { + const metrics = ctx.measureText(text); + if (metrics.actualBoundingBoxAscent != null && metrics.actualBoundingBoxDescent != null) { + ctx.textBaseline = "alphabetic"; + ctx.fillText(text, x, yMid + (metrics.actualBoundingBoxAscent - metrics.actualBoundingBoxDescent) / 2); + return; + } + ctx.textBaseline = "middle"; + ctx.fillText(text, x, yMid); +} + +function drawSizeBadge(ctx, cx, cy, label) { + ctx.save(); + ctx.fillStyle = BRAND; + ctx.beginPath(); + ctx.arc(cx, cy, BADGE_R, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#ffffff"; + ctx.font = BADGE_FONT; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(String(label), cx, cy + 0.5); + ctx.restore(); +} + +function drawSizeLabels(ctx, node, width) { + if (!node._cmpImg1 && !node._cmpImg2) return; + + const layout = rowLayout(width); + const infoFull = { + x: layout.leftPad, + y: INFO_TOP, + w: layout.buttonWidth * MODE_LABELS.length + BUTTON_GAP * (MODE_LABELS.length - 1), + h: BUTTON_H, + }; + const rightEdge = infoFull.x + infoFull.w; + const yMid = infoFull.y + infoFull.h / 2; + const textColor = sizesDiffer(node) ? BRAND : "#cfcfcf"; + + if (node._cmpImg1) { + ctx.save(); + ctx.beginPath(); + ctx.rect(infoFull.x, infoFull.y, SIZE_RESERVE, infoFull.h); + ctx.clip(); + const cx = infoFull.x + BADGE_R; + drawSizeBadge(ctx, cx, yMid, 1); + ctx.font = SIZE_FONT; + ctx.fillStyle = textColor; + ctx.textAlign = "left"; + fillTextVCenter(ctx, dimsText(node._cmpImg1), cx + BADGE_R + 5, yMid); + ctx.restore(); + } + + if (node._cmpImg2) { + ctx.save(); + ctx.beginPath(); + ctx.rect(rightEdge - SIZE_RESERVE, infoFull.y, SIZE_RESERVE, infoFull.h); + ctx.clip(); + const cx = rightEdge - BADGE_R; + drawSizeBadge(ctx, cx, yMid, 2); + ctx.font = SIZE_FONT; + ctx.fillStyle = textColor; + ctx.textAlign = "right"; + fillTextVCenter(ctx, dimsText(node._cmpImg2), cx - BADGE_R - 5, yMid); + ctx.restore(); + } +} + function drawInfoBar(ctx, node, width) { - const info = getInfoRect(width); + const info = getInfoRect(node, width); ctx.save(); ctx.fillStyle = PANEL; ctx.strokeStyle = BORDER; @@ -150,7 +259,7 @@ function drawInfoBar(ctx, node, width) { const images = getAvailableImages(node); if (!images.length) { ctx.fillStyle = MUTED; - ctx.font = "11px sans-serif"; + ctx.font = "11px 'Segoe UI',sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("Connect images and run to compare", info.x + info.w / 2, info.y + info.h / 2); @@ -159,9 +268,9 @@ function drawInfoBar(ctx, node, width) { } if (node._cmpMode === 4) { - const slider = getSliderRect(width); + const slider = getSliderRect(node, width); ctx.fillStyle = TEXT; - ctx.font = "11px sans-serif"; + ctx.font = "11px 'Segoe UI',sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(`Opacity ${Math.round(node._cmpOpacity * 100)}%`, info.x + 10, info.y + info.h / 2); @@ -187,13 +296,18 @@ function drawInfoBar(ctx, node, width) { return; } - const parts = images.map(({ slot, image }) => `${slot}: ${image.naturalWidth}x${image.naturalHeight}`); - ctx.fillStyle = TEXT; - ctx.font = "11px sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(parts.join(" "), info.x + info.w / 2, info.y + info.h / 2); + if (node._cmpMode >= 2) { + const hint = MODE_HINTS[node._cmpMode]; + if (hint) { + ctx.fillStyle = TEXT; + ctx.font = "11px 'Segoe UI',sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(hint, info.x + info.w / 2, info.y + info.h / 2); + } + } ctx.restore(); + drawSizeLabels(ctx, node, width); } function drawEmptyState(ctx, rect) { @@ -325,10 +439,10 @@ function paintNode(ctx, node, width, height, mouse) { if (node._cmpMode === 2 || node._cmpMode === 3) { ctx.save(); ctx.fillStyle = MUTED; - ctx.font = "11px sans-serif"; + ctx.font = "11px 'Segoe UI',sans-serif"; ctx.textAlign = "right"; ctx.textBaseline = "bottom"; - ctx.fillText("Move over the image to slide", width - OUTER_PAD, height - 4); + ctx.fillText("Move over the image to slide", width - 8, height - 4); ctx.restore(); } } @@ -348,10 +462,32 @@ function updateImageInteraction(node, x, y, width, height) { } function updateOpacityFromX(node, x, width) { - const slider = getSliderRect(width); + const slider = getSliderRect(node, width); node._cmpOpacity = clamp((x - slider.x) / slider.w, 0, 1); } +function getCursor(node, x, y, width, height) { + const buttons = getButtons(node, width); + if (buttons.some((button) => pointInRect(x, y, button))) return "pointer"; + if (node._cmpMode === 4) { + const slider = getSliderRect(node, width); + if (pointInRect(x, y, { + x: slider.x - 10, + y: slider.y - 10, + w: slider.w + 20, + h: slider.h + 20, + })) { + return "pointer"; + } + } + const rect = getImageRect(width, height); + if (pointInRect(x, y, rect)) { + if (node._cmpMode === 2) return "ew-resize"; + if (node._cmpMode === 3) return "ns-resize"; + } + return "default"; +} + function pointerDown(node, x, y, width, height) { const buttons = getButtons(node, width); for (const button of buttons) { @@ -362,11 +498,12 @@ function pointerDown(node, x, y, width, height) { } } + const slider = getSliderRect(node, width); if (node._cmpMode === 4 && pointInRect(x, y, { - x: getSliderRect(width).x - 10, - y: getSliderRect(width).y - 10, - w: getSliderRect(width).w + 20, - h: getSliderRect(width).h + 20, + x: slider.x - 10, + y: slider.y - 10, + w: slider.w + 20, + h: slider.h + 20, })) { node._cmpDraggingOpacity = true; updateOpacityFromX(node, x, width); @@ -445,6 +582,7 @@ function createDomWidget(node) { root.addEventListener("pointerdown", (event) => { const [x, y] = localPos(event); + root.style.cursor = getCursor(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM); if (pointerDown(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM)) { event.stopPropagation(); if (node._cmpDraggingOpacity) { @@ -461,11 +599,14 @@ function createDomWidget(node) { root.addEventListener("pointermove", (event) => { const [x, y] = localPos(event); pointerMove(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM); + root.style.cursor = getCursor(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM); render(); }); root.addEventListener("pointerup", (event) => { pointerUp(node); + const [x, y] = localPos(event); + root.style.cursor = getCursor(node, x, y, root.clientWidth, root.clientHeight + TOP_TRIM); try { root.releasePointerCapture(event.pointerId); } catch (_error) { @@ -477,6 +618,7 @@ function createDomWidget(node) { root.addEventListener("pointerleave", () => { pointerUp(node); node._cmpMouse = null; + root.style.cursor = "default"; render(); });