Skip to content

Commit a2ab3ed

Browse files
committed
add snapshot
1 parent 262f8d5 commit a2ab3ed

File tree

2 files changed

+302
-1
lines changed

2 files changed

+302
-1
lines changed

src/content_ui.js

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
ruler_offset_label: "Offsets (X, Y):",
3636
ruler_area_label: "Area (sq units):",
3737
ruler_clear: "Clear",
38+
snapshot: "Take a snapshot",
3839
ruler_pick_toast: "Picked",
3940
ruler_wait_pick: "Click on the map to pick",
4041
ruler_no_coords: "No coords available to pick",
@@ -80,6 +81,7 @@
8081
ruler_offset_label: "横纵偏移 (X, Y):",
8182
ruler_area_label: "面积 (平方单位):",
8283
ruler_clear: "清除",
84+
snapshot: "拍快照",
8385
ruler_pick_toast: "已拾取",
8486
ruler_wait_pick: "请在地图上点击以拾取",
8587
ruler_no_coords: "没有可用的坐标",
@@ -2970,6 +2972,7 @@ try {
29702972
29712973
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:6px;">
29722974
<button id="wplace_ruler_clear" class="wplace_btn small">${t('ruler_clear')}</button>
2975+
<button id="wplace_ruler_snapshot" class="wplace_btn small">${t('snapshot')}</button>
29732976
</div>
29742977
</div>
29752978
`;
@@ -3087,7 +3090,302 @@ try {
30873090
panel.style.display = 'none';
30883091
pickingMode = null;
30893092
}
3093+
(function installRulerSnapshot() {
3094+
// config
3095+
const TILE_SIZE = 1000; // 每个 tile 的像素宽高
3096+
const BACKEND_TILE_URL = 'https://backend.wplace.live/files/s0/tiles'; // 模板前缀
3097+
const FETCH_TIMEOUT_MS = 7000; // 每个 tile fetch 超时
3098+
const MAX_CONCURRENT_FETCH = 6; // 并发限制以防并发过高
3099+
3100+
function showRulerToast(msg, timeout = 2500) {
3101+
try { showToast && showToast(msg, timeout); } catch (e) { console.log(msg); }
3102+
}
3103+
3104+
// 计算全局像素坐标(X,Y)工具,复用你已有 computeXYFromFour 风格
3105+
function fourToGlobalXY(arr) {
3106+
if (!arr || arr.length !== 4) return null;
3107+
const [TlX, TlY, PxX, PxY] = arr.map(Number);
3108+
if ([TlX, TlY, PxX, PxY].some(n => Number.isNaN(n))) return null;
3109+
const X = TlX * TILE_SIZE + PxX;
3110+
const Y = TlY * TILE_SIZE + PxY;
3111+
return { X, Y };
3112+
}
3113+
3114+
// 给 fetch 加超时
3115+
function fetchWithTimeout(url, opts = {}, timeout = FETCH_TIMEOUT_MS) {
3116+
return new Promise((resolve, reject) => {
3117+
const timer = setTimeout(() => reject(new Error('timeout')), timeout);
3118+
fetch(url, Object.assign({ mode: 'cors', credentials: 'same-origin' }, opts))
3119+
.then(r => {
3120+
clearTimeout(timer);
3121+
if (!r.ok) throw new Error('http ' + r.status);
3122+
return r.blob();
3123+
})
3124+
.then(b => resolve(b))
3125+
.catch(err => { clearTimeout(timer); reject(err); });
3126+
});
3127+
}
30903128

3129+
// 根据 start/end 四元坐标生成截图并触发下载
3130+
async function captureRulerSelectionAsPNG(startArr, endArr) {
3131+
if (!startArr || !endArr) { showRulerToast('需要先设置起点和终点 | Please set start and end points first'); return; }
3132+
3133+
const a = fourToGlobalXY(startArr);
3134+
const b = fourToGlobalXY(endArr);
3135+
if (!a || !b) { showRulerToast('坐标解析失败 | Coordinate parsing failed'); return; }
3136+
3137+
// 规范化为左上与右下(包含边界)
3138+
const leftX = Math.min(a.X, b.X);
3139+
const rightX = Math.max(a.X, b.X);
3140+
const topY = Math.min(a.Y, b.Y);
3141+
const bottomY = Math.max(a.Y, b.Y);
3142+
3143+
const outW = rightX - leftX + 1;
3144+
const outH = bottomY - topY + 1;
3145+
if (outW <= 0 || outH <= 0) { showRulerToast('选区大小为零 | Selection size is zero'); return; }
3146+
3147+
// tile 范围(基于 TILE_SIZE)
3148+
const tileLeft = Math.floor(leftX / TILE_SIZE);
3149+
const tileRight = Math.floor(rightX / TILE_SIZE);
3150+
const tileTop = Math.floor(topY / TILE_SIZE);
3151+
const tileBottom = Math.floor(bottomY / TILE_SIZE);
3152+
3153+
// 计算需要请求的所有 tile 列表
3154+
const tiles = [];
3155+
for (let ty = tileTop; ty <= tileBottom; ty++) {
3156+
for (let tx = tileLeft; tx <= tileRight; tx++) {
3157+
tiles.push({ tx, ty });
3158+
}
3159+
}
3160+
3161+
showRulerToast(`开始抓取 ${tiles.length} 张 tile,可能需要一些时间... | Fetching ${tiles.length} tiles, this may take a while...`, 4000);
3162+
3163+
// 限流并行 fetch
3164+
async function fetchAllTiles(list) {
3165+
const results = new Map(); // key 'tx,ty' -> ImageBitmap (or HTMLImageElement)
3166+
let idx = 0;
3167+
3168+
async function worker() {
3169+
while (true) {
3170+
let item;
3171+
// get next
3172+
if (idx >= list.length) return;
3173+
item = list[idx++];
3174+
const url = `${BACKEND_TILE_URL}/${item.tx}/${item.ty}.png`;
3175+
try {
3176+
const blob = await fetchWithTimeout(url, {}, FETCH_TIMEOUT_MS);
3177+
// create ImageBitmap if supported (更快),否则用 img element
3178+
let imgBitmap = null;
3179+
if (typeof createImageBitmap === 'function') {
3180+
try { imgBitmap = await createImageBitmap(blob); }
3181+
catch (e) { imgBitmap = null; }
3182+
}
3183+
if (!imgBitmap) {
3184+
// fallback to HTMLImageElement
3185+
const objectURL = URL.createObjectURL(blob);
3186+
const img = await new Promise((res, rej) => {
3187+
const el = new Image();
3188+
el.crossOrigin = 'anonymous';
3189+
el.onload = () => { URL.revokeObjectURL(objectURL); res(el); };
3190+
el.onerror = (ev) => { URL.revokeObjectURL(objectURL); rej(new Error('img load error')); };
3191+
el.src = objectURL;
3192+
});
3193+
results.set(`${item.tx},${item.ty}`, img);
3194+
} else {
3195+
results.set(`${item.tx},${item.ty}`, imgBitmap);
3196+
}
3197+
} catch (err) {
3198+
// store null to indicate missing tile
3199+
results.set(`${item.tx},${item.ty}`, null);
3200+
console.warn('tile fetch failed', url, err);
3201+
}
3202+
}
3203+
}
3204+
3205+
// spawn workers
3206+
const workers = [];
3207+
const concurrency = Math.min(MAX_CONCURRENT_FETCH, Math.max(1, Math.floor(navigator.hardwareConcurrency || 4)));
3208+
for (let i = 0; i < concurrency; i++) workers.push(worker());
3209+
await Promise.all(workers);
3210+
return results;
3211+
}
3212+
3213+
let fetchedMap;
3214+
try {
3215+
fetchedMap = await fetchAllTiles(tiles);
3216+
} catch (e) {
3217+
showRulerToast('抓取 tiles 失败: ' + (e && e.message ? e.message : 'error') + ' | Fetching tiles failed: ' + (e && e.message ? e.message : 'error'), 4000);
3218+
return;
3219+
}
3220+
3221+
// 创建离屏 canvas(尺寸 outW x outH)
3222+
const canvas = document.createElement('canvas');
3223+
canvas.width = outW;
3224+
canvas.height = outH;
3225+
const ctx = canvas.getContext('2d');
3226+
if (!ctx) { showRulerToast('无法获取 2D 上下文 | Unable to get 2D context'); return; }
3227+
3228+
// 对每个 tile,计算在输出 canvas 的目标位置以及从 tile 内部要裁切的子区域
3229+
for (let ty = tileTop; ty <= tileBottom; ty++) {
3230+
for (let tx = tileLeft; tx <= tileRight; tx++) {
3231+
const key = `${tx},${ty}`;
3232+
const tileImg = fetchedMap.get(key);
3233+
// tile 的全局像素范围
3234+
const tileX0 = tx * TILE_SIZE;
3235+
const tileY0 = ty * TILE_SIZE;
3236+
const tileX1 = tileX0 + TILE_SIZE - 1;
3237+
const tileY1 = tileY0 + TILE_SIZE - 1;
3238+
3239+
// 求交集(选区 vs tile)
3240+
const sx = Math.max(leftX, tileX0);
3241+
const ex = Math.min(rightX, tileX1);
3242+
const sy = Math.max(topY, tileY0);
3243+
const ey = Math.min(bottomY, tileY1);
3244+
if (ex < sx || ey < sy) continue;
3245+
3246+
const srcX = sx - tileX0; // 在 tile 内的左上 x
3247+
const srcY = sy - tileY0; // 在 tile 内的左上 y
3248+
const srcW = ex - sx + 1;
3249+
const srcH = ey - sy + 1;
3250+
3251+
const destX = sx - leftX; // 在输出 canvas 上的位置
3252+
const destY = sy - topY;
3253+
3254+
try {
3255+
if (!tileImg) {
3256+
// tile 丢失或获取失败 -> 用透明填充或占位(这里用半透明红提示缺图)
3257+
ctx.fillStyle = 'rgba(255,0,0,0.25)';
3258+
ctx.fillRect(destX, destY, srcW, srcH);
3259+
ctx.fillStyle = 'rgba(0,0,0,0.0)';
3260+
continue;
3261+
}
3262+
3263+
// tileImg 可能是 ImageBitmap 或 HTMLImageElement
3264+
// drawImage 支持 ImageBitmap 与 HTMLImageElement
3265+
ctx.drawImage(tileImg,
3266+
Math.round(srcX), Math.round(srcY), Math.round(srcW), Math.round(srcH),
3267+
Math.round(destX), Math.round(destY), Math.round(srcW), Math.round(srcH));
3268+
} catch (e) {
3269+
console.warn('drawImage fail', key, e);
3270+
}
3271+
}
3272+
}
3273+
3274+
// 导出 PNG 并触发下载
3275+
try {
3276+
const filename = `wplace_ruler_${leftX}_${topY}_${outW}x${outH}.png`;
3277+
canvas.toBlob((blob) => {
3278+
if (!blob) {
3279+
showRulerToast('生成图片失败 | Failed to generate image');
3280+
return;
3281+
}
3282+
const url = URL.createObjectURL(blob);
3283+
const a = document.createElement('a');
3284+
a.href = url;
3285+
a.download = filename;
3286+
document.body.appendChild(a);
3287+
a.click();
3288+
a.remove();
3289+
setTimeout(() => URL.revokeObjectURL(url), 3000);
3290+
showRulerToast('截图已下载 | Screenshot downloaded', 2500);
3291+
}, 'image/png');
3292+
} catch (e) {
3293+
showRulerToast('导出 PNG 失败 | Export PNG failed');
3294+
console.error(e);
3295+
}
3296+
}
3297+
3298+
// 绑定到面板按钮(等待 panel 已创建)
3299+
function bindSnapshotButton() {
3300+
try {
3301+
const panelEl = document.getElementById('wplace_ruler_panel');
3302+
if (!panelEl) return;
3303+
const snapBtn = panelEl.querySelector('#wplace_ruler_snapshot');
3304+
if (!snapBtn) return;
3305+
3306+
if (snapBtn.__wplace_snapshot_bound) return;
3307+
snapBtn.__wplace_snapshot_bound = true;
3308+
snapBtn.addEventListener('click', async () => {
3309+
try {
3310+
// 读取当前输入框的四元坐标(优先 panel 中的 input 文本)
3311+
const sIn = panelEl.querySelector('#wplace_ruler_start');
3312+
const eIn = panelEl.querySelector('#wplace_ruler_end');
3313+
const sVal = sIn ? String(sIn.value || '').trim() : '';
3314+
const eVal = eIn ? String(eIn.value || '').trim() : '';
3315+
const sArr = parseFourCoords(sVal);
3316+
const eArr = parseFourCoords(eVal);
3317+
if (!sArr || !eArr) {
3318+
showRulerToast('请先设置有效的起点和终点坐标 | Please set valid start and end coordinates first');
3319+
return;
3320+
}
3321+
await captureRulerSelectionAsPNG(sArr, eArr);
3322+
} catch (err) {
3323+
console.error('snapshot error', err);
3324+
showRulerToast('截图失败 | Snapshot failed');
3325+
}
3326+
}, { passive: true });
3327+
} catch (e) {
3328+
console.warn('bindSnapshotButton failed', e);
3329+
}
3330+
}
3331+
3332+
// 插入按钮并绑定(如果 panel 尚未创建,等待并观察)
3333+
(function ensureButtonExistsAndBind() {
3334+
const panel = document.getElementById('wplace_ruler_panel');
3335+
if (panel) {
3336+
// 如果按钮不存在,尝试在结果区域后追加
3337+
try {
3338+
if (!panel.querySelector('#wplace_ruler_snapshot')) {
3339+
const footer = panel.querySelector('#wplace_ruler_body > div:last-of-type') || panel.querySelector('#wplace_ruler_body');
3340+
// create button and insert near clear
3341+
const btn = document.createElement('button');
3342+
btn.id = 'wplace_ruler_snapshot';
3343+
btn.className = 'wplace_btn small';
3344+
btn.textContent = '截图';
3345+
// try to place near clear button
3346+
const clearBtn = panel.querySelector('#wplace_ruler_clear');
3347+
if (clearBtn && clearBtn.parentElement) {
3348+
clearBtn.parentElement.insertBefore(btn, clearBtn.nextSibling);
3349+
} else if (footer) {
3350+
footer.appendChild(btn);
3351+
} else {
3352+
panel.appendChild(btn);
3353+
}
3354+
}
3355+
} catch (e) {}
3356+
// bind
3357+
bindSnapshotButton();
3358+
return;
3359+
}
3360+
3361+
// 如果 panel 还没创建,则用 MutationObserver 等待创建后插入并绑定
3362+
const mo = new MutationObserver((muts, obs) => {
3363+
for (const m of muts) {
3364+
if (m.addedNodes && m.addedNodes.length) {
3365+
const p = document.getElementById('wplace_ruler_panel');
3366+
if (p) {
3367+
try { obs.disconnect(); } catch (_) {}
3368+
try {
3369+
if (!p.querySelector('#wplace_ruler_snapshot')) {
3370+
const btn = document.createElement('button');
3371+
btn.id = 'wplace_ruler_snapshot';
3372+
btn.className = 'wplace_btn small';
3373+
btn.textContent = '截图';
3374+
const clearBtn = p.querySelector('#wplace_ruler_clear');
3375+
if (clearBtn && clearBtn.parentElement) clearBtn.parentElement.insertBefore(btn, clearBtn.nextSibling);
3376+
else p.appendChild(btn);
3377+
}
3378+
} catch (e) {}
3379+
bindSnapshotButton();
3380+
return;
3381+
}
3382+
}
3383+
}
3384+
});
3385+
try { mo.observe(document.body, { childList: true, subtree: true }); } catch (e) { /* ignore */ }
3386+
})();
3387+
3388+
})();
30913389
function updateRulerI18n() {
30923390
try {
30933391
const m = document.getElementById('wplace_ruler_panel');
@@ -3107,6 +3405,9 @@ try {
31073405
const clearBtn = m.querySelector('#wplace_ruler_clear');
31083406
if (clearBtn) clearBtn.textContent = t('ruler_clear');
31093407

3408+
const snapshotBtn = m.querySelector('#wplace_ruler_snapshot');
3409+
if (snapshotBtn) snapshotBtn.textContent = t('snapshot');
3410+
31103411
// labels: pick all label elements and set by index if present
31113412
const labels = Array.from(m.querySelectorAll('label'));
31123413
if (labels.length >= 1) labels[0].textContent = t('ruler_start_label');

src/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "Wplace_Versatile_Tool",
4-
"version": "2.3.2",
4+
"version": "2.4",
55
"description": "A versatile Wplace tool",
66
"permissions": [
77
"scripting",

0 commit comments

Comments
 (0)