Skip to content

Commit

Permalink
support copy/pasting assets between projects (#10383)
Browse files Browse the repository at this point in the history
* serialize assets for copy/paste

* also support copying tileset fields

* refactor and inflate

* don't keep duplicating assets when pasting multiple times
  • Loading branch information
riknoll authored Feb 13, 2025
1 parent 1006dd1 commit 7114176
Show file tree
Hide file tree
Showing 4 changed files with 303 additions and 23 deletions.
25 changes: 23 additions & 2 deletions pxtblocks/fields/field_asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,6 +68,27 @@ export abstract class FieldAssetEditor<U extends FieldAssetEditorOptions, V exte
return this.getValueText();
}

saveState(_doFullSerialization?: boolean) {
if (this.asset && !this.isTemporaryAsset()) {
return getAssetSaveState(this.asset);
}
else {
return super.saveState(_doFullSerialization);
}
}

loadState(state: any) {
if (typeof state === "string") {
super.loadState(state);
return;
}

const asset = loadAssetFromSaveState(state);
super.loadState(pxt.getTSReferenceForAsset(asset));
this.asset = asset;
this.setBlockData(this.asset.id);
}

showEditor_() {
if (this.isGreyBlock) return;

Expand Down Expand Up @@ -581,4 +602,4 @@ export class BlocklyTilemapChange extends Blockly.Events.BlockChange {

Blockly.Events.fire(ev)
}
}
}
35 changes: 34 additions & 1 deletion pxtblocks/fields/field_tileset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import * as Blockly from "blockly";
import { FieldImageDropdownOptions } from "./field_imagedropdown";
import { FieldImages } from "./field_images";
import { FieldCustom, getAllReferencedTiles, bitmapToImageURI, needsTilemapUpgrade } from "./field_utils";
import { FieldCustom, getAllReferencedTiles, bitmapToImageURI, needsTilemapUpgrade, getAssetSaveState, loadAssetFromSaveState } from "./field_utils";

export interface ImageJSON {
src: string;
Expand Down Expand Up @@ -269,6 +269,39 @@ export class FieldTileset extends FieldImages implements FieldCustom {
this.doValueUpdate_(this.getValue());
this.forceRerender();
}

saveState(_doFullSerialization?: boolean) {
let asset = this.localTile || this.selectedOption_?.[2];
const project = pxt.react.getTilemapProject();

if (!asset) {
const value = this.getValue();

const parsedTsReference = pxt.parseAssetTSReference(value);
if (parsedTsReference) {
asset = project.lookupAssetByName(pxt.AssetType.Tile, parsedTsReference.name);
}

if (!asset) {
asset = project.lookupAsset(pxt.AssetType.Tile, value);
}
}
if (asset?.isProjectTile) {
return getAssetSaveState(asset)
}
return super.saveState(_doFullSerialization);
}

loadState(state: any) {
if (typeof state === "string") {
super.loadState(state);
return;
}

const asset = loadAssetFromSaveState(state);
this.localTile = asset as pxt.Tile;
super.loadState(pxt.getTSReferenceForAsset(asset));
}
}

function constructTransparentTile(): TilesetDropdownOption {
Expand Down
221 changes: 209 additions & 12 deletions pxtblocks/fields/field_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export interface PXTBlockData {
fieldData: pxt.Map<string>;
}

export interface AssetSaveState {
version: number;
assetType: pxt.AssetType;
jres: pxt.Map<pxt.JRes>;
assetId: string;
}


export namespace svg {
export function hasClass(el: SVGElement, cls: string): boolean {
return pxt.BrowserUtils.containsClass(el, cls);
Expand Down Expand Up @@ -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
);

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<any>, 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.JRes | string>): pxt.Map<pxt.JRes> {
const meta = jres["*"] as pxt.JRes;
const result: pxt.Map<pxt.JRes> = {
"*": 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;
}
Loading

0 comments on commit 7114176

Please sign in to comment.