diff --git a/pxtblocks/fields/field_asset.ts b/pxtblocks/fields/field_asset.ts index ae81ec2ae562..0d718cddbd86 100644 --- a/pxtblocks/fields/field_asset.ts +++ b/pxtblocks/fields/field_asset.ts @@ -4,7 +4,7 @@ import * as Blockly from "blockly"; import svg = pxt.svgUtil; import { FieldBase } from "./field_base"; -import { getTemporaryAssets, getTilesReferencedByTilesets, setMelodyEditorOpen, workspaceToScreenCoordinates, bitmapToImageURI, tilemapToImageURI, songToDataURI, setBlockDataForField } from "./field_utils"; +import { getTemporaryAssets, getTilesReferencedByTilesets, setMelodyEditorOpen, workspaceToScreenCoordinates, bitmapToImageURI, tilemapToImageURI, songToDataURI, setBlockDataForField, loadAssetFromSaveState, getAssetSaveState } from "./field_utils"; export interface FieldAssetEditorOptions { initWidth?: string; @@ -68,6 +68,27 @@ export abstract class FieldAssetEditor; } +export interface AssetSaveState { + version: number; + assetType: pxt.AssetType; + jres: pxt.Map; + assetId: string; +} + + export namespace svg { export function hasClass(el: SVGElement, cls: string): boolean { return pxt.BrowserUtils.containsClass(el, cls); @@ -283,18 +291,18 @@ export function updateTilemapXml(dom: Element, proj: pxt.TilemapProject) { const newData = new pxt.sprite.TilemapData( legacy.tilemap, { - tileWidth: legacy.tileset.tileWidth, - tiles: legacy.tileset.tiles.map((t, index) => { - if (t.projectId != null) { - return upgradedTileMapping["myTiles.tile" + t.projectId]; - } - if (!mapping[index]) { - mapping[index] = proj.resolveTile(t.qualifiedName) - } - - return mapping[index]; - }) - }, + tileWidth: legacy.tileset.tileWidth, + tiles: legacy.tileset.tiles.map((t, index) => { + if (t.projectId != null) { + return upgradedTileMapping["myTiles.tile" + t.projectId]; + } + if (!mapping[index]) { + mapping[index] = proj.resolveTile(t.qualifiedName) + } + + return mapping[index]; + }) + }, legacy.layers ); @@ -416,6 +424,129 @@ export function getTemporaryAssets(workspace: Blockly.Workspace, type: pxt.Asset } } +export function getAssetSaveState(asset: pxt.Asset) { + const serialized: AssetSaveState = { + version: 1, + assetType: asset.type, + assetId: asset.id, + jres: {} + }; + + const project = pxt.react.getTilemapProject(); + if (asset.type === pxt.AssetType.Tilemap) { + const jres = project.getProjectTilesetJRes(); + + for (const key of Object.keys(jres)) { + if (key === "*") continue; + const entry = jres[key]; + if (entry.mimeType === pxt.TILEMAP_MIME_TYPE) { + if (entry.id !== asset.id) { + delete jres[key]; + } + } + else { + const id = addDotToNamespace(jres["*"].namespace) + key; + + if (!asset.data.tileset.tiles.some(tile => tile.id === id)) { + delete jres[key]; + } + } + } + + serialized.jres = jres; + } + else { + const jres = asset.type === pxt.AssetType.Tile ? + project.getProjectTilesetJRes() : + project.getProjectAssetsJRes(); + + serialized.jres["*"] = jres["*"]; + const [key, entry] = findEntryInJres(jres, asset.id); + serialized.jres[key] = entry; + } + + return serialized; +} + + +export function loadAssetFromSaveState(serialized: AssetSaveState) { + let newId = serialized.assetId; + serialized.jres = inflateJRes(serialized.jres); + + const globalProject = pxt.react.getTilemapProject(); + const existing = globalProject.lookupAsset(serialized.assetType, serialized.assetId); + + // if this id is already in the project, we need to check to see + // if it's the same as what we're loading. if it isn't, we'll need + // to create new assets + if (existing) { + // load the jres into a throwaway project so that we don't pollute + // the actual one + const tempProject = new pxt.TilemapProject(); + + // if this is a tilemap, we need the gallery populated in case + // there are gallery tiles in the tileset + tempProject.loadGallerySnapshot(globalProject.saveGallerySnapshot()); + + if (serialized.assetType === "tilemap" || serialized.assetType === "tile") { + tempProject.loadTilemapJRes(serialized.jres); + } + else { + tempProject.loadAssetsJRes(serialized.jres); + } + + const tempAsset = tempProject.lookupAsset(serialized.assetType, serialized.assetId); + + if (pxt.assetEquals(tempAsset, existing, true)) { + return existing; + } + else { + // the asset ids collided! first try to find another asset in the + // project that has the same value. for example, if the same code + // is copy/pasted multiple times then we will have already created + // a new asset for this code + const valueMatch = globalProject.lookupAssetByValue(tempAsset.type, tempAsset); + + if (valueMatch) { + return valueMatch; + } + + // no existing asset, so remap the id in the jres before loading + // it in the project. in the case of a tilemap, we only need to + // remap the tilemap id because loadTilemapJRes automatically remaps + // tile ids and resolves duplicates + newId = globalProject.generateNewID(serialized.assetType); + + const [key, entry] = findEntryInJres(serialized.jres, serialized.assetId); + delete serialized.jres[key]; + + if (serialized.assetType === "tilemap") { + // tilemap ids don't have namespaces + entry.id = newId; + serialized.jres[newId] = entry; + } + else { + const [namespace, key] = newId.split("."); + if (addDotToNamespace(namespace) !== addDotToNamespace(serialized.jres["*"].namespace)) { + entry.namespace = addDotToNamespace(namespace); + } + entry.id = newId; + serialized.jres[key] = entry; + } + } + } + + + if (serialized.assetType === "tilemap" || serialized.assetType === "tile") { + globalProject.loadTilemapJRes(serialized.jres, true); + } + else { + globalProject.loadAssetsJRes(serialized.jres); + } + + return globalProject.lookupAsset(serialized.assetType, newId); +} + export const FIELD_EDITOR_OPEN_EVENT_TYPE = "field_editor_open"; export class FieldEditorOpenEvent extends Blockly.Events.UiBase { @@ -495,4 +626,70 @@ export function deleteBlockDataForField(block: Blockly.Block, field: string) { const blockData = getBlockData(block); delete blockData.fieldData[field]; setBlockData(block, blockData); +} + +function addDotToNamespace(namespace: string) { + if (namespace.endsWith(".")) { + return namespace; + } + return namespace + "."; +} + +function findEntryInJres(jres: pxt.Map, assetId: string): [string, any] { + const defaultNamespace = jres["*"].namespace; + + for (const key of Object.keys(jres)) { + if (key === "*") continue; + + const entry = jres[key]; + let id: string; + + if (entry.id) { + if (entry.namespace) { + id = addDotToNamespace(entry.namespace) + entry.id; + } + else { + id = entry.id; + } + } + else if (entry.namespace) { + id = addDotToNamespace(entry.namespace) + key; + } + else { + id = addDotToNamespace(defaultNamespace) + key; + } + + if (id === assetId) { + return [key, jres[key]]; + } + } + + // should never happen + return undefined; +} + +// simply replaces the string entries with objects; doesn't do a full inflate like pxt.inflateJRes +function inflateJRes(jres: pxt.Map): pxt.Map { + const meta = jres["*"] as pxt.JRes; + const result: pxt.Map = { + "*": meta + }; + + for (const key of Object.keys(jres)) { + if (key === "*") continue; + + const entry = jres[key]; + if (typeof entry === "string") { + result[key] = { + id: undefined, + data: entry, + mimeType: meta.mimeType + } + } + else { + result[key] = entry; + } + } + + return result; } \ No newline at end of file diff --git a/pxtlib/tilemap.ts b/pxtlib/tilemap.ts index 5d4a0edd751b..bb334718acfb 100644 --- a/pxtlib/tilemap.ts +++ b/pxtlib/tilemap.ts @@ -197,6 +197,15 @@ namespace pxt { return undefined; } + getByValue(toFind: U) { + for (const asset of this.assets) { + if (assetEquals(toFind, asset, true)) { + return asset; + } + } + return undefined; + } + isIDTaken(id: string) { return !!this.takenNames[id]; } @@ -896,6 +905,16 @@ namespace pxt { return getAssetCollection(this.state, assetType).getByDisplayName(name); } + public lookupAssetByValue(assetType: AssetType.Image, toFind: ProjectImage): ProjectImage; + public lookupAssetByValue(assetType: AssetType.Tile, toFind: Tile): Tile; + public lookupAssetByValue(assetType: AssetType.Tilemap, toFind: ProjectTilemap): ProjectTilemap; + public lookupAssetByValue(assetType: AssetType.Animation, toFind: Animation): Animation; + public lookupAssetByValue(assetType: AssetType.Song, toFind: Song): Song; + public lookupAssetByValue(assetType: AssetType, toFind: Asset): Asset; + public lookupAssetByValue(assetType: AssetType, toFind: Asset) { + return getAssetCollection(this.state, assetType).getByValue(toFind); + } + public getAssets(type: AssetType.Image): ProjectImage[]; public getAssets(type: AssetType.Tile): Tile[]; public getAssets(type: AssetType.Tilemap): ProjectTilemap[]; @@ -1206,6 +1225,14 @@ namespace pxt { return clone; } + saveGallerySnapshot() { + return this.gallery; + } + + loadGallerySnapshot(snapshot: AssetSnapshot) { + this.gallery = snapshot; + } + protected generateImage(entry: JRes, type: AssetType.Image): ProjectImage; protected generateImage(entry: JRes, type: AssetType.Tile): Tile; protected generateImage(entry: JRes, type: AssetType.Image | AssetType.Tile): ProjectImage | Tile { @@ -1778,15 +1805,17 @@ namespace pxt { } - export function assetEquals(a: Asset, b: Asset): boolean { + export function assetEquals(a: Asset, b: Asset, valueOnly = false): boolean { if (a == b) return true; - if (!a && b || !b && a) return false; - if (a.id !== b.id || a.type !== b.type || - !U.arrayEquals(a.meta.tags, b.meta.tags) || - !U.arrayEquals(a.meta.blockIDs, b.meta.blockIDs) || - a.meta.displayName !== b.meta.displayName - ) - return false; + if (!a && b || !b && a || a.type !== b.type) return false; + if (!valueOnly) { + if (a.id !== b.id || + !U.arrayEquals(a.meta.tags, b.meta.tags) || + !U.arrayEquals(a.meta.blockIDs, b.meta.blockIDs) || + a.meta.displayName !== b.meta.displayName + ) + return false; + } switch (a.type) { case AssetType.Image: