mirror of
https://github.com/ShinkoNet/Wplace-Overlay-Pro.git
synced 2026-01-11 22:40:37 +00:00
V3.0
This commit is contained in:
commit
0e8f4f5c1e
23 changed files with 3512 additions and 0 deletions
130
.gitignore
vendored
Normal file
130
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
npm
|
||||
|
||||
# Optional eslint cache
|
||||
eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
rpt2_cache/
|
||||
rts2_cache_cjs/
|
||||
rts2_cache_es/
|
||||
rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
env
|
||||
env.development.local
|
||||
env.test.local
|
||||
env.production.local
|
||||
env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
cache
|
||||
parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
temp
|
||||
cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
docusaurus
|
||||
|
||||
# Serverless directories
|
||||
serverless/
|
||||
|
||||
# FuseBox cache
|
||||
fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
vscode-test
|
||||
|
||||
# yarn v2
|
||||
yarn/cache
|
||||
yarn/unplugged
|
||||
yarn/build-state.yml
|
||||
yarn/install-state.gz
|
||||
pnp.*
|
||||
60
esbuild.config.mjs
Normal file
60
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import esbuild from 'esbuild';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DEV = process.argv.includes('--watch');
|
||||
const outFile = resolve(__dirname, 'dist', 'Wplace Overlay Pro.user.js');
|
||||
const metaPath = resolve(__dirname, 'src', 'meta.js');
|
||||
|
||||
// Plugin: prepend metadata banner after each build
|
||||
const MetaBannerPlugin = {
|
||||
name: 'meta-banner',
|
||||
setup(build) {
|
||||
build.onEnd(async (result) => {
|
||||
try {
|
||||
await mkdir(dirname(outFile), { recursive: true });
|
||||
const [meta, js] = await Promise.all([
|
||||
readFile(metaPath, 'utf8'),
|
||||
readFile(outFile, 'utf8'),
|
||||
]);
|
||||
// Normalize CRLF and prepend
|
||||
const banner = meta.trim() + '\n';
|
||||
await writeFile(outFile, (banner + js).replace(/\r\n/g, '\n'), 'utf8');
|
||||
} catch (err) {
|
||||
console.error('[meta-banner] Failed to prepend metadata:', err);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const buildOptions = {
|
||||
entryPoints: [resolve(__dirname, 'src', 'main.ts')],
|
||||
outfile: outFile,
|
||||
bundle: true,
|
||||
minify: false,
|
||||
legalComments: 'none',
|
||||
target: ['es2021'],
|
||||
format: 'iife',
|
||||
sourcemap: false,
|
||||
logLevel: 'info',
|
||||
plugins: [MetaBannerPlugin],
|
||||
};
|
||||
|
||||
async function buildOnce() {
|
||||
await esbuild.build(buildOptions);
|
||||
console.log('[build] Done.');
|
||||
}
|
||||
|
||||
async function buildAndWatch() {
|
||||
const ctx = await esbuild.context(buildOptions);
|
||||
await ctx.watch();
|
||||
console.log('[watch] Building and watching for changes...');
|
||||
}
|
||||
|
||||
if (DEV) {
|
||||
await buildAndWatch();
|
||||
} else {
|
||||
await buildOnce();
|
||||
}
|
||||
522
package-lock.json
generated
Normal file
522
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
{
|
||||
"name": "wplace-overlay-pro",
|
||||
"version": "2.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wplace-overlay-pro",
|
||||
"version": "2.8.0",
|
||||
"devDependencies": {
|
||||
"@types/tampermonkey": "^5.0.4",
|
||||
"esbuild": "^0.25.8",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz",
|
||||
"integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz",
|
||||
"integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz",
|
||||
"integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz",
|
||||
"integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz",
|
||||
"integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz",
|
||||
"integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz",
|
||||
"integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz",
|
||||
"integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz",
|
||||
"integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz",
|
||||
"integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz",
|
||||
"integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz",
|
||||
"integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tampermonkey": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/tampermonkey/-/tampermonkey-5.0.4.tgz",
|
||||
"integrity": "sha512-HEJ0O5CkDZSwIgd9Zip4xM8o7gZC8zBmJuH1YEXNINIC+aCWEXD10pnpo3NQG5IWHx+Xr4fMSGq1jqeKXH2lpA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.8",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
|
||||
"integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.8",
|
||||
"@esbuild/android-arm": "0.25.8",
|
||||
"@esbuild/android-arm64": "0.25.8",
|
||||
"@esbuild/android-x64": "0.25.8",
|
||||
"@esbuild/darwin-arm64": "0.25.8",
|
||||
"@esbuild/darwin-x64": "0.25.8",
|
||||
"@esbuild/freebsd-arm64": "0.25.8",
|
||||
"@esbuild/freebsd-x64": "0.25.8",
|
||||
"@esbuild/linux-arm": "0.25.8",
|
||||
"@esbuild/linux-arm64": "0.25.8",
|
||||
"@esbuild/linux-ia32": "0.25.8",
|
||||
"@esbuild/linux-loong64": "0.25.8",
|
||||
"@esbuild/linux-mips64el": "0.25.8",
|
||||
"@esbuild/linux-ppc64": "0.25.8",
|
||||
"@esbuild/linux-riscv64": "0.25.8",
|
||||
"@esbuild/linux-s390x": "0.25.8",
|
||||
"@esbuild/linux-x64": "0.25.8",
|
||||
"@esbuild/netbsd-arm64": "0.25.8",
|
||||
"@esbuild/netbsd-x64": "0.25.8",
|
||||
"@esbuild/openbsd-arm64": "0.25.8",
|
||||
"@esbuild/openbsd-x64": "0.25.8",
|
||||
"@esbuild/openharmony-arm64": "0.25.8",
|
||||
"@esbuild/sunos-x64": "0.25.8",
|
||||
"@esbuild/win32-arm64": "0.25.8",
|
||||
"@esbuild/win32-ia32": "0.25.8",
|
||||
"@esbuild/win32-x64": "0.25.8"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
package.json
Normal file
15
package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "wplace-overlay-pro",
|
||||
"version": "2.8.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs",
|
||||
"watch": "node esbuild.config.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tampermonkey": "^5.0.4",
|
||||
"esbuild": "^0.25.8",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
15
src/app.ts
Normal file
15
src/app.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/// <reference types="tampermonkey" />
|
||||
import { loadConfig, applyTheme } from './core/store';
|
||||
import { ensureHook, setUpdateUI } from './core/hook';
|
||||
import { injectStyles } from './ui/styles';
|
||||
import { createUI, updateUI } from './ui/panel';
|
||||
|
||||
export async function bootstrapApp() {
|
||||
injectStyles();
|
||||
await loadConfig();
|
||||
applyTheme();
|
||||
createUI();
|
||||
setUpdateUI(() => updateUI());
|
||||
ensureHook();
|
||||
console.log('Overlay Pro UI ready.');
|
||||
}
|
||||
30
src/core/cache.ts
Normal file
30
src/core/cache.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export class LRUCache<K=any, V=any> {
|
||||
private max: number;
|
||||
private map: Map<K, V>;
|
||||
constructor(max = 400) { this.max = max; this.map = new Map(); }
|
||||
get(key: K): V | undefined {
|
||||
if (!this.map.has(key)) return undefined;
|
||||
const val = this.map.get(key)!;
|
||||
this.map.delete(key); this.map.set(key, val);
|
||||
return val;
|
||||
}
|
||||
set(key: K, val: V) {
|
||||
if (this.map.has(key)) this.map.delete(key);
|
||||
this.map.set(key, val);
|
||||
if (this.map.size > this.max) {
|
||||
const first = this.map.keys().next().value as K;
|
||||
this.map.delete(first);
|
||||
}
|
||||
}
|
||||
has(key: K) { return this.map.has(key); }
|
||||
clear() { this.map.clear(); }
|
||||
}
|
||||
|
||||
export const overlayCache = new LRUCache<string, any>(500);
|
||||
export const imageDecodeCache = new LRUCache<string, HTMLImageElement>(64);
|
||||
export const tooLargeOverlays = new Set<string>();
|
||||
export function clearOverlayCache() {
|
||||
overlayCache.clear();
|
||||
imageDecodeCache.clear();
|
||||
tooLargeOverlays.clear();
|
||||
}
|
||||
70
src/core/canvas.ts
Normal file
70
src/core/canvas.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
export function createCanvas(w: number, h: number): OffscreenCanvas | HTMLCanvasElement {
|
||||
if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(w, h);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
return c;
|
||||
}
|
||||
|
||||
export function createHTMLCanvas(w: number, h: number): HTMLCanvasElement {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = h;
|
||||
return c;
|
||||
}
|
||||
|
||||
export function canvasToBlob(canvas: OffscreenCanvas | HTMLCanvasElement): Promise<Blob> {
|
||||
if ((canvas as any).convertToBlob) return (canvas as any).convertToBlob();
|
||||
return new Promise((resolve, reject) => (canvas as HTMLCanvasElement).toBlob(b => b ? resolve(b) : reject(new Error("toBlob failed")), "image/png"));
|
||||
}
|
||||
|
||||
export async function canvasToDataURLSafe(canvas: OffscreenCanvas | HTMLCanvasElement): Promise<string> {
|
||||
const anyCanvas = canvas as any;
|
||||
if (canvas && typeof (canvas as HTMLCanvasElement).toDataURL === 'function') {
|
||||
return (canvas as HTMLCanvasElement).toDataURL('image/png');
|
||||
}
|
||||
if (canvas && typeof anyCanvas.convertToBlob === 'function') {
|
||||
const blob = await anyCanvas.convertToBlob();
|
||||
return await blobToDataURL(blob);
|
||||
}
|
||||
if (typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas) {
|
||||
const bmp = (canvas as any).transferToImageBitmap?.();
|
||||
if (bmp) {
|
||||
const html = createHTMLCanvas(canvas.width, canvas.height);
|
||||
const ctx = html.getContext('2d')!;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
return html.toDataURL('image/png');
|
||||
}
|
||||
}
|
||||
throw new Error('Cannot export canvas to data URL');
|
||||
}
|
||||
|
||||
export async function blobToImage(blob: Blob): Promise<ImageBitmap | HTMLImageElement> {
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
try { return await createImageBitmap(blob); } catch {}
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
|
||||
img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function blobToDataURL(blob: Blob) {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result));
|
||||
fr.onerror = reject;
|
||||
fr.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
3
src/core/constants.ts
Normal file
3
src/core/constants.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const TILE_SIZE = 1000;
|
||||
export const MAX_OVERLAY_DIM = 1000;
|
||||
export const MINIFY_SCALE = 3;
|
||||
9
src/core/events.ts
Normal file
9
src/core/events.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const EV_ANCHOR_SET = 'op-anchor-set';
|
||||
export const EV_AUTOCAP_CHANGED = 'op-autocap-changed';
|
||||
|
||||
export function emit(name: string, detail?: any) {
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail }));
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
59
src/core/gm.ts
Normal file
59
src/core/gm.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export const NATIVE_FETCH = window.fetch;
|
||||
|
||||
export const gmGet = (key: string, def: any) => {
|
||||
try {
|
||||
if (typeof GM !== 'undefined' && typeof GM.getValue === 'function') return GM.getValue(key, def);
|
||||
if (typeof GM_getValue === 'function') return Promise.resolve(GM_getValue(key, def));
|
||||
} catch {}
|
||||
return Promise.resolve(def);
|
||||
};
|
||||
|
||||
export const gmSet = (key: string, value: any) => {
|
||||
try {
|
||||
if (typeof GM !== 'undefined' && typeof GM.setValue === 'function') return GM.setValue(key, value);
|
||||
if (typeof GM_setValue === 'function') return Promise.resolve(GM_setValue(key, value));
|
||||
} catch {}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
export function gmFetchBlob(url: string): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
GM_xmlhttpRequest({
|
||||
method: 'GET',
|
||||
url,
|
||||
responseType: 'blob',
|
||||
onload: (res) => {
|
||||
if (res.status >= 200 && res.status < 300 && res.response) resolve(res.response as Blob);
|
||||
else reject(new Error(`GM_xhr failed: ${res.status} ${res.statusText}`));
|
||||
},
|
||||
onerror: () => reject(new Error('GM_xhr network error')),
|
||||
ontimeout: () => reject(new Error('GM_xhr timeout')),
|
||||
});
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
}
|
||||
|
||||
export function blobToDataURL(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result));
|
||||
fr.onerror = reject;
|
||||
fr.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export async function urlToDataURL(url: string) {
|
||||
const blob = await gmFetchBlob(url);
|
||||
if (!blob || !String(blob.type).startsWith('image/')) throw new Error('URL did not return an image blob');
|
||||
return await blobToDataURL(blob);
|
||||
}
|
||||
|
||||
export function fileToDataURL(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result));
|
||||
fr.onerror = reject;
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
112
src/core/hook.ts
Normal file
112
src/core/hook.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/// <reference types="tampermonkey" />
|
||||
import { NATIVE_FETCH } from './gm';
|
||||
import { config, saveConfig } from './store';
|
||||
import { matchTileUrl, matchPixelUrl, extractPixelCoords, buildOverlayDataForChunkUnified, composeTileUnified } from './overlay';
|
||||
import { emit, EV_ANCHOR_SET, EV_AUTOCAP_CHANGED } from './events';
|
||||
|
||||
let hookInstalled = false;
|
||||
let updateUICallback: null | (() => void) = null;
|
||||
const page: any = unsafeWindow;
|
||||
|
||||
export function setUpdateUI(cb: () => void) {
|
||||
updateUICallback = cb;
|
||||
}
|
||||
|
||||
export function overlaysNeedingHook() {
|
||||
const hasImage = config.overlays.some(o => o.enabled && o.imageBase64);
|
||||
const placing = !!config.autoCapturePixelUrl && !!config.activeOverlayId;
|
||||
const needsHookMode = (config.overlayMode === 'behind' || config.overlayMode === 'above' || config.overlayMode === 'minify');
|
||||
return needsHookMode && (hasImage || placing) && config.overlays.length > 0;
|
||||
}
|
||||
|
||||
export function ensureHook() { if (overlaysNeedingHook()) attachHook(); else detachHook(); }
|
||||
|
||||
export function attachHook() {
|
||||
if (hookInstalled) return;
|
||||
const originalFetch = NATIVE_FETCH;
|
||||
|
||||
const hookedFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const urlStr = typeof input === 'string' ? input : ((input as Request).url) || '';
|
||||
|
||||
// Anchor auto-capture: watch pixel endpoint, then store/normalize
|
||||
if (config.autoCapturePixelUrl && config.activeOverlayId) {
|
||||
const pixelMatch = matchPixelUrl(urlStr);
|
||||
if (pixelMatch) {
|
||||
const ov = config.overlays.find(o => o.id === config.activeOverlayId);
|
||||
if (ov) {
|
||||
const changed = (ov.pixelUrl !== pixelMatch.normalized);
|
||||
if (changed) {
|
||||
ov.pixelUrl = pixelMatch.normalized;
|
||||
ov.offsetX = 0; ov.offsetY = 0;
|
||||
await saveConfig(['overlays']);
|
||||
|
||||
// turn off autocapture and notify UI (via events)
|
||||
config.autoCapturePixelUrl = false;
|
||||
await saveConfig(['autoCapturePixelUrl']);
|
||||
|
||||
// keep legacy callback for any existing wiring
|
||||
updateUICallback?.();
|
||||
|
||||
const c = extractPixelCoords(ov.pixelUrl);
|
||||
emit(EV_ANCHOR_SET, { overlayId: ov.id, name: ov.name, chunk1: c.chunk1, chunk2: c.chunk2, posX: c.posX, posY: c.posY });
|
||||
emit(EV_AUTOCAP_CHANGED, { enabled: false });
|
||||
|
||||
ensureHook(); // reevaluate whether hook is still needed after capture
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay modes: rewrite tile images
|
||||
const tileMatch = matchTileUrl(urlStr);
|
||||
const validModes = ['behind', 'above', 'minify'];
|
||||
if (!tileMatch || !validModes.includes(config.overlayMode)) {
|
||||
return originalFetch(input as any, init as any);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await originalFetch(input as any, init as any);
|
||||
if (!response.ok) return response;
|
||||
|
||||
const ct = (response.headers.get('Content-Type') || '').toLowerCase();
|
||||
if (!ct.includes('image')) return response;
|
||||
|
||||
const enabledOverlays = config.overlays.filter(o => o.enabled && o.imageBase64 && o.pixelUrl);
|
||||
if (enabledOverlays.length === 0) return response;
|
||||
|
||||
const originalBlob = await response.blob();
|
||||
if (originalBlob.size > 15 * 1024 * 1024) return response;
|
||||
|
||||
const mode = config.overlayMode as 'behind'|'above'|'minify';
|
||||
const overlayDatas = [];
|
||||
for (const ov of enabledOverlays) {
|
||||
overlayDatas.push(await buildOverlayDataForChunkUnified(ov, tileMatch.chunk1, tileMatch.chunk2, mode));
|
||||
}
|
||||
|
||||
const finalBlob = await composeTileUnified(originalBlob, overlayDatas.filter(Boolean) as any[], mode);
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('Content-Type', 'image/png');
|
||||
headers.delete('Content-Length');
|
||||
|
||||
return new Response(finalBlob, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Overlay Pro: Error processing tile", e);
|
||||
return originalFetch(input as any, init as any);
|
||||
}
|
||||
};
|
||||
|
||||
page.fetch = hookedFetch;
|
||||
window.fetch = hookedFetch as any;
|
||||
hookInstalled = true;
|
||||
}
|
||||
|
||||
export function detachHook() {
|
||||
if (!hookInstalled) return;
|
||||
page.fetch = NATIVE_FETCH;
|
||||
window.fetch = NATIVE_FETCH as any;
|
||||
hookInstalled = false;
|
||||
}
|
||||
233
src/core/overlay.ts
Normal file
233
src/core/overlay.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { createCanvas, createHTMLCanvas, canvasToBlob, blobToImage, loadImage } from './canvas';
|
||||
import { MINIFY_SCALE, TILE_SIZE, MAX_OVERLAY_DIM } from './constants';
|
||||
import { imageDecodeCache, overlayCache, tooLargeOverlays } from './cache';
|
||||
import { showToast } from './toast';
|
||||
|
||||
export function extractPixelCoords(pixelUrl: string) {
|
||||
try {
|
||||
const u = new URL(pixelUrl);
|
||||
const parts = u.pathname.split('/');
|
||||
const sp = new URLSearchParams(u.search);
|
||||
return {
|
||||
chunk1: parseInt(parts[3], 10),
|
||||
chunk2: parseInt(parts[4], 10),
|
||||
posX: parseInt(sp.get('x') || '0', 10),
|
||||
posY: parseInt(sp.get('y') || '0', 10)
|
||||
};
|
||||
} catch {
|
||||
return { chunk1: 0, chunk2: 0, posX: 0, posY: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export function matchTileUrl(urlStr: string) {
|
||||
try {
|
||||
const u = new URL(urlStr, location.href);
|
||||
if (u.hostname !== 'backend.wplace.live' || !u.pathname.startsWith('/files/')) return null;
|
||||
const m = u.pathname.match(/\/(\d+)\/(\d+)\.png$/i);
|
||||
if (!m) return null;
|
||||
return { chunk1: parseInt(m[1], 10), chunk2: parseInt(m[2], 10) };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function matchPixelUrl(urlStr: string) {
|
||||
try {
|
||||
const u = new URL(urlStr, location.href);
|
||||
if (u.hostname !== 'backend.wplace.live') return null;
|
||||
const m = u.pathname.match(/\/s0\/pixel\/(\d+)\/(\d+)$/);
|
||||
if (!m) return null;
|
||||
const sp = u.searchParams;
|
||||
return { normalized: `https://backend.wplace.live/s0/pixel/${m[1]}/${m[2]}?x=${sp.get('x')||0}&y=${sp.get('y')||0}` };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function rectIntersect(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number) {
|
||||
const x = Math.max(ax, bx), y = Math.max(ay, by);
|
||||
const r = Math.min(ax + aw, bx + bw), b = Math.min(ay + ah, by + bh);
|
||||
const w = Math.max(0, r - x), h = Math.max(0, b - y);
|
||||
return { x, y, w, h };
|
||||
}
|
||||
|
||||
export async function decodeOverlayImage(imageBase64: string | null) {
|
||||
if (!imageBase64) return null;
|
||||
const key = imageBase64;
|
||||
const cached = imageDecodeCache.get(key);
|
||||
if (cached) return cached;
|
||||
const img = await loadImage(imageBase64);
|
||||
imageDecodeCache.set(key, img);
|
||||
return img;
|
||||
}
|
||||
|
||||
export function overlaySignature(ov: {
|
||||
imageBase64: string | null,
|
||||
pixelUrl: string | null,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
opacity: number,
|
||||
}) {
|
||||
const imgKey = ov.imageBase64 ? ov.imageBase64.slice(0, 64) + ':' + ov.imageBase64.length : 'none';
|
||||
return [imgKey, ov.pixelUrl || 'null', ov.offsetX, ov.offsetY, ov.opacity].join('|');
|
||||
}
|
||||
|
||||
export async function buildOverlayDataForChunkUnified(
|
||||
ov: {
|
||||
id: string, name: string, enabled: boolean,
|
||||
imageBase64: string | null, pixelUrl: string | null,
|
||||
offsetX: number, offsetY: number, opacity: number
|
||||
},
|
||||
targetChunk1: number,
|
||||
targetChunk2: number,
|
||||
mode: 'behind' | 'above' | 'minify'
|
||||
) {
|
||||
if (!ov?.enabled || !ov.imageBase64 || !ov.pixelUrl) return null;
|
||||
if (tooLargeOverlays.has(ov.id)) return null;
|
||||
|
||||
const img = await decodeOverlayImage(ov.imageBase64);
|
||||
if (!img) return null;
|
||||
|
||||
const wImg = img.width, hImg = img.height;
|
||||
if (wImg >= MAX_OVERLAY_DIM || hImg >= MAX_OVERLAY_DIM) {
|
||||
tooLargeOverlays.add(ov.id);
|
||||
showToast(`Overlay "${ov.name}" skipped: image too large (must be smaller than ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM}; got ${wImg}×${hImg}).`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const base = extractPixelCoords(ov.pixelUrl);
|
||||
if (!Number.isFinite(base.chunk1) || !Number.isFinite(base.chunk2)) return null;
|
||||
|
||||
const drawX = (base.chunk1 * TILE_SIZE + base.posX + ov.offsetX) - (targetChunk1 * TILE_SIZE);
|
||||
const drawY = (base.chunk2 * TILE_SIZE + base.posY + ov.offsetY) - (targetChunk2 * TILE_SIZE);
|
||||
|
||||
const sig = overlaySignature(ov);
|
||||
const cacheKey = `ov:${ov.id}|sig:${sig}|tile:${targetChunk1},${targetChunk2}|mode:${mode}`;
|
||||
const cached = overlayCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const colorStrength = ov.opacity;
|
||||
const whiteStrength = 1 - colorStrength;
|
||||
|
||||
if (mode !== 'minify') {
|
||||
const isect = rectIntersect(0, 0, TILE_SIZE, TILE_SIZE, drawX, drawY, wImg, hImg);
|
||||
if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; }
|
||||
|
||||
const canvas = createCanvas(TILE_SIZE, TILE_SIZE) as any;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
ctx.drawImage(img as any, drawX, drawY);
|
||||
|
||||
const imageData = ctx.getImageData(isect.x, isect.y, isect.w, isect.h);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] > 0) {
|
||||
data[i] = Math.round(data[i] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 1] = Math.round(data[i + 1] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 2] = Math.round(data[i + 2] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
const result = { imageData, dx: isect.x, dy: isect.y, scaled: false };
|
||||
overlayCache.set(cacheKey, result);
|
||||
return result;
|
||||
} else {
|
||||
const scale = MINIFY_SCALE;
|
||||
const tileW = TILE_SIZE * scale;
|
||||
const tileH = TILE_SIZE * scale;
|
||||
const drawXScaled = Math.round(drawX * scale);
|
||||
const drawYScaled = Math.round(drawY * scale);
|
||||
const wScaled = wImg * scale;
|
||||
const hScaled = hImg * scale;
|
||||
|
||||
const isect = rectIntersect(0, 0, tileW, tileH, drawXScaled, drawYScaled, wScaled, hScaled);
|
||||
if (isect.w === 0 || isect.h === 0) { overlayCache.set(cacheKey, null); return null; }
|
||||
|
||||
const canvas = createCanvas(tileW, tileH) as any;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, tileW, tileH);
|
||||
ctx.drawImage(img as any, 0, 0, wImg, hImg, drawXScaled, drawYScaled, wScaled, hScaled);
|
||||
|
||||
const imageData = ctx.getImageData(isect.x, isect.y, isect.w, isect.h);
|
||||
const data = imageData.data;
|
||||
const center = Math.floor(scale / 2);
|
||||
const width = isect.w;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a === 0) continue;
|
||||
|
||||
const px = (i / 4) % width;
|
||||
const py = Math.floor((i / 4) / width);
|
||||
const absX = isect.x + px;
|
||||
const absY = isect.y + py;
|
||||
|
||||
if ((absX % scale) === center && (absY % scale) === center) {
|
||||
data[i] = Math.round(data[i] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 1] = Math.round(data[i + 1] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 2] = Math.round(data[i + 2] * colorStrength + 255 * whiteStrength);
|
||||
data[i + 3] = 255;
|
||||
} else {
|
||||
data[i] = 0; data[i + 1] = 0; data[i + 2] = 0; data[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const result = { imageData, dx: isect.x, dy: isect.y, scaled: true, scale };
|
||||
overlayCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export async function composeTileUnified(
|
||||
originalBlob: Blob,
|
||||
overlayDatas: Array<{ imageData: ImageData, dx: number, dy: number, scaled?: boolean } | null>,
|
||||
mode: 'behind' | 'above' | 'minify'
|
||||
) {
|
||||
if (!overlayDatas || overlayDatas.length === 0) return originalBlob;
|
||||
const originalImage = await blobToImage(originalBlob) as any;
|
||||
|
||||
if (mode === 'minify') {
|
||||
const scale = MINIFY_SCALE;
|
||||
const w = originalImage.width, h = originalImage.height;
|
||||
const canvas = createCanvas(w * scale, h * scale) as any;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(originalImage, 0, 0, w * scale, h * scale);
|
||||
|
||||
for (const ovd of overlayDatas) {
|
||||
if (!ovd) continue;
|
||||
const tw = ovd.imageData.width;
|
||||
const th = ovd.imageData.height;
|
||||
if (!tw || !th) continue;
|
||||
const temp = createCanvas(tw, th) as any;
|
||||
const tctx = temp.getContext('2d', { willReadFrequently: true })!;
|
||||
tctx.putImageData(ovd.imageData, 0, 0);
|
||||
ctx.drawImage(temp, ovd.dx, ovd.dy);
|
||||
}
|
||||
return await canvasToBlob(canvas);
|
||||
}
|
||||
|
||||
const w = originalImage.width, h = originalImage.height;
|
||||
const canvas = createCanvas(w, h) as any;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
|
||||
if (mode === 'behind') {
|
||||
for (const ovd of overlayDatas) {
|
||||
if (!ovd) continue;
|
||||
const temp = createCanvas(ovd.imageData.width, ovd.imageData.height) as any;
|
||||
const tctx = temp.getContext('2d', { willReadFrequently: true })!;
|
||||
tctx.putImageData(ovd.imageData, 0, 0);
|
||||
ctx.drawImage(temp, ovd.dx, ovd.dy);
|
||||
}
|
||||
ctx.drawImage(originalImage, 0, 0);
|
||||
return await canvasToBlob(canvas);
|
||||
} else {
|
||||
ctx.drawImage(originalImage, 0, 0);
|
||||
for (const ovd of overlayDatas) {
|
||||
if (!ovd) continue;
|
||||
const temp = createCanvas(ovd.imageData.width, ovd.imageData.height) as any;
|
||||
const tctx = temp.getContext('2d', { willReadFrequently: true })!;
|
||||
tctx.putImageData(ovd.imageData, 0, 0);
|
||||
ctx.drawImage(temp, ovd.dx, ovd.dy);
|
||||
}
|
||||
return await canvasToBlob(canvas);
|
||||
}
|
||||
}
|
||||
47
src/core/palette.ts
Normal file
47
src/core/palette.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export const WPLACE_FREE: number[][] = [
|
||||
[0,0,0],[60,60,60],[120,120,120],[210,210,210],[255,255,255],
|
||||
[96,0,24],[237,28,36],[255,127,39],[246,170,9],[249,221,59],[255,250,188],
|
||||
[14,185,104],[19,230,123],[135,255,94],
|
||||
[12,129,110],[16,174,166],[19,225,190],[96,247,242],
|
||||
[40,80,158],[64,147,228],
|
||||
[107,80,246],[153,177,251],
|
||||
[120,12,153],[170,56,185],[224,159,249],
|
||||
[203,0,122],[236,31,128],[243,141,169],
|
||||
[104,70,52],[149,104,42],[248,178,119],
|
||||
];
|
||||
export const WPLACE_PAID: number[][] = [
|
||||
[170,170,170],
|
||||
[165,14,30],[250,128,114],
|
||||
[228,92,26],[156,132,49],[197,173,49],[232,212,95],
|
||||
[74,107,58],[90,148,74],[132,197,115],
|
||||
[15,121,159],[187,250,242],[125,199,255],
|
||||
[77,49,184],[74,66,132],[122,113,196],[181,174,241],
|
||||
[155,82,73],[209,128,120],[250,182,164],
|
||||
[219,164,99],[123,99,82],[156,132,107],[214,181,148],
|
||||
[209,128,81],[255,197,165],
|
||||
[109,100,63],[148,140,107],[205,197,158],
|
||||
[51,57,65],[109,117,141],[179,185,209],
|
||||
];
|
||||
export const WPLACE_NAMES: Record<string,string> = {
|
||||
"0,0,0":"Black","60,60,60":"Dark Gray","120,120,120":"Gray","210,210,210":"Light Gray","255,255,255":"White",
|
||||
"96,0,24":"Deep Red","237,28,36":"Red","255,127,39":"Orange","246,170,9":"Gold","249,221,59":"Yellow","255,250,188":"Light Yellow",
|
||||
"14,185,104":"Dark Green","19,230,123":"Green","135,255,94":"Light Green",
|
||||
"12,129,110":"Dark Teal","16,174,166":"Teal","19,225,190":"Light Teal","96,247,242":"Cyan",
|
||||
"40,80,158":"Dark Blue","64,147,228":"Blue",
|
||||
"107,80,246":"Indigo","153,177,251":"Light Indigo",
|
||||
"120,12,153":"Dark Purple","170,56,185":"Purple","224,159,249":"Light Purple",
|
||||
"203,0,122":"Dark Pink","236,31,128":"Pink","243,141,169":"Light Pink",
|
||||
"104,70,52":"Dark Brown","149,104,42":"Brown","248,178,119":"Beige",
|
||||
"170,170,170":"Medium Gray","165,14,30":"Dark Red","250,128,114":"Light Red",
|
||||
"228,92,26":"Dark Orange","156,132,49":"Dark Goldenrod","197,173,49":"Goldenrod","232,212,95":"Light Goldenrod",
|
||||
"74,107,58":"Dark Olive","90,148,74":"Olive","132,197,115":"Light Olive",
|
||||
"15,121,159":"Dark Cyan","187,250,242":"Light Cyan","125,199,255":"Light Blue",
|
||||
"77,49,184":"Dark Indigo","74,66,132":"Dark Slate Blue","122,113,196":"Slate Blue","181,174,241":"Light Slate Blue",
|
||||
"155,82,73":"Dark Peach","209,128,120":"Peach","250,182,164":"Light Peach",
|
||||
"219,164,99":"Light Brown","123,99,82":"Dark Tan","156,132,107":"Tan","214,181,148":"Light Tan",
|
||||
"209,128,81":"Dark Beige","255,197,165":"Light Beige",
|
||||
"109,100,63":"Dark Stone","148,140,107":"Stone","205,197,158":"Light Stone",
|
||||
"51,57,65":"Dark Slate","109,117,141":"Slate","179,185,209":"Light Slate"
|
||||
};
|
||||
export const DEFAULT_FREE_KEYS = WPLACE_FREE.map(([r,g,b]) => `${r},${g},${b}`);
|
||||
export const DEFAULT_PAID_KEYS: string[] = [];
|
||||
87
src/core/store.ts
Normal file
87
src/core/store.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/// <reference types="tampermonkey" />
|
||||
import { gmGet, gmSet } from './gm';
|
||||
import { DEFAULT_FREE_KEYS, DEFAULT_PAID_KEYS } from './palette';
|
||||
|
||||
export type OverlayItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
imageUrl: string | null;
|
||||
imageBase64: string | null;
|
||||
isLocal: boolean;
|
||||
pixelUrl: string | null;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
overlays: OverlayItem[];
|
||||
activeOverlayId: string | null;
|
||||
overlayMode: 'behind' | 'above' | 'minify' | 'original';
|
||||
isPanelCollapsed: boolean;
|
||||
autoCapturePixelUrl: boolean;
|
||||
panelX: number | null;
|
||||
panelY: number | null;
|
||||
theme: 'light' | 'dark';
|
||||
collapseList: boolean;
|
||||
collapseEditor: boolean;
|
||||
collapseNudge: boolean;
|
||||
ccFreeKeys: string[];
|
||||
ccPaidKeys: string[];
|
||||
ccZoom: number;
|
||||
ccRealtime: boolean;
|
||||
};
|
||||
|
||||
export const config: Config = {
|
||||
overlays: [],
|
||||
activeOverlayId: null,
|
||||
overlayMode: 'behind',
|
||||
isPanelCollapsed: false,
|
||||
autoCapturePixelUrl: false,
|
||||
panelX: null,
|
||||
panelY: null,
|
||||
theme: 'light',
|
||||
collapseList: false,
|
||||
collapseEditor: false,
|
||||
collapseNudge: false,
|
||||
ccFreeKeys: DEFAULT_FREE_KEYS.slice(),
|
||||
ccPaidKeys: DEFAULT_PAID_KEYS.slice(),
|
||||
ccZoom: 1.0,
|
||||
ccRealtime: false,
|
||||
};
|
||||
|
||||
export const CONFIG_KEYS = Object.keys(config) as (keyof Config)[];
|
||||
|
||||
export async function loadConfig() {
|
||||
try {
|
||||
await Promise.all(CONFIG_KEYS.map(async k => {
|
||||
(config as any)[k] = await gmGet(k as string, (config as any)[k]);
|
||||
}));
|
||||
if (!Array.isArray(config.ccFreeKeys) || config.ccFreeKeys.length === 0) config.ccFreeKeys = DEFAULT_FREE_KEYS.slice();
|
||||
if (!Array.isArray(config.ccPaidKeys)) config.ccPaidKeys = DEFAULT_PAID_KEYS.slice();
|
||||
if (!Number.isFinite(config.ccZoom) || config.ccZoom <= 0) config.ccZoom = 1.0;
|
||||
if (typeof config.ccRealtime !== 'boolean') config.ccRealtime = false;
|
||||
} catch (e) {
|
||||
console.error('Overlay Pro: Failed to load config', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(keys: (keyof Config)[] = CONFIG_KEYS) {
|
||||
try {
|
||||
await Promise.all(keys.map(k => gmSet(k as string, (config as any)[k])));
|
||||
} catch (e) {
|
||||
console.error('Overlay Pro: Failed to save config', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveOverlay(): OverlayItem | null {
|
||||
return config.overlays.find(o => o.id === config.activeOverlayId) || null;
|
||||
}
|
||||
|
||||
export function applyTheme() {
|
||||
document.body.classList.toggle('op-theme-dark', config.theme === 'dark');
|
||||
document.body.classList.toggle('op-theme-light', config.theme !== 'dark');
|
||||
const stack = document.getElementById('op-toast-stack');
|
||||
if (stack) stack.classList.toggle('op-dark', config.theme === 'dark');
|
||||
}
|
||||
22
src/core/toast.ts
Normal file
22
src/core/toast.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { config } from './store';
|
||||
|
||||
export function showToast(message: string, duration = 3000) {
|
||||
let stack = document.getElementById('op-toast-stack');
|
||||
if (!stack) {
|
||||
stack = document.createElement('div');
|
||||
stack.className = 'op-toast-stack';
|
||||
stack.id = 'op-toast-stack';
|
||||
document.body.appendChild(stack);
|
||||
}
|
||||
stack.classList.toggle('op-dark', config.theme === 'dark');
|
||||
|
||||
const t = document.createElement('div');
|
||||
t.className = 'op-toast';
|
||||
t.textContent = message;
|
||||
stack.appendChild(t);
|
||||
requestAnimationFrame(() => t.classList.add('show'));
|
||||
setTimeout(() => {
|
||||
t.classList.remove('show');
|
||||
setTimeout(() => t.remove(), 200);
|
||||
}, duration);
|
||||
}
|
||||
11
src/core/util.ts
Normal file
11
src/core/util.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export function uid() {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`;
|
||||
}
|
||||
|
||||
export function uniqueName(base: string, existing: string[]) {
|
||||
const names = new Set(existing.map(n => (n || '').toLowerCase()));
|
||||
if (!names.has(base.toLowerCase())) return base;
|
||||
let i = 1;
|
||||
while (names.has(`${base} (${i})`.toLowerCase())) i++;
|
||||
return `${base} (${i})`;
|
||||
}
|
||||
10
src/main.ts
Normal file
10
src/main.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/// <reference types="tampermonkey" />
|
||||
// @ts-nocheck
|
||||
|
||||
import { bootstrapApp } from './app';
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
bootstrapApp().catch(e => console.error('Overlay Pro bootstrap failed', e));
|
||||
})();
|
||||
export {};
|
||||
19
src/meta.js
Normal file
19
src/meta.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// ==UserScript==
|
||||
// @name Wplace Overlay Pro
|
||||
// @namespace http://netcavy.net/
|
||||
// @version 3.0.0
|
||||
// @description Overlays tiles on wplace.live. Can also resize, and color-match your overlay to wplace's palette. Make sure to comply with the site's Terms of Service, and rules! This script is not affiliated with Wplace.live in any way, use at your own risk. This script is not affiliated with TamperMonkey. The author of this userscript is not responsible for any damages, issues, loss of data, or punishment that may occur as a result of using this script. This script is provided "as is" under GPLv3.
|
||||
// @author shinkonet
|
||||
// @match https://wplace.live/*
|
||||
// @license GPLv3
|
||||
// @grant GM_setValue
|
||||
// @grant GM_getValue
|
||||
// @grant GM.setValue
|
||||
// @grant GM.getValue
|
||||
// @grant GM_xmlhttpRequest
|
||||
// @grant unsafeWindow
|
||||
// @connect *
|
||||
// @run-at document-start
|
||||
// @downloadURL https://update.greasyfork.org/scripts/545041/Wplace%20Overlay%20Pro.user.js
|
||||
// @updateURL https://update.greasyfork.org/scripts/545041/Wplace%20Overlay%20Pro.meta.js
|
||||
// ==/UserScript==
|
||||
402
src/ui/ccModal.ts
Normal file
402
src/ui/ccModal.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
/// <reference types="tampermonkey" />
|
||||
import { WPLACE_FREE, WPLACE_PAID, WPLACE_NAMES, DEFAULT_FREE_KEYS } from '../core/palette';
|
||||
import { createCanvas } from '../core/canvas';
|
||||
import { config, saveConfig } from '../core/store';
|
||||
import { MAX_OVERLAY_DIM } from '../core/constants';
|
||||
import { ensureHook } from '../core/hook';
|
||||
import { clearOverlayCache } from '../core/cache';
|
||||
import { showToast } from '../core/toast';
|
||||
|
||||
// dispatch when an overlay image is updated
|
||||
function emitOverlayChanged() {
|
||||
document.dispatchEvent(new CustomEvent('op-overlay-changed'));
|
||||
}
|
||||
|
||||
type CCState = {
|
||||
backdrop: HTMLDivElement;
|
||||
modal: HTMLDivElement;
|
||||
previewCanvas: HTMLCanvasElement;
|
||||
previewCtx: CanvasRenderingContext2D;
|
||||
sourceCanvas: HTMLCanvasElement | null;
|
||||
sourceCtx: CanvasRenderingContext2D | null;
|
||||
sourceImageData: ImageData | null;
|
||||
processedCanvas: HTMLCanvasElement | null;
|
||||
processedCtx: CanvasRenderingContext2D | null;
|
||||
|
||||
freeGrid: HTMLDivElement;
|
||||
paidGrid: HTMLDivElement;
|
||||
freeToggle: HTMLButtonElement;
|
||||
paidToggle: HTMLButtonElement;
|
||||
|
||||
meta: HTMLElement;
|
||||
applyBtn: HTMLButtonElement;
|
||||
recalcBtn: HTMLButtonElement;
|
||||
realtimeBtn: HTMLButtonElement;
|
||||
|
||||
zoom: number;
|
||||
selectedFree: Set<string>;
|
||||
selectedPaid: Set<string>;
|
||||
realtime: boolean;
|
||||
|
||||
overlay: any | null;
|
||||
lastColorCounts: Record<string, number>;
|
||||
isStale: boolean;
|
||||
};
|
||||
|
||||
let cc: CCState | null = null;
|
||||
|
||||
export function buildCCModal() {
|
||||
if (document.getElementById('op-cc-modal')) return;
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'op-cc-backdrop';
|
||||
backdrop.id = 'op-cc-backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'op-cc-modal';
|
||||
modal.id = 'op-cc-modal';
|
||||
modal.style.display = 'none';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="op-cc-header" id="op-cc-header">
|
||||
<div class="op-cc-title">Color Match</div>
|
||||
<div class="op-row" style="gap:6px;">
|
||||
<button class="op-button op-cc-pill" id="op-cc-realtime">Realtime: OFF</button>
|
||||
<button class="op-cc-close" id="op-cc-close" title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-cc-body">
|
||||
<div class="op-cc-preview-wrap" style="grid-area: preview;">
|
||||
<canvas id="op-cc-preview" class="op-cc-canvas"></canvas>
|
||||
<div class="op-cc-zoom">
|
||||
<button class="op-icon-btn" id="op-cc-zoom-out" title="Zoom out">−</button>
|
||||
<button class="op-icon-btn" id="op-cc-zoom-in" title="Zoom in">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-cc-controls" style="grid-area: controls;">
|
||||
<div class="op-cc-palette" id="op-cc-free">
|
||||
<div class="op-row space">
|
||||
<label>Free Colors</label>
|
||||
<button class="op-button" id="op-cc-free-toggle">Unselect All</button>
|
||||
</div>
|
||||
<div id="op-cc-free-grid" class="op-cc-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="op-cc-palette" id="op-cc-paid">
|
||||
<div class="op-row space">
|
||||
<label>Paid Colors (2000💧each)</label>
|
||||
<button class="op-button" id="op-cc-paid-toggle">Select All</button>
|
||||
</div>
|
||||
<div id="op-cc-paid-grid" class="op-cc-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-cc-footer">
|
||||
<div class="op-cc-ghost" id="op-cc-meta"></div>
|
||||
<div class="op-cc-actions">
|
||||
<button class="op-button" id="op-cc-recalc" title="Recalculate color mapping">Calculate</button>
|
||||
<button class="op-button" id="op-cc-apply" title="Apply changes to overlay">Apply</button>
|
||||
<button class="op-button" id="op-cc-cancel" title="Close without saving">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const previewCanvas = modal.querySelector('#op-cc-preview') as HTMLCanvasElement;
|
||||
const previewCtx = previewCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||
|
||||
cc = {
|
||||
backdrop,
|
||||
modal,
|
||||
previewCanvas,
|
||||
previewCtx,
|
||||
|
||||
sourceCanvas: null,
|
||||
sourceCtx: null,
|
||||
sourceImageData: null,
|
||||
|
||||
processedCanvas: null,
|
||||
processedCtx: null,
|
||||
|
||||
freeGrid: modal.querySelector('#op-cc-free-grid') as HTMLDivElement,
|
||||
paidGrid: modal.querySelector('#op-cc-paid-grid') as HTMLDivElement,
|
||||
freeToggle: modal.querySelector('#op-cc-free-toggle') as HTMLButtonElement,
|
||||
paidToggle: modal.querySelector('#op-cc-paid-toggle') as HTMLButtonElement,
|
||||
|
||||
meta: modal.querySelector('#op-cc-meta') as HTMLElement,
|
||||
applyBtn: modal.querySelector('#op-cc-apply') as HTMLButtonElement,
|
||||
recalcBtn: modal.querySelector('#op-cc-recalc') as HTMLButtonElement,
|
||||
realtimeBtn: modal.querySelector('#op-cc-realtime') as HTMLButtonElement,
|
||||
|
||||
zoom: 1.0,
|
||||
selectedFree: new Set(config.ccFreeKeys),
|
||||
selectedPaid: new Set(config.ccPaidKeys),
|
||||
realtime: !!config.ccRealtime,
|
||||
|
||||
overlay: null,
|
||||
lastColorCounts: {},
|
||||
isStale: false
|
||||
};
|
||||
|
||||
modal.querySelector('#op-cc-close')!.addEventListener('click', closeCCModal);
|
||||
backdrop.addEventListener('click', closeCCModal);
|
||||
modal.querySelector('#op-cc-cancel')!.addEventListener('click', closeCCModal);
|
||||
|
||||
const zoomIn = async () => {
|
||||
cc!.zoom = Math.min(8, (cc!.zoom || 1) * 1.25);
|
||||
config.ccZoom = cc!.zoom; await saveConfig(['ccZoom']);
|
||||
applyPreview(); updateMeta();
|
||||
};
|
||||
const zoomOut = async () => {
|
||||
cc!.zoom = Math.max(0.1, (cc!.zoom || 1) / 1.25);
|
||||
config.ccZoom = cc!.zoom; await saveConfig(['ccZoom']);
|
||||
applyPreview(); updateMeta();
|
||||
};
|
||||
modal.querySelector('#op-cc-zoom-in')!.addEventListener('click', zoomIn);
|
||||
modal.querySelector('#op-cc-zoom-out')!.addEventListener('click', zoomOut);
|
||||
|
||||
cc.realtimeBtn.addEventListener('click', async () => {
|
||||
cc!.realtime = !cc!.realtime;
|
||||
cc!.realtimeBtn.textContent = `Realtime: ${cc!.realtime ? 'ON' : 'OFF'}`;
|
||||
cc!.realtimeBtn.classList.toggle('op-danger', cc!.realtime);
|
||||
config.ccRealtime = cc!.realtime; await saveConfig(['ccRealtime']);
|
||||
if (cc!.realtime && cc!.isStale) recalcNow();
|
||||
});
|
||||
|
||||
cc.recalcBtn.addEventListener('click', () => { recalcNow(); });
|
||||
|
||||
cc.applyBtn.addEventListener('click', async () => {
|
||||
const ov = cc!.overlay; if (!ov || !cc!.processedCanvas) return;
|
||||
if (cc!.processedCanvas.width >= MAX_OVERLAY_DIM || cc!.processedCanvas.height >= MAX_OVERLAY_DIM) {
|
||||
showToast(`Image too large to apply (must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM}).`);
|
||||
return;
|
||||
}
|
||||
const dataUrl = cc!.processedCanvas.toDataURL('image/png');
|
||||
ov.imageBase64 = dataUrl; ov.imageUrl = null; ov.isLocal = true;
|
||||
await saveConfig(['overlays']); clearOverlayCache(); ensureHook();
|
||||
emitOverlayChanged();
|
||||
const uniqueColors = Object.keys(cc!.lastColorCounts).length;
|
||||
showToast(`Overlay updated (${cc!.processedCanvas.width}×${cc!.processedCanvas.height}, ${uniqueColors} colors).`);
|
||||
closeCCModal();
|
||||
});
|
||||
|
||||
renderPaletteGrid();
|
||||
}
|
||||
|
||||
export function openCCModal(overlay: any) {
|
||||
if (!cc) return;
|
||||
cc.overlay = overlay;
|
||||
|
||||
document.body.classList.add('op-scroll-lock');
|
||||
|
||||
cc.zoom = Number(config.ccZoom) || 1.0;
|
||||
cc.realtime = !!config.ccRealtime;
|
||||
cc.realtimeBtn.textContent = `Realtime: ${cc.realtime ? 'ON' : 'OFF'}`;
|
||||
cc.realtimeBtn.classList.toggle('op-danger', cc.realtime);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!cc!.sourceCanvas) { cc!.sourceCanvas = document.createElement('canvas'); cc!.sourceCtx = cc!.sourceCanvas.getContext('2d', { willReadFrequently: true })!; }
|
||||
cc!.sourceCanvas.width = img.width; cc!.sourceCanvas.height = img.height;
|
||||
cc!.sourceCtx!.clearRect(0,0,img.width,img.height);
|
||||
cc!.sourceCtx!.drawImage(img, 0, 0);
|
||||
|
||||
cc!.sourceImageData = cc!.sourceCtx!.getImageData(0,0,img.width,img.height);
|
||||
|
||||
if (!cc!.processedCanvas) { cc!.processedCanvas = document.createElement('canvas'); cc!.processedCtx = cc!.processedCanvas.getContext('2d')!; }
|
||||
|
||||
processImage();
|
||||
cc!.isStale = false;
|
||||
applyPreview();
|
||||
updateMeta();
|
||||
|
||||
cc!.backdrop.classList.add('show');
|
||||
cc!.modal.style.display = 'flex';
|
||||
};
|
||||
img.src = overlay.imageBase64;
|
||||
}
|
||||
|
||||
function closeCCModal() {
|
||||
if (!cc) return;
|
||||
cc.backdrop.classList.remove('show');
|
||||
cc.modal.style.display = 'none';
|
||||
cc.overlay = null;
|
||||
document.body.classList.remove('op-scroll-lock');
|
||||
}
|
||||
|
||||
function weightedNearest(r: number, g: number, b: number, palette: number[][]) {
|
||||
let best: number[] | null = null, bestDist = Infinity;
|
||||
for (let i = 0; i < palette.length; i++) {
|
||||
const [pr, pg, pb] = palette[i];
|
||||
const rmean = (pr + r) / 2;
|
||||
const rdiff = pr - r;
|
||||
const gdiff = pg - g;
|
||||
const bdiff = pb - b;
|
||||
const x = (512 + rmean) * rdiff * rdiff >> 8;
|
||||
const y = 4 * gdiff * gdiff;
|
||||
const z = (767 - rmean) * bdiff * bdiff >> 8;
|
||||
const dist = Math.sqrt(x + y + z);
|
||||
if (dist < bestDist) { bestDist = dist; best = [pr, pg, pb]; }
|
||||
}
|
||||
return best || [0,0,0];
|
||||
}
|
||||
|
||||
function getActivePalette(): number[][] {
|
||||
if (!cc) return [];
|
||||
const arr: number[][] = [];
|
||||
cc.selectedFree.forEach(k => { const [r,g,b] = k.split(',').map(n => parseInt(n,10)); if (Number.isFinite(r)) arr.push([r,g,b]); });
|
||||
cc.selectedPaid.forEach(k => { const [r,g,b] = k.split(',').map(n => parseInt(n,10)); if (Number.isFinite(r)) arr.push([r,g,b]); });
|
||||
return arr;
|
||||
}
|
||||
|
||||
function processImage() {
|
||||
if (!cc || !cc.sourceImageData) return;
|
||||
const w = cc.sourceImageData.width, h = cc.sourceImageData.height;
|
||||
|
||||
const src = cc.sourceImageData.data;
|
||||
const out = new Uint8ClampedArray(src.length);
|
||||
|
||||
const palette = getActivePalette();
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < src.length; i += 4) {
|
||||
const r = src[i], g = src[i+1], b = src[i+2], a = src[i+3];
|
||||
if (a === 0) { out[i]=0; out[i+1]=0; out[i+2]=0; out[i+3]=0; continue; }
|
||||
const [nr, ng, nb] = palette.length ? weightedNearest(r,g,b,palette) : [r,g,b];
|
||||
out[i]=nr; out[i+1]=ng; out[i+2]=nb; out[i+3]=255;
|
||||
const key = `${nr},${ng},${nb}`;
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
|
||||
if (!cc.processedCanvas) { cc.processedCanvas = document.createElement('canvas'); cc.processedCtx = cc.processedCanvas.getContext('2d')!; }
|
||||
cc.processedCanvas.width = w; cc.processedCanvas.height = h;
|
||||
|
||||
const outImg = new ImageData(out, w, h);
|
||||
cc.processedCtx!.putImageData(outImg, 0, 0);
|
||||
cc.lastColorCounts = counts;
|
||||
}
|
||||
|
||||
function applyPreview() {
|
||||
if (!cc || !cc.processedCanvas) return;
|
||||
const zoom = Number(cc.zoom) || 1.0;
|
||||
const srcCanvas = cc.processedCanvas;
|
||||
|
||||
const pw = Math.max(1, Math.round(srcCanvas.width * zoom));
|
||||
const ph = Math.max(1, Math.round(srcCanvas.height * zoom));
|
||||
|
||||
cc.previewCanvas.width = pw;
|
||||
cc.previewCanvas.height = ph;
|
||||
|
||||
const ctx = cc.previewCtx;
|
||||
ctx.clearRect(0,0,pw,ph);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(srcCanvas, 0,0, srcCanvas.width, srcCanvas.height, 0,0, pw, ph);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
}
|
||||
|
||||
function updateMeta() {
|
||||
if (!cc || !cc.sourceImageData) { if (cc) cc.meta.textContent = ''; return; }
|
||||
const w = cc.sourceImageData.width, h = cc.sourceImageData.height;
|
||||
const colorsUsed = Object.keys(cc.lastColorCounts||{}).length;
|
||||
const status = cc.isStale ? 'pending recalculation' : 'up to date';
|
||||
cc.meta.textContent = `Size: ${w}×${h} | Zoom: ${cc.zoom.toFixed(2)}× | Colors: ${colorsUsed} | Status: ${status}`;
|
||||
}
|
||||
|
||||
function renderPaletteGrid() {
|
||||
if (!cc) return;
|
||||
cc.freeGrid.innerHTML = '';
|
||||
cc.paidGrid.innerHTML = '';
|
||||
|
||||
for (const [r,g,b] of WPLACE_FREE) {
|
||||
const key = `${r},${g},${b}`;
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'op-cc-cell';
|
||||
cell.style.background = `rgb(${r},${g},${b})`;
|
||||
cell.title = WPLACE_NAMES[key] || key;
|
||||
cell.dataset.key = key;
|
||||
cell.dataset.type = 'free';
|
||||
if (cc.selectedFree.has(key)) cell.classList.add('active');
|
||||
cell.addEventListener('click', async () => {
|
||||
if (cc!.selectedFree.has(key)) cc!.selectedFree.delete(key); else cc!.selectedFree.add(key);
|
||||
cell.classList.toggle('active', cc!.selectedFree.has(key));
|
||||
config.ccFreeKeys = Array.from(cc!.selectedFree); await saveConfig(['ccFreeKeys']);
|
||||
if (cc!.realtime) processImage(); else { cc!.isStale = true; }
|
||||
applyPreview(); updateMeta(); updateMasterButtons();
|
||||
});
|
||||
cc.freeGrid.appendChild(cell);
|
||||
}
|
||||
|
||||
for (const [r,g,b] of WPLACE_PAID) {
|
||||
const key = `${r},${g},${b}`;
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'op-cc-cell';
|
||||
cell.style.background = `rgb(${r},${g},${b})`;
|
||||
cell.title = WPLACE_NAMES[key] || key;
|
||||
cell.dataset.key = key;
|
||||
cell.dataset.type = 'paid';
|
||||
if (cc.selectedPaid.has(key)) cell.classList.add('active');
|
||||
cell.addEventListener('click', async () => {
|
||||
if (cc!.selectedPaid.has(key)) cc!.selectedPaid.delete(key); else cc!.selectedPaid.add(key);
|
||||
cell.classList.toggle('active', cc!.selectedPaid.has(key));
|
||||
config.ccPaidKeys = Array.from(cc!.selectedPaid); await saveConfig(['ccPaidKeys']);
|
||||
if (cc!.realtime) processImage(); else { cc!.isStale = true; }
|
||||
applyPreview(); updateMeta(); updateMasterButtons();
|
||||
});
|
||||
cc.paidGrid.appendChild(cell);
|
||||
}
|
||||
|
||||
cc.freeToggle.addEventListener('click', async () => {
|
||||
const allActive = isAllFreeActive();
|
||||
setAllActive('free', !allActive);
|
||||
config.ccFreeKeys = Array.from(cc!.selectedFree);
|
||||
await saveConfig(['ccFreeKeys']);
|
||||
if (cc!.realtime) recalcNow(); else markStale();
|
||||
applyPreview(); updateMeta(); updateMasterButtons();
|
||||
});
|
||||
cc.paidToggle.addEventListener('click', async () => {
|
||||
const allActive = isAllPaidActive();
|
||||
setAllActive('paid', !allActive);
|
||||
config.ccPaidKeys = Array.from(cc!.selectedPaid);
|
||||
await saveConfig(['ccPaidKeys']);
|
||||
if (cc!.realtime) recalcNow(); else markStale();
|
||||
applyPreview(); updateMeta(); updateMasterButtons();
|
||||
});
|
||||
|
||||
updateMasterButtons();
|
||||
}
|
||||
|
||||
function isAllFreeActive() { return DEFAULT_FREE_KEYS.every(k => cc!.selectedFree.has(k)); }
|
||||
function isAllPaidActive() {
|
||||
const allPaidKeys = WPLACE_PAID.map(([r,g,b]) => `${r},${g},${b}`);
|
||||
return allPaidKeys.every(k => cc!.selectedPaid.has(k)) && allPaidKeys.length > 0;
|
||||
}
|
||||
function setAllActive(type: 'free'|'paid', active: boolean) {
|
||||
if (type === 'free') {
|
||||
const keys = DEFAULT_FREE_KEYS;
|
||||
if (active) keys.forEach(k => cc!.selectedFree.add(k)); else cc!.selectedFree.clear();
|
||||
cc!.freeGrid.querySelectorAll('.op-cc-cell').forEach(cell => cell.classList.toggle('active', active));
|
||||
} else {
|
||||
const keys = WPLACE_PAID.map(([r,g,b]) => `${r},${g},${b}`);
|
||||
if (active) keys.forEach(k => cc!.selectedPaid.add(k)); else cc!.selectedPaid.clear();
|
||||
cc!.paidGrid.querySelectorAll('.op-cc-cell').forEach(cell => cell.classList.toggle('active', active));
|
||||
}
|
||||
}
|
||||
function updateMasterButtons() {
|
||||
cc!.freeToggle.textContent = isAllFreeActive() ? 'Unselect All' : 'Select All';
|
||||
cc!.paidToggle.textContent = isAllPaidActive() ? 'Unselect All' : 'Select All';
|
||||
}
|
||||
|
||||
function recalcNow() {
|
||||
processImage();
|
||||
cc!.isStale = false;
|
||||
applyPreview();
|
||||
updateMeta();
|
||||
}
|
||||
function markStale() {
|
||||
cc!.isStale = true;
|
||||
cc!.meta.textContent = cc!.meta.textContent.replace(/ \| Status: .+$/, '') + ' | Status: pending recalculation';
|
||||
}
|
||||
483
src/ui/panel.ts
Normal file
483
src/ui/panel.ts
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
/// <reference types="tampermonkey" />
|
||||
import { config, saveConfig, getActiveOverlay, applyTheme } from '../core/store';
|
||||
import { ensureHook } from '../core/hook';
|
||||
import { clearOverlayCache } from '../core/cache';
|
||||
import { showToast } from '../core/toast';
|
||||
import { urlToDataURL, fileToDataURL } from '../core/gm';
|
||||
import { uniqueName, uid } from '../core/util';
|
||||
import { extractPixelCoords } from '../core/overlay';
|
||||
import { buildCCModal, openCCModal } from './ccModal';
|
||||
import { buildRSModal, openRSModal } from './rsModal';
|
||||
import { EV_ANCHOR_SET, EV_AUTOCAP_CHANGED } from '../core/events';
|
||||
|
||||
let panelEl: HTMLDivElement | null = null;
|
||||
|
||||
function $(id: string) { return document.getElementById(id)!; }
|
||||
|
||||
export function createUI() {
|
||||
if (document.getElementById('overlay-pro-panel')) return;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'overlay-pro-panel';
|
||||
panelEl = panel;
|
||||
|
||||
const panelW = 340;
|
||||
const defaultLeft = Math.max(12, window.innerWidth - panelW - 80);
|
||||
panel.style.left = (Number.isFinite(config.panelX as any) ? (config.panelX as any) : defaultLeft) + 'px';
|
||||
panel.style.top = (Number.isFinite(config.panelY as any) ? (config.panelY as any) : 120) + 'px';
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="op-header" id="op-header">
|
||||
<h3>Overlay Pro</h3>
|
||||
<div class="op-header-actions">
|
||||
<button class="op-hdr-btn" id="op-theme-toggle" title="Toggle theme">☀️/🌙</button>
|
||||
<button class="op-hdr-btn" id="op-refresh-btn" title="Refresh">⟲</button>
|
||||
<button class="op-toggle-btn" id="op-panel-toggle" title="Collapse">▾</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="op-content" id="op-content">
|
||||
<div class="op-section">
|
||||
<div class="op-row space">
|
||||
<button class="op-button" id="op-mode-toggle">Mode</button>
|
||||
<div class="op-row">
|
||||
<span class="op-muted" id="op-place-label">Place overlay:</span>
|
||||
<button class="op-button" id="op-autocap-toggle" title="Capture next clicked pixel as anchor">OFF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-section">
|
||||
<div class="op-section-title">
|
||||
<div class="op-title-left">
|
||||
<span class="op-title-text">Overlays</span>
|
||||
</div>
|
||||
<div class="op-title-right">
|
||||
<div class="op-row">
|
||||
<button class="op-button" id="op-add-overlay" title="Create a new overlay">+ Add</button>
|
||||
<button class="op-button" id="op-import-overlay" title="Import overlay JSON">Import</button>
|
||||
<button class="op-button" id="op-export-overlay" title="Export active overlay JSON">Export</button>
|
||||
<button class="op-chevron" id="op-collapse-list" title="Collapse/Expand">▾</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="op-list-wrap">
|
||||
<div class="op-list" id="op-overlay-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-section" id="op-editor-section">
|
||||
<div class="op-section-title">
|
||||
<div class="op-title-left">
|
||||
<span class="op-title-text">Editor</span>
|
||||
</div>
|
||||
<div class="op-title-right">
|
||||
<button class="op-chevron" id="op-collapse-editor" title="Collapse/Expand">▾</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="op-editor-body">
|
||||
<div class="op-row">
|
||||
<label style="width: 90px;">Name</label>
|
||||
<input type="text" class="op-input op-grow" id="op-name">
|
||||
</div>
|
||||
|
||||
<div id="op-image-source">
|
||||
<div class="op-row">
|
||||
<label style="width: 90px;">Image</label>
|
||||
<input type="text" class="op-input op-grow" id="op-image-url" placeholder="Paste a direct image link">
|
||||
<button class="op-button" id="op-fetch">Fetch</button>
|
||||
</div>
|
||||
<div class="op-preview" id="op-dropzone">
|
||||
<div class="op-drop-hint">Drop here or click to browse.</div>
|
||||
<input type="file" id="op-file-input" accept="image/*" style="display:none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-preview" id="op-preview-wrap" style="display:none;">
|
||||
<img id="op-image-preview" alt="No image">
|
||||
</div>
|
||||
|
||||
<div class="op-row" id="op-cc-btn-row" style="display:none; justify-content:space-between; gap:8px; flex-wrap:wrap;">
|
||||
<button class="op-button" id="op-download-overlay" title="Download this overlay image">Download</button>
|
||||
<button class="op-button" id="op-open-resize" title="Resize the overlay image">Resize</button>
|
||||
<button class="op-button" id="op-open-cc" title="Match colors to Wplace palette">Color Match</button>
|
||||
</div>
|
||||
|
||||
<div class="op-row"><span class="op-muted" id="op-coord-display"></span></div>
|
||||
|
||||
<div class="op-row" style="width: 100%; gap: 12px; padding: 6px 0;">
|
||||
<label style="width: 60px;">Opacity</label>
|
||||
<input type="range" min="0" max="1" step="0.05" class="op-slider op-grow" id="op-opacity-slider">
|
||||
<span id="op-opacity-value" style="width: 36px; text-align: right;">70%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-section" id="op-nudge-section">
|
||||
<div class="op-section-title">
|
||||
<div class="op-title-left">
|
||||
<span class="op-title-text">Nudge overlay</span>
|
||||
</div>
|
||||
<div class="op-title-right">
|
||||
<span class="op-muted" id="op-offset-indicator">Offset X 0, Y 0</span>
|
||||
<button class="op-chevron" id="op-collapse-nudge" title="Collapse/Expand">▾</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="op-nudge-body">
|
||||
<div class="op-nudge-row" style="text-align: right;">
|
||||
<button class="op-icon-btn" id="op-nudge-left" title="Left">←</button>
|
||||
<button class="op-icon-btn" id="op-nudge-down" title="Down">↓</button>
|
||||
<button class="op-icon-btn" id="op-nudge-up" title="Up">↑</button>
|
||||
<button class="op-icon-btn" id="op-nudge-right" title="Right">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
buildCCModal();
|
||||
buildRSModal();
|
||||
addEventListeners(panel);
|
||||
enableDrag(panel);
|
||||
updateUI();
|
||||
|
||||
// Core → UI events
|
||||
document.addEventListener('op-overlay-changed', updateUI);
|
||||
document.addEventListener(EV_ANCHOR_SET, (ev: any) => {
|
||||
const d = ev?.detail || {};
|
||||
showToast(`Anchor set for "${d.name ?? 'overlay'}": chunk ${d.chunk1}/${d.chunk2} at (${d.posX}, ${d.posY}). Offset reset to (0,0).`);
|
||||
updateUI();
|
||||
});
|
||||
document.addEventListener(EV_AUTOCAP_CHANGED, () => updateUI());
|
||||
}
|
||||
|
||||
function rebuildOverlayListUI() {
|
||||
const list = $('op-overlay-list');
|
||||
list.innerHTML = '';
|
||||
for (const ov of config.overlays) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'op-item' + (ov.id === config.activeOverlayId ? ' active' : '');
|
||||
const localTag = ov.isLocal ? ' (local)' : (!ov.imageBase64 ? ' (no image)' : '');
|
||||
item.innerHTML = `
|
||||
<input type="radio" name="op-active" ${ov.id === config.activeOverlayId ? 'checked' : ''} title="Set active"/>
|
||||
<input type="checkbox" ${ov.enabled ? 'checked' : ''} title="Toggle enabled"/>
|
||||
<div class="op-item-name" title="${(ov.name || '(unnamed)') + localTag}">${(ov.name || '(unnamed)') + localTag}</div>
|
||||
<button class="op-icon-btn" title="Delete overlay">🗑️</button>
|
||||
`;
|
||||
const [radio, checkbox, nameDiv, trashBtn] = item.children as any as [HTMLInputElement, HTMLInputElement, HTMLDivElement, HTMLButtonElement];
|
||||
radio.addEventListener('change', async () => { config.activeOverlayId = ov.id; await saveConfig(['activeOverlayId']); updateUI(); });
|
||||
checkbox.addEventListener('change', async () => {
|
||||
ov.enabled = checkbox.checked; await saveConfig(['overlays']); clearOverlayCache(); ensureHook(); updateUI();
|
||||
});
|
||||
nameDiv.addEventListener('click', async () => { config.activeOverlayId = ov.id; await saveConfig(['activeOverlayId']); updateUI(); });
|
||||
trashBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete overlay "${ov.name || '(unnamed)'}"?`)) return;
|
||||
const idx = config.overlays.findIndex(o => o.id === ov.id);
|
||||
if (idx >= 0) {
|
||||
config.overlays.splice(idx, 1);
|
||||
if (config.activeOverlayId === ov.id) config.activeOverlayId = config.overlays[0]?.id || null;
|
||||
await saveConfig(['overlays', 'activeOverlayId']); clearOverlayCache(); ensureHook(); updateUI();
|
||||
}
|
||||
});
|
||||
list.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function addBlankOverlay() {
|
||||
const name = uniqueName('Overlay', config.overlays.map(o => o.name || ''));
|
||||
const ov = { id: uid(), name, enabled: true, imageUrl: null, imageBase64: null, isLocal: false, pixelUrl: null, offsetX: 0, offsetY: 0, opacity: 0.7 };
|
||||
config.overlays.push(ov);
|
||||
config.activeOverlayId = ov.id;
|
||||
await saveConfig(['overlays', 'activeOverlayId']);
|
||||
clearOverlayCache(); ensureHook(); updateUI();
|
||||
return ov;
|
||||
}
|
||||
|
||||
async function setOverlayImageFromURL(ov: any, url: string) {
|
||||
const base64 = await urlToDataURL(url);
|
||||
ov.imageUrl = url; ov.imageBase64 = base64; ov.isLocal = false;
|
||||
await saveConfig(['overlays']); clearOverlayCache();
|
||||
config.autoCapturePixelUrl = true; await saveConfig(['autoCapturePixelUrl']);
|
||||
ensureHook(); updateUI();
|
||||
showToast(`Image loaded. Placement mode ON -- click once to set anchor.`);
|
||||
}
|
||||
async function setOverlayImageFromFile(ov: any, file: File) {
|
||||
if (!file || !file.type || !file.type.startsWith('image/')) { alert('Please choose an image file.'); return; }
|
||||
if (!confirm('Local PNGs cannot be exported to friends! Are you sure?')) return;
|
||||
const base64 = await fileToDataURL(file);
|
||||
ov.imageBase64 = base64; ov.imageUrl = null; ov.isLocal = true;
|
||||
await saveConfig(['overlays']); clearOverlayCache();
|
||||
config.autoCapturePixelUrl = true; await saveConfig(['autoCapturePixelUrl']);
|
||||
ensureHook(); updateUI();
|
||||
showToast(`Local image loaded. Placement mode ON -- click once to set anchor.`);
|
||||
}
|
||||
|
||||
async function importOverlayFromJSON(jsonText: string) {
|
||||
let obj; try { obj = JSON.parse(jsonText); } catch { alert('Invalid JSON'); return; }
|
||||
const arr = Array.isArray(obj) ? obj : [obj];
|
||||
let imported = 0, failed = 0;
|
||||
for (const item of arr) {
|
||||
const name = uniqueName(item.name || 'Imported Overlay', config.overlays.map(o => o.name || ''));
|
||||
const imageUrl = item.imageUrl;
|
||||
const pixelUrl = item.pixelUrl ?? null;
|
||||
const offsetX = Number.isFinite(item.offsetX) ? item.offsetX : 0;
|
||||
const offsetY = Number.isFinite(item.offsetY) ? item.offsetY : 0;
|
||||
const opacity = Number.isFinite(item.opacity) ? item.opacity : 0.7;
|
||||
if (!imageUrl) { failed++; continue; }
|
||||
try {
|
||||
const base64 = await urlToDataURL(imageUrl);
|
||||
const ov = { id: uid(), name, enabled: true, imageUrl, imageBase64: base64, isLocal: false, pixelUrl, offsetX, offsetY, opacity };
|
||||
config.overlays.push(ov); imported++;
|
||||
} catch (e) { console.error('Import failed for', imageUrl, e); failed++; }
|
||||
}
|
||||
if (imported > 0) {
|
||||
config.activeOverlayId = config.overlays[config.overlays.length - 1].id;
|
||||
await saveConfig(['overlays', 'activeOverlayId']); clearOverlayCache(); ensureHook(); updateUI();
|
||||
}
|
||||
alert(`Import finished. Imported: ${imported}${failed ? `, Failed: ${failed}` : ''}`);
|
||||
}
|
||||
|
||||
function exportActiveOverlayToClipboard() {
|
||||
const ov = getActiveOverlay();
|
||||
if (!ov) { alert('No active overlay selected.'); return; }
|
||||
if (ov.isLocal || !ov.imageUrl) { alert('This overlay uses a local image and cannot be exported. Please host the image and set an image URL.'); return; }
|
||||
const payload = { version: 1, name: ov.name, imageUrl: ov.imageUrl, pixelUrl: ov.pixelUrl ?? null, offsetX: ov.offsetX, offsetY: ov.offsetY, opacity: ov.opacity };
|
||||
const text = JSON.stringify(payload, null, 2);
|
||||
copyText(text).then(() => alert('Overlay JSON copied to clipboard!')).catch(() => { prompt('Copy the JSON below:', text); });
|
||||
}
|
||||
function copyText(text: string) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(text);
|
||||
return Promise.reject(new Error('Clipboard API not available'));
|
||||
}
|
||||
|
||||
function addEventListeners(panel: HTMLDivElement) {
|
||||
$('op-theme-toggle').addEventListener('click', async (e) => { e.stopPropagation(); config.theme = config.theme === 'light' ? 'dark' : 'light'; await saveConfig(['theme']); applyTheme(); });
|
||||
$('op-refresh-btn').addEventListener('click', (e) => { e.stopPropagation(); location.reload(); });
|
||||
$('op-panel-toggle').addEventListener('click', (e) => { e.stopPropagation(); config.isPanelCollapsed = !config.isPanelCollapsed; saveConfig(['isPanelCollapsed']); updateUI(); });
|
||||
|
||||
$('op-mode-toggle').addEventListener('click', () => {
|
||||
const modes: any[] = ['behind', 'above', 'minify', 'original'];
|
||||
const current = modes.indexOf(config.overlayMode);
|
||||
config.overlayMode = modes[(current + 1) % modes.length] as any;
|
||||
saveConfig(['overlayMode']);
|
||||
ensureHook();
|
||||
updateUI();
|
||||
});
|
||||
$('op-autocap-toggle').addEventListener('click', () => { config.autoCapturePixelUrl = !config.autoCapturePixelUrl; saveConfig(['autoCapturePixelUrl']); ensureHook(); updateUI(); });
|
||||
|
||||
$('op-add-overlay').addEventListener('click', async () => { try { await addBlankOverlay(); } catch (e) { console.error(e); } });
|
||||
$('op-import-overlay').addEventListener('click', async () => { const text = prompt('Paste overlay JSON (single or array):'); if (!text) return; await importOverlayFromJSON(text); });
|
||||
$('op-export-overlay').addEventListener('click', () => exportActiveOverlayToClipboard());
|
||||
$('op-collapse-list').addEventListener('click', () => { config.collapseList = !config.collapseList; saveConfig(['collapseList']); updateUI(); });
|
||||
$('op-collapse-editor').addEventListener('click', () => { config.collapseEditor = !config.collapseEditor; saveConfig(['collapseEditor']); updateUI(); });
|
||||
$('op-collapse-nudge').addEventListener('click', () => { config.collapseNudge = !config.collapseNudge; saveConfig(['collapseNudge']); updateUI(); });
|
||||
|
||||
$('op-name').addEventListener('change', async (e: any) => {
|
||||
const ov = getActiveOverlay(); if (!ov) return;
|
||||
const desired = (e.target.value || '').trim() || 'Overlay';
|
||||
if (config.overlays.some(o => o.id !== ov.id && (o.name || '').toLowerCase() === desired.toLowerCase())) {
|
||||
ov.name = uniqueName(desired, config.overlays.map(o => o.name || ''));
|
||||
showToast(`Name in use. Renamed to "${ov.name}".`);
|
||||
} else { ov.name = desired; }
|
||||
await saveConfig(['overlays']); rebuildOverlayListUI();
|
||||
});
|
||||
|
||||
$('op-fetch').addEventListener('click', async () => {
|
||||
const ov = getActiveOverlay(); if (!ov) { alert('No active overlay selected.'); return; }
|
||||
if (ov.imageBase64) { alert('This overlay already has an image. Create a new overlay to change the image.'); return; }
|
||||
const url = ( $('op-image-url') as HTMLInputElement ).value.trim(); if (!url) { alert('Enter an image link first.'); return; }
|
||||
try { await setOverlayImageFromURL(ov, url); } catch (e) { console.error(e); alert('Failed to fetch image.'); }
|
||||
});
|
||||
|
||||
const dropzone = $('op-dropzone');
|
||||
dropzone.addEventListener('click', () => $('op-file-input').click());
|
||||
$('op-file-input').addEventListener('change', async (e: any) => {
|
||||
const file = e.target.files && e.target.files[0]; e.target.value=''; if (!file) return;
|
||||
const ov = getActiveOverlay(); if (!ov) return;
|
||||
if (ov.imageBase64) { alert('This overlay already has an image. Create a new overlay to change the image.'); return; }
|
||||
try { await setOverlayImageFromFile(ov, file); } catch (err) { console.error(err); alert('Failed to load local image.'); }
|
||||
});
|
||||
['dragenter', 'dragover'].forEach(evt => dropzone.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropzone.classList.add('drop-highlight'); }));
|
||||
['dragleave', 'drop'].forEach(evt => dropzone.addEventListener(evt, (e: any) => { e.preventDefault(); e.stopPropagation(); if (evt === 'dragleave' && e.target !== dropzone) return; dropzone.classList.remove('drop-highlight'); }));
|
||||
dropzone.addEventListener('drop', async (e: any) => {
|
||||
const dt = e.dataTransfer; if (!dt) return; const file = dt.files && dt.files[0]; if (!file) return;
|
||||
const ov = getActiveOverlay(); if (!ov) return;
|
||||
if (ov.imageBase64) { alert('This overlay already has an image. Create a new overlay to change the image.'); return; }
|
||||
try { await setOverlayImageFromFile(ov, file); } catch (err) { console.error(err); alert('Failed to load dropped image.'); }
|
||||
});
|
||||
|
||||
const nudge = async (dx: number, dy: number) => {
|
||||
const ov = getActiveOverlay(); if (!ov) return;
|
||||
ov.offsetX += dx; ov.offsetY += dy;
|
||||
await saveConfig(['overlays']); clearOverlayCache(); updateUI();
|
||||
};
|
||||
$('op-nudge-up').addEventListener('click', () => nudge(0, -1));
|
||||
$('op-nudge-down').addEventListener('click', () => nudge(0, 1));
|
||||
$('op-nudge-left').addEventListener('click', () => nudge(-1, 0));
|
||||
$('op-nudge-right').addEventListener('click', () => nudge(1, 0));
|
||||
|
||||
$('op-opacity-slider').addEventListener('input', (e: any) => {
|
||||
const ov = getActiveOverlay(); if (!ov) return;
|
||||
ov.opacity = parseFloat(e.target.value);
|
||||
$('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%';
|
||||
});
|
||||
$('op-opacity-slider').addEventListener('change', async () => { await saveConfig(['overlays']); clearOverlayCache(); });
|
||||
|
||||
$('op-download-overlay').addEventListener('click', () => {
|
||||
const ov = getActiveOverlay();
|
||||
if (!ov || !ov.imageBase64) { showToast('No overlay image to download.'); return; }
|
||||
const a = document.createElement('a');
|
||||
a.href = ov.imageBase64;
|
||||
a.download = `${(ov.name || 'overlay').replace(/[^\w.-]+/g, '_')}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
});
|
||||
|
||||
$('op-open-cc').addEventListener('click', () => {
|
||||
const ov = getActiveOverlay(); if (!ov || !ov.imageBase64) { showToast('No overlay image to edit.'); return; }
|
||||
openCCModal(ov);
|
||||
});
|
||||
const resizeBtn = $('op-open-resize');
|
||||
if (resizeBtn) {
|
||||
resizeBtn.addEventListener('click', () => {
|
||||
const ov = getActiveOverlay();
|
||||
if (!ov || !ov.imageBase64) { showToast('No overlay image to resize.'); return; }
|
||||
openRSModal(ov);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function enableDrag(panel: HTMLDivElement) {
|
||||
const header = panel.querySelector('#op-header') as HTMLDivElement;
|
||||
if (!header) return;
|
||||
|
||||
let isDragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0, moved = false;
|
||||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
isDragging = true; moved = false; startX = e.clientX; startY = e.clientY;
|
||||
const rect = panel.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top;
|
||||
(header as any).setPointerCapture?.(e.pointerId); e.preventDefault();
|
||||
};
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||
const maxLeft = Math.max(8, window.innerWidth - panel.offsetWidth - 8);
|
||||
const maxTop = Math.max(8, window.innerHeight - panel.offsetHeight - 8);
|
||||
panel.style.left = clamp(startLeft + dx, 8, maxLeft) + 'px';
|
||||
panel.style.top = clamp(startTop + dy, 8, maxTop) + 'px';
|
||||
moved = true;
|
||||
};
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (!isDragging) return;
|
||||
isDragging = false; (header as any).releasePointerCapture?.(e.pointerId);
|
||||
if (moved) {
|
||||
config.panelX = parseInt(panel.style.left, 10) || 0;
|
||||
config.panelY = parseInt(panel.style.top, 10) || 0;
|
||||
saveConfig(['panelX', 'panelY']);
|
||||
}
|
||||
};
|
||||
header.addEventListener('pointerdown', onPointerDown);
|
||||
header.addEventListener('pointermove', onPointerMove);
|
||||
header.addEventListener('pointerup', onPointerUp);
|
||||
header.addEventListener('pointercancel', onPointerUp);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const maxLeft = Math.max(8, window.innerWidth - panel.offsetWidth - 8);
|
||||
const maxTop = Math.max(8, window.innerHeight - panel.offsetHeight - 8);
|
||||
const newLeft = Math.min(Math.max(rect.left, 8), maxLeft);
|
||||
const newTop = Math.min(Math.max(rect.top, 8), maxTop);
|
||||
panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px';
|
||||
config.panelX = newLeft; config.panelY = newTop; saveConfig(['panelX', 'panelY']);
|
||||
});
|
||||
}
|
||||
|
||||
function updateEditorUI() {
|
||||
const editorSect = $('op-editor-section');
|
||||
const editorBody = $('op-editor-body');
|
||||
const ov = getActiveOverlay();
|
||||
|
||||
editorSect.style.display = ov ? 'flex' : 'none';
|
||||
if (!ov) return;
|
||||
|
||||
( $('op-name') as HTMLInputElement ).value = ov.name || '';
|
||||
|
||||
const srcWrap = $('op-image-source');
|
||||
const previewWrap = $('op-preview-wrap');
|
||||
const previewImg = $('op-image-preview') as HTMLImageElement;
|
||||
const ccRow = $('op-cc-btn-row');
|
||||
|
||||
if (ov.imageBase64) {
|
||||
srcWrap.style.display = 'none';
|
||||
previewWrap.style.display = 'flex';
|
||||
previewImg.src = ov.imageBase64;
|
||||
ccRow.style.display = 'flex';
|
||||
} else {
|
||||
srcWrap.style.display = 'block';
|
||||
previewWrap.style.display = 'none';
|
||||
ccRow.style.display = 'none';
|
||||
( $('op-image-url') as HTMLInputElement ).value = ov.imageUrl || '';
|
||||
}
|
||||
|
||||
const coords = ov.pixelUrl ? extractPixelCoords(ov.pixelUrl) : { chunk1: '-', chunk2: '-', posX: '-', posY: '-' } as any;
|
||||
$('op-coord-display').textContent = ov.pixelUrl
|
||||
? `Ref: chunk ${coords.chunk1}/${coords.chunk2} at (${coords.posX}, ${coords.posY})`
|
||||
: `No pixel anchor set. Turn ON "Place overlay" and click a pixel once.`;
|
||||
|
||||
( $('op-opacity-slider') as HTMLInputElement ).value = String(ov.opacity);
|
||||
$('op-opacity-value').textContent = Math.round(ov.opacity * 100) + '%';
|
||||
|
||||
const indicator = $('op-offset-indicator');
|
||||
if (indicator) indicator.textContent = `Offset X ${ov.offsetX}, Y ${ov.offsetY}`;
|
||||
|
||||
editorBody.style.display = config.collapseEditor ? 'none' : 'block';
|
||||
const chevron = $('op-collapse-editor');
|
||||
if (chevron) chevron.textContent = config.collapseEditor ? '▸' : '▾';
|
||||
}
|
||||
|
||||
export function updateUI() {
|
||||
if (!panelEl) return;
|
||||
|
||||
applyTheme();
|
||||
|
||||
const content = $('op-content');
|
||||
const toggle = $('op-panel-toggle');
|
||||
const collapsed = !!config.isPanelCollapsed;
|
||||
content.style.display = collapsed ? 'none' : 'flex';
|
||||
toggle.textContent = collapsed ? '▸' : '▾';
|
||||
toggle.title = collapsed ? 'Expand' : 'Collapse';
|
||||
|
||||
const modeBtn = $('op-mode-toggle');
|
||||
const modeMap: any = { behind: 'Overlay Behind', above: 'Overlay Above', minify: `Minified`, original: 'Original' };
|
||||
modeBtn.textContent = `Mode: ${modeMap[config.overlayMode] || 'Original'}`;
|
||||
|
||||
const autoBtn = $('op-autocap-toggle');
|
||||
const placeLabel = $('op-place-label');
|
||||
autoBtn.textContent = config.autoCapturePixelUrl ? 'ON' : 'OFF';
|
||||
autoBtn.classList.toggle('op-danger', !!config.autoCapturePixelUrl);
|
||||
placeLabel.classList.toggle('op-danger-text', !!config.autoCapturePixelUrl);
|
||||
|
||||
const listWrap = $('op-list-wrap');
|
||||
const listCz = $('op-collapse-list');
|
||||
listWrap.style.display = config.collapseList ? 'none' : 'block';
|
||||
if (listCz) listCz.textContent = config.collapseList ? '▸' : '▾';
|
||||
|
||||
const nudgeBody = $('op-nudge-body');
|
||||
const nudgeCz = $('op-collapse-nudge');
|
||||
nudgeBody.style.display = config.collapseNudge ? 'none' : 'block';
|
||||
if (nudgeCz) nudgeCz.textContent = config.collapseNudge ? '▸' : '▾';
|
||||
|
||||
rebuildOverlayListUI();
|
||||
updateEditorUI();
|
||||
|
||||
const exportBtn = $('op-export-overlay') as HTMLButtonElement;
|
||||
const ov = getActiveOverlay();
|
||||
const canExport = !!(ov && ov.imageUrl && !ov.isLocal);
|
||||
exportBtn.disabled = !canExport;
|
||||
exportBtn.title = canExport ? 'Export active overlay JSON' : 'Export disabled for local images';
|
||||
}
|
||||
970
src/ui/rsModal.ts
Normal file
970
src/ui/rsModal.ts
Normal file
|
|
@ -0,0 +1,970 @@
|
|||
/// <reference types="tampermonkey" />
|
||||
import { createCanvas, createHTMLCanvas, canvasToDataURLSafe, loadImage } from '../core/canvas';
|
||||
import { config, saveConfig } from '../core/store';
|
||||
import { MAX_OVERLAY_DIM } from '../core/constants';
|
||||
import { ensureHook } from '../core/hook';
|
||||
import { clearOverlayCache } from '../core/cache';
|
||||
import { showToast } from '../core/toast';
|
||||
|
||||
// dispatch when an overlay image is updated
|
||||
function emitOverlayChanged() {
|
||||
document.dispatchEvent(new CustomEvent('op-overlay-changed'));
|
||||
}
|
||||
|
||||
type RSRefs = {
|
||||
backdrop: HTMLDivElement;
|
||||
modal: HTMLDivElement;
|
||||
tabSimple: HTMLButtonElement;
|
||||
tabAdvanced: HTMLButtonElement;
|
||||
paneSimple: HTMLDivElement;
|
||||
paneAdvanced: HTMLDivElement;
|
||||
orig: HTMLInputElement;
|
||||
w: HTMLInputElement;
|
||||
h: HTMLInputElement;
|
||||
lock: HTMLInputElement;
|
||||
note?: HTMLElement | null;
|
||||
onex: HTMLButtonElement;
|
||||
half: HTMLButtonElement;
|
||||
third: HTMLButtonElement;
|
||||
quarter: HTMLButtonElement;
|
||||
double: HTMLButtonElement;
|
||||
scale: HTMLInputElement;
|
||||
applyScale: HTMLButtonElement;
|
||||
simWrap: HTMLDivElement;
|
||||
simOrig: HTMLCanvasElement;
|
||||
simNew: HTMLCanvasElement;
|
||||
colLeft: HTMLDivElement;
|
||||
colRight: HTMLDivElement;
|
||||
|
||||
advWrap: HTMLDivElement;
|
||||
preview: HTMLCanvasElement;
|
||||
|
||||
meta: HTMLElement;
|
||||
|
||||
zoomIn: HTMLButtonElement;
|
||||
zoomOut: HTMLButtonElement;
|
||||
|
||||
multRange: HTMLInputElement;
|
||||
multInput: HTMLInputElement;
|
||||
bind: HTMLInputElement;
|
||||
blockW: HTMLInputElement;
|
||||
blockH: HTMLInputElement;
|
||||
offX: HTMLInputElement;
|
||||
offY: HTMLInputElement;
|
||||
dotR: HTMLInputElement;
|
||||
dotRVal: HTMLElement;
|
||||
gridToggle: HTMLInputElement;
|
||||
advNote: HTMLElement;
|
||||
resWrap: HTMLDivElement;
|
||||
resCanvas: HTMLCanvasElement;
|
||||
resMeta: HTMLElement;
|
||||
|
||||
calcBtn: HTMLButtonElement;
|
||||
applyBtn: HTMLButtonElement;
|
||||
cancelBtn: HTMLButtonElement;
|
||||
closeBtn: HTMLButtonElement;
|
||||
};
|
||||
|
||||
type RSState = RSRefs & {
|
||||
ov: any | null;
|
||||
img: HTMLImageElement | null;
|
||||
origW: number; origH: number;
|
||||
mode: 'simple'|'advanced';
|
||||
zoom: number;
|
||||
updating: boolean;
|
||||
|
||||
mult: number;
|
||||
gapX: number; gapY: number;
|
||||
offx: number; offy: number;
|
||||
dotr: number;
|
||||
|
||||
viewX: number; viewY: number;
|
||||
|
||||
panning: boolean;
|
||||
panStart: { x: number; y: number; viewX: number; viewY: number } | null;
|
||||
|
||||
calcCanvas: HTMLCanvasElement | null;
|
||||
calcCols: number;
|
||||
calcRows: number;
|
||||
calcReady: boolean;
|
||||
|
||||
_drawSimplePreview?: () => void;
|
||||
_drawAdvancedPreview?: () => void;
|
||||
_drawAdvancedResultPreview?: () => void;
|
||||
_syncAdvancedMeta?: () => void;
|
||||
_syncSimpleNote?: () => void;
|
||||
_setMode?: (m: 'simple'|'advanced') => void;
|
||||
_resizeHandler?: () => void;
|
||||
};
|
||||
|
||||
let rs: RSState | null = null;
|
||||
|
||||
export function buildRSModal() {
|
||||
if (document.getElementById('op-rs-modal')) return;
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'op-rs-backdrop';
|
||||
backdrop.id = 'op-rs-backdrop';
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'op-rs-modal';
|
||||
modal.id = 'op-rs-modal';
|
||||
modal.style.display = 'none';
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="op-rs-header" id="op-rs-header">
|
||||
<div class="op-rs-title">Resize Overlay</div>
|
||||
<button class="op-rs-close" id="op-rs-close" title="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="op-rs-tabs">
|
||||
<button class="op-rs-tab-btn active" id="op-rs-tab-simple">Simple</button>
|
||||
<button class="op-rs-tab-btn" id="op-rs-tab-advanced">Advanced (grid)</button>
|
||||
</div>
|
||||
|
||||
<div class="op-rs-body">
|
||||
<div class="op-rs-pane show" id="op-rs-pane-simple">
|
||||
<div class="op-rs-row">
|
||||
<label style="width:110px;">Original</label>
|
||||
<input type="text" class="op-input" id="op-rs-orig" disabled>
|
||||
</div>
|
||||
<div class="op-rs-row">
|
||||
<label style="width:110px;">Width</label>
|
||||
<input type="number" min="1" step="1" class="op-input" id="op-rs-w">
|
||||
</div>
|
||||
<div class="op-rs-row">
|
||||
<label style="width:110px;">Height</label>
|
||||
<input type="number" min="1" step="1" class="op-input" id="op-rs-h">
|
||||
</div>
|
||||
<div class="op-rs-row">
|
||||
<input type="checkbox" id="op-rs-lock" checked>
|
||||
<label for="op-rs-lock">Lock aspect ratio</label>
|
||||
</div>
|
||||
<div class="op-rs-row" style="gap:6px; flex-wrap:wrap;">
|
||||
<label style="width:110px;">Quick</label>
|
||||
<button class="op-button" id="op-rs-double">2x</button>
|
||||
<button class="op-button" id="op-rs-onex">1x</button>
|
||||
<button class="op-button" id="op-rs-half">0.5x</button>
|
||||
<button class="op-button" id="op-rs-third">0.33x</button>
|
||||
<button class="op-button" id="op-rs-quarter">0.25x</button>
|
||||
</div>
|
||||
<div class="op-rs-row">
|
||||
<label style="width:110px;">Scale factor</label>
|
||||
<input type="number" step="0.01" min="0.01" class="op-input" id="op-rs-scale" placeholder="e.g. 0.5">
|
||||
<button class="op-button" id="op-rs-apply-scale">Apply</button>
|
||||
</div>
|
||||
|
||||
<div class="op-rs-preview-wrap" id="op-rs-sim-wrap">
|
||||
<div class="op-rs-dual">
|
||||
<div class="op-rs-col" id="op-rs-col-left">
|
||||
<div class="label">Original</div>
|
||||
<div class="pad-top"></div>
|
||||
<canvas id="op-rs-sim-orig" class="op-rs-canvas op-rs-thumb"></canvas>
|
||||
</div>
|
||||
<div class="op-rs-col" id="op-rs-col-right">
|
||||
<div class="label">Result (downscale → upscale preview)</div>
|
||||
<div class="pad-top"></div>
|
||||
<canvas id="op-rs-sim-new" class="op-rs-canvas op-rs-thumb"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-rs-pane" id="op-rs-pane-advanced">
|
||||
<div class="op-rs-preview-wrap op-pan-grab" id="op-rs-adv-wrap">
|
||||
<canvas id="op-rs-preview" class="op-rs-canvas"></canvas>
|
||||
<div class="op-rs-zoom">
|
||||
<button class="op-icon-btn" id="op-rs-zoom-out" title="Zoom out">−</button>
|
||||
<button class="op-icon-btn" id="op-rs-zoom-in" title="Zoom in">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-rs-row" style="margin-top:8px;">
|
||||
<label style="width:160px;">Multiplier</label>
|
||||
<input type="range" id="op-rs-mult-range" min="1" max="64" step="0.1" style="flex:1;">
|
||||
<input type="number" id="op-rs-mult-input" class="op-input op-rs-mini" min="1" step="0.05">
|
||||
</div>
|
||||
|
||||
<div class="op-rs-row">
|
||||
<input type="checkbox" id="op-rs-bind" checked>
|
||||
<label for="op-rs-bind">Bind X/Y block sizes</label>
|
||||
</div>
|
||||
|
||||
<div class="op-rs-row">
|
||||
<label style="width:160px;">Block W / H</label>
|
||||
<input type="number" id="op-rs-blockw" class="op-input op-rs-mini" min="1" step="0.1">
|
||||
<input type="number" id="op-rs-blockh" class="op-input op-rs-mini" min="1" step="0.1">
|
||||
</div>
|
||||
|
||||
<div class="op-rs-row">
|
||||
<label style="width:160px;">Offset X / Y</label>
|
||||
<input type="number" id="op-rs-offx" class="op-input op-rs-mini" min="0" step="0.1">
|
||||
<input type="number" id="op-rs-offy" class="op-input op-rs-mini" min="0" step="0.1">
|
||||
</div>
|
||||
|
||||
<div class="op-rs-row">
|
||||
<label style="width:160px;">Dot radius</label>
|
||||
<input type="range" id="op-rs-dotr" min="1" max="8" step="1" style="flex:1;">
|
||||
<span id="op-rs-dotr-val" class="op-muted" style="width:36px; text-align:right;"></span>
|
||||
</div>
|
||||
|
||||
<div class="op-rs-row">
|
||||
<input type="checkbox" id="op-rs-grid" checked>
|
||||
<label for="op-rs-grid">Show grid wireframe</label>
|
||||
</div>
|
||||
|
||||
<div class="op-rs-grid-note" id="op-rs-adv-note">Align red dots to block centers. Drag to pan; use buttons or Ctrl+wheel to zoom.</div>
|
||||
|
||||
<div class="op-rs-row" style="margin-top:8px;">
|
||||
<label style="width:160px;">Calculated preview</label>
|
||||
<span class="op-muted" id="op-rs-adv-resmeta"></span>
|
||||
</div>
|
||||
<div class="op-rs-preview-wrap" id="op-rs-adv-result-wrap" style="height: clamp(200px, 26vh, 420px);">
|
||||
<canvas id="op-rs-adv-result" class="op-rs-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="op-rs-footer">
|
||||
<div class="op-cc-ghost" id="op-rs-meta">Nearest-neighbor OR grid center sampling; alpha hardened (no semi-transparent pixels).</div>
|
||||
<div class="op-cc-actions">
|
||||
<button class="op-button" id="op-rs-calc">Calculate</button>
|
||||
<button class="op-button" id="op-rs-apply">Apply</button>
|
||||
<button class="op-button" id="op-rs-cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const refs: RSRefs = {
|
||||
backdrop,
|
||||
modal,
|
||||
tabSimple: modal.querySelector('#op-rs-tab-simple') as HTMLButtonElement,
|
||||
tabAdvanced: modal.querySelector('#op-rs-tab-advanced') as HTMLButtonElement,
|
||||
paneSimple: modal.querySelector('#op-rs-pane-simple') as HTMLDivElement,
|
||||
paneAdvanced: modal.querySelector('#op-rs-pane-advanced') as HTMLDivElement,
|
||||
orig: modal.querySelector('#op-rs-orig') as HTMLInputElement,
|
||||
w: modal.querySelector('#op-rs-w') as HTMLInputElement,
|
||||
h: modal.querySelector('#op-rs-h') as HTMLInputElement,
|
||||
lock: modal.querySelector('#op-rs-lock') as HTMLInputElement,
|
||||
note: modal.querySelector('#op-rs-note') as any,
|
||||
onex: modal.querySelector('#op-rs-onex') as HTMLButtonElement,
|
||||
half: modal.querySelector('#op-rs-half') as HTMLButtonElement,
|
||||
third: modal.querySelector('#op-rs-third') as HTMLButtonElement,
|
||||
quarter: modal.querySelector('#op-rs-quarter') as HTMLButtonElement,
|
||||
double: modal.querySelector('#op-rs-double') as HTMLButtonElement,
|
||||
scale: modal.querySelector('#op-rs-scale') as HTMLInputElement,
|
||||
applyScale: modal.querySelector('#op-rs-apply-scale') as HTMLButtonElement,
|
||||
simWrap: modal.querySelector('#op-rs-sim-wrap') as HTMLDivElement,
|
||||
simOrig: modal.querySelector('#op-rs-sim-orig') as HTMLCanvasElement,
|
||||
simNew: modal.querySelector('#op-rs-sim-new') as HTMLCanvasElement,
|
||||
colLeft: modal.querySelector('#op-rs-col-left') as HTMLDivElement,
|
||||
colRight: modal.querySelector('#op-rs-col-right') as HTMLDivElement,
|
||||
|
||||
advWrap: modal.querySelector('#op-rs-adv-wrap') as HTMLDivElement,
|
||||
preview: modal.querySelector('#op-rs-preview') as HTMLCanvasElement,
|
||||
|
||||
meta: modal.querySelector('#op-rs-meta') as HTMLElement,
|
||||
|
||||
zoomIn: modal.querySelector('#op-rs-zoom-in') as HTMLButtonElement,
|
||||
zoomOut: modal.querySelector('#op-rs-zoom-out') as HTMLButtonElement,
|
||||
|
||||
multRange: modal.querySelector('#op-rs-mult-range') as HTMLInputElement,
|
||||
multInput: modal.querySelector('#op-rs-mult-input') as HTMLInputElement,
|
||||
bind: modal.querySelector('#op-rs-bind') as HTMLInputElement,
|
||||
blockW: modal.querySelector('#op-rs-blockw') as HTMLInputElement,
|
||||
blockH: modal.querySelector('#op-rs-blockh') as HTMLInputElement,
|
||||
offX: modal.querySelector('#op-rs-offx') as HTMLInputElement,
|
||||
offY: modal.querySelector('#op-rs-offy') as HTMLInputElement,
|
||||
dotR: modal.querySelector('#op-rs-dotr') as HTMLInputElement,
|
||||
dotRVal: modal.querySelector('#op-rs-dotr-val') as HTMLElement,
|
||||
gridToggle: modal.querySelector('#op-rs-grid') as HTMLInputElement,
|
||||
advNote: modal.querySelector('#op-rs-adv-note') as HTMLElement,
|
||||
resWrap: modal.querySelector('#op-rs-adv-result-wrap') as HTMLDivElement,
|
||||
resCanvas: modal.querySelector('#op-rs-adv-result') as HTMLCanvasElement,
|
||||
resMeta: modal.querySelector('#op-rs-adv-resmeta') as HTMLElement,
|
||||
|
||||
calcBtn: modal.querySelector('#op-rs-calc') as HTMLButtonElement,
|
||||
applyBtn: modal.querySelector('#op-rs-apply') as HTMLButtonElement,
|
||||
cancelBtn: modal.querySelector('#op-rs-cancel') as HTMLButtonElement,
|
||||
closeBtn: modal.querySelector('#op-rs-close') as HTMLButtonElement,
|
||||
};
|
||||
|
||||
const ctxPrev = refs.preview.getContext('2d', { willReadFrequently: true })!;
|
||||
const ctxSimOrig = refs.simOrig.getContext('2d', { willReadFrequently: true })!;
|
||||
const ctxSimNew = refs.simNew.getContext('2d', { willReadFrequently: true })!;
|
||||
const ctxRes = refs.resCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||
|
||||
rs = {
|
||||
...refs,
|
||||
ov: null,
|
||||
img: null,
|
||||
origW: 0, origH: 0,
|
||||
mode: 'simple',
|
||||
zoom: 1.0,
|
||||
updating: false,
|
||||
|
||||
mult: 4,
|
||||
gapX: 4, gapY: 4,
|
||||
offx: 0, offy: 0,
|
||||
dotr: 1,
|
||||
|
||||
viewX: 0, viewY: 0,
|
||||
|
||||
panning: false,
|
||||
panStart: null,
|
||||
|
||||
calcCanvas: null,
|
||||
calcCols: 0,
|
||||
calcRows: 0,
|
||||
calcReady: false,
|
||||
};
|
||||
|
||||
function computeSimpleFooterText() {
|
||||
const W = parseInt(rs!.w.value||'0',10);
|
||||
const H = parseInt(rs!.h.value||'0',10);
|
||||
const ok = Number.isFinite(W) && Number.isFinite(H) && W>0 && H>0;
|
||||
const limit = (W >= MAX_OVERLAY_DIM || H >= MAX_OVERLAY_DIM);
|
||||
return ok ? (limit ? `Target: ${W}×${H} (exceeds limit: must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})`
|
||||
: `Target: ${W}×${H} (OK)`)
|
||||
: 'Enter positive width and height.';
|
||||
}
|
||||
function sampleDims() {
|
||||
const cols = Math.floor((rs!.origW - rs!.offx) / rs!.gapX);
|
||||
const rows = Math.floor((rs!.origH - rs!.offy) / rs!.gapY);
|
||||
return { cols: Math.max(0, cols), rows: Math.max(0, rows) };
|
||||
}
|
||||
function computeAdvancedFooterText() {
|
||||
const { cols, rows } = sampleDims();
|
||||
const limit = (cols >= MAX_OVERLAY_DIM || rows >= MAX_OVERLAY_DIM);
|
||||
return (cols>0 && rows>0)
|
||||
? `Samples: ${cols} × ${rows} | Output: ${cols}×${rows}${limit ? ` (exceeds limit: < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})` : ''}`
|
||||
: 'Adjust multiplier/offset until dots sit at centers.';
|
||||
}
|
||||
const updateFooterMeta = () => {
|
||||
rs!.meta.textContent = (rs!.mode === 'advanced') ? computeAdvancedFooterText() : computeSimpleFooterText();
|
||||
};
|
||||
|
||||
function drawSimplePreview() {
|
||||
if (!rs!.img) return;
|
||||
const leftLabelH = rs!.colLeft.querySelector('.pad-top')!.clientHeight;
|
||||
const rightLabelH = rs!.colRight.querySelector('.pad-top')!.clientHeight;
|
||||
const leftW = rs!.colLeft.clientWidth;
|
||||
const rightW = rs!.colRight.clientWidth;
|
||||
const leftH = rs!.colLeft.clientHeight - leftLabelH;
|
||||
const rightH = rs!.colRight.clientHeight - rightLabelH;
|
||||
|
||||
rs!.simOrig.width = leftW; rs!.simOrig.height = leftH;
|
||||
rs!.simNew.width = rightW; rs!.simNew.height = rightH;
|
||||
|
||||
ctxSimOrig.save();
|
||||
ctxSimOrig.imageSmoothingEnabled = false;
|
||||
ctxSimOrig.clearRect(0,0,leftW,leftH);
|
||||
const sFit = Math.min(leftW / rs!.origW, leftH / rs!.origH);
|
||||
const dW = Math.max(1, Math.floor(rs!.origW * sFit));
|
||||
const dH = Math.max(1, Math.floor(rs!.origH * sFit));
|
||||
const dx0 = Math.floor((leftW - dW) / 2);
|
||||
const dy0 = Math.floor((leftH - dH) / 2);
|
||||
ctxSimOrig.drawImage(rs!.img!, 0,0, rs!.origW,rs!.origH, dx0,dy0, dW,dH);
|
||||
ctxSimOrig.restore();
|
||||
|
||||
const W = parseInt(rs!.w.value||'0',10);
|
||||
const H = parseInt(rs!.h.value||'0',10);
|
||||
ctxSimNew.save();
|
||||
ctxSimNew.imageSmoothingEnabled = false;
|
||||
ctxSimNew.clearRect(0,0,rightW,rightH);
|
||||
if (Number.isFinite(W) && Number.isFinite(H) && W>0 && H>0) {
|
||||
const tiny = createCanvas(W, H) as any;
|
||||
const tctx = tiny.getContext('2d', { willReadFrequently: true })!;
|
||||
tctx.imageSmoothingEnabled = false;
|
||||
tctx.clearRect(0,0,W,H);
|
||||
tctx.drawImage(rs!.img!, 0,0, rs!.origW,rs!.origH, 0,0, W,H);
|
||||
const id = tctx.getImageData(0,0,W,H);
|
||||
const data = id.data;
|
||||
for (let i=0;i<data.length;i+=4) { if (data[i+3] !== 0) data[i+3]=255; }
|
||||
tctx.putImageData(id, 0, 0);
|
||||
|
||||
const s2 = Math.min(rightW / W, rightH / H);
|
||||
const dW2 = Math.max(1, Math.floor(W * s2));
|
||||
const dH2 = Math.max(1, Math.floor(H * s2));
|
||||
const dx2 = Math.floor((rightW - dW2)/2);
|
||||
const dy2 = Math.floor((rightH - dH2)/2);
|
||||
ctxSimNew.drawImage(tiny, 0,0, W,H, dx2,dy2, dW2,dH2);
|
||||
} else {
|
||||
ctxSimNew.drawImage(rs!.img!, 0,0, rs!.origW,rs!.origH, dx0,dy0, dW,dH);
|
||||
}
|
||||
ctxSimNew.restore();
|
||||
}
|
||||
|
||||
function syncAdvancedMeta() {
|
||||
const { cols, rows } = sampleDims();
|
||||
const limit = (cols >= MAX_OVERLAY_DIM || rows >= MAX_OVERLAY_DIM);
|
||||
if (rs!.mode === 'advanced') {
|
||||
rs!.applyBtn.disabled = !rs!.calcReady;
|
||||
} else {
|
||||
const W = parseInt(rs!.w.value||'0',10), H = parseInt(rs!.h.value||'0',10);
|
||||
const ok = Number.isFinite(W)&&Number.isFinite(H)&&W>0&&H>0&&W<MAX_OVERLAY_DIM&&H<MAX_OVERLAY_DIM;
|
||||
rs!.applyBtn.disabled = !ok;
|
||||
}
|
||||
updateFooterMeta();
|
||||
}
|
||||
function drawAdvancedPreview() {
|
||||
if (rs!.mode !== 'advanced' || !rs!.img) return;
|
||||
const w = rs!.origW, h = rs!.origH;
|
||||
|
||||
const destW = Math.max(50, Math.floor(rs!.advWrap.clientWidth));
|
||||
const destH = Math.max(50, Math.floor(rs!.advWrap.clientHeight));
|
||||
rs!.preview.width = destW;
|
||||
rs!.preview.height = destH;
|
||||
|
||||
const sw = Math.max(1, Math.floor(destW / rs!.zoom));
|
||||
const sh = Math.max(1, Math.floor(destH / rs!.zoom));
|
||||
const maxX = Math.max(0, w - sw);
|
||||
const maxY = Math.max(0, h - sh);
|
||||
rs!.viewX = Math.min(Math.max(0, rs!.viewX), maxX);
|
||||
rs!.viewY = Math.min(Math.max(0, rs!.viewY), maxY);
|
||||
|
||||
ctxPrev.save();
|
||||
ctxPrev.imageSmoothingEnabled = false;
|
||||
ctxPrev.clearRect(0,0,destW,destH);
|
||||
ctxPrev.drawImage(rs!.img!, rs!.viewX, rs!.viewY, sw, sh, 0, 0, destW, destH);
|
||||
|
||||
if (rs!.gridToggle.checked && rs!.gapX >= 1 && rs!.gapY >= 1) {
|
||||
ctxPrev.strokeStyle = 'rgba(255,59,48,0.45)';
|
||||
ctxPrev.lineWidth = 1;
|
||||
const startGX = Math.ceil((rs!.viewX - rs!.offx) / rs!.gapX);
|
||||
const endGX = Math.floor((rs!.viewX + sw - rs!.offx) / rs!.gapX);
|
||||
const startGY = Math.ceil((rs!.viewY - rs!.offy) / rs!.gapY);
|
||||
const endGY = Math.floor((rs!.viewY + sh - rs!.offy) / rs!.gapY);
|
||||
const linesX = Math.max(0, endGX - startGX + 1);
|
||||
const linesY = Math.max(0, endGY - startGY + 1);
|
||||
if (linesX <= 4000 && linesY <= 4000) {
|
||||
ctxPrev.beginPath();
|
||||
for (let gx = startGX; gx <= endGX; gx++) {
|
||||
const x = rs!.offx + gx * rs!.gapX;
|
||||
const px = Math.round((x - rs!.viewX) * rs!.zoom);
|
||||
ctxPrev.moveTo(px + 0.5, 0);
|
||||
ctxPrev.lineTo(px + 0.5, destH);
|
||||
}
|
||||
for (let gy = startGY; gy <= endGY; gy++) {
|
||||
const y = rs!.offy + gy * rs!.gapY;
|
||||
const py = Math.round((y - rs!.viewY) * rs!.zoom);
|
||||
ctxPrev.moveTo(0, py + 0.5);
|
||||
ctxPrev.lineTo(destW, py + 0.5);
|
||||
}
|
||||
ctxPrev.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
if (rs!.gapX >= 1 && rs!.gapY >= 1) {
|
||||
ctxPrev.fillStyle = '#ff3b30';
|
||||
const cx0 = rs!.offx + Math.floor(rs!.gapX/2);
|
||||
const cy0 = rs!.offy + Math.floor(rs!.gapY/2);
|
||||
if (cx0 >= 0 && cy0 >= 0) {
|
||||
const startX = Math.ceil((rs!.viewX - cx0) / rs!.gapX);
|
||||
const startY = Math.ceil((rs!.viewY - cy0) / rs!.gapY);
|
||||
const endY = Math.floor((rs!.viewY + sh - 1 - cy0) / rs!.gapY);
|
||||
const endX2 = Math.floor((rs!.viewX + sw - 1 - cx0) / rs!.gapX);
|
||||
const r = rs!.dotr;
|
||||
const dotsX = Math.max(0, endX2 - startX + 1);
|
||||
const dotsY = Math.max(0, endY - startY + 1);
|
||||
const maxDots = 300000;
|
||||
if (dotsX * dotsY <= maxDots) {
|
||||
for (let gy = startY; gy <= endY; gy++) {
|
||||
const y = cy0 + gy * rs!.gapY;
|
||||
for (let gx = startX; gx <= endX2; gx++) {
|
||||
const x = cx0 + gx * rs!.gapX;
|
||||
const px = Math.round((x - rs!.viewX) * rs!.zoom);
|
||||
const py = Math.round((y - rs!.viewY) * rs!.zoom);
|
||||
ctxPrev.beginPath();
|
||||
ctxPrev.arc(px, py, r, 0, Math.PI*2);
|
||||
ctxPrev.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctxPrev.restore();
|
||||
}
|
||||
|
||||
function drawAdvancedResultPreview() {
|
||||
const canvas = rs!.calcCanvas;
|
||||
const wrap = rs!.resWrap;
|
||||
if (!wrap || !canvas) {
|
||||
ctxRes.clearRect(0,0, rs!.resCanvas.width, rs!.resCanvas.height);
|
||||
rs!.resMeta.textContent = 'No result. Click Calculate.';
|
||||
return;
|
||||
}
|
||||
const W = canvas.width, H = canvas.height;
|
||||
const availW = Math.max(50, Math.floor(wrap.clientWidth - 16));
|
||||
const availH = Math.max(50, Math.floor(wrap.clientHeight - 16));
|
||||
const s = Math.min(availW / W, availH / H);
|
||||
const dW = Math.max(1, Math.floor(W * s));
|
||||
const dH = Math.max(1, Math.floor(H * s));
|
||||
rs!.resCanvas.width = dW;
|
||||
rs!.resCanvas.height = dH;
|
||||
ctxRes.save();
|
||||
ctxRes.imageSmoothingEnabled = false;
|
||||
ctxRes.clearRect(0,0,dW,dH);
|
||||
ctxRes.drawImage(canvas, 0,0, W,H, 0,0, dW,dH);
|
||||
ctxRes.restore();
|
||||
rs!.resMeta.textContent = `Output: ${W}×${H}${(W>=MAX_OVERLAY_DIM||H>=MAX_OVERLAY_DIM) ? ` (exceeds limit: < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})` : ''}`;
|
||||
}
|
||||
|
||||
rs._drawSimplePreview = drawSimplePreview;
|
||||
rs._drawAdvancedPreview = drawAdvancedPreview;
|
||||
rs._drawAdvancedResultPreview = drawAdvancedResultPreview;
|
||||
|
||||
const setMode = (m: 'simple'|'advanced') => {
|
||||
rs!.mode = m;
|
||||
rs!.tabSimple.classList.toggle('active', m === 'simple');
|
||||
rs!.tabAdvanced.classList.toggle('active', m === 'advanced');
|
||||
rs!.paneSimple.classList.toggle('show', m === 'simple');
|
||||
rs!.paneAdvanced.classList.toggle('show', m === 'advanced');
|
||||
updateFooterMeta();
|
||||
|
||||
rs!.calcBtn.style.display = (m === 'advanced') ? 'inline-block' : 'none';
|
||||
if (m === 'advanced') {
|
||||
rs!.applyBtn.disabled = !rs!.calcReady;
|
||||
} else {
|
||||
syncSimpleNote();
|
||||
}
|
||||
|
||||
syncAdvancedMeta();
|
||||
if (m === 'advanced') {
|
||||
drawAdvancedPreview();
|
||||
drawAdvancedResultPreview();
|
||||
} else {
|
||||
drawSimplePreview();
|
||||
}
|
||||
};
|
||||
rs._setMode = (m) => {
|
||||
const evt = new Event('click');
|
||||
(m === 'simple' ? rs!.tabSimple : rs!.tabAdvanced).dispatchEvent(evt);
|
||||
};
|
||||
rs.tabSimple.addEventListener('click', () => setMode('simple'));
|
||||
rs.tabAdvanced.addEventListener('click', () => setMode('advanced'));
|
||||
|
||||
function onWidthInput() {
|
||||
if (rs!.updating) return;
|
||||
rs!.updating = true;
|
||||
const W = parseInt(rs!.w.value||'0',10);
|
||||
if (rs!.lock.checked && rs!.origW>0 && rs!.origH>0 && W>0) {
|
||||
rs!.h.value = String(Math.max(1, Math.round(W * rs!.origH / rs!.origW)));
|
||||
}
|
||||
rs!.updating = false;
|
||||
syncSimpleNote();
|
||||
if (rs!.mode === 'simple') drawSimplePreview();
|
||||
}
|
||||
function onHeightInput() {
|
||||
if (rs!.updating) return;
|
||||
rs!.updating = true;
|
||||
const H = parseInt(rs!.h.value||'0',10);
|
||||
if (rs!.lock.checked && rs!.origW>0 && rs!.origH>0 && H>0) {
|
||||
rs!.w.value = String(Math.max(1, Math.round(H * rs!.origW / rs!.origH)));
|
||||
}
|
||||
rs!.updating = false;
|
||||
syncSimpleNote();
|
||||
if (rs!.mode === 'simple') drawSimplePreview();
|
||||
}
|
||||
rs.w.addEventListener('input', onWidthInput);
|
||||
rs.h.addEventListener('input', onHeightInput);
|
||||
rs.onex.addEventListener('click', () => { applyScaleToFields(1); drawSimplePreview(); });
|
||||
rs.half.addEventListener('click', () => { applyScaleToFields(0.5); drawSimplePreview(); });
|
||||
rs.third.addEventListener('click', () => { applyScaleToFields(1/3); drawSimplePreview(); });
|
||||
rs.quarter.addEventListener('click', () => { applyScaleToFields(1/4); drawSimplePreview(); });
|
||||
rs.double.addEventListener('click', () => { applyScaleToFields(2); drawSimplePreview(); });
|
||||
rs.applyScale.addEventListener('click', () => {
|
||||
const s = parseFloat(rs!.scale.value||'');
|
||||
if (!Number.isFinite(s) || s<=0) { showToast('Enter a valid scale factor > 0'); return; }
|
||||
applyScaleToFields(s);
|
||||
drawSimplePreview();
|
||||
});
|
||||
|
||||
const markCalcStale = () => {
|
||||
if (rs!.mode === 'advanced') {
|
||||
rs!.calcReady = false;
|
||||
rs!.applyBtn.disabled = true;
|
||||
drawAdvancedResultPreview();
|
||||
updateFooterMeta();
|
||||
}
|
||||
};
|
||||
|
||||
const onMultChange = (v: string) => {
|
||||
if (rs!.updating) return;
|
||||
const parsed = parseFloat(v);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const clamped = Math.min(Math.max(parsed, 1), 128);
|
||||
rs!.mult = clamped;
|
||||
if (rs!.bind.checked) { rs!.gapX = clamped; rs!.gapY = clamped; }
|
||||
syncAdvFieldsToState();
|
||||
syncAdvancedMeta();
|
||||
drawAdvancedPreview();
|
||||
markCalcStale();
|
||||
};
|
||||
rs.multRange.addEventListener('input', (e) => onMultChange((e.target as HTMLInputElement).value));
|
||||
rs.multInput.addEventListener('input', (e) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
if (!Number.isFinite(parseFloat(v))) return;
|
||||
onMultChange(v);
|
||||
});
|
||||
rs.bind.addEventListener('change', () => {
|
||||
if (rs!.bind.checked) { rs!.gapX = rs!.mult; rs!.gapY = rs!.mult; syncAdvFieldsToState(); }
|
||||
syncAdvancedMeta();
|
||||
drawAdvancedPreview();
|
||||
markCalcStale();
|
||||
});
|
||||
rs.blockW.addEventListener('input', (e) => {
|
||||
const val = parseFloat((e.target as HTMLInputElement).value);
|
||||
if (!Number.isFinite(val)) return;
|
||||
rs!.gapX = Math.min(Math.max(val, 1), 4096);
|
||||
if (rs!.bind.checked) { rs!.mult = rs!.gapX; rs!.gapY = rs!.gapX; }
|
||||
syncAdvFieldsToState();
|
||||
syncAdvancedMeta();
|
||||
drawAdvancedPreview();
|
||||
markCalcStale();
|
||||
});
|
||||
rs.blockH.addEventListener('input', (e) => {
|
||||
const val = parseFloat((e.target as HTMLInputElement).value);
|
||||
if (!Number.isFinite(val)) return;
|
||||
rs!.gapY = Math.min(Math.max(val, 1), 4096);
|
||||
if (rs!.bind.checked) { rs!.mult = rs!.gapY; rs!.gapX = rs!.gapY; }
|
||||
syncAdvFieldsToState();
|
||||
syncAdvancedMeta();
|
||||
drawAdvancedPreview();
|
||||
markCalcStale();
|
||||
});
|
||||
rs.offX.addEventListener('input', (e) => {
|
||||
const val = parseFloat((e.target as HTMLInputElement).value);
|
||||
if (!Number.isFinite(val)) return;
|
||||
rs!.offx = Math.min(Math.max(val, 0), Math.max(0, rs!.origH-0.0001));
|
||||
rs!.viewX = Math.min(rs!.viewX, Math.max(0, rs!.origW - 1));
|
||||
syncAdvancedMeta();
|
||||
drawAdvancedPreview();
|
||||
markCalcStale();
|
||||
});
|
||||
rs.offY.addEventListener('input', (e) => {
|
||||
const val = parseFloat((e.target as HTMLInputElement).value);
|
||||
if (!Number.isFinite(val)) return;
|
||||
rs!.offy = Math.min(Math.max(val, 0), Math.max(0, rs!.origH-0.0001));
|
||||
rs!.viewY = Math.min(rs!.viewY, Math.max(0, rs!.origH - 1));
|
||||
syncAdvancedMeta();
|
||||
drawAdvancedPreview();
|
||||
markCalcStale();
|
||||
});
|
||||
rs.dotR.addEventListener('input', (e) => {
|
||||
rs!.dotr = Math.max(1, Math.round(Number((e.target as HTMLInputElement).value)||1));
|
||||
rs!.dotRVal.textContent = String(rs!.dotr);
|
||||
drawAdvancedPreview();
|
||||
});
|
||||
rs.gridToggle.addEventListener('change', drawAdvancedPreview);
|
||||
|
||||
function applyZoom(factor: number) {
|
||||
const destW = Math.max(50, Math.floor(rs!.advWrap.clientWidth));
|
||||
const destH = Math.max(50, Math.floor(rs!.advWrap.clientHeight));
|
||||
const sw = Math.max(1, Math.floor(destW / rs!.zoom));
|
||||
const sh = Math.max(1, Math.floor(destH / rs!.zoom));
|
||||
const cx = rs!.viewX + sw / 2;
|
||||
const cy = rs!.viewY + sh / 2;
|
||||
rs!.zoom = Math.min(32, Math.max(0.1, rs!.zoom * factor));
|
||||
const sw2 = Math.max(1, Math.floor(destW / rs!.zoom));
|
||||
const sh2 = Math.max(1, Math.floor(destH / rs!.zoom));
|
||||
rs!.viewX = Math.min(Math.max(0, Math.round(cx - sw2 / 2)), Math.max(0, rs!.origW - sw2));
|
||||
rs!.viewY = Math.min(Math.max(0, Math.round(cy - sh2 / 2)), Math.max(0, rs!.origH - sh2));
|
||||
drawAdvancedPreview();
|
||||
}
|
||||
rs.zoomIn.addEventListener('click', () => applyZoom(1.25));
|
||||
rs.zoomOut.addEventListener('click', () => applyZoom(1/1.25));
|
||||
rs.advWrap.addEventListener('wheel', (e) => {
|
||||
if (!(e as WheelEvent).ctrlKey) return;
|
||||
e.preventDefault();
|
||||
const delta = (e as WheelEvent).deltaY || 0;
|
||||
applyZoom(delta > 0 ? 1/1.15 : 1.15);
|
||||
}, { passive: false });
|
||||
|
||||
const onPanDown = (e: PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest('.op-rs-zoom')) return;
|
||||
rs!.panning = true;
|
||||
rs!.panStart = { x: e.clientX, y: e.clientY, viewX: rs!.viewX, viewY: rs!.viewY };
|
||||
rs!.advWrap.classList.remove('op-pan-grab');
|
||||
rs!.advWrap.classList.add('op-pan-grabbing');
|
||||
(rs!.advWrap as any).setPointerCapture?.(e.pointerId);
|
||||
};
|
||||
const onPanMove = (e: PointerEvent) => {
|
||||
if (!rs!.panning) return;
|
||||
const dx = e.clientX - rs!.panStart!.x;
|
||||
const dy = e.clientY - rs!.panStart!.y;
|
||||
const wrapW = rs!.advWrap.clientWidth;
|
||||
const wrapH = rs!.advWrap.clientHeight;
|
||||
const sw = Math.max(1, Math.floor(wrapW / rs!.zoom));
|
||||
const sh = Math.max(1, Math.floor(wrapH / rs!.zoom));
|
||||
let nx = rs!.panStart!.viewX - Math.round(dx / rs!.zoom);
|
||||
let ny = rs!.panStart!.viewY - Math.round(dy / rs!.zoom);
|
||||
nx = Math.min(Math.max(0, nx), Math.max(0, rs!.origW - sw));
|
||||
ny = Math.min(Math.max(0, ny), Math.max(0, rs!.origH - sh));
|
||||
rs!.viewX = nx;
|
||||
rs!.viewY = ny;
|
||||
drawAdvancedPreview();
|
||||
};
|
||||
const onPanUp = (e: PointerEvent) => {
|
||||
if (!rs!.panning) return;
|
||||
rs!.panning = false;
|
||||
rs!.panStart = null;
|
||||
rs!.advWrap.classList.remove('op-pan-grabbing');
|
||||
rs!.advWrap.classList.add('op-pan-grab');
|
||||
(rs!.advWrap as any).releasePointerCapture?.(e.pointerId);
|
||||
};
|
||||
rs.advWrap.addEventListener('pointerdown', onPanDown);
|
||||
rs.advWrap.addEventListener('pointermove', onPanMove);
|
||||
rs.advWrap.addEventListener('pointerup', onPanUp);
|
||||
rs.advWrap.addEventListener('pointercancel', onPanUp);
|
||||
rs.advWrap.addEventListener('pointerleave', onPanUp);
|
||||
|
||||
const close = () => closeRSModal();
|
||||
rs.cancelBtn.addEventListener('click', close);
|
||||
rs.closeBtn.addEventListener('click', close);
|
||||
backdrop.addEventListener('click', close);
|
||||
|
||||
rs.calcBtn.addEventListener('click', async () => {
|
||||
if (rs!.mode !== 'advanced' || !rs!.img) return;
|
||||
try {
|
||||
const { cols, rows } = sampleDims();
|
||||
if (cols<=0 || rows<=0) { showToast('No samples. Adjust multiplier/offset.'); return; }
|
||||
if (cols >= MAX_OVERLAY_DIM || rows >= MAX_OVERLAY_DIM) { showToast(`Output too large. Must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM}.`); return; }
|
||||
const canvas = await reconstructViaGrid(rs!.img, rs!.origW, rs!.origH, rs!.offx, rs!.offy, rs!.gapX, rs!.gapY);
|
||||
rs!.calcCanvas = canvas;
|
||||
rs!.calcCols = cols;
|
||||
rs!.calcRows = rows;
|
||||
rs!.calcReady = true;
|
||||
rs!.applyBtn.disabled = false;
|
||||
drawAdvancedResultPreview();
|
||||
updateFooterMeta();
|
||||
showToast(`Calculated ${cols}×${rows}. Review preview, then Apply.`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Calculation failed.');
|
||||
}
|
||||
});
|
||||
|
||||
rs.applyBtn.addEventListener('click', async () => {
|
||||
if (!rs!.ov) return;
|
||||
try {
|
||||
if (rs!.mode === 'simple') {
|
||||
const W = parseInt(rs!.w.value||'0',10);
|
||||
const H = parseInt(rs!.h.value||'0',10);
|
||||
if (!Number.isFinite(W) || !Number.isFinite(H) || W<=0 || H<=0) { showToast('Invalid dimensions'); return; }
|
||||
if (W >= MAX_OVERLAY_DIM || H >= MAX_OVERLAY_DIM) { showToast(`Too large. Must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM}.`); return; }
|
||||
await resizeOverlayImage(rs!.ov, W, H);
|
||||
closeRSModal();
|
||||
showToast(`Resized to ${W}×${H}.`);
|
||||
} else {
|
||||
if (!rs!.calcReady || !rs!.calcCanvas) { showToast('Calculate first.'); return; }
|
||||
const dataUrl = await canvasToDataURLSafe(rs!.calcCanvas);
|
||||
rs!.ov.imageBase64 = dataUrl;
|
||||
rs!.ov.imageUrl = null;
|
||||
rs!.ov.isLocal = true;
|
||||
await saveConfig(['overlays']);
|
||||
clearOverlayCache();
|
||||
ensureHook();
|
||||
emitOverlayChanged();
|
||||
closeRSModal();
|
||||
showToast(`Applied ${rs!.calcCols}×${rs!.calcRows}.`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Apply failed.');
|
||||
}
|
||||
});
|
||||
|
||||
function syncSimpleNote() {
|
||||
const W = parseInt(rs!.w.value||'0',10);
|
||||
const H = parseInt(rs!.h.value||'0',10);
|
||||
const ok = Number.isFinite(W) && Number.isFinite(H) && W>0 && H>0;
|
||||
const limit = (W >= MAX_OVERLAY_DIM || H >= MAX_OVERLAY_DIM);
|
||||
const simpleText = ok
|
||||
? (limit ? `Target: ${W}×${H} (exceeds limit: must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})`
|
||||
: `Target: ${W}×${H} (OK)`)
|
||||
: 'Enter positive width and height.';
|
||||
if (rs!.note) rs!.note.textContent = simpleText;
|
||||
if (rs!.mode === 'simple') rs!.applyBtn.disabled = (!ok || limit);
|
||||
if (rs!.mode === 'simple') rs!.meta.textContent = simpleText;
|
||||
}
|
||||
function applyScaleToFields(scale: number) {
|
||||
const W = Math.max(1, Math.round(rs!.origW * scale));
|
||||
const H = Math.max(1, Math.round(rs!.origH * scale));
|
||||
rs!.updating = true;
|
||||
rs!.w.value = String(W);
|
||||
rs!.h.value = rs!.lock.checked ? String(Math.max(1, Math.round(W * rs!.origH / rs!.origW))) : String(H);
|
||||
rs!.updating = false;
|
||||
syncSimpleNote();
|
||||
}
|
||||
function syncAdvFieldsToState() {
|
||||
rs!.updating = true;
|
||||
rs!.multRange.value = String(rs!.mult);
|
||||
rs!.multInput.value = String(rs!.mult);
|
||||
rs!.blockW.value = String(rs!.gapX);
|
||||
rs!.blockH.value = String(rs!.gapY);
|
||||
rs!.offX.value = String(rs!.offx);
|
||||
rs!.offY.value = String(rs!.offy);
|
||||
rs!.dotR.value = String(rs!.dotr);
|
||||
rs!.dotRVal.textContent = String(rs!.dotr);
|
||||
rs!.updating = false;
|
||||
}
|
||||
|
||||
rs._syncAdvancedMeta = syncAdvancedMeta;
|
||||
rs._syncSimpleNote = syncSimpleNote;
|
||||
|
||||
rs._resizeHandler = () => {
|
||||
if (rs!.mode === 'simple') rs!._drawSimplePreview?.();
|
||||
else {
|
||||
rs!._drawAdvancedPreview?.();
|
||||
rs!._drawAdvancedResultPreview?.();
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', rs._resizeHandler);
|
||||
}
|
||||
|
||||
export function openRSModal(overlay: any) {
|
||||
if (!rs) return;
|
||||
rs.ov = overlay;
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
rs!.img = img;
|
||||
rs!.origW = img.width; rs!.origH = img.height;
|
||||
|
||||
rs!.orig.value = `${rs!.origW}×${rs!.origH}`;
|
||||
rs!.w.value = String(rs!.origW);
|
||||
rs!.h.value = String(rs!.origH);
|
||||
rs!.lock.checked = true;
|
||||
|
||||
rs!.zoom = 1.0;
|
||||
rs!.mult = 4;
|
||||
rs!.gapX = 4; rs!.gapY = 4;
|
||||
rs!.offx = 0; rs!.offy = 0;
|
||||
rs!.dotr = 1;
|
||||
rs!.viewX = 0; rs!.viewY = 0;
|
||||
|
||||
rs!.bind.checked = true;
|
||||
rs!.multRange.value = '4';
|
||||
rs!.multInput.value = '4';
|
||||
rs!.blockW.value = '4';
|
||||
rs!.blockH.value = '4';
|
||||
rs!.offX.value = '0';
|
||||
rs!.offY.value = '0';
|
||||
rs!.dotR.value = '1';
|
||||
rs!.dotRVal.textContent = '1';
|
||||
rs!.gridToggle.checked = true;
|
||||
|
||||
rs!.calcCanvas = null;
|
||||
rs!.calcCols = 0;
|
||||
rs!.calcRows = 0;
|
||||
rs!.calcReady = false;
|
||||
rs!.applyBtn.disabled = (rs!.mode === 'advanced');
|
||||
|
||||
rs!._setMode!('simple');
|
||||
|
||||
document.body.classList.add('op-scroll-lock');
|
||||
rs!.backdrop.classList.add('show');
|
||||
rs!.modal.style.display = 'flex';
|
||||
|
||||
rs!._drawSimplePreview?.();
|
||||
rs!._drawAdvancedPreview?.();
|
||||
rs!._drawAdvancedResultPreview?.();
|
||||
rs!._syncAdvancedMeta?.();
|
||||
rs!._syncSimpleNote?.();
|
||||
|
||||
const setFooterNow = () => {
|
||||
if (rs!.mode === 'advanced') {
|
||||
const cols = Math.floor((rs!.origW - rs!.offx) / rs!.gapX);
|
||||
const rows = Math.floor((rs!.origH - rs!.offy) / rs!.gapY);
|
||||
rs!.meta.textContent = (cols>0&&rows>0) ? `Samples: ${cols} × ${rows} | Output: ${cols}×${rows}${(cols>=MAX_OVERLAY_DIM||rows>=MAX_OVERLAY_DIM)?` (exceeds limit: < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})`:''}` : 'Adjust multiplier/offset until dots sit at centers.';
|
||||
} else {
|
||||
const W = parseInt(rs!.w.value||'0',10); const H = parseInt(rs!.h.value||'0',10);
|
||||
const ok = Number.isFinite(W)&&Number.isFinite(H)&&W>0&&H>0;
|
||||
const limit = (W>=MAX_OVERLAY_DIM||H>=MAX_OVERLAY_DIM);
|
||||
rs!.meta.textContent = ok ? (limit ? `Target: ${W}×${H} (exceeds limit: must be < ${MAX_OVERLAY_DIM}×${MAX_OVERLAY_DIM})` : `Target: ${W}×${H} (OK)`) : 'Enter positive width and height.';
|
||||
}
|
||||
};
|
||||
setFooterNow();
|
||||
};
|
||||
img.src = overlay.imageBase64;
|
||||
}
|
||||
|
||||
function closeRSModal() {
|
||||
if (!rs) return;
|
||||
window.removeEventListener('resize', rs._resizeHandler || (()=>{}));
|
||||
rs.backdrop.classList.remove('show');
|
||||
rs.modal.style.display = 'none';
|
||||
rs.ov = null;
|
||||
rs.img = null;
|
||||
document.body.classList.remove('op-scroll-lock');
|
||||
}
|
||||
|
||||
async function reconstructViaGrid(img: HTMLImageElement, origW: number, origH: number, offx: number, offy: number, gapX: number, gapY: number) {
|
||||
const srcCanvas = createCanvas(origW, origH) as any;
|
||||
const sctx = srcCanvas.getContext('2d', { willReadFrequently: true })!;
|
||||
sctx.imageSmoothingEnabled = false;
|
||||
sctx.drawImage(img, 0, 0);
|
||||
const srcData = sctx.getImageData(0,0,origW,origH).data;
|
||||
|
||||
const cols = Math.floor((origW - offx) / gapX);
|
||||
const rows = Math.floor((origH - offy) / gapY);
|
||||
if (cols <= 0 || rows <= 0) throw new Error('No samples available with current offset/gap');
|
||||
|
||||
const outCanvas = createHTMLCanvas(cols, rows);
|
||||
const octx = outCanvas.getContext('2d')!;
|
||||
const out = octx.createImageData(cols, rows);
|
||||
const odata = out.data;
|
||||
|
||||
const cx0 = offx + gapX / 2;
|
||||
const cy0 = offy + gapY / 2;
|
||||
|
||||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
|
||||
for (let ry=0; ry<rows; ry++) {
|
||||
for (let rx=0; rx<cols; rx++) {
|
||||
const sx = Math.round(clamp(cx0 + rx*gapX, 0, origW-1));
|
||||
const sy = Math.round(clamp(cy0 + ry*gapY, 0, origH-1));
|
||||
const si = (sy*origW + sx) * 4;
|
||||
|
||||
const r = srcData[si];
|
||||
const g = srcData[si+1];
|
||||
const b = srcData[si+2];
|
||||
const a = srcData[si+3];
|
||||
|
||||
const oi = (ry*cols + rx) * 4;
|
||||
if (a === 0) {
|
||||
odata[oi] = 0; odata[oi+1] = 0; odata[oi+2] = 0; odata[oi+3] = 0;
|
||||
} else {
|
||||
odata[oi] = r; odata[oi+1] = g; odata[oi+2] = b; odata[oi+3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
octx.putImageData(out, 0, 0);
|
||||
return outCanvas;
|
||||
}
|
||||
|
||||
async function resizeOverlayImage(ov: any, targetW: number, targetH: number) {
|
||||
const img = await loadImage(ov.imageBase64);
|
||||
const canvas = createHTMLCanvas(targetW, targetH);
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0,0,targetW,targetH);
|
||||
ctx.drawImage(img, 0,0, img.width,img.height, 0,0, targetW,targetH);
|
||||
const id = ctx.getImageData(0,0,targetW,targetH);
|
||||
const data = id.data;
|
||||
for (let i=0;i<data.length;i+=4) {
|
||||
if (data[i+3] === 0) { data[i]=0; data[i+1]=0; data[i+2]=0; data[i+3]=0; }
|
||||
else { data[i+3] = 255; }
|
||||
}
|
||||
ctx.putImageData(id, 0, 0);
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
ov.imageBase64 = dataUrl;
|
||||
ov.imageUrl = null;
|
||||
ov.isLocal = true;
|
||||
await saveConfig(['overlays']);
|
||||
clearOverlayCache();
|
||||
ensureHook();
|
||||
emitOverlayChanged();
|
||||
}
|
||||
186
src/ui/styles.ts
Normal file
186
src/ui/styles.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
export function injectStyles() {
|
||||
if (document.getElementById('op-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'op-styles';
|
||||
style.textContent = `
|
||||
body.op-theme-light {
|
||||
--op-bg: #ffffff;
|
||||
--op-border: #e6ebf2;
|
||||
--op-muted: #6b7280;
|
||||
--op-text: #111827;
|
||||
--op-subtle: #f4f6fb;
|
||||
--op-btn: #eef2f7;
|
||||
--op-btn-border: #d8dee8;
|
||||
--op-btn-hover: #e7ecf5;
|
||||
--op-accent: #1e88e5;
|
||||
}
|
||||
body.op-theme-dark {
|
||||
--op-bg: #1b1e24;
|
||||
--op-border: #2a2f3a;
|
||||
--op-muted: #a0a7b4;
|
||||
--op-text: #f5f6f9;
|
||||
--op-subtle: #151922;
|
||||
--op-btn: #262b36;
|
||||
--op-btn-border: #384050;
|
||||
--op-btn-hover: #2f3542;
|
||||
--op-accent: #64b5f6;
|
||||
}
|
||||
.op-scroll-lock { overflow: hidden !important; }
|
||||
|
||||
#overlay-pro-panel {
|
||||
position: fixed; z-index: 9999; background: var(--op-bg); border: 1px solid var(--op-border);
|
||||
border-radius: 16px; color: var(--op-text); font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
font-size: 14px; width: 340px; box-shadow: 0 10px 24px rgba(16,24,40,0.12), 0 2px 6px rgba(16,24,40,0.08); user-select: none;
|
||||
}
|
||||
|
||||
.op-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--op-border); border-radius: 16px 16px 0 0; cursor: grab; }
|
||||
.op-header:active { cursor: grabbing; }
|
||||
.op-header h3 { margin: 0; font-size: 15px; font-weight: 600; }
|
||||
.op-header-actions { display: flex; gap: 6px; }
|
||||
.op-toggle-btn, .op-hdr-btn { background: transparent; border: 1px solid var(--op-border); color: var(--op-text); border-radius: 10px; padding: 4px 8px; cursor: pointer; }
|
||||
.op-toggle-btn:hover, .op-hdr-btn:hover { background: var(--op-btn); }
|
||||
|
||||
.op-content { padding: 12px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.op-section { display: flex; flex-direction: column; gap: 8px; background: var(--op-subtle); border: 1px solid var(--op-border); border-radius: 12px; padding: 5px; }
|
||||
|
||||
.op-section-title { display: flex; align-items: center; justify-content: space-between; }
|
||||
.op-title-text { font-weight: 600; }
|
||||
.op-chevron { background: transparent; border: 1px solid var(--op-border); border-radius: 8px; padding: 2px 6px; cursor: pointer; }
|
||||
.op-chevron:hover { background: var(--op-btn); }
|
||||
|
||||
.op-row { display: flex; align-items: center; gap: 8px; }
|
||||
.op-row.space { justify-content: space-between; }
|
||||
|
||||
.op-button { background: var(--op-btn); color: var(--op-text); border: 1px solid var(--op-btn-border); border-radius: 10px; padding: 6px 10px; cursor: pointer; }
|
||||
.op-button:hover { background: var(--op-btn-hover); }
|
||||
.op-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.op-button.icon { width: 30px; height: 30px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 16px; }
|
||||
|
||||
.op-input, .op-select { background: var(--op-bg); border: 1px solid var(--op-border); color: var(--op-text); border-radius: 10px; padding: 6px 8px; }
|
||||
.op-slider { width: 100%; }
|
||||
|
||||
.op-list { display: flex; flex-direction: column; gap: 6px; max-height: 140px; overflow: auto; border: 1px solid var(--op-border); padding: 6px; border-radius: 10px; background: var(--op-bg); }
|
||||
|
||||
.op-item { display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 8px; border: 1px solid var(--op-border); background: var(--op-subtle); }
|
||||
.op-item.active { outline: 2px solid color-mix(in oklab, var(--op-accent) 35%, transparent); background: var(--op-bg); }
|
||||
.op-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.op-muted { color: var(--op-muted); font-size: 12px; }
|
||||
|
||||
.op-preview { width: 100%; height: 90px; background: var(--op-bg); display: flex; align-items: center; justify-content: center; border: 2px dashed color-mix(in oklab, var(--op-accent) 40%, var(--op-border)); border-radius: 10px; overflow: hidden; position: relative; cursor: pointer; }
|
||||
.op-preview img { max-width: 100%; max-height: 100%; display: block; pointer-events: none; }
|
||||
.op-preview.drop-highlight { background: color-mix(in oklab, var(--op-accent) 12%, transparent); }
|
||||
.op-preview .op-drop-hint { position: absolute; bottom: 6px; right: 8px; font-size: 11px; color: var(--op-muted); pointer-events: none; }
|
||||
|
||||
.op-icon-btn { background: var(--op-btn); color: var(--op-text); border: 1px solid var(--op-btn-border); border-radius: 10px; width: 34px; height: 34px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.op-icon-btn:hover { background: var(--op-btn-hover); }
|
||||
|
||||
.op-danger { background: #fee2e2; border-color: #fecaca; color: #7f1d1d; }
|
||||
.op-danger-text { color: #dc2626; font-weight: 600; }
|
||||
|
||||
.op-toast-stack { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; z-index: 999999; width: min(92vw, 480px); }
|
||||
.op-toast { background: rgba(255,255,255,0.98); border: 1px solid #e6ebf2; color: #111827; padding: 8px 12px; border-radius: 10px; font-size: 12px; box-shadow: 0 6px 16px rgba(16,24,40,0.12); opacity: 0; transform: translateY(-6px); transition: opacity .18s ease, transform .18s ease; max-width: 100%; text-align: center; }
|
||||
.op-toast.show { opacity: 1; transform: translateY(0); }
|
||||
.op-toast-stack.op-dark .op-toast { background: rgba(27,30,36,0.98); border-color: #2a2f3a; color: #f5f6f9; }
|
||||
|
||||
.op-cc-backdrop { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.45); display: none; }
|
||||
.op-cc-backdrop.show { display: block; }
|
||||
|
||||
.op-cc-modal {
|
||||
position: fixed; z-index: 10001;
|
||||
width: min(1280px, 98vw);
|
||||
max-height: 92vh;
|
||||
left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||
background: var(--op-bg); color: var(--op-text);
|
||||
border: 1px solid var(--op-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.28);
|
||||
display: none; flex-direction: column;
|
||||
}
|
||||
.op-cc-header { padding: 10px 12px; border-bottom: 1px solid var(--op-border); display: flex; align-items: center; justify-content: space-between; user-select: none; cursor: default; }
|
||||
.op-cc-title { font-weight: 600; }
|
||||
.op-cc-close { border: 1px solid var(--op-border); background: transparent; border-radius: 8px; padding: 4px 8px; cursor: pointer; }
|
||||
.op-cc-close:hover { background: var(--op-btn); }
|
||||
.op-cc-pill { border-radius: 999px; padding: 4px 10px; border: 1px solid var(--op-border); background: var(--op-bg); }
|
||||
|
||||
.op-cc-body {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 420px;
|
||||
grid-template-areas: "preview controls";
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.op-cc-body { grid-template-columns: 1fr; grid-template-areas: "preview" "controls"; max-height: calc(92vh - 100px); overflow: auto; }
|
||||
}
|
||||
|
||||
.op-cc-preview-wrap { grid-area: preview; background: var(--op-subtle); border: 1px solid var(--op-border); border-radius: 12px; position: relative; min-height: 320px; display: flex; align-items: center; justify-content: center; overflow: auto; }
|
||||
.op-cc-canvas { image-rendering: pixelated; }
|
||||
.op-cc-zoom { position: absolute; top: 8px; right: 8px; display: inline-flex; gap: 6px; }
|
||||
.op-cc-zoom .op-icon-btn { width: 34px; height: 34px; }
|
||||
|
||||
.op-cc-controls { grid-area: controls; display: flex; flex-direction: column; gap: 12px; background: var(--op-subtle); border: 1px solid var(--op-border); border-radius: 12px; padding: 10px; overflow: auto; max-height: calc(92vh - 160px); }
|
||||
.op-cc-block { display: flex; flex-direction: column; gap: 6px; }
|
||||
.op-cc-block label { color: var(--op-muted); font-weight: 600; }
|
||||
|
||||
.op-cc-palette { display: flex; flex-direction: column; gap: 8px; background: var(--op-bg); border: 1px dashed var(--op-border); border-radius: 10px; padding: 8px; }
|
||||
.op-cc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(22px, 22px)); gap: 6px; }
|
||||
.op-cc-cell { width: 22px; height: 22px; border-radius: 4px; border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,0.15) inset; cursor: pointer; }
|
||||
.op-cc-cell.active { outline: 2px solid var(--op-accent); }
|
||||
|
||||
.op-cc-footer { padding: 10px 12px; border-top: 1px solid var(--op-border); display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||
.op-cc-actions { display: inline-flex; gap: 8px; }
|
||||
.op-cc-ghost { color: var(--op-muted); font-size: 12px; }
|
||||
|
||||
.op-rs-backdrop { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.45); display: none; }
|
||||
.op-rs-backdrop.show { display: block; }
|
||||
|
||||
.op-rs-modal {
|
||||
position: fixed; z-index: 10001;
|
||||
width: min(1200px, 96vw);
|
||||
left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||
background: var(--op-bg); color: var(--op-text);
|
||||
border: 1px solid var(--op-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.28);
|
||||
display: none; flex-direction: column;
|
||||
max-height: 92vh;
|
||||
}
|
||||
.op-rs-header { padding: 10px 12px; border-bottom: 1px solid var(--op-border); display: flex; align-items: center; justify-content: space-between; user-select: none; cursor: default; }
|
||||
.op-rs-title { font-weight: 600; }
|
||||
.op-rs-close { border: 1px solid var(--op-border); background: transparent; border-radius: 8px; padding: 4px 8px; cursor: pointer; }
|
||||
.op-rs-close:hover { background: var(--op-btn); }
|
||||
|
||||
.op-rs-tabs { display: flex; gap: 6px; padding: 8px 12px 0 12px; }
|
||||
.op-rs-tab-btn { background: var(--op-btn); color: var(--op-text); border: 1px solid var(--op-btn-border); border-radius: 10px; padding: 6px 10px; cursor: pointer; }
|
||||
.op-rs-tab-btn.active { outline: 2px solid color-mix(in oklab, var(--op-accent) 35%, transparent); background: var(--op-btn-hover); }
|
||||
|
||||
.op-rs-body { padding: 12px; display: grid; grid-template-columns: 1fr; gap: 10px; overflow: auto; }
|
||||
.op-rs-row { display: flex; align-items: center; gap: 8px; }
|
||||
.op-rs-row .op-input { flex: 1; }
|
||||
|
||||
.op-rs-pane { display: none; }
|
||||
.op-rs-pane.show { display: block; }
|
||||
|
||||
.op-rs-preview-wrap { background: var(--op-subtle); border: 1px solid var(--op-border); border-radius: 12px; position: relative; height: clamp(260px, 36vh, 540px); display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||
.op-rs-canvas { image-rendering: pixelated; }
|
||||
|
||||
.op-rs-zoom { position: absolute; top: 8px; right: 8px; display: inline-flex; gap: 6px; }
|
||||
|
||||
.op-rs-grid-note { color: var(--op-muted); font-size: 12px; }
|
||||
.op-rs-mini { width: 96px; }
|
||||
|
||||
.op-rs-dual { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; width: 100%; height: 100%; padding: 8px; box-sizing: border-box; }
|
||||
.op-rs-col { position: relative; background: var(--op-bg); border: 1px dashed var(--op-border); border-radius: 10px; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; overflow: hidden; }
|
||||
.op-rs-col .label { position: absolute; top: 2px; left: 0; right: 0; text-align: center; font-size: 12px; color: var(--op-muted); pointer-events: none; }
|
||||
.op-rs-col .pad-top { height: 18px; width: 100%; flex: 0 0 auto; }
|
||||
.op-rs-thumb { width: 100%; height: calc(100% - 18px); display: block; }
|
||||
|
||||
.op-pan-grab { cursor: grab; }
|
||||
.op-pan-grabbing { cursor: grabbing; }
|
||||
|
||||
.op-rs-footer { padding: 10px 12px; border-top: 1px solid var(--op-border); display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2021", "DOM", "WebWorker"],
|
||||
"strict": false,
|
||||
"skipLibCheck": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["tampermonkey"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Reference in a new issue