Skip to content

Commit 3423375

Browse files
committed
MapStitch: save/load state as ZIP with embedded images; fix feather preview
Made-with: Cursor
1 parent 67a8ea6 commit 3423375

1 file changed

Lines changed: 142 additions & 96 deletions

File tree

frontend/src/components/MapStitch.tsx

Lines changed: 142 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,16 @@ const MIN_ZOOM = 0.05
4848
const MAX_ZOOM = 8
4949
const WHEEL_ZOOM_SENSITIVITY = 0.0015
5050
const 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

5254
type 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

259265
function 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-
280277
function 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-z0-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+
290326
function 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(/^images\//, ''), 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

Comments
 (0)