Last active
August 8, 2026 15:49
-
-
Save ericek111/f65103ae5075c3bfdd0d046f6f62051a to your computer and use it in GitHub Desktop.
A simple LLM-coded magnifier app
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Local Image Magnifier</title> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| --bg: #111418; | |
| --panel: #1b2027; | |
| --panel-2: #242a33; | |
| --text: #eef2f6; | |
| --muted: #9ea9b6; | |
| --line: #343c48; | |
| --accent: #4fb3ff; | |
| --danger: #ff6b6b; | |
| --shadow: 0 16px 50px rgba(0, 0, 0, 0.35); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background: | |
| linear-gradient(45deg, rgba(255, 255, 255, 0.035) 25%, transparent 25%), | |
| linear-gradient(-45deg, rgba(255, 255, 255, 0.035) 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.035) 75%), | |
| linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.035) 75%), | |
| var(--bg); | |
| background-position: 0 0, 0 12px, 12px -12px, -12px 0; | |
| background-size: 24px 24px; | |
| color: var(--text); | |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| } | |
| button, | |
| input { | |
| font: inherit; | |
| } | |
| .app { | |
| display: grid; | |
| grid-template-rows: auto 1fr; | |
| height: 100%; | |
| min-width: 320px; | |
| } | |
| .toolbar { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| min-height: 64px; | |
| padding: 10px 14px; | |
| border-bottom: 1px solid var(--line); | |
| background: rgba(27, 32, 39, 0.94); | |
| box-shadow: var(--shadow); | |
| backdrop-filter: blur(12px); | |
| z-index: 5; | |
| } | |
| .brand { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| min-width: 150px; | |
| margin-right: 6px; | |
| } | |
| .brand strong { | |
| font-size: 14px; | |
| line-height: 1.1; | |
| } | |
| .brand span { | |
| color: var(--muted); | |
| font-size: 12px; | |
| line-height: 1.2; | |
| } | |
| .controls { | |
| display: flex; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| width: 100%; | |
| } | |
| .control-group { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .control-group.open-group { | |
| margin-right: 10px; | |
| padding-right: 18px; | |
| border-right: 1px solid var(--line); | |
| } | |
| .button, | |
| .file-label, | |
| .toggle, | |
| .zoom-field, | |
| .meta-label { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 7px; | |
| min-height: 36px; | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| background: var(--panel-2); | |
| color: var(--text); | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .button, | |
| .file-label { | |
| min-width: 36px; | |
| padding: 0 11px; | |
| } | |
| .button:hover, | |
| .file-label:hover, | |
| .toggle:hover { | |
| border-color: #566170; | |
| background: #2b323d; | |
| } | |
| .button.active { | |
| border-color: rgba(79, 179, 255, 0.9); | |
| background: rgba(79, 179, 255, 0.18); | |
| color: #dff2ff; | |
| } | |
| .button:active, | |
| .file-label:active { | |
| transform: translateY(1px); | |
| } | |
| .button:disabled { | |
| cursor: not-allowed; | |
| opacity: 0.45; | |
| } | |
| .file-label input { | |
| position: absolute; | |
| width: 1px; | |
| height: 1px; | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .zoom-field { | |
| width: 92px; | |
| padding: 0 10px; | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| background: #151a20; | |
| color: var(--text); | |
| text-align: center; | |
| font-variant-numeric: tabular-nums; | |
| font-size: 13px; | |
| } | |
| .meta-label { | |
| min-height: 36px; | |
| padding: 0 10px; | |
| background: #151a20; | |
| color: var(--muted); | |
| font-size: 13px; | |
| font-variant-numeric: tabular-nums; | |
| cursor: default; | |
| } | |
| .zoom-field:focus { | |
| border-color: var(--accent); | |
| outline: none; | |
| box-shadow: 0 0 0 3px rgba(79, 179, 255, 0.16); | |
| } | |
| .toggle { | |
| position: relative; | |
| padding: 0 10px; | |
| font-size: 13px; | |
| white-space: nowrap; | |
| } | |
| .toggle input { | |
| width: 16px; | |
| height: 16px; | |
| margin: 0; | |
| accent-color: var(--accent); | |
| } | |
| .hint { | |
| margin-left: auto; | |
| color: var(--muted); | |
| font-size: 12px; | |
| line-height: 1.25; | |
| text-align: right; | |
| } | |
| .stage { | |
| position: relative; | |
| overflow: hidden; | |
| min-height: 0; | |
| cursor: grab; | |
| touch-action: none; | |
| } | |
| .stage.selecting { | |
| cursor: crosshair; | |
| } | |
| .stage.dragging { | |
| cursor: grabbing; | |
| } | |
| .stage.drop-ready::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 18px; | |
| border: 2px dashed var(--accent); | |
| border-radius: 12px; | |
| background: rgba(79, 179, 255, 0.08); | |
| pointer-events: none; | |
| z-index: 4; | |
| } | |
| .empty { | |
| position: absolute; | |
| inset: 0; | |
| display: grid; | |
| place-items: center; | |
| padding: 24px; | |
| text-align: center; | |
| pointer-events: none; | |
| } | |
| .empty-box { | |
| width: min(520px, 100%); | |
| padding: 28px; | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| background: rgba(27, 32, 39, 0.9); | |
| box-shadow: var(--shadow); | |
| } | |
| .empty-box h1 { | |
| margin: 0 0 10px; | |
| font-size: clamp(24px, 5vw, 44px); | |
| line-height: 1; | |
| } | |
| .empty-box p { | |
| margin: 0; | |
| color: var(--muted); | |
| line-height: 1.5; | |
| } | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| .toast { | |
| position: absolute; | |
| right: 16px; | |
| bottom: 16px; | |
| max-width: min(360px, calc(100vw - 32px)); | |
| padding: 10px 12px; | |
| border: 1px solid rgba(255, 107, 107, 0.45); | |
| border-radius: 8px; | |
| background: rgba(51, 20, 24, 0.95); | |
| color: #ffdede; | |
| font-size: 13px; | |
| box-shadow: var(--shadow); | |
| opacity: 0; | |
| transform: translateY(8px); | |
| transition: opacity 160ms ease, transform 160ms ease; | |
| pointer-events: none; | |
| z-index: 6; | |
| } | |
| .toast.visible { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| @media (max-width: 760px) { | |
| .toolbar { | |
| align-items: flex-start; | |
| flex-direction: column; | |
| min-height: 0; | |
| } | |
| .brand { | |
| min-width: 0; | |
| } | |
| .hint { | |
| display: none; | |
| } | |
| .controls { | |
| width: 100%; | |
| } | |
| .control-group.open-group { | |
| margin-right: 0; | |
| padding-right: 0; | |
| border-right: 0; | |
| } | |
| .zoom-field { | |
| width: 82px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="app"> | |
| <header class="toolbar"> | |
| <div class="brand"> | |
| <strong>Local Image Magnifier</strong> | |
| <span>No upload leaves this browser</span> | |
| </div> | |
| <div class="controls"> | |
| <div class="control-group open-group"> | |
| <label class="file-label" title="Open an image file"> | |
| Open | |
| <input id="fileInput" type="file" accept="image/*"> | |
| </label> | |
| </div> | |
| <div class="control-group"> | |
| <button class="button" id="zoomOut" type="button" title="Zoom out one step">-</button> | |
| <input class="zoom-field" id="zoomInput" type="text" value="100%" inputmode="decimal" title="Enter zoom percentage"> | |
| <button class="button" id="zoomIn" type="button" title="Zoom in one step">+</button> | |
| </div> | |
| <button class="button" id="fitImage" type="button" title="Fit image to window">Fit</button> | |
| <button class="button" id="actualSize" type="button" title="Show one image pixel per screen pixel">1:1</button> | |
| <button class="button" id="centerImage" type="button" title="Center the current image position">Center</button> | |
| <button class="button" id="selectToggle" type="button" title="Draw or resize a pixel-snapped selection">Select</button> | |
| <button class="button" id="saveSelection" type="button" title="Save the current selection as a PNG" disabled>Save PNG</button> | |
| <button class="button" id="fullscreenToggle" type="button" title="Toggle fullscreen">Fullscreen</button> | |
| <div class="meta-label" id="resolutionLabel">No image</div> | |
| <label class="toggle" title="Use nearest-neighbor scaling for crisp pixels"> | |
| <input id="pixelated" type="checkbox" checked> | |
| Pixelated | |
| </label> | |
| <label class="toggle" title="Mouse wheel moves between integer zoom levels"> | |
| <input id="integerWheel" type="checkbox"> | |
| Integer wheel | |
| </label> | |
| <div class="hint">Wheel zooms at cursor. Drag pans; middle-drag pans while selecting.</div> | |
| </div> | |
| </header> | |
| <section class="stage" id="stage" tabindex="0" aria-label="Image viewer"> | |
| <canvas id="canvas"></canvas> | |
| <div class="empty" id="empty"> | |
| <div class="empty-box"> | |
| <h1>Drop, paste, or open a picture</h1> | |
| <p>Use the mouse wheel for smooth map-style zooming, toggle integer wheel steps for exact scaling, and turn Pixelated off to smooth scaling.</p> | |
| </div> | |
| </div> | |
| <div class="toast" id="toast"></div> | |
| </section> | |
| </main> | |
| <script> | |
| const stage = document.getElementById("stage"); | |
| const canvas = document.getElementById("canvas"); | |
| const ctx = canvas.getContext("2d"); | |
| const empty = document.getElementById("empty"); | |
| const toast = document.getElementById("toast"); | |
| const fileInput = document.getElementById("fileInput"); | |
| const zoomOut = document.getElementById("zoomOut"); | |
| const zoomIn = document.getElementById("zoomIn"); | |
| const fitImage = document.getElementById("fitImage"); | |
| const actualSize = document.getElementById("actualSize"); | |
| const centerImageButton = document.getElementById("centerImage"); | |
| const selectToggle = document.getElementById("selectToggle"); | |
| const saveSelection = document.getElementById("saveSelection"); | |
| const fullscreenToggle = document.getElementById("fullscreenToggle"); | |
| const resolutionLabel = document.getElementById("resolutionLabel"); | |
| const zoomInput = document.getElementById("zoomInput"); | |
| const pixelated = document.getElementById("pixelated"); | |
| const integerWheel = document.getElementById("integerWheel"); | |
| const state = { | |
| image: null, | |
| scale: 1, | |
| offsetX: 0, | |
| offsetY: 0, | |
| dragging: false, | |
| dragStartX: 0, | |
| dragStartY: 0, | |
| dragOffsetX: 0, | |
| dragOffsetY: 0, | |
| selectMode: false, | |
| selection: null, | |
| selectionAction: null, | |
| dpr: 1 | |
| }; | |
| const minScale = 0.01; | |
| const maxScale = 128; | |
| const integerLevels = [ | |
| 0.03125, 0.0625, 0.125, 0.25, 0.5, | |
| 1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 24, 32, 48, 64, 96, 128 | |
| ]; | |
| function clamp(value, min, max) { | |
| return Math.min(max, Math.max(min, value)); | |
| } | |
| function showToast(message) { | |
| toast.textContent = message; | |
| toast.classList.add("visible"); | |
| window.clearTimeout(showToast.timer); | |
| showToast.timer = window.setTimeout(() => toast.classList.remove("visible"), 2600); | |
| } | |
| function formatZoom(scale) { | |
| const percent = scale * 100; | |
| if (percent >= 100) { | |
| return `${Math.round(percent)}%`; | |
| } | |
| if (percent >= 10) { | |
| return `${Math.round(percent * 10) / 10}%`; | |
| } | |
| return `${Math.round(percent * 100) / 100}%`; | |
| } | |
| function syncZoomInput() { | |
| if (document.activeElement !== zoomInput) { | |
| zoomInput.value = formatZoom(state.scale); | |
| } | |
| } | |
| function syncUi() { | |
| resolutionLabel.textContent = state.image | |
| ? `${state.image.naturalWidth} x ${state.image.naturalHeight} px` | |
| : "No image"; | |
| selectToggle.classList.toggle("active", state.selectMode); | |
| selectToggle.setAttribute("aria-pressed", String(state.selectMode)); | |
| saveSelection.disabled = !state.image || !normalizedSelection(); | |
| stage.classList.toggle("selecting", state.selectMode); | |
| } | |
| function parseZoomInput(value) { | |
| const normalized = value.trim().replace("%", ""); | |
| if (!normalized) return null; | |
| const parsed = Number(normalized); | |
| if (!Number.isFinite(parsed) || parsed <= 0) return null; | |
| return parsed / 100; | |
| } | |
| function resizeCanvas() { | |
| const rect = stage.getBoundingClientRect(); | |
| state.dpr = window.devicePixelRatio || 1; | |
| canvas.width = Math.max(1, Math.round(rect.width * state.dpr)); | |
| canvas.height = Math.max(1, Math.round(rect.height * state.dpr)); | |
| ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); | |
| draw(); | |
| } | |
| function draw() { | |
| const rect = stage.getBoundingClientRect(); | |
| ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0); | |
| ctx.clearRect(0, 0, rect.width, rect.height); | |
| empty.style.display = state.image ? "none" : "grid"; | |
| syncZoomInput(); | |
| syncUi(); | |
| if (!state.image) { | |
| return; | |
| } | |
| ctx.imageSmoothingEnabled = !pixelated.checked; | |
| ctx.imageSmoothingQuality = "high"; | |
| const width = state.image.naturalWidth * state.scale; | |
| const height = state.image.naturalHeight * state.scale; | |
| ctx.drawImage(state.image, state.offsetX, state.offsetY, width, height); | |
| drawSelection(); | |
| } | |
| function drawSelection() { | |
| const selection = normalizedSelection(); | |
| if (!selection) return; | |
| const x = state.offsetX + selection.x * state.scale; | |
| const y = state.offsetY + selection.y * state.scale; | |
| const width = selection.w * state.scale; | |
| const height = selection.h * state.scale; | |
| const label = `${selection.w} x ${selection.h} px`; | |
| ctx.save(); | |
| ctx.fillStyle = "rgba(79, 179, 255, 0.16)"; | |
| ctx.strokeStyle = "#4fb3ff"; | |
| ctx.lineWidth = 1; | |
| ctx.setLineDash([6, 4]); | |
| ctx.fillRect(x, y, width, height); | |
| ctx.strokeRect(x + 0.5, y + 0.5, width, height); | |
| ctx.setLineDash([]); | |
| const handles = selectionHandles(selection); | |
| ctx.fillStyle = "#eef2f6"; | |
| ctx.strokeStyle = "#111418"; | |
| ctx.lineWidth = 1; | |
| for (const handle of handles) { | |
| ctx.fillRect(handle.x - 4, handle.y - 4, 8, 8); | |
| ctx.strokeRect(handle.x - 4.5, handle.y - 4.5, 9, 9); | |
| } | |
| ctx.font = "12px Inter, ui-sans-serif, system-ui, sans-serif"; | |
| ctx.textBaseline = "middle"; | |
| const metrics = ctx.measureText(label); | |
| const labelWidth = Math.ceil(metrics.width) + 12; | |
| const labelHeight = 22; | |
| const rect = stage.getBoundingClientRect(); | |
| const labelX = clamp(x + width - labelWidth, 4, rect.width - labelWidth - 4); | |
| const labelY = clamp(y + height - labelHeight, 4, rect.height - labelHeight - 4); | |
| ctx.fillStyle = "rgba(17, 20, 24, 0.88)"; | |
| ctx.strokeStyle = "rgba(79, 179, 255, 0.8)"; | |
| ctx.lineWidth = 1; | |
| ctx.fillRect(labelX, labelY, labelWidth, labelHeight); | |
| ctx.strokeRect(labelX + 0.5, labelY + 0.5, labelWidth, labelHeight); | |
| ctx.fillStyle = "#eef2f6"; | |
| ctx.fillText(label, labelX + 6, labelY + labelHeight / 2); | |
| ctx.restore(); | |
| } | |
| function fitToStage() { | |
| if (!state.image) return; | |
| const rect = stage.getBoundingClientRect(); | |
| const padding = 32; | |
| const scaleX = (rect.width - padding) / state.image.naturalWidth; | |
| const scaleY = (rect.height - padding) / state.image.naturalHeight; | |
| state.scale = clamp(Math.min(scaleX, scaleY), minScale, maxScale); | |
| centerImage(); | |
| draw(); | |
| } | |
| function centerImage() { | |
| if (!state.image) return; | |
| const rect = stage.getBoundingClientRect(); | |
| state.offsetX = (rect.width - state.image.naturalWidth * state.scale) / 2; | |
| state.offsetY = (rect.height - state.image.naturalHeight * state.scale) / 2; | |
| } | |
| function imagePointFromEvent(event) { | |
| const rect = stage.getBoundingClientRect(); | |
| const x = (event.clientX - rect.left - state.offsetX) / state.scale; | |
| const y = (event.clientY - rect.top - state.offsetY) / state.scale; | |
| return { | |
| x: clamp(Math.round(x), 0, state.image.naturalWidth), | |
| y: clamp(Math.round(y), 0, state.image.naturalHeight) | |
| }; | |
| } | |
| function screenPointFromImagePoint(x, y) { | |
| return { | |
| x: state.offsetX + x * state.scale, | |
| y: state.offsetY + y * state.scale | |
| }; | |
| } | |
| function normalizedSelection(selection = state.selection) { | |
| if (!selection) return null; | |
| const x1 = clamp(Math.min(selection.x, selection.x + selection.w), 0, state.image.naturalWidth); | |
| const y1 = clamp(Math.min(selection.y, selection.y + selection.h), 0, state.image.naturalHeight); | |
| const x2 = clamp(Math.max(selection.x, selection.x + selection.w), 0, state.image.naturalWidth); | |
| const y2 = clamp(Math.max(selection.y, selection.y + selection.h), 0, state.image.naturalHeight); | |
| const normalized = { | |
| x: Math.round(x1), | |
| y: Math.round(y1), | |
| w: Math.round(x2 - x1), | |
| h: Math.round(y2 - y1) | |
| }; | |
| return normalized.w > 0 && normalized.h > 0 ? normalized : null; | |
| } | |
| function selectionHandles(selection) { | |
| const left = state.offsetX + selection.x * state.scale; | |
| const top = state.offsetY + selection.y * state.scale; | |
| const right = state.offsetX + (selection.x + selection.w) * state.scale; | |
| const bottom = state.offsetY + (selection.y + selection.h) * state.scale; | |
| const centerX = (left + right) / 2; | |
| const centerY = (top + bottom) / 2; | |
| return [ | |
| { name: "nw", x: left, y: top }, | |
| { name: "n", x: centerX, y: top }, | |
| { name: "ne", x: right, y: top }, | |
| { name: "e", x: right, y: centerY }, | |
| { name: "se", x: right, y: bottom }, | |
| { name: "s", x: centerX, y: bottom }, | |
| { name: "sw", x: left, y: bottom }, | |
| { name: "w", x: left, y: centerY } | |
| ]; | |
| } | |
| function hitSelection(event) { | |
| const selection = normalizedSelection(); | |
| if (!selection) return { type: "create" }; | |
| const rect = stage.getBoundingClientRect(); | |
| const point = { | |
| x: event.clientX - rect.left, | |
| y: event.clientY - rect.top | |
| }; | |
| const handleRadius = 8; | |
| for (const handle of selectionHandles(selection)) { | |
| if (Math.abs(point.x - handle.x) <= handleRadius && Math.abs(point.y - handle.y) <= handleRadius) { | |
| return { type: "resize", handle: handle.name, selection }; | |
| } | |
| } | |
| const topLeft = screenPointFromImagePoint(selection.x, selection.y); | |
| const bottomRight = screenPointFromImagePoint(selection.x + selection.w, selection.y + selection.h); | |
| if ( | |
| point.x >= topLeft.x && | |
| point.x <= bottomRight.x && | |
| point.y >= topLeft.y && | |
| point.y <= bottomRight.y | |
| ) { | |
| return { type: "move", selection }; | |
| } | |
| return { type: "create" }; | |
| } | |
| function beginSelection(event) { | |
| const point = imagePointFromEvent(event); | |
| const hit = hitSelection(event); | |
| state.selectionAction = { | |
| type: hit.type, | |
| handle: hit.handle, | |
| startPoint: point, | |
| startSelection: hit.selection || normalizedSelection() | |
| }; | |
| if (hit.type === "create") { | |
| state.selection = { x: point.x, y: point.y, w: 0, h: 0 }; | |
| } | |
| } | |
| function updateSelection(event) { | |
| if (!state.selectionAction) return; | |
| const point = imagePointFromEvent(event); | |
| const action = state.selectionAction; | |
| if (action.type === "create") { | |
| state.selection.w = point.x - state.selection.x; | |
| state.selection.h = point.y - state.selection.y; | |
| } else if (action.type === "move" && action.startSelection) { | |
| const dx = point.x - action.startPoint.x; | |
| const dy = point.y - action.startPoint.y; | |
| state.selection = { | |
| x: clamp(action.startSelection.x + dx, 0, state.image.naturalWidth - action.startSelection.w), | |
| y: clamp(action.startSelection.y + dy, 0, state.image.naturalHeight - action.startSelection.h), | |
| w: action.startSelection.w, | |
| h: action.startSelection.h | |
| }; | |
| } else if (action.type === "resize" && action.startSelection) { | |
| const start = action.startSelection; | |
| let left = start.x; | |
| let top = start.y; | |
| let right = start.x + start.w; | |
| let bottom = start.y + start.h; | |
| if (action.handle.includes("w")) left = point.x; | |
| if (action.handle.includes("e")) right = point.x; | |
| if (action.handle.includes("n")) top = point.y; | |
| if (action.handle.includes("s")) bottom = point.y; | |
| state.selection = { | |
| x: clamp(Math.min(left, right), 0, state.image.naturalWidth), | |
| y: clamp(Math.min(top, bottom), 0, state.image.naturalHeight), | |
| w: clamp(Math.max(left, right), 0, state.image.naturalWidth) - clamp(Math.min(left, right), 0, state.image.naturalWidth), | |
| h: clamp(Math.max(top, bottom), 0, state.image.naturalHeight) - clamp(Math.min(top, bottom), 0, state.image.naturalHeight) | |
| }; | |
| } | |
| draw(); | |
| } | |
| function finishSelection() { | |
| state.selection = normalizedSelection(); | |
| state.selectionAction = null; | |
| draw(); | |
| } | |
| function saveSelectionAsPng() { | |
| const selection = normalizedSelection(); | |
| if (!state.image || !selection) { | |
| showToast("Create a selection first."); | |
| return; | |
| } | |
| const output = document.createElement("canvas"); | |
| output.width = selection.w; | |
| output.height = selection.h; | |
| const outputContext = output.getContext("2d"); | |
| outputContext.imageSmoothingEnabled = false; | |
| outputContext.drawImage( | |
| state.image, | |
| selection.x, | |
| selection.y, | |
| selection.w, | |
| selection.h, | |
| 0, | |
| 0, | |
| selection.w, | |
| selection.h | |
| ); | |
| output.toBlob(blob => { | |
| if (!blob) { | |
| showToast("The selected area could not be saved."); | |
| return; | |
| } | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement("a"); | |
| link.href = url; | |
| link.download = `selection-${selection.w}x${selection.h}.png`; | |
| link.click(); | |
| window.setTimeout(() => URL.revokeObjectURL(url), 1000); | |
| }, "image/png"); | |
| } | |
| function setScaleAtPoint(nextScale, pointX, pointY) { | |
| if (!state.image) return; | |
| const oldScale = state.scale; | |
| nextScale = clamp(nextScale, minScale, maxScale); | |
| const imageX = (pointX - state.offsetX) / oldScale; | |
| const imageY = (pointY - state.offsetY) / oldScale; | |
| state.scale = nextScale; | |
| state.offsetX = pointX - imageX * nextScale; | |
| state.offsetY = pointY - imageY * nextScale; | |
| draw(); | |
| } | |
| function stepScale(direction, pointX, pointY) { | |
| const current = state.scale; | |
| let index = integerLevels.findIndex(level => level > current * 1.00001); | |
| if (direction < 0) { | |
| const lower = integerLevels.filter(level => level < current / 1.00001); | |
| index = lower.length ? integerLevels.indexOf(lower[lower.length - 1]) : 0; | |
| } else if (index === -1) { | |
| index = integerLevels.length - 1; | |
| } | |
| setScaleAtPoint(integerLevels[index], pointX, pointY); | |
| } | |
| async function loadFile(file) { | |
| if (!file || !file.type.startsWith("image/")) { | |
| showToast("Please choose an image file."); | |
| return; | |
| } | |
| const url = URL.createObjectURL(file); | |
| const image = new Image(); | |
| image.onload = () => { | |
| if (state.imageUrl) { | |
| URL.revokeObjectURL(state.imageUrl); | |
| } | |
| state.imageUrl = url; | |
| state.image = image; | |
| state.selection = null; | |
| state.selectionAction = null; | |
| fitToStage(); | |
| stage.focus(); | |
| }; | |
| image.onerror = () => { | |
| URL.revokeObjectURL(url); | |
| showToast("That image could not be opened."); | |
| }; | |
| image.src = url; | |
| } | |
| function firstImageFile(items) { | |
| for (const item of items) { | |
| if (item.kind === "file") { | |
| const file = item.getAsFile(); | |
| if (file && file.type.startsWith("image/")) { | |
| return file; | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| fileInput.addEventListener("change", event => { | |
| loadFile(event.target.files[0]); | |
| event.target.value = ""; | |
| }); | |
| stage.addEventListener("wheel", event => { | |
| if (!state.image) return; | |
| event.preventDefault(); | |
| const rect = stage.getBoundingClientRect(); | |
| const pointX = event.clientX - rect.left; | |
| const pointY = event.clientY - rect.top; | |
| if (integerWheel.checked) { | |
| stepScale(event.deltaY > 0 ? -1 : 1, pointX, pointY); | |
| return; | |
| } | |
| const zoomFactor = Math.exp(-event.deltaY * 0.0015); | |
| setScaleAtPoint(state.scale * zoomFactor, pointX, pointY); | |
| }, { passive: false }); | |
| stage.addEventListener("pointerdown", event => { | |
| if (!state.image || (event.button !== 0 && event.button !== 1)) return; | |
| event.preventDefault(); | |
| if (state.selectMode && event.button === 0) { | |
| beginSelection(event); | |
| stage.setPointerCapture(event.pointerId); | |
| draw(); | |
| return; | |
| } | |
| state.dragging = true; | |
| state.dragStartX = event.clientX; | |
| state.dragStartY = event.clientY; | |
| state.dragOffsetX = state.offsetX; | |
| state.dragOffsetY = state.offsetY; | |
| stage.classList.add("dragging"); | |
| stage.setPointerCapture(event.pointerId); | |
| }); | |
| stage.addEventListener("pointermove", event => { | |
| if (state.selectionAction) { | |
| if (event.buttons & 4) { | |
| state.offsetX += event.movementX; | |
| state.offsetY += event.movementY; | |
| } | |
| updateSelection(event); | |
| return; | |
| } | |
| if (!state.dragging) return; | |
| state.offsetX = state.dragOffsetX + event.clientX - state.dragStartX; | |
| state.offsetY = state.dragOffsetY + event.clientY - state.dragStartY; | |
| draw(); | |
| }); | |
| stage.addEventListener("pointerup", event => { | |
| if (state.selectionAction) { | |
| if (event.button !== 0 && event.buttons & 1) { | |
| return; | |
| } | |
| finishSelection(); | |
| stage.releasePointerCapture(event.pointerId); | |
| return; | |
| } | |
| state.dragging = false; | |
| stage.classList.remove("dragging"); | |
| stage.releasePointerCapture(event.pointerId); | |
| }); | |
| stage.addEventListener("pointercancel", () => { | |
| state.dragging = false; | |
| state.selectionAction = null; | |
| stage.classList.remove("dragging"); | |
| draw(); | |
| }); | |
| stage.addEventListener("auxclick", event => { | |
| if (event.button === 1) { | |
| event.preventDefault(); | |
| } | |
| }); | |
| stage.addEventListener("dragover", event => { | |
| event.preventDefault(); | |
| stage.classList.add("drop-ready"); | |
| }); | |
| stage.addEventListener("dragleave", event => { | |
| if (!stage.contains(event.relatedTarget)) { | |
| stage.classList.remove("drop-ready"); | |
| } | |
| }); | |
| stage.addEventListener("drop", event => { | |
| event.preventDefault(); | |
| stage.classList.remove("drop-ready"); | |
| loadFile(event.dataTransfer.files[0]); | |
| }); | |
| document.addEventListener("paste", event => { | |
| const file = firstImageFile(event.clipboardData.items); | |
| if (file) { | |
| loadFile(file); | |
| } | |
| }); | |
| zoomOut.addEventListener("click", () => { | |
| const rect = stage.getBoundingClientRect(); | |
| stepScale(-1, rect.width / 2, rect.height / 2); | |
| }); | |
| zoomIn.addEventListener("click", () => { | |
| const rect = stage.getBoundingClientRect(); | |
| stepScale(1, rect.width / 2, rect.height / 2); | |
| }); | |
| fitImage.addEventListener("click", fitToStage); | |
| actualSize.addEventListener("click", () => { | |
| const rect = stage.getBoundingClientRect(); | |
| setScaleAtPoint(1, rect.width / 2, rect.height / 2); | |
| }); | |
| pixelated.addEventListener("change", draw); | |
| selectToggle.addEventListener("click", () => { | |
| state.selectMode = !state.selectMode; | |
| draw(); | |
| }); | |
| saveSelection.addEventListener("click", saveSelectionAsPng); | |
| centerImageButton.addEventListener("click", () => { | |
| centerImage(); | |
| draw(); | |
| }); | |
| fullscreenToggle.addEventListener("click", async () => { | |
| try { | |
| if (document.fullscreenElement) { | |
| await document.exitFullscreen(); | |
| } else { | |
| await document.documentElement.requestFullscreen(); | |
| } | |
| } catch (error) { | |
| showToast("Fullscreen is not available in this browser context."); | |
| } | |
| }); | |
| document.addEventListener("fullscreenchange", () => { | |
| fullscreenToggle.textContent = document.fullscreenElement ? "Exit Fullscreen" : "Fullscreen"; | |
| window.setTimeout(resizeCanvas, 60); | |
| }); | |
| zoomInput.addEventListener("keydown", event => { | |
| if (event.key === "Enter") { | |
| zoomInput.blur(); | |
| } | |
| }); | |
| zoomInput.addEventListener("blur", () => { | |
| const nextScale = parseZoomInput(zoomInput.value); | |
| if (!nextScale) { | |
| syncZoomInput(); | |
| showToast("Enter a positive zoom percentage."); | |
| return; | |
| } | |
| const rect = stage.getBoundingClientRect(); | |
| setScaleAtPoint(nextScale, rect.width / 2, rect.height / 2); | |
| syncZoomInput(); | |
| }); | |
| window.addEventListener("resize", resizeCanvas); | |
| resizeCanvas(); | |
| </script> | |
| </body> | |
| </html> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Use here: https://gisthost.github.io/?f65103ae5075c3bfdd0d046f6f62051a