Spaces:
Running
Running
| <!-- saved from url=(0032)https://quickcensor.lyonade.net/ --> | |
| <html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Quick Censor</title> | |
| <!-- Tailwind CSS CDN --> | |
| <script src="./censor_files/saved_resource"></script> | |
| <!-- Inter Font --> | |
| <link href="./censor_files/css2" rel="stylesheet"> | |
| <script src="./censor_files/jszip.min.js.download"></script> | |
| <script src="./censor_files/gif-frames.js.download"></script> | |
| <style> | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| /* Ensure canvas fills its parent container */ | |
| .canvas-container { | |
| position: relative; | |
| width: 100%; | |
| /* Height is determined by the canvas content */ | |
| min-height: 300px; /* Ensure a minimum height for the drop zone */ | |
| max-height: 80vh; /* Limit height to prevent excessive vertical scrolling */ | |
| background-color: #374151; /* Dark gray background for empty canvas */ | |
| border-radius: 0.5rem; /* Rounded corners */ | |
| overflow: auto; /* Add scrollbars if canvas is larger than container */ | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| #drop-prompt { | |
| text-align: center; | |
| color: #9ca3af; /* gray-400 */ | |
| font-size: 1.125rem; /* text-lg */ | |
| pointer-events: none; /* Allow clicks to pass through to the container */ | |
| } | |
| canvas { | |
| /* position: absolute; */ | |
| /* top: 0; */ | |
| /* left: 0; */ | |
| /* width: 100%; */ | |
| /* height: 100%; */ | |
| display: block; | |
| border-radius: 0.5rem; | |
| max-width: 100%; | |
| max-height: 100%; | |
| /* Rounded corners for canvas */ | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: #4F46E5; /* Indigo-600 */ | |
| cursor: pointer; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); | |
| margin-top: -8px; /* Center thumb vertically */ | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 50%; | |
| background: #4F46E5; /* Indigo-600 */ | |
| cursor: pointer; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); | |
| } | |
| input[type="range"]::-webkit-slider-runnable-track { | |
| width: 100%; | |
| height: 6px; | |
| background: #6366f1; /* Indigo-500 */ | |
| border-radius: 3px; | |
| } | |
| input[type="range"]::-moz-range-track { | |
| width: 100%; | |
| height: 6px; | |
| background: #6366f1; /* Indigo-500 */ | |
| border-radius: 3px; | |
| } | |
| /* Hide drag & drop text in portrait mode (mobile) */ | |
| @media (orientation: portrait) { | |
| #drop-prompt { | |
| display: none ; | |
| } | |
| } | |
| /* Fix CSS warnings - override Tailwind defaults */ | |
| audio, canvas, embed, iframe, img, object, svg, video { | |
| vertical-align: initial ; | |
| } | |
| /* Fix appearance vendor prefix warnings */ | |
| input[type="range"]::-webkit-slider-thumb, | |
| input[type="range"]::-moz-range-thumb, | |
| .appearance-none { | |
| -webkit-appearance: none; | |
| -moz-appearance: none; | |
| appearance: none; | |
| } | |
| </style> | |
| <style>*, ::before, ::after{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / 0.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/* ! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com */*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}::after,::before{--tw-content:''}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none;-moz-appearance:none;appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;-moz-appearance:button;appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.absolute{position:absolute}.relative{position:relative}.left-0{left:0px}.right-0{right:0px}.z-10{z-index:10}.mb-2{margin-bottom:0.5rem}.mb-3{margin-bottom:0.75rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:0.5rem}.mt-2{margin-top:0.5rem}.mt-3{margin-top:0.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-2{height:0.5rem}.min-h-screen{min-height:100vh}.w-56{width:14rem}.w-full{width:100%}.max-w-4xl{max-width:56rem}.origin-top-left{transform-origin:top left}.origin-top-right{transform-origin:top right}.transform{transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-cols-1{grid-template-columns:repeat(1, minmax(0, 1fr))}.flex-row{flex-direction:row}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.gap-3{gap:0.75rem}.gap-6{gap:1.5rem}.space-x-2 > :not([hidden]) ~ :not([hidden]){--tw-space-x-reverse:0;margin-right:calc(0.5rem * var(--tw-space-x-reverse));margin-left:calc(0.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-x-4 > :not([hidden]) ~ :not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem * var(--tw-space-x-reverse));margin-left:calc(1rem * calc(1 - var(--tw-space-x-reverse)))}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:0.5rem}.rounded-md{border-radius:0.375rem}.rounded-xl{border-radius:0.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.bg-indigo-500{--tw-bg-opacity:1;background-color:rgb(99 102 241 / var(--tw-bg-opacity, 1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.p-4{padding:1rem}.p-8{padding:2rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.py-1{padding-top:0.25rem;padding-bottom:0.25rem}.py-2{padding-top:0.5rem;padding-bottom:0.5rem}.py-3{padding-top:0.75rem;padding-bottom:0.75rem}.text-left{text-align:left}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:0.875rem;line-height:1.25rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgb(0 0 0 / 0.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)}.ring-gray-600{--tw-ring-opacity:1;--tw-ring-color:rgb(75 85 99 / var(--tw-ring-opacity, 1))}.ring-opacity-5{--tw-ring-opacity:0.05}.transition{transition-property:color, background-color, border-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-text-decoration-color, -webkit-backdrop-filter;transition-property:color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;transition-property:color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-text-decoration-color, -webkit-backdrop-filter;transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);transition-duration:150ms}.duration-300{transition-duration:300ms}.ease-in-out{transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61 / var(--tw-bg-opacity, 1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28 / var(--tw-bg-opacity, 1))}.hover\:bg-yellow-600:hover{--tw-bg-opacity:1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:0.5}@media (min-width: 640px){.sm\:inline{display:inline}}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2, minmax(0, 1fr))}}</style></head> | |
| <body class="bg-gray-900 flex items-center justify-center min-h-screen p-4"> | |
| <div class="w-full max-w-4xl bg-gray-800 rounded-xl shadow-2xl p-8"> | |
| <h1 class="text-3xl font-bold text-center text-gray-100 mb-8">Quick Censor</h1> | |
| <!-- Controls --> | |
| <div class="mb-8 flex flex-row items-center justify-center space-x-4"> | |
| <div class="relative inline-block text-left z-10"> | |
| <button id="uploadDropdownBtn" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-7 rounded-full transition duration-300 ease-in-out shadow-lg transform hover:scale-105"> | |
| 📁<span class="hidden sm:inline ml-2">Upload</span> | |
| </button> | |
| <div id="uploadDropdown" class="origin-top-left absolute left-0 mt-2 w-56 rounded-md shadow-lg bg-gray-700 ring-1 ring-gray-600 ring-opacity-5 hidden"> | |
| <div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu"> | |
| <label for="imageUpload" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 cursor-pointer" role="menuitem"> | |
| Upload Image(s) | |
| <input type="file" id="imageUpload" accept="image/*" multiple="" class="hidden"> | |
| </label> | |
| <label for="gifUpload" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 cursor-pointer" role="menuitem"> | |
| Upload GIF | |
| <input type="file" id="gifUpload" accept="image/gif" class="hidden"> | |
| </label> | |
| <label for="zipUpload" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 cursor-pointer" role="menuitem"> | |
| Upload Zip | |
| <input type="file" id="zipUpload" accept=".zip,application/zip,application/x-zip,application/x-zip-compressed" class="hidden"> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| <button id="clearCanvas" class="bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-7 rounded-full transition duration-300 ease-in-out shadow-lg transform hover:scale-105"> | |
| 🗑️<span class="hidden sm:inline ml-2">Clear</span> | |
| </button> | |
| <button id="undoCensor" class="bg-yellow-500 hover:bg-yellow-600 text-white font-semibold py-3 px-7 rounded-full transition duration-300 ease-in-out shadow-lg transform hover:scale-105"> | |
| ↶<span class="hidden sm:inline ml-2">Undo</span> | |
| </button> | |
| <div class="relative inline-block text-left z-10"> | |
| <button id="downloadDropdownBtn" class="bg-green-600 hover:bg-green-700 text-white font-semibold py-3 px-7 rounded-full transition duration-300 ease-in-out shadow-lg transform hover:scale-105"> | |
| 💾<span class="hidden sm:inline ml-2">Download</span> | |
| </button> | |
| <div id="downloadDropdown" class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-gray-700 ring-1 ring-gray-600 ring-opacity-5 hidden"> | |
| <div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu"> | |
| <button id="downloadCurrentImageBtn" class="block w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-600" role="menuitem"> | |
| Download Current Image | |
| </button> | |
| <button id="downloadAllAsZipBtn" class="block w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-600" role="menuitem"> | |
| Download All as ZIP | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Canvas Container --> | |
| <div class="canvas-container border-2 border-gray-600 mb-6"> | |
| <div id="drop-prompt" style="display: block;"> | |
| <p class="font-semibold">Drag & Drop, or Paste Image</p> | |
| <p class="text-sm">You can also drop Zip/GIF files, or use the Upload button.</p> | |
| </div> | |
| <canvas id="displayCanvas"></canvas> | |
| <canvas id="baseCanvas" style="display:none;"></canvas> <!-- Hidden canvas for base content --> | |
| </div> | |
| <!-- Image Navigation Slider --> | |
| <div class="mb-6"> | |
| <label for="imageSlider" class="block text-gray-200 font-semibold mb-2">Image <span id="currentImageIndexDisplay">0</span> of <span id="totalImagesDisplay">0</span></label> | |
| <input type="range" id="imageSlider" min="0" value="0" class="w-full h-2 bg-indigo-500 rounded-lg appearance-none cursor-pointer disabled:opacity-50" disabled="" max="0"> | |
| <div class="flex justify-center space-x-4 mt-3"> | |
| <button id="prevImageBtn" class="bg-gray-600 hover:bg-gray-500 text-white font-semibold py-2 px-4 rounded-lg transition duration-300 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed" disabled=""> | |
| Previous | |
| </button> | |
| <button id="nextImageBtn" class="bg-gray-600 hover:bg-gray-500 text-white font-semibold py-2 px-4 rounded-lg transition duration-300 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed" disabled=""> | |
| Next | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Censor Area Size Sliders --> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> | |
| <div> | |
| <label for="censorWidthSlider" class="block text-gray-200 font-semibold mb-2">Censor Width: <span id="censorWidthDisplay">20</span>px</label> | |
| <input type="range" id="censorWidthSlider" min="10" max="500" value="20" class="w-full h-2 bg-indigo-500 rounded-lg appearance-none cursor-pointer"> | |
| </div> | |
| <div> | |
| <label for="censorHeightSlider" class="block text-gray-200 font-semibold mb-2">Censor Height: <span id="censorHeightDisplay">20</span>px</label> | |
| <input type="range" id="censorHeightSlider" min="10" max="500" value="20" class="w-full h-2 bg-indigo-500 rounded-lg appearance-none cursor-pointer"> | |
| </div> | |
| </div> | |
| <!-- Mosaic Block Size Slider --> | |
| <div class="mb-6"> | |
| <label for="mosaicBlockSizeSlider" class="block text-gray-200 font-semibold mb-2">Mosaic Block Size: <span id="mosaicBlockSizeDisplay">5</span>px</label> | |
| <input type="range" id="mosaicBlockSizeSlider" min="4" max="100" value="5" class="w-full h-2 bg-indigo-500 rounded-lg appearance-none cursor-pointer"> | |
| </div> | |
| <!-- Instructions --> | |
| <div class="mt-6 bg-gray-700 border border-gray-600 rounded-lg p-4"> | |
| <h3 class="text-lg font-semibold text-gray-100 mb-3">Instructions</h3> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-gray-300"> | |
| <div class="flex items-start space-x-2"> | |
| <span class="font-semibold">Click/Tap:</span> | |
| <span>Apply censor once and move to next frame</span> | |
| </div> | |
| <div class="flex items-start space-x-2"> | |
| <span class="font-semibold">Click + Drag:</span> | |
| <span>Apply censor in the drawn area</span> | |
| </div> | |
| <div class="flex items-start space-x-2"> | |
| <span class="font-semibold">Hold Right Mouse:</span> | |
| <span>Apply censor continuously (desktop)</span> | |
| </div> | |
| <div class="flex items-start space-x-2"> | |
| <span class="font-semibold">Touch (Mobile):</span> | |
| <span>Apply censor without advancing frame</span> | |
| </div> | |
| <div class="flex items-start space-x-2"> | |
| <span class="font-semibold">Hold Ctrl:</span> | |
| <span>Apply censor without moving to next frame</span> | |
| </div> | |
| <div class="flex items-start space-x-2"> | |
| <span class="font-semibold">Hover:</span> | |
| <span>Preview censor area (desktop only)</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Message Area --> | |
| <div id="message" class="mt-4 text-center text-gray-300 text-sm">Welcome! Upload images to begin.</div> | |
| </div> | |
| <script> | |
| // Get canvas elements and their contexts | |
| const displayCanvas = document.getElementById('displayCanvas'); | |
| const canvasContainer = displayCanvas.parentElement; | |
| const dropPrompt = document.getElementById('drop-prompt'); | |
| const displayCtx = displayCanvas.getContext('2d'); | |
| const baseCanvas = document.getElementById('baseCanvas'); | |
| const baseCtx = baseCanvas.getContext('2d'); | |
| // Get control elements | |
| const uploadDropdownBtn = document.getElementById('uploadDropdownBtn'); | |
| const uploadDropdown = document.getElementById('uploadDropdown'); | |
| const imageUploadInput = document.getElementById('imageUpload'); | |
| const gifUploadInput = document.getElementById('gifUpload'); | |
| const zipUploadInput = document.getElementById('zipUpload'); | |
| const clearCanvasBtn = document.getElementById('clearCanvas'); | |
| const undoCensorBtn = document.getElementById('undoCensor'); | |
| const imageSlider = document.getElementById('imageSlider'); | |
| const currentImageIndexDisplay = document.getElementById('currentImageIndexDisplay'); | |
| const totalImagesDisplay = document.getElementById('totalImagesDisplay'); | |
| const censorWidthSlider = document.getElementById('censorWidthSlider'); | |
| const censorWidthDisplay = document.getElementById('censorWidthDisplay'); | |
| const censorHeightSlider = document.getElementById('censorHeightSlider'); | |
| const censorHeightDisplay = document.getElementById('censorHeightDisplay'); | |
| const mosaicBlockSizeSlider = document.getElementById('mosaicBlockSizeSlider'); | |
| const mosaicBlockSizeDisplay = document.getElementById('mosaicBlockSizeDisplay'); | |
| const messageDiv = document.getElementById('message'); | |
| const downloadDropdownBtn = document.getElementById('downloadDropdownBtn'); | |
| const downloadDropdown = document.getElementById('downloadDropdown'); | |
| const downloadCurrentImageBtn = document.getElementById('downloadCurrentImageBtn'); | |
| const downloadAllAsZipBtn = document.getElementById('downloadAllAsZipBtn'); | |
| const prevImageBtn = document.getElementById('prevImageBtn'); | |
| const nextImageBtn = document.getElementById('nextImageBtn'); | |
| // --- State Variables --- | |
| let processedImages = []; | |
| let currentImageIndex = -1; | |
| let draggingToCensor = false; | |
| let lastX = 0, lastY = 0; | |
| let censorAreaWidth = parseInt(censorWidthSlider.value); | |
| let censorAreaHeight = parseInt(censorHeightSlider.value); | |
| let currentMosaicBlockSize = parseInt(mosaicBlockSizeSlider.value); | |
| let pixivMinimumBlockSize = 4; | |
| let currentMouseX = -1, currentMouseY = -1; | |
| let mouseOverCanvas = false; | |
| // --- Mouse/Interaction State --- | |
| let leftMouseDown = false; | |
| let leftMouseInterval = null; | |
| const leftMouseDelay = 500; // Delay for continuous censoring | |
| // --- Zoom and Pan State --- | |
| let transform = { panX: 0, panY: 0, zoom: 1.0 }; | |
| let isPanning = false; | |
| let panStart = { x: 0, y: 0 }; | |
| // --- Utility Functions --- | |
| function showMessage(msg) { | |
| messageDiv.textContent = msg; | |
| } | |
| function resetTransform() { | |
| transform.panX = 0; | |
| transform.panY = 0; | |
| transform.zoom = 1.0; | |
| } | |
| function resizeCanvas() { | |
| renderCurrentImage(); | |
| } | |
| /** | |
| * Renders the current image, handling scaling for display and setting up | |
| * the full-resolution base canvas. | |
| */ | |
| function renderCurrentImage() { | |
| resetTransform(); // Reset zoom/pan on image change | |
| baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height); | |
| displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height); | |
| if (currentImageIndex !== -1 && processedImages[currentImageIndex]) { | |
| dropPrompt.style.display = 'none'; | |
| const imageData = processedImages[currentImageIndex].currentDataURL; | |
| const img = new Image(); | |
| img.onload = () => { | |
| // Set base canvas to full image resolution | |
| baseCanvas.width = img.naturalWidth; | |
| baseCanvas.height = img.naturalHeight; | |
| baseCtx.drawImage(img, 0, 0); | |
| // Scale display canvas to fit nicely in the container | |
| const container = displayCanvas.parentElement; | |
| const containerWidth = container.clientWidth; | |
| const maxHeight = window.innerHeight * 0.85; // Increased for better viewing | |
| const aspectRatio = img.naturalWidth / img.naturalHeight; | |
| let targetWidth = containerWidth; | |
| let targetHeight = containerWidth / aspectRatio; | |
| if (targetHeight > maxHeight) { | |
| targetHeight = maxHeight; | |
| targetWidth = maxHeight * aspectRatio; | |
| } | |
| displayCanvas.width = targetWidth; | |
| displayCanvas.height = targetHeight; | |
| // Update sliders and warnings | |
| pixivMinimumBlockSize = getPixivMinimumBlockSize(); | |
| mosaicBlockSizeSlider.min = 4; | |
| // Update slider value and display to reflect the current setting | |
| mosaicBlockSizeSlider.value = currentMosaicBlockSize; | |
| mosaicBlockSizeDisplay.textContent = currentMosaicBlockSize; | |
| // Check if we need to show Pixiv warning, but don't force the value to change | |
| if (currentMosaicBlockSize < pixivMinimumBlockSize) { | |
| showMessage(`Warning: Mosaic block size (${currentMosaicBlockSize}px) is below Pixiv's recommended minimum (${pixivMinimumBlockSize}px) for this image.`); | |
| } else { | |
| showMessage('Image loaded. Scroll to zoom, right-click drag to pan.'); | |
| } | |
| copyBaseToDisplay(); // Initial render | |
| }; | |
| img.onerror = () => showMessage('Error loading image data.'); | |
| img.src = imageData; | |
| // Update UI elements | |
| imageSlider.max = processedImages.length - 1; | |
| imageSlider.value = currentImageIndex; | |
| currentImageIndexDisplay.textContent = currentImageIndex + 1; | |
| totalImagesDisplay.textContent = processedImages.length; | |
| imageSlider.disabled = processedImages.length <= 1; | |
| prevImageBtn.disabled = currentImageIndex <= 0; | |
| nextImageBtn.disabled = currentImageIndex >= processedImages.length - 1; | |
| } else { | |
| // Clear everything if no image | |
| currentImageIndexDisplay.textContent = 0; | |
| totalImagesDisplay.textContent = 0; | |
| imageSlider.max = 0; | |
| imageSlider.value = 0; | |
| imageSlider.disabled = true; | |
| prevImageBtn.disabled = true; | |
| nextImageBtn.disabled = true; | |
| showMessage('Upload images to begin.'); | |
| dropPrompt.style.display = 'block'; | |
| } | |
| } | |
| /** | |
| * Copies the full-res base canvas to the display canvas, applying zoom and pan. | |
| */ | |
| function copyBaseToDisplay() { | |
| displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height); | |
| displayCtx.save(); | |
| displayCtx.translate(transform.panX, transform.panY); | |
| displayCtx.scale(transform.zoom, transform.zoom); | |
| displayCtx.drawImage(baseCanvas, 0, 0, displayCanvas.width, displayCanvas.height); | |
| displayCtx.restore(); | |
| if (mouseOverCanvas && !draggingToCensor && !isPanning) { | |
| applyPreviewMosaic(currentMouseX, currentMouseY); | |
| } | |
| } | |
| /** | |
| * Converts coordinates from the display canvas (screen space) to the base canvas (image space). | |
| */ | |
| function displayToBaseCoords(displayX, displayY) { | |
| const worldX = (displayX - transform.panX) / transform.zoom; | |
| const worldY = (displayY - transform.panY) / transform.zoom; | |
| const scaleX = baseCanvas.width / displayCanvas.width; | |
| const scaleY = baseCanvas.height / displayCanvas.height; | |
| return { x: worldX * scaleX, y: worldY * scaleY }; | |
| } | |
| /** | |
| * Applies a mosaic effect to a given ImageData object. | |
| */ | |
| function applyMosaic(imageData, blockSize) { | |
| const data = imageData.data; | |
| const width = imageData.width; | |
| const height = imageData.height; | |
| const intBlockSize = Math.max(1, Math.floor(blockSize)); | |
| for (let y = 0; y < height; y += intBlockSize) { | |
| for (let x = 0; x < width; x += intBlockSize) { | |
| const pixelIndex = (y * width + x) * 4; | |
| const r = data[pixelIndex], g = data[pixelIndex + 1], b = data[pixelIndex + 2], a = data[pixelIndex + 3]; | |
| for (let by = 0; by < intBlockSize; by++) { | |
| for (let bx = 0; bx < intBlockSize; bx++) { | |
| if ((y + by) < height && (x + bx) < width) { | |
| const currentPixelIndex = ((y + by) * width + (x + bx)) * 4; | |
| data[currentPixelIndex] = r; | |
| data[currentPixelIndex + 1] = g; | |
| data[currentPixelIndex + 2] = b; | |
| data[currentPixelIndex + 3] = a; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Applies a permanent mosaic to a rectangular area on the full-resolution base canvas. | |
| */ | |
| function applyPermanentMosaicToRect(startX, startY, endX, endY) { | |
| if (currentImageIndex === -1 || baseCanvas.width === 0) return; | |
| const baseStart = displayToBaseCoords(startX, startY); | |
| const baseEnd = displayToBaseCoords(endX, endY); | |
| const scale = baseCanvas.width / (displayCanvas.width * transform.zoom); | |
| const scaledBlockSize = Math.max(1, Math.round(currentMosaicBlockSize * scale)); | |
| const snappedStartX = Math.floor(baseStart.x / scaledBlockSize) * scaledBlockSize; | |
| const snappedStartY = Math.floor(baseStart.y / scaledBlockSize) * scaledBlockSize; | |
| const widthToGet = baseEnd.x - snappedStartX; | |
| const heightToGet = baseEnd.y - snappedStartY; | |
| if (widthToGet > 0 && heightToGet > 0) { | |
| try { | |
| const imageData = baseCtx.getImageData(snappedStartX, snappedStartY, widthToGet, heightToGet); | |
| applyMosaic(imageData, scaledBlockSize); | |
| baseCtx.putImageData(imageData, snappedStartX, snappedStartY); | |
| } catch (e) { | |
| console.error("Error applying mosaic:", e); | |
| } | |
| } | |
| } | |
| function applyPermanentMosaic(x, y) { | |
| const censorStartX = Math.max(0, x - censorAreaWidth / 2); | |
| const censorStartY = Math.max(0, y - censorAreaHeight / 2); | |
| const censorEndX = Math.min(displayCanvas.width, x + censorAreaWidth / 2); | |
| const censorEndY = Math.min(displayCanvas.height, y + censorAreaHeight / 2); | |
| applyPermanentMosaicToRect(censorStartX, censorStartY, censorEndX, censorEndY); | |
| } | |
| function applyPreviewMosaic(mouseX, mouseY) { | |
| if (isPanning) return; | |
| const censorStartX = Math.max(0, mouseX - censorAreaWidth / 2); | |
| const censorStartY = Math.max(0, mouseY - censorAreaHeight / 2); | |
| const censorEndX = Math.min(displayCanvas.width, mouseX + censorAreaWidth / 2); | |
| const censorEndY = Math.min(displayCanvas.height, mouseY + censorAreaHeight / 2); | |
| const snappedStartX = Math.floor(censorStartX / currentMosaicBlockSize) * currentMosaicBlockSize; | |
| const snappedStartY = Math.floor(censorStartY / currentMosaicBlockSize) * currentMosaicBlockSize; | |
| const widthToGet = censorEndX - snappedStartX; | |
| const heightToGet = censorEndY - snappedStartY; | |
| if (widthToGet > 0 && heightToGet > 0) { | |
| const imageData = displayCtx.getImageData(snappedStartX, snappedStartY, widthToGet, heightToGet); | |
| applyMosaic(imageData, currentMosaicBlockSize); | |
| displayCtx.putImageData(imageData, snappedStartX, snappedStartY); | |
| } | |
| } | |
| function getPixivMinimumBlockSize() { | |
| const maxDim = Math.max(displayCanvas.width, displayCanvas.height); | |
| return maxDim > 400 ? Math.ceil(maxDim / 100) : 4; | |
| } | |
| function advanceToNextImage() { | |
| if (currentImageIndex >= processedImages.length - 1) { | |
| showMessage('Reached the last image.'); | |
| return false; | |
| } | |
| currentImageIndex++; | |
| renderCurrentImage(); | |
| return true; | |
| } | |
| function applyMosaicAndAdvance(x, y, shouldAdvance = true) { | |
| applyPermanentMosaic(x, y); | |
| processedImages[currentImageIndex].currentDataURL = baseCanvas.toDataURL(); | |
| if (shouldAdvance) { | |
| const advanced = advanceToNextImage(); | |
| if (advanced) { | |
| showMessage('Mosaic applied! Moving to next image...'); | |
| } else { | |
| showMessage('Mosaic applied! All images processed.'); | |
| } | |
| return advanced; | |
| } else { | |
| showMessage('Mosaic applied! (Ctrl held - staying on current frame)'); | |
| copyBaseToDisplay(); | |
| return true; | |
| } | |
| } | |
| // --- File Handlers --- | |
| function handleDroppedFiles(files) { | |
| if (files.length === 1) { | |
| const file = files[0]; | |
| if (file.type === 'image/gif') handleGifFile(file); | |
| else if (file.type.startsWith('application/zip') || file.name.endsWith('.zip')) handleZipFile(file); | |
| else if (file.type.startsWith('image/')) handleImageFiles([file]); | |
| else showMessage('Unsupported file type dropped.'); | |
| } else { | |
| const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/')); | |
| if (imageFiles.length > 0) handleImageFiles(imageFiles); | |
| else showMessage('No supported image files found in drop.'); | |
| } | |
| } | |
| function handleImageFiles(files) { | |
| const sortedFiles = Array.from(files).sort((a, b) => a.name.localeCompare(b.name)); | |
| processedImages = []; | |
| currentImageIndex = -1; | |
| showMessage('Loading images...'); | |
| const filePromises = sortedFiles.map(file => new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = e => resolve({ originalDataURL: e.target.result, currentDataURL: e.target.result }); | |
| reader.onerror = reject; | |
| reader.readAsDataURL(file); | |
| })); | |
| Promise.all(filePromises).then(newImages => { | |
| processedImages = newImages; | |
| currentImageIndex = 0; | |
| renderCurrentImage(); | |
| showMessage(`Loaded ${newImages.length} image(s).`); | |
| }).catch(error => { | |
| showMessage('Error loading images.'); | |
| console.error(error); | |
| }); | |
| } | |
| function handleGifFile(file) { | |
| processedImages = []; | |
| currentImageIndex = -1; | |
| showMessage('Extracting frames from GIF...'); | |
| const objectURL = URL.createObjectURL(file); | |
| gifFrames({ url: objectURL, frames: 'all', outputType: 'canvas', cumulative: true }) | |
| .then(frameData => Promise.all(frameData.map(frame => { | |
| const frameCanvas = frame.getImage(); | |
| const dataURL = frameCanvas.toDataURL(); | |
| return { originalDataURL: dataURL, currentDataURL: dataURL }; | |
| }))) | |
| .then(newImages => { | |
| processedImages = newImages; | |
| currentImageIndex = 0; | |
| renderCurrentImage(); | |
| showMessage(`Extracted ${newImages.length} frames from GIF.`); | |
| }).catch(error => { | |
| showMessage('Error processing GIF.'); | |
| console.error(error); | |
| }).finally(() => URL.revokeObjectURL(objectURL)); | |
| } | |
| async function handleZipFile(file) { | |
| processedImages = []; | |
| currentImageIndex = -1; | |
| showMessage('Unzipping and loading images...'); | |
| try { | |
| const zip = await JSZip.loadAsync(file); | |
| const imageFiles = []; | |
| zip.forEach((_, zipEntry) => { | |
| if (!zipEntry.dir && /\.(jpe?g|png|gif)$/i.test(zipEntry.name)) { | |
| imageFiles.push(zipEntry); | |
| } | |
| }); | |
| imageFiles.sort((a, b) => a.name.localeCompare(b.name)); | |
| const loadedImages = await Promise.all(imageFiles.map(zipEntry => | |
| zipEntry.async('base64').then(base64 => { | |
| const mimeType = `image/${zipEntry.name.split('.').pop().toLowerCase()}`; | |
| const dataUrl = `data:${mimeType};base64,${base64}`; | |
| return { originalDataURL: dataUrl, currentDataURL: dataUrl }; | |
| }) | |
| )); | |
| if (loadedImages.length > 0) { | |
| processedImages = loadedImages; | |
| currentImageIndex = 0; | |
| renderCurrentImage(); | |
| showMessage(`Loaded ${loadedImages.length} image(s) from ZIP.`); | |
| } else { | |
| showMessage('No valid images found in ZIP.'); | |
| } | |
| } catch (error) { | |
| showMessage('Error reading ZIP file.'); | |
| console.error(error); | |
| } | |
| } | |
| // --- Event Listeners --- | |
| // Dropdowns and Uploads | |
| uploadDropdownBtn.addEventListener('click', () => uploadDropdown.classList.toggle('hidden')); | |
| downloadDropdownBtn.addEventListener('click', () => downloadDropdown.classList.toggle('hidden')); | |
| window.addEventListener('click', (event) => { | |
| if (!uploadDropdownBtn.contains(event.target) && !uploadDropdown.contains(event.target)) uploadDropdown.classList.add('hidden'); | |
| if (downloadDropdownBtn && !downloadDropdownBtn.contains(event.target) && !downloadDropdown.contains(event.target)) downloadDropdown.classList.add('hidden'); | |
| }); | |
| imageUploadInput.addEventListener('change', (e) => handleImageFiles(e.target.files)); | |
| gifUploadInput.addEventListener('change', (e) => handleGifFile(e.target.files[0])); | |
| zipUploadInput.addEventListener('change', (e) => handleZipFile(e.target.files[0])); | |
| // Main Controls | |
| clearCanvasBtn.addEventListener('click', () => { | |
| processedImages = []; | |
| currentImageIndex = -1; | |
| imageUploadInput.value = gifUploadInput.value = zipUploadInput.value = ''; | |
| renderCurrentImage(); | |
| showMessage('All content cleared.'); | |
| }); | |
| undoCensorBtn.addEventListener('click', () => { | |
| if (currentImageIndex === -1) return; | |
| processedImages[currentImageIndex].currentDataURL = processedImages[currentImageIndex].originalDataURL; | |
| renderCurrentImage(); | |
| showMessage('Current image reverted to original.'); | |
| }); | |
| // Navigation | |
| imageSlider.addEventListener('input', (e) => { | |
| currentImageIndex = parseInt(e.target.value); | |
| renderCurrentImage(); | |
| }); | |
| prevImageBtn.addEventListener('click', () => { | |
| if (currentImageIndex > 0) { | |
| currentImageIndex--; | |
| renderCurrentImage(); | |
| } | |
| }); | |
| nextImageBtn.addEventListener('click', () => { | |
| if (currentImageIndex < processedImages.length - 1) { | |
| currentImageIndex++; | |
| renderCurrentImage(); | |
| } | |
| }); | |
| // Sliders | |
| censorWidthSlider.addEventListener('input', (e) => { | |
| censorAreaWidth = parseInt(e.target.value); | |
| censorWidthDisplay.textContent = censorAreaWidth; | |
| }); | |
| censorHeightSlider.addEventListener('input', (e) => { | |
| censorAreaHeight = parseInt(e.target.value); | |
| censorHeightDisplay.textContent = censorAreaHeight; | |
| }); | |
| mosaicBlockSizeSlider.addEventListener('input', (e) => { | |
| currentMosaicBlockSize = parseInt(e.target.value); | |
| mosaicBlockSizeDisplay.textContent = currentMosaicBlockSize; | |
| }); | |
| // --- Canvas Interaction: Drag/Drop, Zoom, Pan, Censor --- | |
| function getEventCoordinates(e) { | |
| const rect = displayCanvas.getBoundingClientRect(); | |
| const touch = e.touches && e.touches[0]; | |
| return { | |
| x: (touch || e).clientX - rect.left, | |
| y: (touch || e).clientY - rect.top | |
| }; | |
| } | |
| // Drag and Drop | |
| canvasContainer.addEventListener('dragover', e => { | |
| e.preventDefault(); | |
| canvasContainer.classList.add('border-blue-500'); | |
| }); | |
| canvasContainer.addEventListener('dragleave', e => { | |
| e.preventDefault(); | |
| canvasContainer.classList.remove('border-blue-500'); | |
| }); | |
| canvasContainer.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| canvasContainer.classList.remove('border-blue-500'); | |
| if (e.dataTransfer.files.length > 0) { | |
| handleDroppedFiles(e.dataTransfer.files); | |
| } | |
| }); | |
| // Zoom with Scroll Wheel | |
| canvasContainer.addEventListener('wheel', e => { | |
| if (currentImageIndex === -1) return; | |
| e.preventDefault(); | |
| const coords = getEventCoordinates(e); | |
| const worldX = (coords.x - transform.panX) / transform.zoom; | |
| const worldY = (coords.y - transform.panY) / transform.zoom; | |
| const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; | |
| const newZoom = Math.max(0.1, transform.zoom * zoomFactor); | |
| transform.panX = coords.x - worldX * newZoom; | |
| transform.panY = coords.y - worldY * newZoom; | |
| transform.zoom = newZoom; | |
| copyBaseToDisplay(); | |
| }, { passive: false }); | |
| // Mouse/Touch Handlers | |
| displayCanvas.addEventListener('mousedown', e => { | |
| if (currentImageIndex === -1) return; | |
| const coords = getEventCoordinates(e); | |
| if (e.button === 0) { // Left-click for censoring | |
| e.preventDefault(); | |
| leftMouseDown = true; | |
| lastX = coords.x; | |
| lastY = coords.y; | |
| // Apply first mosaic immediately on click | |
| applyPermanentMosaic(coords.x, coords.y); | |
| copyBaseToDisplay(); | |
| // Start a timer. If the mouse is still down after the delay and not dragging, start continuous censoring. | |
| leftMouseInterval = setTimeout(() => { | |
| if (leftMouseDown && !draggingToCensor) { | |
| const continuousCensor = () => { | |
| if (!leftMouseDown) { | |
| clearTimeout(leftMouseInterval); | |
| leftMouseInterval = null; | |
| return; | |
| } | |
| const mosaicX = currentMouseX >= 0 ? currentMouseX : displayCanvas.width / 2; | |
| const mosaicY = currentMouseY >= 0 ? currentMouseY : displayCanvas.height / 2; | |
| const stillAdvancing = applyMosaicAndAdvance(mosaicX, mosaicY, !e.ctrlKey); | |
| if (stillAdvancing) { | |
| leftMouseInterval = setTimeout(continuousCensor, leftMouseDelay); | |
| } else { | |
| leftMouseDown = false; | |
| } | |
| }; | |
| continuousCensor(); | |
| } | |
| }, 300); // A short delay to distinguish a click/drag from a hold | |
| } else if (e.button === 2) { // Right-click for panning | |
| e.preventDefault(); | |
| isPanning = true; | |
| panStart.x = e.clientX - transform.panX; | |
| panStart.y = e.clientY - transform.panY; | |
| displayCanvas.style.cursor = 'grabbing'; | |
| } | |
| }); | |
| displayCanvas.addEventListener('mousemove', e => { | |
| const coords = getEventCoordinates(e); | |
| currentMouseX = coords.x; | |
| currentMouseY = coords.y; | |
| if (leftMouseDown) { | |
| // If we move, it's a drag. Cancel the continuous censor timer. | |
| if (Math.abs(coords.x - lastX) > 5 || Math.abs(coords.y - lastY) > 5) { | |
| clearTimeout(leftMouseInterval); | |
| leftMouseInterval = null; | |
| draggingToCensor = true; | |
| } | |
| } | |
| if (isPanning) { | |
| transform.panX = e.clientX - panStart.x; | |
| transform.panY = e.clientY - panStart.y; | |
| copyBaseToDisplay(); | |
| } else if (draggingToCensor) { | |
| // This is the corrected drag logic | |
| const minX = Math.min(lastX, currentMouseX); | |
| const minY = Math.min(lastY, currentMouseY); | |
| const maxX = Math.max(lastX, currentMouseX); | |
| const maxY = Math.max(lastY, currentMouseY); | |
| applyPermanentMosaicToRect(minX - censorAreaWidth / 2, minY - censorAreaHeight / 2, maxX + censorAreaWidth / 2, maxY + censorAreaHeight / 2); | |
| lastX = currentMouseX; | |
| lastY = currentMouseY; | |
| copyBaseToDisplay(); | |
| } else { | |
| copyBaseToDisplay(); // For preview | |
| } | |
| }); | |
| window.addEventListener('mouseup', e => { | |
| if (e.button === 0) { // Left mouse button released | |
| clearTimeout(leftMouseInterval); // Clear any pending continuous censor | |
| leftMouseInterval = null; | |
| if (draggingToCensor) { | |
| // If we were dragging, save the final result | |
| processedImages[currentImageIndex].currentDataURL = baseCanvas.toDataURL(); | |
| showMessage('Censoring saved!'); | |
| } else if(leftMouseDown) { | |
| // If it was just a click (no drag, no hold), advance the image | |
| const shouldAdvance = !e.ctrlKey; | |
| if (shouldAdvance) { | |
| advanceToNextImage(); | |
| } | |
| } | |
| leftMouseDown = false; | |
| draggingToCensor = false; | |
| } | |
| if (e.button === 2) { // Right mouse button released | |
| if (isPanning) { | |
| isPanning = false; | |
| displayCanvas.style.cursor = 'default'; | |
| } | |
| } | |
| }); | |
| displayCanvas.addEventListener('mouseenter', () => mouseOverCanvas = true); | |
| displayCanvas.addEventListener('mouseout', () => { | |
| mouseOverCanvas = false; | |
| copyBaseToDisplay(); // Remove preview | |
| }); | |
| displayCanvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| // --- Initialization and Global Listeners --- | |
| window.addEventListener('load', resizeCanvas); | |
| window.addEventListener('resize', resizeCanvas); | |
| window.addEventListener('paste', (e) => { | |
| const items = (e.clipboardData || e.originalEvent.clipboardData).items; | |
| for (let i = 0; i < items.length; i++) { | |
| if (items[i].type.indexOf('image') !== -1) { | |
| handleImageFiles([items[i].getAsFile()]); | |
| break; | |
| } | |
| } | |
| }); | |
| // --- Download Logic --- | |
| downloadCurrentImageBtn.addEventListener('click', () => { | |
| if (currentImageIndex === -1) return; | |
| downloadDropdown.classList.add('hidden'); | |
| const link = document.createElement('a'); | |
| link.href = processedImages[currentImageIndex].currentDataURL; | |
| link.download = `image_${currentImageIndex + 1}.png`; | |
| link.click(); | |
| showMessage('Current image download started.'); | |
| }); | |
| downloadAllAsZipBtn.addEventListener('click', async () => { | |
| if (processedImages.length === 0) return; | |
| downloadDropdown.classList.add('hidden'); | |
| showMessage('Zipping images...'); | |
| const zip = new JSZip(); | |
| const dataURLtoBlob = (dataurl) => { | |
| const arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], bstr = atob(arr[1]); | |
| let n = bstr.length; | |
| const u8arr = new Uint8Array(n); | |
| while (n--) u8arr[n] = bstr.charCodeAt(n); | |
| return new Blob([u8arr], { type: mime }); | |
| }; | |
| processedImages.forEach((imgData, index) => { | |
| const blob = dataURLtoBlob(imgData.currentDataURL); | |
| if (blob) zip.file(`image_${index + 1}.png`, blob); | |
| }); | |
| try { | |
| const content = await zip.generateAsync({ type: 'blob' }); | |
| const link = document.createElement('a'); | |
| link.href = URL.createObjectURL(content); | |
| link.download = 'censored_images.zip'; | |
| link.click(); | |
| showMessage('ZIP download started.'); | |
| } catch (e) { | |
| showMessage('Error creating ZIP file.'); | |
| console.error(e); | |
| } | |
| }); | |
| </script> | |
| <!-- Cloudflare Pages Analytics --><script defer="" src="./censor_files/beacon.min.js.download" data-cf-beacon="{"token": "90458de470ba4595a21e7b208c3b1fcf"}"></script><!-- Cloudflare Pages Analytics --> | |
| <deepl-input-controller translate="no"><template shadowrootmode="open"><link rel="stylesheet" href="chrome-extension://cofdbpoegempjloogbagkncekinflcnj/build/content.css"><div dir="ltr" style="visibility: initial !important;"><div class="dl-input-translation-container svelte-95aucy"><div></div></div></div></template></deepl-input-controller></body></html> |