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" ,
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' ) ;
0 commit comments