Site updated: 2026-05-13 16:50:34

This commit is contained in:
llbzow
2026-05-13 16:50:38 +08:00
parent 8f2e89e58c
commit 54f0d09b31
361 changed files with 204078 additions and 0 deletions

77
js/tools/base64-img.js Normal file
View File

@@ -0,0 +1,77 @@
(function() {
function initBase64Tool() {
const container = document.getElementById('tool-base64-container');
if (!container) return;
container.addEventListener('click', (e) => e.stopPropagation());
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const outputBase64 = document.getElementById('output-base64');
const btnCopy = document.getElementById('btn-copy');
const btnClear = document.getElementById('btn-clear');
const inputBase64 = document.getElementById('input-base64');
const btnPreview = document.getElementById('btn-preview');
const imagePreview = document.getElementById('image-preview');
// Image to Base64
function processFile(file) {
if (!file.type.startsWith('image/')) {
alert('请上传图片文件');
return;
}
outputBase64.value = '处理中...';
const reader = new FileReader();
reader.onload = (e) => {
outputBase64.value = e.target.result;
};
reader.onerror = () => {
alert('读取文件失败');
outputBase64.value = '';
};
reader.readAsDataURL(file);
}
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.background = '#eef4fe';
});
dropZone.addEventListener('dragleave', () => {
dropZone.style.background = '';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.background = '';
const files = e.dataTransfer.files;
if (files.length) processFile(files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) processFile(fileInput.files[0]);
});
btnCopy.addEventListener('click', () => {
if (!outputBase64.value) return;
outputBase64.select();
navigator.clipboard.writeText(outputBase64.value).then(() => alert('已复制到剪贴板'));
});
btnClear.addEventListener('click', () => {
outputBase64.value = '';
fileInput.value = '';
});
// Base64 to Image
btnPreview.addEventListener('click', () => {
const val = inputBase64.value.trim();
if (!val) {
imagePreview.innerHTML = '<span style="color: #999;">预览区域</span>';
return;
}
imagePreview.innerHTML = `<img src="${val}" style="max-width:100%; box-shadow:0 2px 8px rgba(0,0,0,0.2);" onerror="this.parentElement.innerHTML='<span style=\\'color:red\\'>无法解析图片,请检查 Base64 代码是否完整</span>'">`;
});
}
document.addEventListener('DOMContentLoaded', initBase64Tool);
document.addEventListener('pjax:complete', initBase64Tool);
})();

605
js/tools/converter.js Normal file
View File

@@ -0,0 +1,605 @@
(function () {
const DEFAULT_BASE_URL = 'https://doc.luozili.work';
const DEFAULT_API_KEY = '';
const POLL_INTERVAL_MS = 3000;
const MAX_POLL_ROUNDS = 120;
const MAX_CONCURRENCY = 2;
const DEBUG_PREFIX = '[ConvertX-Debug]';
const DEBUG_LOG_LIMIT = 200;
const ASSET_STORAGE_KEY = 'convertx_asset_center_v1';
const FALLBACK_CONVERTER_MAP = {
jpg: 'imagemagick', png: 'imagemagick', webp: 'imagemagick',
pdf: 'libreoffice', docx: 'libreoffice', csv: 'dasel',
xlsx: 'libreoffice', md: 'markitDown', txt: 'pandoc',
gif: 'ffmpeg', mp4: 'ffmpeg', mp3: 'ffmpeg'
};
const Bus = {
listeners: {},
on(event, handler) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(handler);
},
emit(event, payload) {
(this.listeners[event] || []).forEach((fn) => {
try { fn(payload); } catch (e) { console.warn(e); }
});
}
};
const Store = {
state: {
files: [],
targetFormat: null,
tasks: {},
running: 0,
converterCapabilities: {}
},
setFiles(files) {
this.state.files = files;
Bus.emit('files:changed', files);
},
setTargetFormat(fmt) {
this.state.targetFormat = fmt;
Bus.emit('target:changed', fmt);
},
upsertTask(task) {
this.state.tasks[task.id] = { ...(this.state.tasks[task.id] || {}), ...task };
Bus.emit('task:updated', this.state.tasks[task.id]);
},
getTask(id) {
return this.state.tasks[id] || null;
},
getTasks() {
return Object.values(this.state.tasks).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
},
clearTasks() {
this.state.tasks = {};
Bus.emit('queue:reset');
}
};
const Api = {
getBaseUrl() {
const val = (document.getElementById('convertx-base-url')?.value || '').trim();
return (val || DEFAULT_BASE_URL).replace(/\/+$/, '');
},
getApiKey() {
const val = (document.getElementById('api-key')?.value || '').trim();
return val || DEFAULT_API_KEY;
},
getAuthMode(apiKey) {
return apiKey ? 'X-API-Key + Cookie' : 'Cookie';
},
async request(path, options = {}) {
const baseUrl = this.getBaseUrl();
const apiKey = this.getApiKey();
const url = `${baseUrl}${path}`;
const pageOrigin = window.location.origin;
let credentialsMode = 'include';
let sameOrigin = true;
try {
sameOrigin = new URL(url, window.location.href).origin === window.location.origin;
credentialsMode = sameOrigin ? 'include' : 'omit';
} catch (_) {
credentialsMode = 'include';
sameOrigin = true;
}
const traceId = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const headers = { ...(options.headers || {}) };
const useApiKey = options.useApiKey !== false;
if (useApiKey && apiKey) headers['X-API-Key'] = apiKey;
const debugMeta = options.debugMeta || null;
const parseResponse = async (res, traceIdForLog, requestUrl) => {
const text = await res.text();
debugLog('api.response', { traceId: traceIdForLog, status: res.status, url: requestUrl, body_preview: (text || '').slice(0, 300) });
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; }
return { ok: res.ok, status: res.status, text, data, headers: res.headers, traceId: traceIdForLog };
};
debugLog('api.request', {
traceId,
url,
method: options.method || 'GET',
auth: this.getAuthMode(useApiKey ? apiKey : ''),
page_origin: pageOrigin,
online: navigator.onLine,
has_custom_headers: Object.keys(headers).length > 0,
use_api_key: useApiKey,
credentials_mode: credentialsMode,
same_origin: sameOrigin,
debug_meta: debugMeta
});
let res;
try {
res = await fetch(url, { ...options, headers, credentials: credentialsMode });
} catch (err) {
debugLog('api.network_error', {
traceId,
url,
page_origin: pageOrigin,
error_name: err?.name || 'UnknownError',
error_message: err?.message || 'Failed to fetch',
online: navigator.onLine,
protocol_mode: (document.getElementById('protocol-mode')?.value || 'auto'),
credentials_mode: credentialsMode,
same_origin: sameOrigin,
debug_meta: debugMeta,
curl_probe: {
options: `curl -i -X OPTIONS "${url}" -H "Origin: ${pageOrigin}" -H "Access-Control-Request-Method: ${options.method || 'POST'}" -H "Access-Control-Request-Headers: x-api-key,content-type"`,
post_no_key: `curl -i -X ${(options.method || 'POST').toUpperCase()} "${url}" -H "Origin: ${pageOrigin}"`,
post_with_key: `curl -i -X ${(options.method || 'POST').toUpperCase()} "${url}" -H "Origin: ${pageOrigin}" -H "X-API-Key: <api_key>"`
}
});
if (url.includes('/convert')) {
throw new Error('网络层失败:浏览器未拿到可用的跨域响应(非业务 4xx/5xx。请检查 POST /convert 是否返回 Access-Control-Allow-Origin以及 OPTIONS/POST 的 CORS 头是否一致,并确认放行 X-API-Key');
}
throw new Error('网络层失败(可能是 CSP connect-src / 跨域预检 / 浏览器安全策略),请检查浏览器 Network 与响应头');
}
return await parseResponse(res, traceId, url);
}
};
function createTaskFromFile(file) {
const id = Date.now() + Math.floor(Math.random() * 100000);
const manualConverter = (document.getElementById('converter-name')?.value || '').trim();
const converter = manualConverter || getRecommendedConverter(Store.state.targetFormat) || FALLBACK_CONVERTER_MAP[Store.state.targetFormat] || '';
return {
id,
createdAt: Date.now(),
file,
filename: file.name,
targetFormat: Store.state.targetFormat,
converter,
status: 'pending',
attempts: 0,
message: '等待中...'
};
}
async function runScheduler() {
const pending = Store.getTasks().filter((t) => t.status === 'pending');
if (!pending.length) return;
while (Store.state.running < MAX_CONCURRENCY && pending.length) {
const task = pending.shift();
Store.state.running += 1;
processTask(task.id).finally(() => {
Store.state.running -= 1;
runScheduler();
});
}
}
async function processTask(taskId) {
const task = Store.getTask(taskId);
if (!task) return;
Store.upsertTask({ id: taskId, status: 'running', message: '准备提交任务...', attempts: (task.attempts || 0) + 1 });
try {
const formData = new FormData();
formData.append('file', task.file);
formData.append('target_format', task.targetFormat);
if (task.converter) formData.append('converter', task.converter);
debugLog('task.submit.meta', {
taskId,
filename: task.filename,
file_size: task.file?.size || 0,
file_size_bucket: (function () {
const size = task.file?.size || 0;
if (size < 100 * 1024) return '<100KB';
if (size < 1024 * 1024) return '100KB-1MB';
if (size < 10 * 1024 * 1024) return '1MB-10MB';
return '>=10MB';
})(),
target_format: task.targetFormat,
converter: task.converter || null,
protocol_mode: (document.getElementById('protocol-mode')?.value || 'auto')
});
let submit;
try {
submit = await Api.request('/convert', {
method: 'POST',
body: formData,
useApiKey: true,
debugMeta: { filename: task.filename, file_size: task.file?.size || 0, target_format: task.targetFormat }
});
} catch (firstErr) {
const msg = String(firstErr?.message || firstErr || '');
const looksLikeCors = /Failed to fetch|CORS|预检|跨域|X-API-Key/i.test(msg);
if (!looksLikeCors) throw firstErr;
debugLog('task.submit.fallback_without_api_key', {
taskId,
reason: msg,
hint: '首次带 X-API-Key 网络失败,降级为不带自定义头重试一次'
});
submit = await Api.request('/convert', {
method: 'POST',
body: formData,
useApiKey: false,
debugMeta: { filename: task.filename, file_size: task.file?.size || 0, target_format: task.targetFormat, retry_without_api_key: true }
});
}
if (!submit.ok) throw new Error(`提交失败(${submit.status}): ${(submit.text || '').slice(0, 200)}`);
const protocolMode = (document.getElementById('protocol-mode')?.value || 'auto').toLowerCase();
const isSyncPayload = Boolean(submit.data && (submit.data.status === 'completed' || Array.isArray(submit.data.files)));
if (protocolMode !== 'async' && isSyncPayload) {
persistAsset(task, submit.data, null);
Store.upsertTask({
id: taskId,
status: 'done',
message: '同步模式完成',
submitData: submit.data,
statusData: submit.data,
downloadData: { kind: 'json', data: submit.data }
});
return;
}
const jobId = submit.data?.job_id || submit.data?.jobId;
if (protocolMode === 'sync' && !isSyncPayload) {
throw new Error('当前协议模式为仅同步,但后端返回非同步结构');
}
if (!jobId) throw new Error('后端返回成功但无 job_id / files');
Store.upsertTask({ id: taskId, jobId, submitData: submit.data, message: `任务已提交: ${jobId}` });
for (let round = 1; round <= MAX_POLL_ROUNDS; round++) {
const st = await Api.request(`/job/${encodeURIComponent(jobId)}/status`, { method: 'GET' });
if (!st.ok) throw new Error(`状态查询失败(${st.status}): ${(st.text || '').slice(0, 160)}`);
if (st.data && st.data.success === false && /unauthorized/i.test(st.data.message || '')) {
throw new Error('状态接口鉴权失败Unauthorized');
}
const status = st.data?.status || 'processing';
Store.upsertTask({ id: taskId, statusData: st.data, message: `状态: ${status} (${round}/${MAX_POLL_ROUNDS})` });
if (status === 'completed') {
const dl = await fetchDownloadPayload(jobId);
persistAsset(Store.getTask(taskId) || task, st.data, dl);
Store.upsertTask({ id: taskId, status: 'done', message: '转换完成', downloadData: dl });
return;
}
if (status === 'failed' || status === 'timeout') throw new Error(`任务结束状态: ${status}`);
await wait(POLL_INTERVAL_MS);
}
throw new Error('轮询超时,请稍后重试');
} catch (err) {
Store.upsertTask({ id: taskId, status: 'error', message: err.message || '未知错误' });
}
}
async function fetchDownloadPayload(jobId) {
const res = await Api.request(`/job/${encodeURIComponent(jobId)}/download`, { method: 'GET' });
if (!res.ok) throw new Error(`下载接口失败(${res.status})`);
if (res.data) return { kind: 'json', data: res.data };
const blob = new Blob([res.text || ''], { type: 'text/plain' });
return { kind: 'blob', blob, filename: `convertx-${jobId}.txt` };
}
function initConverterTool() {
const container = document.getElementById('tool-converter-container');
if (!container) return;
const fileInput = document.getElementById('file-input');
const dropZone = container.querySelector('.drop-zone');
const baseUrlEl = document.getElementById('convertx-base-url');
const apiKeyEl = document.getElementById('api-key');
if (baseUrlEl && !baseUrlEl.value) baseUrlEl.value = DEFAULT_BASE_URL;
if (apiKeyEl && !apiKeyEl.value) apiKeyEl.value = DEFAULT_API_KEY;
fileInput.addEventListener('change', (e) => handleFileSelect(e.target.files));
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.background = '#eff6ff';
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.style.background = 'white';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.background = 'white';
if (!e.dataTransfer.files.length) return;
fileInput.files = e.dataTransfer.files;
handleFileSelect(e.dataTransfer.files);
});
window.filterFormats = function (type) {
document.querySelectorAll('.format-tab').forEach((t) => t.classList.remove('active'));
if (window.event?.target) window.event.target.classList.add('active');
document.querySelectorAll('.format-item').forEach((item) => {
item.style.display = (type === 'all' || item.dataset.type === type) ? 'flex' : 'none';
});
};
window.selectFormat = function (fmt) {
if (!Store.state.files.length) {
alert('请先选择文件');
return;
}
Store.setTargetFormat(fmt);
document.getElementById('target-format-name').innerText = fmt.toUpperCase();
document.getElementById('action-bar').style.display = 'block';
suggestConverterForTarget(fmt);
document.querySelectorAll('.format-item').forEach((el) => el.classList.remove('selected'));
if (window.event?.currentTarget) window.event.currentTarget.classList.add('selected');
};
window.startConversion = startBatchConversion;
window.startBatchConversion = startBatchConversion;
window.retryFailedTasks = retryFailedTasks;
window.clearTaskQueue = clearTaskQueue;
window.downloadResult = downloadResult;
window.filterTaskView = renderAllTasks;
window.exportDiagnostics = exportDiagnostics;
window.clearDebugLogs = clearDebugLogs;
window.refreshAssetCenter = renderAssetCenter;
window.clearAssetCenter = clearAssetCenter;
window.checkBackendConnectivity = refreshBackendMeta;
Bus.on('task:updated', renderTaskRow);
Bus.on('queue:reset', renderQueueEmpty);
setBackendStatus('未检测后端(点击“手动检测后端”)', 'warn');
renderAssetCenter();
}
function handleFileSelect(fileList) {
const files = Array.from(fileList || []);
if (!files.length) return;
Store.setFiles(files);
const selectedFileName = document.getElementById('selected-file-name');
const uploadTitle = document.querySelector('.upload-section h3');
if (selectedFileName) selectedFileName.innerText = files[0].name;
if (uploadTitle) {
uploadTitle.innerText = files.length === 1
? `已选择: ${files[0].name}`
: `已选择 ${files.length} 个文件(首个: ${files[0].name}`;
}
}
async function startBatchConversion() {
if (!Store.state.files.length || !Store.state.targetFormat) return;
Store.state.files.forEach((file) => {
const task = createTaskFromFile(file);
Store.upsertTask(task);
});
runScheduler();
}
function retryFailedTasks() {
Store.getTasks().filter((t) => t.status === 'error').forEach((t) => {
Store.upsertTask({ id: t.id, status: 'pending', message: '重试排队中...' });
});
runScheduler();
}
function clearTaskQueue() {
Store.clearTasks();
}
async function downloadResult(id) {
const task = Store.getTask(id);
if (!task) return;
try {
const payload = task.downloadData || (task.jobId ? await fetchDownloadPayload(task.jobId) : { kind: 'json', data: task.submitData || {} });
if (payload.kind === 'blob') {
saveAs(payload.blob, payload.filename || `result-${id}.bin`);
} else {
const blob = new Blob([JSON.stringify(payload.data || {}, null, 2)], { type: 'application/json' });
saveAs(blob, `convertx-${id}-result.json`);
}
} catch (err) {
alert(`下载失败: ${err.message || err}`);
}
}
function renderTaskRow(task) {
const filter = (document.getElementById('task-filter')?.value || 'all');
const q = document.getElementById('conversion-queue');
if (!q) return;
if (q.children[0] && q.children[0].innerText.includes('暂无任务')) q.innerHTML = '';
let row = document.getElementById(`task-${task.id}`);
if (!row) {
row = document.createElement('div');
row.className = 'queue-item';
row.id = `task-${task.id}`;
q.prepend(row);
}
if (filter !== 'all' && task.status !== filter) {
row.style.display = 'none';
return;
}
row.style.display = 'flex';
const statusClass = task.status === 'done' ? 'status-done'
: task.status === 'error' ? 'status-error'
: task.status === 'running' ? 'status-processing'
: 'status-pending';
const action = task.status === 'done'
? `完成 <a href="#" onclick="downloadResult(${task.id});return false;">下载</a>`
: task.status === 'error'
? `失败: ${task.message || '未知错误'}`
: (task.message || '处理中...');
row.innerHTML = `<div><strong>${task.filename}</strong> <span style="color:#999">→ ${(task.targetFormat || '').toUpperCase()}</span> <span style="font-size:11px;color:#bbb">#${task.attempts || 0}</span></div><div class="queue-status ${statusClass}">${action}</div>`;
}
function renderAllTasks() {
const q = document.getElementById('conversion-queue');
if (!q) return;
q.innerHTML = '<div class="queue-item" style="color:#999; justify-content:center;">暂无任务</div>';
Store.getTasks().forEach((task) => renderTaskRow(task));
}
function renderQueueEmpty() {
const q = document.getElementById('conversion-queue');
if (!q) return;
q.innerHTML = '<div class="queue-item" style="color:#999; justify-content:center;">暂无任务</div>';
}
async function refreshBackendMeta() {
try {
const health = await Api.request('/health', { method: 'GET', useApiKey: false });
if (!health.ok) throw new Error(`health ${health.status}`);
const converters = await Api.request('/converters', { method: 'GET', useApiKey: false });
if (converters.ok && converters.data) {
Store.state.converterCapabilities = converters.data || {};
const names = Object.keys(converters.data || {});
setBackendStatus(`后端在线,已加载 ${names.length} 个转换器`, 'ok');
} else {
setBackendStatus('初始化告警:转换器列表不可用,仍可尝试手动转换', 'warn');
}
} catch (err) {
setBackendStatus(`初始化告警: ${err.message || err}(不阻断,可继续尝试转换)`, 'warn');
debugLog('init.non_blocking_warning', {
reason: String(err?.message || err),
hint: '请检查 CSP connect-src / CORS / 代理策略'
});
}
}
function getRecommendedConverter(targetFormat) {
const caps = Store.state.converterCapabilities || {};
const names = Object.keys(caps);
for (const name of names) {
const targets = caps[name]?.targets || [];
if (targets.includes(targetFormat)) return name;
}
return '';
}
function suggestConverterForTarget(targetFormat) {
const input = document.getElementById('converter-name');
if (!input || input.value.trim()) return;
const rec = getRecommendedConverter(targetFormat);
if (rec) input.value = rec;
}
function getAssetList() {
try {
return JSON.parse(localStorage.getItem(ASSET_STORAGE_KEY) || '[]');
} catch (_) {
return [];
}
}
function setAssetList(list) {
localStorage.setItem(ASSET_STORAGE_KEY, JSON.stringify(list));
}
function persistAsset(task, statusData, downloadData) {
const list = getAssetList();
const item = {
id: task.id,
filename: task.filename,
targetFormat: task.targetFormat,
converter: task.converter,
jobId: task.jobId || null,
createdAt: Date.now(),
statusData: statusData || null,
hasDownloadBlob: Boolean(downloadData && downloadData.kind === 'blob')
};
list.unshift(item);
setAssetList(list.slice(0, 500));
renderAssetCenter();
}
function renderAssetCenter() {
const el = document.getElementById('asset-center-list');
if (!el) return;
const list = getAssetList();
if (!list.length) {
el.innerHTML = '暂无本地资产';
return;
}
el.innerHTML = list.slice(0, 50).map((x) => {
const t = new Date(x.createdAt || Date.now()).toLocaleString();
return `<div style="padding:6px 0; border-bottom:1px dashed #eee;"><strong>${x.filename}</strong> → ${String(x.targetFormat || '').toUpperCase()} <span style="color:#999; font-size:12px;">${t}</span></div>`;
}).join('');
}
function clearAssetCenter() {
localStorage.removeItem(ASSET_STORAGE_KEY);
renderAssetCenter();
}
function exportDiagnostics() {
const payload = {
exportedAt: new Date().toISOString(),
baseUrl: Api.getBaseUrl(),
taskCount: Store.getTasks().length,
tasks: Store.getTasks(),
assets: getAssetList(),
logs: window.__convertxDebugLogs || []
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
saveAs(blob, `convertx-diagnostics-${Date.now()}.json`);
}
function clearDebugLogs() {
window.__convertxDebugLogs = [];
const panel = document.getElementById('debug-log-panel');
if (panel) panel.innerHTML = '<div style="color:#6b7280;">暂无日志</div>';
}
function setBackendStatus(text, type) {
const el = document.getElementById('backend-status');
if (!el) return;
const color = type === 'ok' ? '#16a34a' : type === 'warn' ? '#d97706' : '#ef4444';
el.style.color = color;
el.innerText = text;
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function debugLog(event, payload) {
try {
const entry = { ts: new Date().toISOString(), event, payload: payload || {} };
window.__convertxDebugLogs = window.__convertxDebugLogs || [];
window.__convertxDebugLogs.push(entry);
if (window.__convertxDebugLogs.length > DEBUG_LOG_LIMIT) window.__convertxDebugLogs.shift();
const panel = document.getElementById('debug-log-panel');
if (panel) {
if (panel.innerText.includes('暂无日志')) panel.innerHTML = '';
const line = document.createElement('div');
line.textContent = `[${entry.ts}] ${event} ${JSON.stringify(entry.payload)}`;
panel.appendChild(line);
panel.scrollTop = panel.scrollHeight;
}
console.log(`${DEBUG_PREFIX} ${event}`, payload || {});
} catch (_) {
// ignore
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initConverterTool);
} else {
initConverterTool();
}
})();

156
js/tools/geojson.js Normal file
View File

@@ -0,0 +1,156 @@
(function() {
let map = null;
let geoJsonLayer = null;
const API_BASE = 'https://geo.datav.aliyun.com/areas_v3/bound/geojson?code=';
function initGeoJsonTool() {
const container = document.getElementById('tool-geojson-container');
if (!container) return;
// Stop click propagation to prevent global theme effects
container.addEventListener('click', (e) => e.stopPropagation());
const input = document.getElementById('geojson-input');
const btnLoad = document.getElementById('btn-load');
const btnDownload = document.getElementById('btn-download-json');
const btnQuery = document.getElementById('btn-query-area');
const selectProv = document.getElementById('select-prov');
const selectCity = document.getElementById('select-city');
const selectDist = document.getElementById('select-dist');
const errorMsg = document.getElementById('error-msg');
if (map) { map.remove(); map = null; }
if (typeof L === 'undefined') {
const checkL = setInterval(() => {
if (typeof L !== 'undefined') {
clearInterval(checkL);
initMap();
}
}, 200);
} else {
initMap();
}
// Init Data
fetchDistricts('100000', selectProv);
function initMap() {
if (map) return;
// Remove attribution
map = L.map('map', { attributionControl: false }).setView([35.8617, 104.1954], 4);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
}
async function fetchDistricts(adcode, targetSelect) {
try {
targetSelect.innerHTML = '<option value="">加载中...</option>';
const url = `${API_BASE}${adcode}_full`;
const res = await fetch(url);
if (!res.ok) throw new Error('Fetch failed');
const data = await res.json();
targetSelect.innerHTML = '<option value="">请选择</option>';
data.features.forEach(f => {
const opt = document.createElement('option');
opt.value = f.properties.adcode;
opt.innerText = f.properties.name;
targetSelect.appendChild(opt);
});
targetSelect.disabled = false;
} catch (e) {
targetSelect.innerHTML = '<option value="">无下级/未找到</option>';
targetSelect.disabled = true;
}
}
selectProv.addEventListener('change', () => {
selectCity.innerHTML = '<option value="">选择城市</option>';
selectDist.innerHTML = '<option value="">选择区县</option>';
if (selectProv.value) fetchDistricts(selectProv.value, selectCity);
});
selectCity.addEventListener('change', () => {
selectDist.innerHTML = '<option value="">选择区县</option>';
if (selectCity.value) fetchDistricts(selectCity.value, selectDist);
});
btnQuery.addEventListener('click', async () => {
const code = selectDist.value || selectCity.value || selectProv.value;
if (!code) {
alert('请先选择一个区域');
return;
}
btnQuery.disabled = true;
btnQuery.innerText = '查询中...';
let data = null;
try {
// Try _full first for children details
const res = await fetch(`${API_BASE}${code}_full`);
if (res.ok) data = await res.json();
else {
// Fallback to boundary only
const res2 = await fetch(`${API_BASE}${code}`);
if (res2.ok) data = await res2.json();
}
} catch (e) {}
btnQuery.disabled = false;
btnQuery.innerText = '查询区域';
if (data) {
input.value = JSON.stringify(data, null, 2);
loadGeoJSON();
} else {
alert('获取数据失败,请稍后重试');
}
});
function loadGeoJSON() {
const val = input.value.trim();
errorMsg.style.display = 'none';
if (!val) return;
try {
const data = JSON.parse(val);
if (geoJsonLayer) map.removeLayer(geoJsonLayer);
geoJsonLayer = L.geoJSON(data, {
onEachFeature: function (feature, layer) {
if (feature.properties) {
let popup = '<div style="max-height: 200px; overflow-y: auto;"><table>';
for (const k in feature.properties) {
popup += `<tr><td><strong>${k}</strong></td><td>${feature.properties[k]}</td></tr>`;
}
popup += '</table></div>';
layer.bindPopup(popup);
}
}
}).addTo(map);
const bounds = geoJsonLayer.getBounds();
if (bounds.isValid()) map.fitBounds(bounds);
} catch (e) {
errorMsg.innerText = 'JSON 解析错误: ' + e.message;
errorMsg.style.display = 'block';
}
}
btnLoad.addEventListener('click', loadGeoJSON);
btnDownload.addEventListener('click', () => {
if (!input.value) return;
const blob = new Blob([input.value], {type: "application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = "data.geojson";
a.click();
});
}
document.addEventListener('DOMContentLoaded', initGeoJsonTool);
document.addEventListener('pjax:complete', initGeoJsonTool);
})();

96
js/tools/gif.js Normal file
View File

@@ -0,0 +1,96 @@
(function() {
let images = [];
function initGifTool() {
const container = document.getElementById('tool-gif-container');
if (!container) return;
container.addEventListener('click', (e) => e.stopPropagation());
const fileInput = document.getElementById('file-input');
const imgList = document.getElementById('img-list');
const btnCreate = document.getElementById('btn-create');
const resultGif = document.getElementById('result-gif');
const downloadLink = document.getElementById('download-link');
const uploadBox = document.getElementById('upload-box');
uploadBox.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', handleFiles);
function handleFiles(e) {
const files = Array.from(e.target.files);
if (!files.length) return;
files.forEach(file => {
const reader = new FileReader();
reader.onload = (event) => {
images.push(event.target.result);
renderPreview();
};
reader.readAsDataURL(file);
});
}
function renderPreview() {
imgList.innerHTML = '';
images.forEach(src => {
const img = document.createElement('img');
img.src = src;
img.className = 'img-item';
img.style.width = '80px';
img.style.height = '80px';
img.style.objectFit = 'cover';
img.style.borderRadius = '4px';
img.style.border = '1px solid #ddd';
imgList.appendChild(img);
});
}
btnCreate.addEventListener('click', createGIF);
function createGIF() {
if (images.length === 0) {
alert('请先上传图片');
return;
}
if (typeof gifshot === 'undefined') {
alert('GIF 库未加载,请检查网络');
return;
}
const interval = parseFloat(document.getElementById('interval').value);
const width = parseInt(document.getElementById('gif-width').value);
const height = parseInt(document.getElementById('gif-height').value);
btnCreate.innerText = '生成中...';
btnCreate.disabled = true;
gifshot.createGIF({
images: images,
interval: interval,
gifWidth: width,
gifHeight: height,
numFrames: images.length
}, function(obj) {
if(!obj.error) {
const image = obj.image;
resultGif.src = image;
resultGif.style.display = 'inline-block';
downloadLink.href = image;
downloadLink.download = 'animation.gif';
downloadLink.style.display = 'inline-block';
} else {
alert('生成失败');
}
btnCreate.innerText = '生成 GIF';
btnCreate.disabled = false;
});
}
}
document.addEventListener('DOMContentLoaded', initGifTool);
document.addEventListener('pjax:complete', initGifTool);
})();

62
js/tools/hash.js Normal file
View File

@@ -0,0 +1,62 @@
(function() {
function initHashTool() {
const container = document.getElementById('tool-hash-container');
if (!container) return;
container.addEventListener('click', (e) => e.stopPropagation());
const inputText = document.getElementById('input-text');
const resMd5 = document.getElementById('res-md5');
const resSha1 = document.getElementById('res-sha1');
const resSha256 = document.getElementById('res-sha256');
const resSha512 = document.getElementById('res-sha512');
const copyBtns = document.querySelectorAll('.copy-btn');
function calculateHash() {
const text = inputText.value;
if (!text) {
resMd5.innerText = '-';
resSha1.innerText = '-';
resSha256.innerText = '-';
resSha512.innerText = '-';
return;
}
if (typeof CryptoJS === 'undefined') {
resMd5.innerText = '加载核心库失败,请检查网络...';
return;
}
resMd5.innerText = CryptoJS.MD5(text).toString();
resSha1.innerText = CryptoJS.SHA1(text).toString();
resSha256.innerText = CryptoJS.SHA256(text).toString();
resSha512.innerText = CryptoJS.SHA512(text).toString();
}
inputText.addEventListener('input', calculateHash);
copyBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const targetId = e.target.getAttribute('data-copy-target');
const text = document.getElementById(targetId).innerText;
if (text === '-' || text.includes('失败')) return;
navigator.clipboard.writeText(text).then(() => {
const originalText = e.target.innerText;
e.target.innerText = '已复制';
setTimeout(() => e.target.innerText = originalText, 1000);
});
});
});
// Check if library loaded
const checkLib = setInterval(() => {
if (typeof CryptoJS !== 'undefined') {
clearInterval(checkLib);
calculateHash();
}
}, 500);
}
document.addEventListener('DOMContentLoaded', initHashTool);
document.addEventListener('pjax:complete', initHashTool);
})();

122
js/tools/mirage-tank.js Normal file
View File

@@ -0,0 +1,122 @@
(function() {
let surfaceImg = null;
let hiddenImg = null;
function initMirageTankTool() {
const container = document.getElementById('tool-mirage-container');
if (!container) return;
container.addEventListener('click', (e) => e.stopPropagation());
const imgSurfaceInput = document.getElementById('img-surface');
const imgHiddenInput = document.getElementById('img-hidden');
const btnGenerate = document.getElementById('generate-btn');
const btnDownload = document.getElementById('btn-download');
const canvas = document.getElementById('result-canvas');
const resultArea = document.getElementById('result-area');
const bgBtns = document.querySelectorAll('.preview-bg-btn');
function handleFile(input, imgId, textId, type) {
input.addEventListener('change', (e) => {
if (!e.target.files.length) return;
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
document.getElementById(imgId).src = img.src;
document.getElementById(imgId).style.display = 'block';
document.getElementById(textId).style.display = 'none';
if (type === 'surface') surfaceImg = img;
else hiddenImg = img;
checkReady();
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
});
}
function checkReady() {
btnGenerate.disabled = !(surfaceImg && hiddenImg);
}
function generateMirageTank() {
if (!surfaceImg || !hiddenImg) return;
const width = Math.max(surfaceImg.width, hiddenImg.width);
const height = Math.max(surfaceImg.height, hiddenImg.height);
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const cvs1 = document.createElement('canvas');
cvs1.width = width; cvs1.height = height;
const ctx1 = cvs1.getContext('2d');
ctx1.fillStyle = '#fff';
ctx1.fillRect(0, 0, width, height);
ctx1.drawImage(surfaceImg, 0, 0, width, height);
const data1 = ctx1.getImageData(0, 0, width, height).data;
const cvs2 = document.createElement('canvas');
cvs2.width = width; cvs2.height = height;
const ctx2 = cvs2.getContext('2d');
ctx2.fillStyle = '#000';
ctx2.fillRect(0, 0, width, height);
ctx2.drawImage(hiddenImg, 0, 0, width, height);
const data2 = ctx2.getImageData(0, 0, width, height).data;
const resultData = ctx.createImageData(width, height);
const d = resultData.data;
for (let i = 0; i < d.length; i += 4) {
const r1 = data1[i], g1 = data1[i+1], b1 = data1[i+2];
const gray1 = 0.299 * r1 + 0.587 * g1 + 0.114 * b1;
const r2 = data2[i], g2 = data2[i+1], b2 = data2[i+2];
const gray2 = 0.299 * r2 + 0.587 * g2 + 0.114 * b2;
let a = (gray2 - gray1 + 255) / 255;
if (a < 0) a = 0;
if (a > 1) a = 1;
let p = 0;
if (a > 0.01) p = gray2 / a;
if (p > 255) p = 255;
d[i] = p;
d[i+1] = p;
d[i+2] = p;
d[i+3] = a * 255;
}
ctx.putImageData(resultData, 0, 0);
resultArea.style.display = 'block';
}
handleFile(imgSurfaceInput, 'preview-surface-img', 'preview-surface-text', 'surface');
handleFile(imgHiddenInput, 'preview-hidden-img', 'preview-hidden-text', 'hidden');
btnGenerate.addEventListener('click', generateMirageTank);
bgBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const type = e.target.getAttribute('data-bg');
if (type === 'white') canvas.style.background = 'white';
else if (type === 'black') canvas.style.background = 'black';
else canvas.style.background = "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"20\" viewBox=\"0 0 20 20\"><rect width=\"10\" height=\"10\" fill=\"%23ccc\"/><rect x=\"10\" y=\"10\" width=\"10\" height=\"10\" fill=\"%23ccc\"/></svg>') repeat";
});
});
btnDownload.addEventListener('click', () => {
const link = document.createElement('a');
link.download = 'mirage-tank.png';
link.href = canvas.toDataURL();
link.click();
});
}
document.addEventListener('DOMContentLoaded', initMirageTankTool);
document.addEventListener('pjax:complete', initMirageTankTool);
})();

103
js/tools/remove-bg.js Normal file
View File

@@ -0,0 +1,103 @@
(function() {
let originalImg = null;
let targetR=255, targetG=255, targetB=255;
function initRemoveBgTool() {
const container = document.getElementById('tool-removebg-container');
if (!container) return;
container.addEventListener('click', (e) => e.stopPropagation());
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const fileInput = document.getElementById('file-input');
const toleranceInput = document.getElementById('tolerance');
const tolVal = document.getElementById('tol-val');
const targetColorBox = document.getElementById('target-color');
const targetColorText = document.getElementById('target-color-text');
const btnReset = document.getElementById('btn-reset');
const btnDownload = document.getElementById('btn-download');
const uploadBox = document.getElementById('upload-box');
uploadBox.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', function(e) {
if (!e.target.files.length) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
originalImg = img;
renderImage();
};
img.src = ev.target.result;
};
reader.readAsDataURL(e.target.files[0]);
});
function renderImage() {
if (!originalImg) return;
canvas.width = originalImg.width;
canvas.height = originalImg.height;
ctx.drawImage(originalImg, 0, 0);
}
btnReset.addEventListener('click', renderImage);
canvas.addEventListener('click', function(e) {
if (!originalImg) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const p = ctx.getImageData(x, y, 1, 1).data;
targetR = p[0];
targetG = p[1];
targetB = p[2];
const hex = "#" + ((1 << 24) + (targetR << 16) + (targetG << 8) + targetB).toString(16).slice(1);
targetColorBox.style.background = hex;
targetColorText.innerText = hex;
processRemoval();
});
function processRemoval() {
if (!originalImg) return;
ctx.drawImage(originalImg, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const tolerance = parseInt(toleranceInput.value);
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i+1], b = data[i+2];
const dist = Math.sqrt(Math.pow(r - targetR, 2) + Math.pow(g - targetG, 2) + Math.pow(b - targetB, 2));
if (dist < tolerance * 1.5) {
data[i+3] = 0;
}
}
ctx.putImageData(imageData, 0, 0);
}
toleranceInput.addEventListener('input', () => {
tolVal.innerText = toleranceInput.value;
});
toleranceInput.addEventListener('change', processRemoval);
btnDownload.addEventListener('click', () => {
const link = document.createElement('a');
link.download = 'removed-bg.png';
link.href = canvas.toDataURL();
link.click();
});
}
document.addEventListener('DOMContentLoaded', initRemoveBgTool);
document.addEventListener('pjax:complete', initRemoveBgTool);
})();

622
js/tools/sort.js Normal file
View File

@@ -0,0 +1,622 @@
/**
* 排序算法演示逻辑 (15种算法)
*/
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('sort-container');
const sizeInput = document.getElementById('array-size');
const algorithmSelect = document.getElementById('algorithm-select');
const speedSelect = document.getElementById('speed-select');
const btnGenerate = document.getElementById('btn-generate');
const btnSort = document.getElementById('btn-sort');
let array = [];
let isSorting = false;
let cancelSort = false;
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
function generateArray() {
if (isSorting) return;
container.innerHTML = '';
array = [];
let size = parseInt(sizeInput.value);
if (isNaN(size) || size < 10) size = 10;
if (size > 100) size = 100;
sizeInput.value = size;
for (let i = 0; i < size; i++) {
const value = Math.floor(Math.random() * 290) + 10;
array.push(value);
const bar = document.createElement('div');
bar.classList.add('sort-bar');
bar.style.height = `${value}px`;
const barWidth = Math.max(2, Math.floor((container.clientWidth - size * 2) / size));
bar.style.width = `${barWidth}px`;
container.appendChild(bar);
}
}
function updateBar(index, height, colorClass) {
const bars = container.children;
if (bars[index]) {
if (height !== null) bars[index].style.height = `${height}px`;
bars[index].classList.remove('comparing', 'swapping', 'sorted');
if (colorClass) bars[index].classList.add(colorClass);
}
}
// --- Algorithm Implementations ---
async function bubbleSort() {
const n = array.length;
for (let i = 0; i < n - 1; i++) {
for (let j = 0; j < n - i - 1; j++) {
if (cancelSort) return;
updateBar(j, null, 'comparing');
updateBar(j + 1, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if (array[j] > array[j + 1]) {
updateBar(j, null, 'swapping');
updateBar(j + 1, null, 'swapping');
let temp = array[j]; array[j] = array[j + 1]; array[j + 1] = temp;
updateBar(j, array[j], null);
updateBar(j + 1, array[j + 1], null);
} else {
updateBar(j, null, null);
updateBar(j + 1, null, null);
}
}
updateBar(n - i - 1, null, 'sorted');
}
updateBar(0, null, 'sorted');
}
async function selectionSort() {
const n = array.length;
for (let i = 0; i < n - 1; i++) {
let minIndex = i;
updateBar(minIndex, null, 'swapping');
for (let j = i + 1; j < n; j++) {
if (cancelSort) return;
updateBar(j, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if (array[j] < array[minIndex]) {
if (minIndex !== i) updateBar(minIndex, null, null);
minIndex = j;
updateBar(minIndex, null, 'swapping');
} else {
updateBar(j, null, null);
}
}
if (minIndex !== i) {
updateBar(i, null, 'swapping');
await sleep(parseInt(speedSelect.value));
let temp = array[i]; array[i] = array[minIndex]; array[minIndex] = temp;
updateBar(i, array[i], null);
updateBar(minIndex, array[minIndex], null);
} else {
updateBar(i, null, null);
}
updateBar(i, null, 'sorted');
}
updateBar(n - 1, null, 'sorted');
}
async function insertionSort() {
const n = array.length;
updateBar(0, null, 'sorted');
for (let i = 1; i < n; i++) {
if (cancelSort) return;
let key = array[i];
let j = i - 1;
updateBar(i, null, 'swapping');
await sleep(parseInt(speedSelect.value));
while (j >= 0 && array[j] > key) {
if (cancelSort) return;
updateBar(j, null, 'comparing');
await sleep(parseInt(speedSelect.value));
array[j + 1] = array[j];
updateBar(j + 1, array[j + 1], 'sorted');
updateBar(j, null, 'swapping');
j = j - 1;
}
array[j + 1] = key;
updateBar(j + 1, array[j + 1], 'sorted');
for(let k=0; k<=i; k++) updateBar(k, null, 'sorted');
}
}
async function quickSort(start = 0, end = array.length - 1) {
if (cancelSort) return;
if (start >= end) {
if(start === end && start >= 0 && start < array.length) updateBar(start, null, 'sorted');
return;
}
let pivotIndex = await partition(start, end);
if (cancelSort) return;
updateBar(pivotIndex, null, 'sorted');
await Promise.all([
quickSort(start, pivotIndex - 1),
quickSort(pivotIndex + 1, end)
]);
}
async function partition(start, end) {
let pivotValue = array[end];
let pivotIndex = start;
updateBar(end, null, 'swapping');
for (let i = start; i < end; i++) {
if (cancelSort) return start;
updateBar(i, null, 'comparing');
updateBar(pivotIndex, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if (array[i] < pivotValue) {
let temp = array[i]; array[i] = array[pivotIndex]; array[pivotIndex] = temp;
updateBar(i, array[i], null);
updateBar(pivotIndex, array[pivotIndex], null);
pivotIndex++;
} else {
updateBar(i, null, null);
if (pivotIndex !== i) updateBar(pivotIndex, null, null);
}
}
let temp = array[pivotIndex]; array[pivotIndex] = array[end]; array[end] = temp;
updateBar(pivotIndex, array[pivotIndex], null);
updateBar(end, array[end], null);
return pivotIndex;
}
async function mergeSort(start = 0, end = array.length - 1) {
if (cancelSort) return;
if (start >= end) {
if(start === end && start >=0) updateBar(start, null, 'sorted');
return;
}
let mid = Math.floor((start + end) / 2);
await mergeSort(start, mid);
await mergeSort(mid + 1, end);
await merge(start, mid, end);
}
async function merge(start, mid, end) {
let temp = [];
let i = start, j = mid + 1;
while(i <= mid && j <= end) {
if(cancelSort) return;
updateBar(i, null, 'comparing');
updateBar(j, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if (array[i] <= array[j]) {
temp.push(array[i]);
updateBar(i, null, null); i++;
} else {
temp.push(array[j]);
updateBar(j, null, null); j++;
}
}
while(i <= mid) { temp.push(array[i]); i++; }
while(j <= end) { temp.push(array[j]); j++; }
for(let k = 0; k < temp.length; k++) {
if(cancelSort) return;
array[start + k] = temp[k];
updateBar(start + k, array[start + k], 'swapping');
await sleep(parseInt(speedSelect.value));
updateBar(start + k, null, 'sorted');
}
}
async function heapSort() {
let n = array.length;
for(let i = Math.floor(n/2)-1; i>=0; i--){
await heapify(n, i);
}
for(let i = n-1; i>0; i--){
if(cancelSort) return;
updateBar(0, null, 'swapping');
updateBar(i, null, 'swapping');
await sleep(parseInt(speedSelect.value));
let temp = array[0]; array[0] = array[i]; array[i] = temp;
updateBar(0, array[0], null);
updateBar(i, array[i], 'sorted');
await heapify(i, 0);
}
if(!cancelSort) updateBar(0, null, 'sorted');
}
async function heapify(n, i) {
if(cancelSort) return;
let largest = i;
let l = 2*i + 1;
let r = 2*i + 2;
if (l < n) {
updateBar(l, null, 'comparing');
updateBar(largest, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if(array[l] > array[largest]) largest = l;
updateBar(l, null, null);
updateBar(i, null, null);
}
if (r < n) {
updateBar(r, null, 'comparing');
updateBar(largest, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if(array[r] > array[largest]) largest = r;
updateBar(r, null, null);
if(largest!=r) updateBar(largest, null, null);
}
if (largest !== i) {
updateBar(i, null, 'swapping');
updateBar(largest, null, 'swapping');
await sleep(parseInt(speedSelect.value));
let temp = array[i]; array[i] = array[largest]; array[largest] = temp;
updateBar(i, array[i], null);
updateBar(largest, array[largest], null);
await heapify(n, largest);
}
}
async function shellSort() {
let n = array.length;
for (let gap = Math.floor(n/2); gap > 0; gap = Math.floor(gap/2)) {
for (let i = gap; i < n; i++) {
if(cancelSort) return;
let temp = array[i];
let j;
updateBar(i, null, 'swapping');
await sleep(parseInt(speedSelect.value));
for (j = i; j >= gap && array[j - gap] > temp; j -= gap) {
if(cancelSort) return;
updateBar(j - gap, null, 'comparing');
await sleep(parseInt(speedSelect.value));
array[j] = array[j - gap];
updateBar(j, array[j], 'swapping');
updateBar(j - gap, null, null);
}
array[j] = temp;
updateBar(j, array[j], null);
updateBar(i, null, null);
}
}
}
async function cocktailShakerSort() {
let is_swapped = true;
let start = 0;
let end = array.length - 1;
while(is_swapped) {
is_swapped = false;
for(let i=start; i<end; i++){
if(cancelSort) return;
updateBar(i, null, 'comparing');
updateBar(i+1, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if(array[i] > array[i+1]){
updateBar(i, null, 'swapping'); updateBar(i+1, null, 'swapping');
let temp = array[i]; array[i] = array[i+1]; array[i+1] = temp;
updateBar(i, array[i], null); updateBar(i+1, array[i+1], null);
is_swapped = true;
} else {
updateBar(i, null, null); updateBar(i+1, null, null);
}
}
if(!is_swapped) break;
updateBar(end, null, 'sorted');
end--;
is_swapped = false;
for(let i=end-1; i>=start; i--){
if(cancelSort) return;
updateBar(i, null, 'comparing');
updateBar(i+1, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if(array[i] > array[i+1]){
updateBar(i, null, 'swapping'); updateBar(i+1, null, 'swapping');
let temp = array[i]; array[i] = array[i+1]; array[i+1] = temp;
updateBar(i, array[i], null); updateBar(i+1, array[i+1], null);
is_swapped = true;
} else {
updateBar(i, null, null); updateBar(i+1, null, null);
}
}
updateBar(start, null, 'sorted');
start++;
}
}
async function combSort() {
let n = array.length;
let gap = n;
let swapped = true;
while(gap !== 1 || swapped) {
if(cancelSort) return;
gap = Math.floor(gap / 1.3);
if(gap < 1) gap = 1;
swapped = false;
for(let i=0; i<n-gap; i++){
if(cancelSort) return;
updateBar(i, null, 'comparing');
updateBar(i+gap, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if(array[i] > array[i+gap]){
updateBar(i, null, 'swapping'); updateBar(i+gap, null, 'swapping');
let temp = array[i]; array[i] = array[i+gap]; array[i+gap] = temp;
updateBar(i, array[i], null); updateBar(i+gap, array[i+gap], null);
swapped = true;
} else {
updateBar(i, null, null); updateBar(i+gap, null, null);
}
}
}
}
async function gnomeSort() {
let index = 0;
while(index < array.length) {
if(cancelSort) return;
if(index == 0) index++;
updateBar(index, null, 'comparing');
updateBar(index-1, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if(array[index] >= array[index-1]){
updateBar(index, null, null); updateBar(index-1, null, null);
index++;
} else {
updateBar(index, null, 'swapping'); updateBar(index-1, null, 'swapping');
let temp = array[index]; array[index] = array[index-1]; array[index-1] = temp;
updateBar(index, array[index], null); updateBar(index-1, array[index-1], null);
index--;
}
}
}
async function oddEvenSort() {
let isSorted = false;
while(!isSorted) {
isSorted = true;
for(let i=1; i<=array.length-2; i=i+2){
if(cancelSort) return;
updateBar(i, null, 'comparing'); updateBar(i+1, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if(array[i] > array[i+1]){
updateBar(i, null, 'swapping'); updateBar(i+1, null, 'swapping');
let temp = array[i]; array[i] = array[i+1]; array[i+1] = temp;
updateBar(i, array[i], null); updateBar(i+1, array[i+1], null);
isSorted = false;
} else {
updateBar(i, null, null); updateBar(i+1, null, null);
}
}
for(let i=0; i<=array.length-2; i=i+2){
if(cancelSort) return;
updateBar(i, null, 'comparing'); updateBar(i+1, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if(array[i] > array[i+1]){
updateBar(i, null, 'swapping'); updateBar(i+1, null, 'swapping');
let temp = array[i]; array[i] = array[i+1]; array[i+1] = temp;
updateBar(i, array[i], null); updateBar(i+1, array[i+1], null);
isSorted = false;
} else {
updateBar(i, null, null); updateBar(i+1, null, null);
}
}
}
}
async function cycleSort() {
let n = array.length;
for(let cycle_start = 0; cycle_start <= n-2; cycle_start++) {
if(cancelSort) return;
let item = array[cycle_start];
let pos = cycle_start;
for(let i = cycle_start+1; i<n; i++){
updateBar(i, null, 'comparing'); await sleep(parseInt(speedSelect.value));
if(array[i] < item) pos++;
updateBar(i, null, null);
}
if(pos == cycle_start) {
updateBar(cycle_start, null, 'sorted'); continue;
}
while(item == array[pos]) pos += 1;
if(pos != cycle_start) {
updateBar(pos, null, 'swapping'); await sleep(parseInt(speedSelect.value));
let temp = item; item = array[pos]; array[pos] = temp;
updateBar(pos, array[pos], 'sorted');
}
while(pos != cycle_start) {
if(cancelSort) return;
pos = cycle_start;
for(let i=cycle_start+1; i<n; i++){
updateBar(i, null, 'comparing'); await sleep(parseInt(speedSelect.value));
if(array[i] < item) pos += 1;
updateBar(i, null, null);
}
while(item == array[pos]) pos += 1;
if(item != array[pos]) {
updateBar(pos, null, 'swapping'); await sleep(parseInt(speedSelect.value));
let temp = item; item = array[pos]; array[pos] = temp;
updateBar(pos, array[pos], 'sorted');
}
}
}
}
async function pancakeSort() {
let n = array.length;
for(let curr_size=n; curr_size>1; --curr_size){
if(cancelSort) return;
let mi = await findMax(curr_size);
if(cancelSort) return;
if(mi != curr_size-1){
await flip(mi);
if(cancelSort) return;
await flip(curr_size-1);
}
updateBar(curr_size-1, null, 'sorted');
}
updateBar(0, null, 'sorted');
}
async function findMax(n){
let max_idx = 0;
for(let i=0; i<n; i++){
updateBar(i, null, 'comparing'); updateBar(max_idx, null, 'comparing');
await sleep(parseInt(speedSelect.value));
if(array[i] > array[max_idx]){
updateBar(max_idx, null, null); max_idx = i;
} else { updateBar(i, null, null); }
}
updateBar(max_idx, null, null);
return max_idx;
}
async function flip(i){
let start = 0;
while(start < i){
updateBar(start, null, 'swapping'); updateBar(i, null, 'swapping');
await sleep(parseInt(speedSelect.value));
let temp = array[start]; array[start] = array[i]; array[i] = temp;
updateBar(start, array[start], null); updateBar(i, array[i], null);
start++; i--;
}
}
async function radixSort() {
let max = Math.max(...array);
for(let exp = 1; Math.floor(max/exp) > 0; exp *= 10) {
if(cancelSort) return;
await countingSortForRadix(exp);
}
}
async function countingSortForRadix(exp) {
let output = new Array(array.length).fill(0);
let count = new Array(10).fill(0);
for(let i=0; i<array.length; i++){
count[Math.floor(array[i]/exp)%10]++;
}
for(let i=1; i<10; i++){
count[i] += count[i-1];
}
for(let i=array.length-1; i>=0; i--){
let idx = Math.floor(array[i]/exp)%10;
output[count[idx]-1] = array[i];
count[idx]--;
}
for(let i=0; i<array.length; i++){
if(cancelSort) return;
array[i] = output[i];
updateBar(i, array[i], 'swapping');
await sleep(parseInt(speedSelect.value));
updateBar(i, null, null);
}
}
async function bogoSort() {
let count = 0;
while(true) {
if(cancelSort) return;
let sorted = true;
for(let i=1; i<array.length; i++){
if(array[i] < array[i-1]){ sorted = false; break; }
}
if(sorted) break;
count++;
for(let i=array.length-1; i>0; i--){
let j = Math.floor(Math.random()*(i+1));
let temp = array[i]; array[i] = array[j]; array[j] = temp;
updateBar(i, array[i], 'swapping'); updateBar(j, array[j], 'swapping');
}
await sleep(parseInt(speedSelect.value));
for(let i=0; i<array.length; i++) updateBar(i, null, null);
if (count > 200) {
alert("猴子排序 (Bogo Sort) 太慢了,为防止卡死,已自动终止!");
return;
}
}
}
// ----------------------------------------
// UI Controls
// ----------------------------------------
function disableControls(disabled) {
sizeInput.disabled = disabled;
algorithmSelect.disabled = disabled;
btnSort.disabled = disabled;
if(disabled) {
btnGenerate.innerText = '⏹️ 停止排序';
btnSort.style.opacity = '0.5';
} else {
btnGenerate.innerText = '🔄 生成新数据';
btnSort.style.opacity = '1';
}
}
btnGenerate.addEventListener('click', () => {
if (isSorting) {
cancelSort = true;
isSorting = false;
disableControls(false);
setTimeout(generateArray, 200);
} else {
generateArray();
}
});
btnSort.addEventListener('click', async () => {
if (isSorting) return;
const isAlreadySorted = Array.from(container.children).every(el => el.classList.contains('sorted'));
if(isAlreadySorted || array.length === 0) {
generateArray();
await sleep(200);
}
isSorting = true;
cancelSort = false;
disableControls(true);
const algo = algorithmSelect.value;
try {
switch(algo) {
case 'bubble': await bubbleSort(); break;
case 'selection': await selectionSort(); break;
case 'insertion': await insertionSort(); break;
case 'quick': await quickSort(); break;
case 'merge': await mergeSort(); break;
case 'heap': await heapSort(); break;
case 'shell': await shellSort(); break;
case 'cocktail': await cocktailShakerSort(); break;
case 'comb': await combSort(); break;
case 'gnome': await gnomeSort(); break;
case 'oddEven': await oddEvenSort(); break;
case 'cycle': await cycleSort(); break;
case 'pancake': await pancakeSort(); break;
case 'radix': await radixSort(); break;
case 'bogo': await bogoSort(); break;
}
if(!cancelSort) {
for(let i=0; i<array.length; i++) {
updateBar(i, null, 'sorted');
}
}
} catch(e) {
console.error("Sorting error:", e);
}
isSorting = false;
disableControls(false);
});
window.addEventListener('resize', () => {
if (array.length > 0 && container.clientWidth > 0) {
const size = array.length;
const barWidth = Math.max(2, Math.floor((container.clientWidth - size * 2) / size));
const bars = container.children;
for (let i = 0; i < bars.length; i++) {
bars[i].style.width = `${barWidth}px`;
}
}
});
generateArray();
});

125
js/tools/time.js Normal file
View File

@@ -0,0 +1,125 @@
(function() {
let timer = null;
let isPaused = false;
function initTimeTool() {
const container = document.getElementById('tool-time-container');
if (!container) return;
container.addEventListener('click', (e) => e.stopPropagation());
// Cleanup
if (timer) clearInterval(timer);
// DOM Elements
const currentNow = document.getElementById('current-now');
const unixS = document.getElementById('current-unix-s');
const unixMs = document.getElementById('current-unix-ms');
const toggleBtn = document.getElementById('toggle-pause');
const inputTs = document.getElementById('input-timestamp');
const btnConvertTs = document.getElementById('btn-convert-ts');
const inputDate = document.getElementById('input-date');
const btnConvertDate = document.getElementById('btn-convert-date');
const copyBtns = document.querySelectorAll('.copy-btn');
// Initial Setup
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
inputDate.value = now.toISOString().slice(0, 19);
// Event Listeners
toggleBtn.addEventListener('click', () => {
isPaused = !isPaused;
toggleBtn.innerText = isPaused ? '▶️ 恢复更新' : '⏸️ 暂停更新';
});
btnConvertTs.addEventListener('click', convertTimestamp);
btnConvertDate.addEventListener('click', convertDate);
copyBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const targetId = e.target.getAttribute('data-copy-target');
const text = document.getElementById(targetId).innerText;
navigator.clipboard.writeText(text).then(() => {
const originalText = e.target.innerText;
e.target.innerText = '已复制';
setTimeout(() => e.target.innerText = originalText, 1000);
});
});
});
// Start Loop
timer = setInterval(() => {
if (isPaused) return;
const d = new Date();
currentNow.innerText = d.toLocaleString();
unixS.innerText = Math.floor(d.getTime() / 1000);
unixMs.innerText = d.getTime();
updateWorldClocks(d);
}, 1000);
// Initial run
currentNow.innerText = new Date().toLocaleString();
updateWorldClocks(new Date());
}
function convertTimestamp() {
let val = document.getElementById('input-timestamp').value.trim();
if (!val) return;
let unit = document.getElementById('timestamp-unit').value;
let ts = parseInt(val);
if (isNaN(ts)) return;
if (unit === 'auto') {
unit = String(ts).length > 11 ? 'ms' : 's';
}
const date = new Date(unit === 's' ? ts * 1000 : ts);
document.getElementById('result-date').innerHTML = `
<strong>北京时间:</strong> ${date.toLocaleString('zh-CN', {timeZone: 'Asia/Shanghai'})}<br>
<strong>UTC 时间:</strong> ${date.toUTCString()}<br>
<strong>ISO 8601:</strong> ${date.toISOString()}
`;
}
function convertDate() {
const val = document.getElementById('input-date').value;
if (!val) return;
const date = new Date(val);
const tsMs = date.getTime();
const tsS = Math.floor(tsMs / 1000);
document.getElementById('result-timestamp').innerHTML = `
<strong>时间戳 (秒):</strong> ${tsS}<br>
<strong>时间戳 (毫秒):</strong> ${tsMs}
`;
}
function updateWorldClocks(now) {
const grid = document.getElementById('world-clocks');
if (!grid) return;
const cities = [
{ name: '北京', tz: 'Asia/Shanghai' },
{ name: '伦敦', tz: 'Europe/London' },
{ name: '纽约', tz: 'America/New_York' },
{ name: '东京', tz: 'Asia/Tokyo' },
{ name: '巴黎', tz: 'Europe/Paris' },
{ name: '悉尼', tz: 'Australia/Sydney' },
{ name: '迪拜', tz: 'Asia/Dubai' },
{ name: '洛杉矶', tz: 'America/Los_Angeles' }
];
let html = '';
cities.forEach(city => {
const timeStr = now.toLocaleTimeString('en-US', { timeZone: city.tz, hour12: false, hour: '2-digit', minute: '2-digit' });
html += `
<div class="clock-card" style="background:var(--card-bg, #f0f0f0); padding:10px; border-radius:4px; text-align:center;">
<div style="color:#666; font-size:0.9em;">${city.name}</div>
<div style="font-size:1.4em; font-weight:bold; color:#49b1f5;">${timeStr}</div>
</div>
`;
});
grid.innerHTML = html;
}
// Init
document.addEventListener('DOMContentLoaded', initTimeTool);
document.addEventListener('pjax:complete', initTimeTool);
})();

420
js/tools/watermark.js Normal file
View File

@@ -0,0 +1,420 @@
(function() {
function initWatermarkTool() {
const container = document.getElementById('tool-watermark-container');
if (!container) return;
// Stop propagation
container.addEventListener('click', (e) => e.stopPropagation());
let wmType = 'text';
let visImg = new Image();
let wmImg = new Image(); // For image watermark
// --- Tabs ---
window.switchTab = function(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tool-section-content').forEach(s => s.style.display = 'none');
if (tab === 'visible') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('tab-visible').style.display = 'block';
} else if (tab === 'invisible') {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('tab-invisible').style.display = 'block';
} else {
document.querySelector('.tab:nth-child(3)').classList.add('active');
document.getElementById('tab-remove').style.display = 'block';
}
};
window.toggleWmType = function() {
const val = document.querySelector('input[name="wm-type"]:checked').value;
wmType = val;
if (val === 'text') {
document.getElementById('ctrl-text-group').style.display = 'contents';
document.getElementById('ctrl-image-group').style.display = 'none';
} else {
document.getElementById('ctrl-text-group').style.display = 'none';
document.getElementById('ctrl-image-group').style.display = 'contents';
}
drawVisible();
};
window.downloadCanvas = function(id, name) {
const c = document.getElementById(id);
if (c.width === 0) return;
const link = document.createElement('a');
link.download = name;
link.href = c.toDataURL();
link.click();
}
// --- Visible Watermark ---
const visFile = document.getElementById('vis-file');
const wmImgFile = document.getElementById('wm-img-file');
visFile.addEventListener('change', function(e) {
if(!e.target.files.length) return;
const reader = new FileReader();
reader.onload = (event) => {
visImg.onload = drawVisible;
visImg.src = event.target.result;
};
reader.readAsDataURL(e.target.files[0]);
});
wmImgFile.addEventListener('change', function(e) {
if(!e.target.files.length) return;
const reader = new FileReader();
reader.onload = (event) => {
wmImg.onload = drawVisible;
wmImg.src = event.target.result;
};
reader.readAsDataURL(e.target.files[0]);
});
// Add listeners to controls
['vis-text', 'vis-size', 'vis-color', 'vis-opacity', 'vis-pos', 'wm-scale'].forEach(id => {
const el = document.getElementById(id);
if(el) {
el.addEventListener('input', drawVisible);
el.addEventListener('change', drawVisible);
}
});
function drawVisible() {
if (!visImg.src) return;
const cvs = document.getElementById('vis-canvas');
const ctx = cvs.getContext('2d');
cvs.width = visImg.width;
cvs.height = visImg.height;
ctx.drawImage(visImg, 0, 0);
const opacity = parseFloat(document.getElementById('vis-opacity').value);
const pos = document.getElementById('vis-pos').value;
ctx.globalAlpha = opacity;
if (wmType === 'text') {
const text = document.getElementById('vis-text').value;
const size = parseInt(document.getElementById('vis-size').value);
const color = document.getElementById('vis-color').value;
ctx.font = `bold ${size}px sans-serif`;
ctx.fillStyle = color;
drawContent(ctx, pos, (x, y) => ctx.fillText(text, x, y), cvs.width, cvs.height, 300, 150);
} else if (wmType === 'image' && wmImg.src) {
const scale = parseFloat(document.getElementById('wm-scale').value);
const w = wmImg.width * scale;
const h = wmImg.height * scale;
drawContent(ctx, pos, (x, y) => ctx.drawImage(wmImg, x - w/2, y - h/2, w, h), cvs.width, cvs.height, w * 1.5, h * 1.5);
}
}
function drawContent(ctx, pos, drawFn, w, h, spaceX, spaceY) {
if (pos === 'center') {
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
drawFn(w/2, h/2);
} else if (pos === 'bottom-right') {
ctx.textAlign = 'right'; ctx.textBaseline = 'bottom';
drawFn(w - 20, h - 20);
} else if (pos === 'top-left') {
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
drawFn(20, 20);
} else if (pos === 'repeat') {
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.rotate(-45 * Math.PI / 180);
const diag = Math.sqrt(w*w + h*h);
// Draw in rotated grid
for (let x = -diag; x < diag; x += spaceX) {
for (let y = -diag; y < diag; y += spaceY) {
drawFn(x, y);
}
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
}
// --- Invisible Watermark (LSB) ---
let invisImg = new Image();
document.getElementById('invis-file').addEventListener('change', function(e) {
if(!e.target.files.length) return;
const reader = new FileReader();
reader.onload = (event) => {
invisImg.onload = () => {
const cvs = document.getElementById('invis-canvas');
cvs.width = invisImg.width;
cvs.height = invisImg.height;
const ctx = cvs.getContext('2d');
ctx.drawImage(invisImg, 0, 0);
document.getElementById('invis-result').innerText = '';
};
invisImg.src = event.target.result;
};
reader.readAsDataURL(e.target.files[0]);
});
window.encodeLSB = function() {
const text = document.getElementById('invis-text').value;
if (!text || !invisImg.src) return alert('请先上传图片并输入文字');
const cvs = document.getElementById('invis-canvas');
const ctx = cvs.getContext('2d');
const imgData = ctx.getImageData(0, 0, cvs.width, cvs.height);
const data = imgData.data;
// Simple LSB encoding (Text -> Binary -> Modify R channel LSB)
// Implementation simplified for brevity
alert('隐写功能演示:实际写入需完整二进制编码逻辑');
};
window.decodeLSB = function() {
alert('隐写功能演示:实际读取需完整二进制解码逻辑');
};
// --- Remove Watermark (New) ---
let removeFile = document.getElementById('remove-file');
let removeImgData = null; // Store original img data for undo
let pdfFileBytes = null;
let isMaskMode = false;
removeFile.addEventListener('change', async function(e) {
if(!e.target.files.length) return;
const file = e.target.files[0];
if (file.type.startsWith('image/')) {
document.getElementById('remove-image-ui').style.display = 'block';
document.getElementById('remove-pdf-ui').style.display = 'none';
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
const cvs = document.getElementById('remove-canvas');
cvs.width = img.width;
cvs.height = img.height;
const ctx = cvs.getContext('2d');
ctx.drawImage(img, 0, 0);
removeImgData = ctx.getImageData(0, 0, cvs.width, cvs.height); // Save for undo
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
initBrush();
} else if (file.type === 'application/pdf') {
document.getElementById('remove-image-ui').style.display = 'none';
document.getElementById('remove-pdf-ui').style.display = 'block';
pdfFileBytes = await file.arrayBuffer();
renderPDF(pdfFileBytes);
}
});
// Image Brush Logic
function initBrush() {
const cvs = document.getElementById('remove-canvas');
const ctx = cvs.getContext('2d');
let isDrawing = false;
cvs.onmousedown = (e) => {
isDrawing = true;
ctx.beginPath();
const rect = cvs.getBoundingClientRect();
const scaleX = cvs.width / rect.width;
const scaleY = cvs.height / rect.height;
ctx.moveTo((e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY);
};
cvs.onmousemove = (e) => {
if (!isDrawing) return;
const rect = cvs.getBoundingClientRect();
const scaleX = cvs.width / rect.width;
const scaleY = cvs.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const size = document.getElementById('brush-size').value;
// Simple Inpainting: Fill with average color of surrounding area?
// Or just blur? Let's do a simple clone effect (taking pixel from nearby)
// For demo, we just smear using a blur-like effect by drawing with low opacity
// Actually, a simple 'blur' brush is easier to implement
ctx.lineWidth = size;
ctx.lineCap = 'round';
ctx.strokeStyle = '#fff'; // White out for simplicity or...
// Better: Get color from nearby? Too complex for this snippet.
// Let's implement a 'Blur' brush by copying a region slightly offset
// Demo: Just paint white (assuming white background document)
// or blur.
ctx.save();
ctx.filter = 'blur(5px)';
ctx.globalCompositeOperation = 'source-over';
// Draw line
ctx.lineTo(x, y);
ctx.stroke();
ctx.restore();
};
cvs.onmouseup = () => isDrawing = false;
}
window.undoRemove = function() {
if (!removeImgData) return;
const cvs = document.getElementById('remove-canvas');
const ctx = cvs.getContext('2d');
ctx.putImageData(removeImgData, 0, 0);
}
// PDF Logic
async function renderPDF(data) {
const loadingTask = pdfjsLib.getDocument(data);
const pdf = await loadingTask.promise;
const container = document.getElementById('pdf-container');
container.innerHTML = ''; // Clear
// Render first 3 pages only for demo performance
const numPages = Math.min(pdf.numPages, 3);
for (let i = 1; i <= numPages; i++) {
const page = await pdf.getPage(i);
const scale = 1.0;
const viewport = page.getViewport({scale: scale});
const wrapper = document.createElement('div');
wrapper.className = 'pdf-page-wrapper';
wrapper.dataset.pageIndex = i - 1; // 0-based
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
const renderContext = {
canvasContext: context,
viewport: viewport
};
await page.render(renderContext).promise;
wrapper.appendChild(canvas);
container.appendChild(wrapper);
// Add mask listener
initMasking(wrapper);
}
}
window.toggleMaskMode = function() {
isMaskMode = !isMaskMode;
const btn = document.getElementById('btn-add-mask-mode');
btn.style.background = isMaskMode ? '#e6a23c' : '#49b1f5';
btn.innerText = isMaskMode ? '退出框选模式' : '🔳 进入框选模式';
}
function initMasking(wrapper) {
let startX, startY;
let currentMask = null;
wrapper.addEventListener('mousedown', (e) => {
if (!isMaskMode) return;
const rect = wrapper.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
currentMask = document.createElement('div');
currentMask.className = 'remove-mask';
currentMask.style.left = startX + 'px';
currentMask.style.top = startY + 'px';
wrapper.appendChild(currentMask);
});
wrapper.addEventListener('mousemove', (e) => {
if (!isMaskMode || !currentMask) return;
const rect = wrapper.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
const left = Math.min(currentX, startX);
const top = Math.min(currentY, startY);
currentMask.style.width = width + 'px';
currentMask.style.height = height + 'px';
currentMask.style.left = left + 'px';
currentMask.style.top = top + 'px';
});
wrapper.addEventListener('mouseup', () => {
currentMask = null;
});
}
window.saveCleanPDF = async function() {
if (!pdfFileBytes) return;
const { PDFDocument, rgb } = PDFLib;
const pdfDoc = await PDFDocument.load(pdfFileBytes);
const pages = pdfDoc.getPages();
// Iterate over all DOM wrappers to find masks
const wrappers = document.querySelectorAll('.pdf-page-wrapper');
wrappers.forEach(wrapper => {
const pageIndex = parseInt(wrapper.dataset.pageIndex);
if (pageIndex >= pages.length) return;
const page = pages[pageIndex];
const { width, height } = page.getSize();
// Canvas size might scale, assume 1:1 for now or calculate scale
const canvas = wrapper.querySelector('canvas');
const scaleX = width / canvas.width;
const scaleY = height / canvas.height;
const masks = wrapper.querySelectorAll('.remove-mask');
masks.forEach(mask => {
// DOM coords (top-left is 0,0)
const x = parseFloat(mask.style.left);
const y = parseFloat(mask.style.top);
const w = parseFloat(mask.style.width);
const h = parseFloat(mask.style.height);
// PDF coords (bottom-left is 0,0 usually, but PDFLib handles it)
// We need to convert DOM Y (from top) to PDF Y (from bottom)
// PDF Y = PageHeight - (DOM Y + Height) * Scale
const rectX = x * scaleX;
const rectW = w * scaleX;
const rectH = h * scaleY;
const rectY = height - (y * scaleY) - rectH;
page.drawRectangle({
x: rectX,
y: rectY,
width: rectW,
height: rectH,
color: rgb(1, 1, 1), // White
});
});
});
const pdfBytes = await pdfDoc.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'cleaned_document.pdf';
link.click();
};
}
// Initialize when DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWatermarkTool);
} else {
initWatermarkTool();
}
})();