Gabszap commited on
Commit
12411ea
·
verified ·
1 Parent(s): 06f296b

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +895 -19
index.html CHANGED
@@ -1,19 +1,895 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <!-- saved from url=(0032)https://quickcensor.lyonade.net/ -->
3
+ <html lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
4
+
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Quick Censor</title>
7
+ <!-- Tailwind CSS CDN -->
8
+ <script src="./censor_files/saved_resource"></script>
9
+ <!-- Inter Font -->
10
+ <link href="./censor_files/css2" rel="stylesheet">
11
+ <script src="./censor_files/jszip.min.js.download"></script>
12
+ <script src="./censor_files/gif-frames.js.download"></script>
13
+ <style>
14
+ body {
15
+ font-family: 'Inter', sans-serif;
16
+ -webkit-font-smoothing: antialiased;
17
+ -moz-osx-font-smoothing: grayscale;
18
+ }
19
+ /* Ensure canvas fills its parent container */
20
+ .canvas-container {
21
+ position: relative;
22
+ width: 100%;
23
+ /* Height is determined by the canvas content */
24
+ min-height: 300px; /* Ensure a minimum height for the drop zone */
25
+ max-height: 80vh; /* Limit height to prevent excessive vertical scrolling */
26
+ background-color: #374151; /* Dark gray background for empty canvas */
27
+ border-radius: 0.5rem; /* Rounded corners */
28
+ overflow: auto; /* Add scrollbars if canvas is larger than container */
29
+ display: flex;
30
+ justify-content: center;
31
+ align-items: center;
32
+ }
33
+ #drop-prompt {
34
+ text-align: center;
35
+ color: #9ca3af; /* gray-400 */
36
+ font-size: 1.125rem; /* text-lg */
37
+ pointer-events: none; /* Allow clicks to pass through to the container */
38
+ }
39
+ canvas {
40
+ /* position: absolute; */
41
+ /* top: 0; */
42
+ /* left: 0; */
43
+ /* width: 100%; */
44
+ /* height: 100%; */
45
+ display: block;
46
+ border-radius: 0.5rem;
47
+ max-width: 100%;
48
+ max-height: 100%;
49
+ /* Rounded corners for canvas */
50
+ }
51
+ input[type="range"]::-webkit-slider-thumb {
52
+ -webkit-appearance: none;
53
+ appearance: none;
54
+ width: 20px;
55
+ height: 20px;
56
+ border-radius: 50%;
57
+ background: #4F46E5; /* Indigo-600 */
58
+ cursor: pointer;
59
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
60
+ margin-top: -8px; /* Center thumb vertically */
61
+ }
62
+ input[type="range"]::-moz-range-thumb {
63
+ width: 20px;
64
+ height: 20px;
65
+ border-radius: 50%;
66
+ background: #4F46E5; /* Indigo-600 */
67
+ cursor: pointer;
68
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
69
+ }
70
+ input[type="range"]::-webkit-slider-runnable-track {
71
+ width: 100%;
72
+ height: 6px;
73
+ background: #6366f1; /* Indigo-500 */
74
+ border-radius: 3px;
75
+ }
76
+ input[type="range"]::-moz-range-track {
77
+ width: 100%;
78
+ height: 6px;
79
+ background: #6366f1; /* Indigo-500 */
80
+ border-radius: 3px;
81
+ }
82
+
83
+ /* Hide drag & drop text in portrait mode (mobile) */
84
+ @media (orientation: portrait) {
85
+ #drop-prompt {
86
+ display: none !important;
87
+ }
88
+ }
89
+
90
+ /* Fix CSS warnings - override Tailwind defaults */
91
+ audio, canvas, embed, iframe, img, object, svg, video {
92
+ vertical-align: initial !important;
93
+ }
94
+
95
+ /* Fix appearance vendor prefix warnings */
96
+ input[type="range"]::-webkit-slider-thumb,
97
+ input[type="range"]::-moz-range-thumb,
98
+ .appearance-none {
99
+ -webkit-appearance: none;
100
+ -moz-appearance: none;
101
+ appearance: none;
102
+ }
103
+ </style>
104
+ <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>
105
+ <body class="bg-gray-900 flex items-center justify-center min-h-screen p-4">
106
+ <div class="w-full max-w-4xl bg-gray-800 rounded-xl shadow-2xl p-8">
107
+ <h1 class="text-3xl font-bold text-center text-gray-100 mb-8">Quick Censor</h1>
108
+
109
+ <!-- Controls -->
110
+ <div class="mb-8 flex flex-row items-center justify-center space-x-4">
111
+ <div class="relative inline-block text-left z-10">
112
+ <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">
113
+ 📁<span class="hidden sm:inline ml-2">Upload</span>
114
+ </button>
115
+ <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">
116
+ <div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
117
+ <label for="imageUpload" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 cursor-pointer" role="menuitem">
118
+ Upload Image(s)
119
+ <input type="file" id="imageUpload" accept="image/*" multiple="" class="hidden">
120
+ </label>
121
+ <label for="gifUpload" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 cursor-pointer" role="menuitem">
122
+ Upload GIF
123
+ <input type="file" id="gifUpload" accept="image/gif" class="hidden">
124
+ </label>
125
+ <label for="zipUpload" class="block px-4 py-2 text-sm text-gray-200 hover:bg-gray-600 cursor-pointer" role="menuitem">
126
+ Upload Zip
127
+ <input type="file" id="zipUpload" accept=".zip,application/zip,application/x-zip,application/x-zip-compressed" class="hidden">
128
+ </label>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ <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">
133
+ 🗑️<span class="hidden sm:inline ml-2">Clear</span>
134
+ </button>
135
+ <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">
136
+ ↶<span class="hidden sm:inline ml-2">Undo</span>
137
+ </button>
138
+ <div class="relative inline-block text-left z-10">
139
+ <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">
140
+ 💾<span class="hidden sm:inline ml-2">Download</span>
141
+ </button>
142
+ <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">
143
+ <div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
144
+ <button id="downloadCurrentImageBtn" class="block w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-600" role="menuitem">
145
+ Download Current Image
146
+ </button>
147
+ <button id="downloadAllAsZipBtn" class="block w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-600" role="menuitem">
148
+ Download All as ZIP
149
+ </button>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- Canvas Container -->
156
+ <div class="canvas-container border-2 border-gray-600 mb-6">
157
+ <div id="drop-prompt" style="display: block;">
158
+ <p class="font-semibold">Drag &amp; Drop, or Paste Image</p>
159
+ <p class="text-sm">You can also drop Zip/GIF files, or use the Upload button.</p>
160
+ </div>
161
+ <canvas id="displayCanvas"></canvas>
162
+ <canvas id="baseCanvas" style="display:none;"></canvas> <!-- Hidden canvas for base content -->
163
+ </div>
164
+
165
+ <!-- Image Navigation Slider -->
166
+ <div class="mb-6">
167
+ <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>
168
+ <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">
169
+ <div class="flex justify-center space-x-4 mt-3">
170
+ <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="">
171
+ Previous
172
+ </button>
173
+ <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="">
174
+ Next
175
+ </button>
176
+ </div>
177
+ </div>
178
+
179
+ <!-- Censor Area Size Sliders -->
180
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
181
+ <div>
182
+ <label for="censorWidthSlider" class="block text-gray-200 font-semibold mb-2">Censor Width: <span id="censorWidthDisplay">20</span>px</label>
183
+ <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">
184
+ </div>
185
+ <div>
186
+ <label for="censorHeightSlider" class="block text-gray-200 font-semibold mb-2">Censor Height: <span id="censorHeightDisplay">20</span>px</label>
187
+ <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">
188
+ </div>
189
+ </div>
190
+
191
+ <!-- Mosaic Block Size Slider -->
192
+ <div class="mb-6">
193
+ <label for="mosaicBlockSizeSlider" class="block text-gray-200 font-semibold mb-2">Mosaic Block Size: <span id="mosaicBlockSizeDisplay">5</span>px</label>
194
+ <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">
195
+ </div>
196
+
197
+ <!-- Instructions -->
198
+ <div class="mt-6 bg-gray-700 border border-gray-600 rounded-lg p-4">
199
+ <h3 class="text-lg font-semibold text-gray-100 mb-3">Instructions</h3>
200
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm text-gray-300">
201
+ <div class="flex items-start space-x-2">
202
+ <span class="font-semibold">Click/Tap:</span>
203
+ <span>Apply censor once and move to next frame</span>
204
+ </div>
205
+ <div class="flex items-start space-x-2">
206
+ <span class="font-semibold">Click + Drag:</span>
207
+ <span>Apply censor in the drawn area</span>
208
+ </div>
209
+ <div class="flex items-start space-x-2">
210
+ <span class="font-semibold">Hold Right Mouse:</span>
211
+ <span>Apply censor continuously (desktop)</span>
212
+ </div>
213
+ <div class="flex items-start space-x-2">
214
+ <span class="font-semibold">Touch (Mobile):</span>
215
+ <span>Apply censor without advancing frame</span>
216
+ </div>
217
+ <div class="flex items-start space-x-2">
218
+ <span class="font-semibold">Hold Ctrl:</span>
219
+ <span>Apply censor without moving to next frame</span>
220
+ </div>
221
+ <div class="flex items-start space-x-2">
222
+ <span class="font-semibold">Hover:</span>
223
+ <span>Preview censor area (desktop only)</span>
224
+ </div>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- Message Area -->
229
+ <div id="message" class="mt-4 text-center text-gray-300 text-sm">Welcome! Upload images to begin.</div>
230
+ </div>
231
+
232
+ <script>
233
+ // Get canvas elements and their contexts
234
+ const displayCanvas = document.getElementById('displayCanvas');
235
+ const canvasContainer = displayCanvas.parentElement;
236
+ const dropPrompt = document.getElementById('drop-prompt');
237
+ const displayCtx = displayCanvas.getContext('2d');
238
+ const baseCanvas = document.getElementById('baseCanvas');
239
+ const baseCtx = baseCanvas.getContext('2d');
240
+
241
+ // Get control elements
242
+ const uploadDropdownBtn = document.getElementById('uploadDropdownBtn');
243
+ const uploadDropdown = document.getElementById('uploadDropdown');
244
+ const imageUploadInput = document.getElementById('imageUpload');
245
+ const gifUploadInput = document.getElementById('gifUpload');
246
+ const zipUploadInput = document.getElementById('zipUpload');
247
+ const clearCanvasBtn = document.getElementById('clearCanvas');
248
+ const undoCensorBtn = document.getElementById('undoCensor');
249
+ const imageSlider = document.getElementById('imageSlider');
250
+ const currentImageIndexDisplay = document.getElementById('currentImageIndexDisplay');
251
+ const totalImagesDisplay = document.getElementById('totalImagesDisplay');
252
+ const censorWidthSlider = document.getElementById('censorWidthSlider');
253
+ const censorWidthDisplay = document.getElementById('censorWidthDisplay');
254
+ const censorHeightSlider = document.getElementById('censorHeightSlider');
255
+ const censorHeightDisplay = document.getElementById('censorHeightDisplay');
256
+ const mosaicBlockSizeSlider = document.getElementById('mosaicBlockSizeSlider');
257
+ const mosaicBlockSizeDisplay = document.getElementById('mosaicBlockSizeDisplay');
258
+ const messageDiv = document.getElementById('message');
259
+ const downloadDropdownBtn = document.getElementById('downloadDropdownBtn');
260
+ const downloadDropdown = document.getElementById('downloadDropdown');
261
+ const downloadCurrentImageBtn = document.getElementById('downloadCurrentImageBtn');
262
+ const downloadAllAsZipBtn = document.getElementById('downloadAllAsZipBtn');
263
+ const prevImageBtn = document.getElementById('prevImageBtn');
264
+ const nextImageBtn = document.getElementById('nextImageBtn');
265
+
266
+ // --- State Variables ---
267
+ let processedImages = [];
268
+ let currentImageIndex = -1;
269
+
270
+ let draggingToCensor = false;
271
+ let lastX = 0, lastY = 0;
272
+
273
+ let censorAreaWidth = parseInt(censorWidthSlider.value);
274
+ let censorAreaHeight = parseInt(censorHeightSlider.value);
275
+ let currentMosaicBlockSize = parseInt(mosaicBlockSizeSlider.value);
276
+ let pixivMinimumBlockSize = 4;
277
+
278
+ let currentMouseX = -1, currentMouseY = -1;
279
+ let mouseOverCanvas = false;
280
+
281
+ // --- Mouse/Interaction State ---
282
+ let leftMouseDown = false;
283
+ let leftMouseInterval = null;
284
+ const leftMouseDelay = 500; // Delay for continuous censoring
285
+
286
+ // --- Zoom and Pan State ---
287
+ let transform = { panX: 0, panY: 0, zoom: 1.0 };
288
+ let isPanning = false;
289
+ let panStart = { x: 0, y: 0 };
290
+
291
+ // --- Utility Functions ---
292
+ function showMessage(msg) {
293
+ messageDiv.textContent = msg;
294
+ }
295
+
296
+ function resetTransform() {
297
+ transform.panX = 0;
298
+ transform.panY = 0;
299
+ transform.zoom = 1.0;
300
+ }
301
+
302
+ function resizeCanvas() {
303
+ renderCurrentImage();
304
+ }
305
+
306
+ /**
307
+ * Renders the current image, handling scaling for display and setting up
308
+ * the full-resolution base canvas.
309
+ */
310
+ function renderCurrentImage() {
311
+ resetTransform(); // Reset zoom/pan on image change
312
+ baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height);
313
+ displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height);
314
+
315
+ if (currentImageIndex !== -1 && processedImages[currentImageIndex]) {
316
+ dropPrompt.style.display = 'none';
317
+ const imageData = processedImages[currentImageIndex].currentDataURL;
318
+
319
+ const img = new Image();
320
+ img.onload = () => {
321
+ // Set base canvas to full image resolution
322
+ baseCanvas.width = img.naturalWidth;
323
+ baseCanvas.height = img.naturalHeight;
324
+ baseCtx.drawImage(img, 0, 0);
325
+
326
+ // Scale display canvas to fit nicely in the container
327
+ const container = displayCanvas.parentElement;
328
+ const containerWidth = container.clientWidth;
329
+ const maxHeight = window.innerHeight * 0.85; // Increased for better viewing
330
+ const aspectRatio = img.naturalWidth / img.naturalHeight;
331
+
332
+ let targetWidth = containerWidth;
333
+ let targetHeight = containerWidth / aspectRatio;
334
+
335
+ if (targetHeight > maxHeight) {
336
+ targetHeight = maxHeight;
337
+ targetWidth = maxHeight * aspectRatio;
338
+ }
339
+
340
+ displayCanvas.width = targetWidth;
341
+ displayCanvas.height = targetHeight;
342
+
343
+ // Update sliders and warnings
344
+ pixivMinimumBlockSize = getPixivMinimumBlockSize();
345
+ mosaicBlockSizeSlider.min = 4;
346
+
347
+ // Update slider value and display to reflect the current setting
348
+ mosaicBlockSizeSlider.value = currentMosaicBlockSize;
349
+ mosaicBlockSizeDisplay.textContent = currentMosaicBlockSize;
350
+
351
+ // Check if we need to show Pixiv warning, but don't force the value to change
352
+ if (currentMosaicBlockSize < pixivMinimumBlockSize) {
353
+ showMessage(`Warning: Mosaic block size (${currentMosaicBlockSize}px) is below Pixiv's recommended minimum (${pixivMinimumBlockSize}px) for this image.`);
354
+ } else {
355
+ showMessage('Image loaded. Scroll to zoom, right-click drag to pan.');
356
+ }
357
+
358
+ copyBaseToDisplay(); // Initial render
359
+ };
360
+ img.onerror = () => showMessage('Error loading image data.');
361
+ img.src = imageData;
362
+
363
+ // Update UI elements
364
+ imageSlider.max = processedImages.length - 1;
365
+ imageSlider.value = currentImageIndex;
366
+ currentImageIndexDisplay.textContent = currentImageIndex + 1;
367
+ totalImagesDisplay.textContent = processedImages.length;
368
+ imageSlider.disabled = processedImages.length <= 1;
369
+ prevImageBtn.disabled = currentImageIndex <= 0;
370
+ nextImageBtn.disabled = currentImageIndex >= processedImages.length - 1;
371
+ } else {
372
+ // Clear everything if no image
373
+ currentImageIndexDisplay.textContent = 0;
374
+ totalImagesDisplay.textContent = 0;
375
+ imageSlider.max = 0;
376
+ imageSlider.value = 0;
377
+ imageSlider.disabled = true;
378
+ prevImageBtn.disabled = true;
379
+ nextImageBtn.disabled = true;
380
+ showMessage('Upload images to begin.');
381
+ dropPrompt.style.display = 'block';
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Copies the full-res base canvas to the display canvas, applying zoom and pan.
387
+ */
388
+ function copyBaseToDisplay() {
389
+ displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height);
390
+ displayCtx.save();
391
+ displayCtx.translate(transform.panX, transform.panY);
392
+ displayCtx.scale(transform.zoom, transform.zoom);
393
+ displayCtx.drawImage(baseCanvas, 0, 0, displayCanvas.width, displayCanvas.height);
394
+ displayCtx.restore();
395
+
396
+ if (mouseOverCanvas && !draggingToCensor && !isPanning) {
397
+ applyPreviewMosaic(currentMouseX, currentMouseY);
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Converts coordinates from the display canvas (screen space) to the base canvas (image space).
403
+ */
404
+ function displayToBaseCoords(displayX, displayY) {
405
+ const worldX = (displayX - transform.panX) / transform.zoom;
406
+ const worldY = (displayY - transform.panY) / transform.zoom;
407
+ const scaleX = baseCanvas.width / displayCanvas.width;
408
+ const scaleY = baseCanvas.height / displayCanvas.height;
409
+ return { x: worldX * scaleX, y: worldY * scaleY };
410
+ }
411
+
412
+ /**
413
+ * Applies a mosaic effect to a given ImageData object.
414
+ */
415
+ function applyMosaic(imageData, blockSize) {
416
+ const data = imageData.data;
417
+ const width = imageData.width;
418
+ const height = imageData.height;
419
+ const intBlockSize = Math.max(1, Math.floor(blockSize));
420
+
421
+ for (let y = 0; y < height; y += intBlockSize) {
422
+ for (let x = 0; x < width; x += intBlockSize) {
423
+ const pixelIndex = (y * width + x) * 4;
424
+ const r = data[pixelIndex], g = data[pixelIndex + 1], b = data[pixelIndex + 2], a = data[pixelIndex + 3];
425
+ for (let by = 0; by < intBlockSize; by++) {
426
+ for (let bx = 0; bx < intBlockSize; bx++) {
427
+ if ((y + by) < height && (x + bx) < width) {
428
+ const currentPixelIndex = ((y + by) * width + (x + bx)) * 4;
429
+ data[currentPixelIndex] = r;
430
+ data[currentPixelIndex + 1] = g;
431
+ data[currentPixelIndex + 2] = b;
432
+ data[currentPixelIndex + 3] = a;
433
+ }
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Applies a permanent mosaic to a rectangular area on the full-resolution base canvas.
442
+ */
443
+ function applyPermanentMosaicToRect(startX, startY, endX, endY) {
444
+ if (currentImageIndex === -1 || baseCanvas.width === 0) return;
445
+
446
+ const baseStart = displayToBaseCoords(startX, startY);
447
+ const baseEnd = displayToBaseCoords(endX, endY);
448
+
449
+ const scale = baseCanvas.width / (displayCanvas.width * transform.zoom);
450
+ const scaledBlockSize = Math.max(1, Math.round(currentMosaicBlockSize * scale));
451
+
452
+ const snappedStartX = Math.floor(baseStart.x / scaledBlockSize) * scaledBlockSize;
453
+ const snappedStartY = Math.floor(baseStart.y / scaledBlockSize) * scaledBlockSize;
454
+
455
+ const widthToGet = baseEnd.x - snappedStartX;
456
+ const heightToGet = baseEnd.y - snappedStartY;
457
+
458
+ if (widthToGet > 0 && heightToGet > 0) {
459
+ try {
460
+ const imageData = baseCtx.getImageData(snappedStartX, snappedStartY, widthToGet, heightToGet);
461
+ applyMosaic(imageData, scaledBlockSize);
462
+ baseCtx.putImageData(imageData, snappedStartX, snappedStartY);
463
+ } catch (e) {
464
+ console.error("Error applying mosaic:", e);
465
+ }
466
+ }
467
+ }
468
+
469
+ function applyPermanentMosaic(x, y) {
470
+ const censorStartX = Math.max(0, x - censorAreaWidth / 2);
471
+ const censorStartY = Math.max(0, y - censorAreaHeight / 2);
472
+ const censorEndX = Math.min(displayCanvas.width, x + censorAreaWidth / 2);
473
+ const censorEndY = Math.min(displayCanvas.height, y + censorAreaHeight / 2);
474
+ applyPermanentMosaicToRect(censorStartX, censorStartY, censorEndX, censorEndY);
475
+ }
476
+
477
+ function applyPreviewMosaic(mouseX, mouseY) {
478
+ if (isPanning) return;
479
+ const censorStartX = Math.max(0, mouseX - censorAreaWidth / 2);
480
+ const censorStartY = Math.max(0, mouseY - censorAreaHeight / 2);
481
+ const censorEndX = Math.min(displayCanvas.width, mouseX + censorAreaWidth / 2);
482
+ const censorEndY = Math.min(displayCanvas.height, mouseY + censorAreaHeight / 2);
483
+
484
+ const snappedStartX = Math.floor(censorStartX / currentMosaicBlockSize) * currentMosaicBlockSize;
485
+ const snappedStartY = Math.floor(censorStartY / currentMosaicBlockSize) * currentMosaicBlockSize;
486
+ const widthToGet = censorEndX - snappedStartX;
487
+ const heightToGet = censorEndY - snappedStartY;
488
+
489
+ if (widthToGet > 0 && heightToGet > 0) {
490
+ const imageData = displayCtx.getImageData(snappedStartX, snappedStartY, widthToGet, heightToGet);
491
+ applyMosaic(imageData, currentMosaicBlockSize);
492
+ displayCtx.putImageData(imageData, snappedStartX, snappedStartY);
493
+ }
494
+ }
495
+
496
+ function getPixivMinimumBlockSize() {
497
+ const maxDim = Math.max(displayCanvas.width, displayCanvas.height);
498
+ return maxDim > 400 ? Math.ceil(maxDim / 100) : 4;
499
+ }
500
+
501
+ function advanceToNextImage() {
502
+ if (currentImageIndex >= processedImages.length - 1) {
503
+ showMessage('Reached the last image.');
504
+ return false;
505
+ }
506
+ currentImageIndex++;
507
+ renderCurrentImage();
508
+ return true;
509
+ }
510
+
511
+ function applyMosaicAndAdvance(x, y, shouldAdvance = true) {
512
+ applyPermanentMosaic(x, y);
513
+ processedImages[currentImageIndex].currentDataURL = baseCanvas.toDataURL();
514
+
515
+ if (shouldAdvance) {
516
+ const advanced = advanceToNextImage();
517
+ if (advanced) {
518
+ showMessage('Mosaic applied! Moving to next image...');
519
+ } else {
520
+ showMessage('Mosaic applied! All images processed.');
521
+ }
522
+ return advanced;
523
+ } else {
524
+ showMessage('Mosaic applied! (Ctrl held - staying on current frame)');
525
+ copyBaseToDisplay();
526
+ return true;
527
+ }
528
+ }
529
+
530
+ // --- File Handlers ---
531
+ function handleDroppedFiles(files) {
532
+ if (files.length === 1) {
533
+ const file = files[0];
534
+ if (file.type === 'image/gif') handleGifFile(file);
535
+ else if (file.type.startsWith('application/zip') || file.name.endsWith('.zip')) handleZipFile(file);
536
+ else if (file.type.startsWith('image/')) handleImageFiles([file]);
537
+ else showMessage('Unsupported file type dropped.');
538
+ } else {
539
+ const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
540
+ if (imageFiles.length > 0) handleImageFiles(imageFiles);
541
+ else showMessage('No supported image files found in drop.');
542
+ }
543
+ }
544
+
545
+ function handleImageFiles(files) {
546
+ const sortedFiles = Array.from(files).sort((a, b) => a.name.localeCompare(b.name));
547
+ processedImages = [];
548
+ currentImageIndex = -1;
549
+ showMessage('Loading images...');
550
+ const filePromises = sortedFiles.map(file => new Promise((resolve, reject) => {
551
+ const reader = new FileReader();
552
+ reader.onload = e => resolve({ originalDataURL: e.target.result, currentDataURL: e.target.result });
553
+ reader.onerror = reject;
554
+ reader.readAsDataURL(file);
555
+ }));
556
+ Promise.all(filePromises).then(newImages => {
557
+ processedImages = newImages;
558
+ currentImageIndex = 0;
559
+ renderCurrentImage();
560
+ showMessage(`Loaded ${newImages.length} image(s).`);
561
+ }).catch(error => {
562
+ showMessage('Error loading images.');
563
+ console.error(error);
564
+ });
565
+ }
566
+
567
+ function handleGifFile(file) {
568
+ processedImages = [];
569
+ currentImageIndex = -1;
570
+ showMessage('Extracting frames from GIF...');
571
+ const objectURL = URL.createObjectURL(file);
572
+ gifFrames({ url: objectURL, frames: 'all', outputType: 'canvas', cumulative: true })
573
+ .then(frameData => Promise.all(frameData.map(frame => {
574
+ const frameCanvas = frame.getImage();
575
+ const dataURL = frameCanvas.toDataURL();
576
+ return { originalDataURL: dataURL, currentDataURL: dataURL };
577
+ })))
578
+ .then(newImages => {
579
+ processedImages = newImages;
580
+ currentImageIndex = 0;
581
+ renderCurrentImage();
582
+ showMessage(`Extracted ${newImages.length} frames from GIF.`);
583
+ }).catch(error => {
584
+ showMessage('Error processing GIF.');
585
+ console.error(error);
586
+ }).finally(() => URL.revokeObjectURL(objectURL));
587
+ }
588
+
589
+ async function handleZipFile(file) {
590
+ processedImages = [];
591
+ currentImageIndex = -1;
592
+ showMessage('Unzipping and loading images...');
593
+ try {
594
+ const zip = await JSZip.loadAsync(file);
595
+ const imageFiles = [];
596
+ zip.forEach((_, zipEntry) => {
597
+ if (!zipEntry.dir && /\.(jpe?g|png|gif)$/i.test(zipEntry.name)) {
598
+ imageFiles.push(zipEntry);
599
+ }
600
+ });
601
+ imageFiles.sort((a, b) => a.name.localeCompare(b.name));
602
+ const loadedImages = await Promise.all(imageFiles.map(zipEntry =>
603
+ zipEntry.async('base64').then(base64 => {
604
+ const mimeType = `image/${zipEntry.name.split('.').pop().toLowerCase()}`;
605
+ const dataUrl = `data:${mimeType};base64,${base64}`;
606
+ return { originalDataURL: dataUrl, currentDataURL: dataUrl };
607
+ })
608
+ ));
609
+ if (loadedImages.length > 0) {
610
+ processedImages = loadedImages;
611
+ currentImageIndex = 0;
612
+ renderCurrentImage();
613
+ showMessage(`Loaded ${loadedImages.length} image(s) from ZIP.`);
614
+ } else {
615
+ showMessage('No valid images found in ZIP.');
616
+ }
617
+ } catch (error) {
618
+ showMessage('Error reading ZIP file.');
619
+ console.error(error);
620
+ }
621
+ }
622
+
623
+ // --- Event Listeners ---
624
+ // Dropdowns and Uploads
625
+ uploadDropdownBtn.addEventListener('click', () => uploadDropdown.classList.toggle('hidden'));
626
+ downloadDropdownBtn.addEventListener('click', () => downloadDropdown.classList.toggle('hidden'));
627
+ window.addEventListener('click', (event) => {
628
+ if (!uploadDropdownBtn.contains(event.target) && !uploadDropdown.contains(event.target)) uploadDropdown.classList.add('hidden');
629
+ if (downloadDropdownBtn && !downloadDropdownBtn.contains(event.target) && !downloadDropdown.contains(event.target)) downloadDropdown.classList.add('hidden');
630
+ });
631
+ imageUploadInput.addEventListener('change', (e) => handleImageFiles(e.target.files));
632
+ gifUploadInput.addEventListener('change', (e) => handleGifFile(e.target.files[0]));
633
+ zipUploadInput.addEventListener('change', (e) => handleZipFile(e.target.files[0]));
634
+
635
+ // Main Controls
636
+ clearCanvasBtn.addEventListener('click', () => {
637
+ processedImages = [];
638
+ currentImageIndex = -1;
639
+ imageUploadInput.value = gifUploadInput.value = zipUploadInput.value = '';
640
+ renderCurrentImage();
641
+ showMessage('All content cleared.');
642
+ });
643
+ undoCensorBtn.addEventListener('click', () => {
644
+ if (currentImageIndex === -1) return;
645
+ processedImages[currentImageIndex].currentDataURL = processedImages[currentImageIndex].originalDataURL;
646
+ renderCurrentImage();
647
+ showMessage('Current image reverted to original.');
648
+ });
649
+
650
+ // Navigation
651
+ imageSlider.addEventListener('input', (e) => {
652
+ currentImageIndex = parseInt(e.target.value);
653
+ renderCurrentImage();
654
+ });
655
+ prevImageBtn.addEventListener('click', () => {
656
+ if (currentImageIndex > 0) {
657
+ currentImageIndex--;
658
+ renderCurrentImage();
659
+ }
660
+ });
661
+ nextImageBtn.addEventListener('click', () => {
662
+ if (currentImageIndex < processedImages.length - 1) {
663
+ currentImageIndex++;
664
+ renderCurrentImage();
665
+ }
666
+ });
667
+
668
+ // Sliders
669
+ censorWidthSlider.addEventListener('input', (e) => {
670
+ censorAreaWidth = parseInt(e.target.value);
671
+ censorWidthDisplay.textContent = censorAreaWidth;
672
+ });
673
+ censorHeightSlider.addEventListener('input', (e) => {
674
+ censorAreaHeight = parseInt(e.target.value);
675
+ censorHeightDisplay.textContent = censorAreaHeight;
676
+ });
677
+ mosaicBlockSizeSlider.addEventListener('input', (e) => {
678
+ currentMosaicBlockSize = parseInt(e.target.value);
679
+ mosaicBlockSizeDisplay.textContent = currentMosaicBlockSize;
680
+ });
681
+
682
+ // --- Canvas Interaction: Drag/Drop, Zoom, Pan, Censor ---
683
+ function getEventCoordinates(e) {
684
+ const rect = displayCanvas.getBoundingClientRect();
685
+ const touch = e.touches && e.touches[0];
686
+ return {
687
+ x: (touch || e).clientX - rect.left,
688
+ y: (touch || e).clientY - rect.top
689
+ };
690
+ }
691
+
692
+ // Drag and Drop
693
+ canvasContainer.addEventListener('dragover', e => {
694
+ e.preventDefault();
695
+ canvasContainer.classList.add('border-blue-500');
696
+ });
697
+ canvasContainer.addEventListener('dragleave', e => {
698
+ e.preventDefault();
699
+ canvasContainer.classList.remove('border-blue-500');
700
+ });
701
+ canvasContainer.addEventListener('drop', e => {
702
+ e.preventDefault();
703
+ canvasContainer.classList.remove('border-blue-500');
704
+ if (e.dataTransfer.files.length > 0) {
705
+ handleDroppedFiles(e.dataTransfer.files);
706
+ }
707
+ });
708
+
709
+ // Zoom with Scroll Wheel
710
+ canvasContainer.addEventListener('wheel', e => {
711
+ if (currentImageIndex === -1) return;
712
+ e.preventDefault();
713
+ const coords = getEventCoordinates(e);
714
+ const worldX = (coords.x - transform.panX) / transform.zoom;
715
+ const worldY = (coords.y - transform.panY) / transform.zoom;
716
+ const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
717
+ const newZoom = Math.max(0.1, transform.zoom * zoomFactor);
718
+ transform.panX = coords.x - worldX * newZoom;
719
+ transform.panY = coords.y - worldY * newZoom;
720
+ transform.zoom = newZoom;
721
+ copyBaseToDisplay();
722
+ }, { passive: false });
723
+
724
+ // Mouse/Touch Handlers
725
+ displayCanvas.addEventListener('mousedown', e => {
726
+ if (currentImageIndex === -1) return;
727
+ const coords = getEventCoordinates(e);
728
+
729
+ if (e.button === 0) { // Left-click for censoring
730
+ e.preventDefault();
731
+ leftMouseDown = true;
732
+ lastX = coords.x;
733
+ lastY = coords.y;
734
+
735
+ // Apply first mosaic immediately on click
736
+ applyPermanentMosaic(coords.x, coords.y);
737
+ copyBaseToDisplay();
738
+
739
+ // Start a timer. If the mouse is still down after the delay and not dragging, start continuous censoring.
740
+ leftMouseInterval = setTimeout(() => {
741
+ if (leftMouseDown && !draggingToCensor) {
742
+ const continuousCensor = () => {
743
+ if (!leftMouseDown) {
744
+ clearTimeout(leftMouseInterval);
745
+ leftMouseInterval = null;
746
+ return;
747
+ }
748
+ const mosaicX = currentMouseX >= 0 ? currentMouseX : displayCanvas.width / 2;
749
+ const mosaicY = currentMouseY >= 0 ? currentMouseY : displayCanvas.height / 2;
750
+
751
+ const stillAdvancing = applyMosaicAndAdvance(mosaicX, mosaicY, !e.ctrlKey);
752
+ if (stillAdvancing) {
753
+ leftMouseInterval = setTimeout(continuousCensor, leftMouseDelay);
754
+ } else {
755
+ leftMouseDown = false;
756
+ }
757
+ };
758
+ continuousCensor();
759
+ }
760
+ }, 300); // A short delay to distinguish a click/drag from a hold
761
+
762
+ } else if (e.button === 2) { // Right-click for panning
763
+ e.preventDefault();
764
+ isPanning = true;
765
+ panStart.x = e.clientX - transform.panX;
766
+ panStart.y = e.clientY - transform.panY;
767
+ displayCanvas.style.cursor = 'grabbing';
768
+ }
769
+ });
770
+
771
+ displayCanvas.addEventListener('mousemove', e => {
772
+ const coords = getEventCoordinates(e);
773
+ currentMouseX = coords.x;
774
+ currentMouseY = coords.y;
775
+
776
+ if (leftMouseDown) {
777
+ // If we move, it's a drag. Cancel the continuous censor timer.
778
+ if (Math.abs(coords.x - lastX) > 5 || Math.abs(coords.y - lastY) > 5) {
779
+ clearTimeout(leftMouseInterval);
780
+ leftMouseInterval = null;
781
+ draggingToCensor = true;
782
+ }
783
+ }
784
+
785
+ if (isPanning) {
786
+ transform.panX = e.clientX - panStart.x;
787
+ transform.panY = e.clientY - panStart.y;
788
+ copyBaseToDisplay();
789
+ } else if (draggingToCensor) {
790
+ // This is the corrected drag logic
791
+ const minX = Math.min(lastX, currentMouseX);
792
+ const minY = Math.min(lastY, currentMouseY);
793
+ const maxX = Math.max(lastX, currentMouseX);
794
+ const maxY = Math.max(lastY, currentMouseY);
795
+ applyPermanentMosaicToRect(minX - censorAreaWidth / 2, minY - censorAreaHeight / 2, maxX + censorAreaWidth / 2, maxY + censorAreaHeight / 2);
796
+ lastX = currentMouseX;
797
+ lastY = currentMouseY;
798
+ copyBaseToDisplay();
799
+ } else {
800
+ copyBaseToDisplay(); // For preview
801
+ }
802
+ });
803
+
804
+ window.addEventListener('mouseup', e => {
805
+ if (e.button === 0) { // Left mouse button released
806
+ clearTimeout(leftMouseInterval); // Clear any pending continuous censor
807
+ leftMouseInterval = null;
808
+
809
+ if (draggingToCensor) {
810
+ // If we were dragging, save the final result
811
+ processedImages[currentImageIndex].currentDataURL = baseCanvas.toDataURL();
812
+ showMessage('Censoring saved!');
813
+ } else if(leftMouseDown) {
814
+ // If it was just a click (no drag, no hold), advance the image
815
+ const shouldAdvance = !e.ctrlKey;
816
+ if (shouldAdvance) {
817
+ advanceToNextImage();
818
+ }
819
+ }
820
+
821
+ leftMouseDown = false;
822
+ draggingToCensor = false;
823
+ }
824
+
825
+ if (e.button === 2) { // Right mouse button released
826
+ if (isPanning) {
827
+ isPanning = false;
828
+ displayCanvas.style.cursor = 'default';
829
+ }
830
+ }
831
+ });
832
+
833
+ displayCanvas.addEventListener('mouseenter', () => mouseOverCanvas = true);
834
+ displayCanvas.addEventListener('mouseout', () => {
835
+ mouseOverCanvas = false;
836
+ copyBaseToDisplay(); // Remove preview
837
+ });
838
+ displayCanvas.addEventListener('contextmenu', e => e.preventDefault());
839
+
840
+ // --- Initialization and Global Listeners ---
841
+ window.addEventListener('load', resizeCanvas);
842
+ window.addEventListener('resize', resizeCanvas);
843
+ window.addEventListener('paste', (e) => {
844
+ const items = (e.clipboardData || e.originalEvent.clipboardData).items;
845
+ for (let i = 0; i < items.length; i++) {
846
+ if (items[i].type.indexOf('image') !== -1) {
847
+ handleImageFiles([items[i].getAsFile()]);
848
+ break;
849
+ }
850
+ }
851
+ });
852
+
853
+ // --- Download Logic ---
854
+ downloadCurrentImageBtn.addEventListener('click', () => {
855
+ if (currentImageIndex === -1) return;
856
+ downloadDropdown.classList.add('hidden');
857
+ const link = document.createElement('a');
858
+ link.href = processedImages[currentImageIndex].currentDataURL;
859
+ link.download = `image_${currentImageIndex + 1}.png`;
860
+ link.click();
861
+ showMessage('Current image download started.');
862
+ });
863
+
864
+ downloadAllAsZipBtn.addEventListener('click', async () => {
865
+ if (processedImages.length === 0) return;
866
+ downloadDropdown.classList.add('hidden');
867
+ showMessage('Zipping images...');
868
+ const zip = new JSZip();
869
+ const dataURLtoBlob = (dataurl) => {
870
+ const arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], bstr = atob(arr[1]);
871
+ let n = bstr.length;
872
+ const u8arr = new Uint8Array(n);
873
+ while (n--) u8arr[n] = bstr.charCodeAt(n);
874
+ return new Blob([u8arr], { type: mime });
875
+ };
876
+ processedImages.forEach((imgData, index) => {
877
+ const blob = dataURLtoBlob(imgData.currentDataURL);
878
+ if (blob) zip.file(`image_${index + 1}.png`, blob);
879
+ });
880
+ try {
881
+ const content = await zip.generateAsync({ type: 'blob' });
882
+ const link = document.createElement('a');
883
+ link.href = URL.createObjectURL(content);
884
+ link.download = 'censored_images.zip';
885
+ link.click();
886
+ showMessage('ZIP download started.');
887
+ } catch (e) {
888
+ showMessage('Error creating ZIP file.');
889
+ console.error(e);
890
+ }
891
+ });
892
+ </script>
893
+ <!-- Cloudflare Pages Analytics --><script defer="" src="./censor_files/beacon.min.js.download" data-cf-beacon="{&quot;token&quot;: &quot;90458de470ba4595a21e7b208c3b1fcf&quot;}"></script><!-- Cloudflare Pages Analytics -->
894
+
895
+ <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>