@@ -48,10 +48,16 @@ const MIN_ZOOM = 0.05
4848const MAX_ZOOM = 8
4949const WHEEL_ZOOM_SENSITIVITY = 0.0015
5050const GENERATE_STITCH_URL = 'https://gemini.google.com/gem/1lJTnukifhxITzO7l084Icn3Q_ctIID9g?usp=sharing'
51+ const STATE_ARCHIVE_VERSION = 2
52+ const STATE_MANIFEST_NAME = 'map_stitch_state.json'
5153
5254type SavedImageState = {
5355 fileName ?: string
5456 type ?: string
57+ size ?: number
58+ width ?: number
59+ height ?: number
60+ path ?: string
5561 dataUrl ?: string
5662}
5763
@@ -230,30 +236,30 @@ function FeatheredPreviewImage({
230236 feather : Feather
231237 alt : string
232238} ) {
233- const [ url , setUrl ] = useState ( upload . url )
239+ const hasFeather = feather . top !== 0 || feather . right !== 0 || feather . bottom !== 0 || feather . left !== 0
240+ const previewKey = `${ upload . url } :${ width } :${ height } :${ feather . top } :${ feather . right } :${ feather . bottom } :${ feather . left } `
241+ const [ generatedPreview , setGeneratedPreview ] = useState < { key : string ; url : string } | null > ( null )
234242
235243 useEffect ( ( ) => {
236- if ( feather . top === 0 && feather . right === 0 && feather . bottom === 0 && feather . left === 0 ) {
237- setUrl ( upload . url )
238- return
239- }
244+ if ( ! hasFeather ) return
240245
241246 let revokedUrl : string | null = null
242247 let cancelled = false
243248 const canvas = featheredImageCanvas ( upload . image , Math . max ( 1 , Math . round ( width ) ) , Math . max ( 1 , Math . round ( height ) ) , feather )
244249 canvas . toBlob ( ( blob ) => {
245250 if ( ! blob || cancelled ) return
246251 revokedUrl = URL . createObjectURL ( blob )
247- setUrl ( revokedUrl )
252+ setGeneratedPreview ( { key : previewKey , url : revokedUrl } )
248253 } , 'image/png' )
249254
250255 return ( ) => {
251256 cancelled = true
252257 if ( revokedUrl ) URL . revokeObjectURL ( revokedUrl )
253258 }
254- } , [ alt , feather . bottom , feather . left , feather . right , feather . top , height , upload , width ] )
259+ } , [ feather , hasFeather , height , previewKey , upload . image , width ] )
255260
256- return < img src = { url } alt = { alt } />
261+ const src = hasFeather && generatedPreview ?. key === previewKey ? generatedPreview . url : upload . url
262+ return < img src = { src } alt = { alt } />
257263}
258264
259265function downloadBlob ( blob : Blob , filename : string ) {
@@ -268,15 +274,6 @@ function downloadBlob(blob: Blob, filename: string) {
268274 window . setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 30_000 )
269275}
270276
271- function fileToDataUrl ( file : File ) : Promise < string > {
272- return new Promise ( ( resolve , reject ) => {
273- const reader = new FileReader ( )
274- reader . onload = ( ) => resolve ( String ( reader . result ?? '' ) )
275- reader . onerror = ( ) => reject ( reader . error ?? new Error ( '文件读取失败' ) )
276- reader . readAsDataURL ( file )
277- } )
278- }
279-
280277function dataUrlToFile ( dataUrl : string , fileName : string , fallbackType = 'image/png' ) : File {
281278 const [ meta = '' , payload = '' ] = dataUrl . split ( ',' , 2 )
282279 if ( ! payload ) throw new Error ( '拼接状态中的图片数据不完整' )
@@ -287,6 +284,45 @@ function dataUrlToFile(dataUrl: string, fileName: string, fallbackType = 'image/
287284 return new File ( [ bytes ] , fileName , { type : mime } )
288285}
289286
287+ function fileExtensionFor ( file : File ) : string {
288+ const fromName = file . name . match ( / \. [ a - z 0 - 9 ] + $ / i) ?. [ 0 ] ?. toLowerCase ( )
289+ if ( fromName && [ '.png' , '.jpg' , '.jpeg' , '.webp' ] . includes ( fromName ) ) return fromName
290+ if ( file . type === 'image/jpeg' ) return '.jpg'
291+ if ( file . type === 'image/webp' ) return '.webp'
292+ return '.png'
293+ }
294+
295+ function uniqueArchiveImagePath ( used : Set < string > , baseName : string , file : File ) : string {
296+ const ext = fileExtensionFor ( file )
297+ const safeBase = safeFilename ( baseName . replace ( / \. [ ^ . ] + $ / , '' ) )
298+ let index = 0
299+ let name = `${ safeBase } ${ ext } `
300+ while ( used . has ( `images/${ name } ` ) ) {
301+ index += 1
302+ name = `${ safeBase } _${ index } ${ ext } `
303+ }
304+ const path = `images/${ name } `
305+ used . add ( path )
306+ return path
307+ }
308+
309+ async function savedImageStateToFile (
310+ image : SavedImageState | undefined ,
311+ fallbackName : string ,
312+ zip ?: JSZip ,
313+ ) : Promise < File > {
314+ if ( ! image ) throw new Error ( '拼接状态缺少图片数据' )
315+ if ( image . dataUrl ) return dataUrlToFile ( image . dataUrl , image . fileName || fallbackName , image . type || 'image/png' )
316+ if ( ! image . path || ! zip ) throw new Error ( '拼接状态中的图片引用无效' )
317+
318+ const entry = zip . file ( image . path )
319+ if ( ! entry ) throw new Error ( `拼接状态缺少图片文件:${ image . path } ` )
320+ const blob = await entry . async ( 'blob' )
321+ return new File ( [ blob ] , image . fileName || image . path . split ( '/' ) . pop ( ) || fallbackName , {
322+ type : image . type || blob . type || 'image/png' ,
323+ } )
324+ }
325+
290326function isRecord ( value : unknown ) : value is Record < string , unknown > {
291327 return typeof value === 'object' && value !== null && ! Array . isArray ( value )
292328}
@@ -1052,36 +1088,36 @@ export default function MapStitch({ onBack }: Props) {
10521088 const downloadEditStateJson = async ( ) => {
10531089 if ( ! source ) return
10541090 try {
1055- const uploads = await Promise . all (
1056- Object . entries ( tileUploads ) . map ( async ( [ key , upload ] ) => {
1057- if ( ! upload ) return null
1058- return [
1059- key ,
1060- {
1061- fileName : upload . file . name ,
1062- type : upload . file . type ,
1063- size : upload . file . size ,
1064- width : upload . width ,
1065- height : upload . height ,
1066- dataUrl : await fileToDataUrl ( upload . file ) ,
1067- } ,
1068- ] as const
1069- } ) ,
1070- )
1091+ const zip = new JSZip ( )
1092+ const imageFolder = zip . folder ( 'images' )
1093+ if ( ! imageFolder ) throw new Error ( '拼接状态图片目录创建失败' )
1094+
1095+ const usedImagePaths = new Set < string > ( )
1096+ const addImageToArchive = ( image : LoadedImage , baseName : string ) : SavedImageState => {
1097+ const path = uniqueArchiveImagePath ( usedImagePaths , baseName , image . file )
1098+ imageFolder . file ( path . replace ( / ^ i m a g e s \/ / , '' ) , image . file )
1099+ return {
1100+ fileName : image . file . name ,
1101+ type : image . file . type ,
1102+ size : image . file . size ,
1103+ width : image . width ,
1104+ height : image . height ,
1105+ path,
1106+ }
1107+ }
1108+
1109+ const uploads = Object . entries ( tileUploads ) . flatMap ( ( [ key , upload ] ) => {
1110+ if ( ! upload ) return [ ]
1111+ return [ [ key , addImageToArchive ( upload , `tile_${ key . replace ( ',' , '_' ) } _${ upload . file . name } ` ) ] as const ]
1112+ } )
10711113
10721114 const state = {
1073- version : 1 ,
1115+ version : STATE_ARCHIVE_VERSION ,
10741116 savedAt : new Date ( ) . toISOString ( ) ,
1075- source : {
1076- fileName : source . file . name ,
1077- type : source . file . type ,
1078- size : source . file . size ,
1079- width : source . width ,
1080- height : source . height ,
1081- dataUrl : await fileToDataUrl ( source . file ) ,
1082- } ,
1117+ format : 'pixelwork-map-stitch-state' ,
1118+ source : addImageToArchive ( source , `source_${ source . file . name } ` ) ,
10831119 tiles,
1084- tileUploads : Object . fromEntries ( uploads . filter ( ( item ) : item is NonNullable < typeof item > => Boolean ( item ) ) ) ,
1120+ tileUploads : Object . fromEntries ( uploads ) ,
10851121 tileFeathers,
10861122 selectedKey,
10871123 horizontalOverlapPercent,
@@ -1093,69 +1129,79 @@ export default function MapStitch({ onBack }: Props) {
10931129 hiddenPreviewTiles,
10941130 }
10951131
1132+ zip . file ( STATE_MANIFEST_NAME , JSON . stringify ( state , null , 2 ) )
10961133 downloadBlob (
1097- new Blob ( [ JSON . stringify ( state , null , 2 ) ] , { type : 'application/json ' } ) ,
1098- `${ safeFilename ( getBaseName ( source . file ) ) } _map_stitch_state.json ` ,
1134+ await zip . generateAsync ( { type : 'blob' , compression : 'STORE ' } ) ,
1135+ `${ safeFilename ( getBaseName ( source . file ) ) } _map_stitch_state.zip ` ,
10991136 )
11001137 message . success ( '拼接状态已保存' )
11011138 } catch ( error ) {
11021139 message . error ( `保存拼接状态失败:${ String ( error ) } ` )
11031140 }
11041141 }
11051142
1143+ const applyEditState = async ( state : SavedMapStitchState , zip ?: JSZip ) => {
1144+ if ( ( state . version !== 1 && state . version !== STATE_ARCHIVE_VERSION ) || ! state . source ) {
1145+ throw new Error ( '不是有效的拼接状态文件' )
1146+ }
1147+
1148+ const nextSource = await loadImageFile ( await savedImageStateToFile (
1149+ state . source ,
1150+ 'map_tile.png' ,
1151+ zip ,
1152+ ) )
1153+
1154+ const uploads = await Promise . all (
1155+ Object . entries ( state . tileUploads ?? { } ) . map ( async ( [ key , upload ] ) => {
1156+ const loaded = await loadImageFile ( await savedImageStateToFile (
1157+ upload ,
1158+ `tile_${ key . replace ( ',' , '_' ) } .png` ,
1159+ zip ,
1160+ ) )
1161+ return [ key , loaded ] as const
1162+ } ) ,
1163+ )
1164+
1165+ const nextTiles = normalizeTilesState ( state . tiles )
1166+ const nextTileUploads = Object . fromEntries ( uploads ) as Partial < Record < TileKey , LoadedImage > >
1167+ const nextExpandSplit : ExpandSplit = state . expandSplit === 8 || state . expandSplit === 12 ? state . expandSplit : 4
1168+ const nextPan = isRecord ( state . pan )
1169+ ? { x : numberOr ( state . pan . x , 0 ) , y : numberOr ( state . pan . y , 0 ) }
1170+ : { x : 0 , y : 0 }
1171+
1172+ setSource ( ( prev ) => {
1173+ if ( prev ) URL . revokeObjectURL ( prev . url )
1174+ return nextSource
1175+ } )
1176+ setTileUploads ( ( prev ) => {
1177+ Object . values ( prev ) . forEach ( ( item ) => item && URL . revokeObjectURL ( item . url ) )
1178+ return nextTileUploads
1179+ } )
1180+ setTiles ( nextTiles )
1181+ setTileFeathers ( normalizeFeathersState ( state . tileFeathers ) )
1182+ setSelectedKey ( typeof state . selectedKey === 'string' ? state . selectedKey : null )
1183+ setHorizontalOverlapPercent ( Math . max ( 0 , Math . min ( 50 , numberOr ( state . horizontalOverlapPercent , 15 ) ) ) )
1184+ setVerticalOverlapPercent ( Math . max ( 0 , Math . min ( 50 , numberOr ( state . verticalOverlapPercent , 15 ) ) ) )
1185+ setExpandSplit ( nextExpandSplit )
1186+ setPan ( nextPan )
1187+ setZoom ( clampZoom ( numberOr ( state . zoom , 1 ) ) )
1188+ setHidePreviewBorders ( typeof state . hidePreviewBorders === 'boolean' ? state . hidePreviewBorders : false )
1189+ setHiddenPreviewTiles ( booleanRecord ( state . hiddenPreviewTiles ) )
1190+ setProcessingKey ( null )
1191+ setPendingUploadKey ( null )
1192+ }
1193+
11061194 const loadEditStateJson = async ( file : File | null ) => {
11071195 if ( ! file ) return
11081196 try {
1109- const state = JSON . parse ( await file . text ( ) ) as SavedMapStitchState
1110- if ( state . version !== 1 || ! state . source ?. dataUrl ) throw new Error ( '不是有效的拼接状态文件' )
1111-
1112- const nextSource = await loadImageFile ( dataUrlToFile (
1113- state . source . dataUrl ,
1114- state . source . fileName || 'map_tile.png' ,
1115- state . source . type || 'image/png' ,
1116- ) )
1117-
1118- const uploads = await Promise . all (
1119- Object . entries ( state . tileUploads ?? { } ) . map ( async ( [ key , upload ] ) => {
1120- if ( ! upload ?. dataUrl ) return null
1121- const loaded = await loadImageFile ( dataUrlToFile (
1122- upload . dataUrl ,
1123- upload . fileName || `tile_${ key . replace ( ',' , '_' ) } .png` ,
1124- upload . type || 'image/png' ,
1125- ) )
1126- return [ key , loaded ] as const
1127- } ) ,
1128- )
1129-
1130- const nextTiles = normalizeTilesState ( state . tiles )
1131- const nextTileUploads = Object . fromEntries (
1132- uploads . filter ( ( item ) : item is NonNullable < typeof item > => Boolean ( item ) ) ,
1133- ) as Partial < Record < TileKey , LoadedImage > >
1134- const nextExpandSplit : ExpandSplit = state . expandSplit === 8 || state . expandSplit === 12 ? state . expandSplit : 4
1135- const nextPan = isRecord ( state . pan )
1136- ? { x : numberOr ( state . pan . x , 0 ) , y : numberOr ( state . pan . y , 0 ) }
1137- : { x : 0 , y : 0 }
1138-
1139- setSource ( ( prev ) => {
1140- if ( prev ) URL . revokeObjectURL ( prev . url )
1141- return nextSource
1142- } )
1143- setTileUploads ( ( prev ) => {
1144- Object . values ( prev ) . forEach ( ( item ) => item && URL . revokeObjectURL ( item . url ) )
1145- return nextTileUploads
1146- } )
1147- setTiles ( nextTiles )
1148- setTileFeathers ( normalizeFeathersState ( state . tileFeathers ) )
1149- setSelectedKey ( typeof state . selectedKey === 'string' ? state . selectedKey : null )
1150- setHorizontalOverlapPercent ( Math . max ( 0 , Math . min ( 50 , numberOr ( state . horizontalOverlapPercent , 15 ) ) ) )
1151- setVerticalOverlapPercent ( Math . max ( 0 , Math . min ( 50 , numberOr ( state . verticalOverlapPercent , 15 ) ) ) )
1152- setExpandSplit ( nextExpandSplit )
1153- setPan ( nextPan )
1154- setZoom ( clampZoom ( numberOr ( state . zoom , 1 ) ) )
1155- setHidePreviewBorders ( typeof state . hidePreviewBorders === 'boolean' ? state . hidePreviewBorders : false )
1156- setHiddenPreviewTiles ( booleanRecord ( state . hiddenPreviewTiles ) )
1157- setProcessingKey ( null )
1158- setPendingUploadKey ( null )
1197+ if ( file . name . toLowerCase ( ) . endsWith ( '.zip' ) || file . type === 'application/zip' || file . type === 'application/x-zip-compressed' ) {
1198+ const zip = await JSZip . loadAsync ( file )
1199+ const manifest = zip . file ( STATE_MANIFEST_NAME )
1200+ if ( ! manifest ) throw new Error ( '拼接状态 ZIP 缺少 map_stitch_state.json' )
1201+ await applyEditState ( JSON . parse ( await manifest . async ( 'text' ) ) as SavedMapStitchState , zip )
1202+ } else {
1203+ await applyEditState ( JSON . parse ( await file . text ( ) ) as SavedMapStitchState )
1204+ }
11591205 message . success ( '拼接状态已加载' )
11601206 } catch ( error ) {
11611207 message . error ( `加载拼接状态失败:${ String ( error ) } ` )
@@ -1331,7 +1377,7 @@ export default function MapStitch({ onBack }: Props) {
13311377 < input
13321378 ref = { stateFileInputRef }
13331379 type = "file"
1334- accept = "application/json,.json"
1380+ accept = "application/zip,application/x-zip-compressed,application/ json,.zip ,.json"
13351381 hidden
13361382 onChange = { ( event ) => {
13371383 void loadEditStateJson ( event . target . files ?. [ 0 ] ?? null )
0 commit comments