Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8151f6c
backpack bubble implementation
jwunderl Sep 15, 2026
95c3f10
make copy / paste act same as backup, so suggest adding ext / warn on…
jwunderl Sep 16, 2026
cb65b06
only list missing extensions
jwunderl Sep 16, 2026
c08e2b9
allow naming the snippets
jwunderl Sep 16, 2026
1deccd8
cleaning up naming & also fixing hover behavior on bubbles
jwunderl Sep 16, 2026
58653d1
fix button design
jwunderl Sep 16, 2026
da20aad
add fuse search for backpack
jwunderl Sep 16, 2026
7c8927a
handle invalid snippets better
jwunderl Sep 16, 2026
9ce616c
better looking pngs
jwunderl Sep 16, 2026
d12d757
move to backpack endpoints
jwunderl Sep 16, 2026
c579bb0
cull bad tests
jwunderl Sep 16, 2026
1732089
better error on too big
jwunderl Sep 16, 2026
c0608be
assets in backpack v1
jwunderl Sep 16, 2026
e98b56d
fix backend not deleting
jwunderl Sep 17, 2026
7ab67f2
no iframe for embed
jwunderl Sep 17, 2026
9783c99
fix race for synced accounts
jwunderl Sep 17, 2026
4c4e51e
allow assets tab in tutorials while still disabling code snippet
jwunderl Sep 17, 2026
e744912
few nitpicks on styling
jwunderl Sep 17, 2026
e226908
caching behavior
jwunderl Sep 17, 2026
5b2a9ae
Apply batched suggestions from code review
jwunderl Sep 17, 2026
2493a80
culling tests
jwunderl Sep 17, 2026
86557c0
filter on field editor not block id
jwunderl Sep 17, 2026
7d07cb6
addressing few comments
jwunderl Sep 17, 2026
96007a1
more fixing
jwunderl Sep 17, 2026
42a2684
add flags for tools, guard identity / asset needs
jwunderl Sep 18, 2026
1e52b56
disabled input a little prettier
jwunderl Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 75 additions & 175 deletions docs/project-tools.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion localtypings/pxtarget.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,9 @@ declare namespace pxt {
docMenu?: DocMenuEntry[];
TOC?: TOCMenuEntry[];
hideSideDocs?: boolean;
projectTools?: boolean; // Enable documentation bubbles and private project whiteboards by default
projectTools?: boolean; // Enable the Project tools documentation bubbles by default
whiteboard?: boolean; // Enable private project whiteboards (requires a runtime palette)
backpack?: boolean; // Enable Backpack; local-only when sign-in is unavailable
homeScreenHero?: string | CodeCard; // home screen hero image or codecard
homeScreenHeroGallery?: string; // path to markdown file containing the gallery to display on homescreen
sideDoc?: string; // deprecated
Expand Down
8 changes: 7 additions & 1 deletion localtypings/pxteditor.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
/// <reference path="./ocv.d.ts" />
/// <reference path="./monaco.d.ts" />

// TilemapProject exposes this shape, but pxtlib's named interface is private.
declare namespace pxt {
export type AssetSnapshot = ReturnType<TilemapProject["saveGallerySnapshot"]>;
}

declare namespace pxt.editor {
export interface EditorMessage {
/**
Expand Down Expand Up @@ -1403,7 +1408,8 @@ declare namespace pxt.editor {
type AssetEditorRequest = OpenAssetEditorRequest | CreateAssetEditorRequest | SaveAssetEditorRequest | DuplicateAssetEditorRequest;

interface BaseAssetEditorResponse {
id?: number;
id?: string | number;
success?: boolean;
}

interface OpenAssetEditorResponse extends BaseAssetEditorResponse {
Expand Down
599 changes: 599 additions & 0 deletions pxtblocks/backpack.ts

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion pxtblocks/blockDragger.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as Blockly from "blockly";
import { clearBackpackDragState, refreshBackpackDragTargets } from "./backpack";

export class BlockDragger extends Blockly.dragging.Dragger {
onDrag(e: PointerEvent, totalDelta: Blockly.utils.Coordinate): void {
refreshBackpackDragTargets(this.draggable.workspace);
super.onDrag(e, totalDelta);

const blocklyToolboxDiv = document.getElementsByClassName('blocklyToolbox')[0] as HTMLElement;
Expand Down Expand Up @@ -31,7 +33,14 @@ export class BlockDragger extends Blockly.dragging.Dragger {
}

onDragEnd(e: PointerEvent): void {
super.onDragEnd(e);
try {
if (refreshBackpackDragTargets(this.draggable.workspace) && e instanceof PointerEvent) {
this.updateDragTarget(new Blockly.utils.Coordinate(e.clientX, e.clientY));
}
super.onDragEnd(e);
} finally {
clearBackpackDragState(this.draggable.workspace);
}

const blocklyToolboxDiv = document.getElementsByClassName('blocklyToolbox')[0] as HTMLElement;
const blocklyTreeRoot = document.getElementsByClassName('blocklyTreeRoot')[0] as HTMLElement
Expand All @@ -43,6 +52,14 @@ export class BlockDragger extends Blockly.dragging.Dragger {
if (blocklyToolboxDiv) pxt.BrowserUtils.removeClass(blocklyToolboxDiv, 'blocklyToolboxDeleting');
}
}

onDragRevert(): void {
try {
super.onDragRevert();
} finally {
clearBackpackDragState(this.draggable.workspace);
}
}
}

function calculateDistance(elemBounds: DOMRect, mouseX: number) {
Expand Down
1 change: 1 addition & 0 deletions pxtblocks/fields/field_asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const TOTAL_HEIGHT = Y_PADDING * 2 + BG_PADDING * 2 + PREVIEW_WIDTH;
const TOTAL_WIDTH = X_PADDING * 2 + BG_PADDING * 2 + PREVIEW_WIDTH;

export abstract class FieldAssetEditor<U extends FieldAssetEditorOptions, V extends ParsedFieldAssetEditorOptions> extends FieldBase<U> {
public readonly isBackpackAsset = true;
protected asset: pxt.Asset;
protected params: V;

Expand Down
4 changes: 4 additions & 0 deletions pxtblocks/fields/field_gridpicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface FieldGridPickerToolTipConfig {
}

export interface FieldGridPickerOptions extends FieldCustomDropdownOptions {
/** Opt asset galleries into Backpack; ordinary enum pickers remain code. */
asset?: boolean | string;
columns?: string;
maxRows?: string;
width?: string;
Expand All @@ -22,6 +24,7 @@ export interface FieldGridPickerOptions extends FieldCustomDropdownOptions {
}

export class FieldGridPicker extends FieldDropdownGrid implements FieldCustom {
public readonly isBackpackAsset: boolean;
private tooltipConfig_: FieldGridPickerToolTipConfig;

private gridTooltip_: HTMLElement;
Expand Down Expand Up @@ -51,6 +54,7 @@ export class FieldGridPicker extends FieldDropdownGrid implements FieldCustom {

constructor(text: string, options: FieldGridPickerOptions, validator?: Function) {
super(options.data);
this.isBackpackAsset = options.asset === true || options.asset === "true";

this.columns_ = parseInt(options.columns) || 4;
this.maxRows_ = parseInt(options.maxRows) || 0;
Expand Down
1 change: 1 addition & 0 deletions pxtblocks/fields/field_melodySandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const melodyEditorDivId = "melody-editor-div";
const melodyGalleryDivId = "melody-editor-gallery";

export class FieldCustomMelody<U extends FieldCustomOptions> extends FieldMatrix implements FieldCustom {
public readonly isBackpackAsset = true;
public isFieldCustom_ = true;
public SERIALIZABLE = true;

Expand Down
4 changes: 4 additions & 0 deletions pxtblocks/fields/field_tileset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type TilesetDropdownOption = [ImageJSON, string, pxt.Tile];
const PREVIEW_SIDE_LENGTH = 32;

export class FieldTileset extends FieldImages implements FieldCustom {
public readonly isBackpackAsset = true;
// private member of FieldDropdown
protected selectedOption_: TilesetDropdownOption;

Expand Down Expand Up @@ -323,6 +324,9 @@ export class FieldTileset extends FieldImages implements FieldCustom {
if (asset?.isProjectTile) {
return getAssetSaveState(asset)
}
// Full captures need the gallery's qualified API name, not a display-name
// assets.tile reference, so dependency collection can find its package.
if (_doFullSerialization && asset) return asset.id;
return super.saveState(_doFullSerialization);
}

Expand Down
44 changes: 33 additions & 11 deletions pxtblocks/fields/field_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { FieldTileset } from "./field_tileset";

export interface FieldCustom {
isFieldCustom_: boolean;
/** Can be saved/edited as an asset when this is a literal block's only editable field. */
isBackpackAsset?: boolean;
saveOptions?(): pxt.Map<string | number | boolean>;
restoreOptions?(map: pxt.Map<string | number | boolean>): void;

Expand Down Expand Up @@ -441,18 +443,29 @@ export function getAssetSaveState(asset: pxt.Asset) {
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];
}
if (entry.mimeType !== pxt.TILEMAP_MIME_TYPE || 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];
}
}
// Full saves must carry every tile, including gallery tiles unavailable in
// another project. Use the tileset itself: project JRES omits gallery tiles
// and can collapse imported tiles from different namespaces to one short id.
const defaultNamespace = addDotToNamespace(jres["*"].namespace);
for (const tile of asset.data.tileset.tiles) {
const namespace = tile.id.slice(0, tile.id.lastIndexOf(".") + 1);
const isDefaultNamespace = namespace === defaultNamespace;
const key = isDefaultNamespace ? tile.id.slice(namespace.length) : tile.id;
jres[key] = {
data: tile.jresData || pxt.sprite.base64EncodeBitmap(tile.bitmap),
mimeType: pxt.IMAGE_MIME_TYPE,
tilemapTile: true,
displayName: tile.meta.displayName,
...(tile.meta.tags?.length ? { tags: tile.meta.tags.slice() } : {}),
// Explicit ids prevent inflateJRes from prefixing the default
// namespace; qualified keys avoid collisions with project tiles.
...(!isDefaultNamespace ? { id: tile.id, namespace, dataEncoding: "base64" } : {})
};
}

serialized.jres = jres;
Expand Down Expand Up @@ -499,6 +512,13 @@ export function loadAssetFromSaveState(serialized: AssetSaveState) {

const tempAsset = tempProject.lookupAsset(serialized.assetType, serialized.assetId);

if (tempAsset.type === pxt.AssetType.Tilemap) {
// Match the tile deduplication performed by loadTilemapJRes below. Tilemap
// equality includes tile ids/metadata, which may have changed on a prior paste.
tempAsset.data.tileset.tiles = tempAsset.data.tileset.tiles.map(tile =>
tile.isProjectTile ? globalProject.resolveTileByBitmap(tile.bitmap) || tile : tile);
}

if (pxt.assetEquals(tempAsset, existing, true)) {
return existing;
}
Expand Down Expand Up @@ -540,7 +560,9 @@ export function loadAssetFromSaveState(serialized: AssetSaveState) {


if (serialized.assetType === "tilemap" || serialized.assetType === "tile") {
globalProject.loadTilemapJRes(serialized.jres, true);
// Tilemaps remap duplicate tile ids internally. A standalone tile must
// retain its id so the lookup below can return the loaded asset.
globalProject.loadTilemapJRes(serialized.jres, serialized.assetType === "tilemap");
}
else {
globalProject.loadAssetsJRes(serialized.jres);
Expand Down
4 changes: 4 additions & 0 deletions pxtblocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export * from "./importer";
export * from "./diff";
export * from "./legacyMutations";
export * from "./blockDragger";
export {
BackpackCode, BackpackWorkspaceOptions, isBackpackContainer, isBackpackBlock, getBackpackAssetField,
captureBackpackBlock, parseBackpackCode, getBackpackBlockTypes, pasteBackpackBlock, registerBackpackWorkspace,
} from "./backpack";
export * from "./workspaceSearch";
export * from "./monkeyPatches";
export * from "./getBlockText";
Expand Down
26 changes: 26 additions & 0 deletions pxtlib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,32 @@ namespace pxt.auth {
res: UserPreferences;
}

export interface BackpackVersions {
target: string;
pxt: string;
}

export type BackpackKind = "code" | "asset";

/** A private, portable capture sent to the dedicated Backpack API, not preferences. */
export interface BackpackItem {
id: string;
name: string;
kind: BackpackKind;
/** Actual editor builds at capture time, including prerelease/build suffixes. */
versions: BackpackVersions;
code: string;
/** Displayed block labels and field values, captured without needing the original extensions to search. */
blockText: string;
dependencies: pxt.Map<string>;
/** Block IDs whose definitions live in project files, rather than extensions. */
projectBlocks?: pxt.Map<string>;
createdAt: number;
previewUri?: string;
/** Raster pixels per CSS pixel; an omitted value uses the PNG's natural size. */
previewPixelDensity?: number;
}

/**
* User preference state that should be synced with the cloud.
*/
Expand Down
2 changes: 2 additions & 0 deletions pxtservices/iframeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export abstract class IframeDriver {
}

protected onMessageReceived = (event: MessageEvent) => {
// Before the private port handshake, accept messages only from this frame.
if (!this.port && event.source !== this.iframe.contentWindow) return;
const data = event.data;

if (data) {
Expand Down
74 changes: 18 additions & 56 deletions tests/project-tools-test/README.md
Original file line number Diff line number Diff line change
@@ -1,61 +1,23 @@
# Project tools tests

Run `gulp testprojecttools` from PXT. It rebuilds the shared library and webapp
modules before running the tests. The task is included in `gulp test`.
Run from PXT, sequentially:

Browser suites share [browser.js](browser.js). On GitHub Actions only, they use
`--no-sandbox`, matching the repository's Karma launcher for Ubuntu runners that
restrict Chromium user namespaces, and `--disable-gpu` for software rendering.
Without a usable display, headless Chromium's GPU initialization can stall
stylesheet/script injection in `beforeEach`; increasing Mocha's timeout does not
fix that renderer hang. DOM, focus, CSS animation, and 2D canvas tests still use
the real browser. Local runs retain the Chromium sandbox and normal graphics.
Neither setting changes the editor or its iframe sandbox attributes.
Browser protocol commands have a 10-second deadline so a stall reports the
underlying DevTools operation before Mocha's unchanged 30-second hook timeout.
```bash
node node_modules/gulp/bin/gulp.js testprojecttools
node node_modules/gulp/bin/gulp.js testpxteditor
```

- [storage.spec.js](storage.spec.js) exercises actual workspace/cloud code with a
fake persistent provider and authenticated API. No network requests or public
shares are created. Private notes must survive save, duplication and account
sync, but never enter anonymous/persistent publish payloads. Fault tests cover
note edits during uploads/downloads and persistent-storage failure/retry.
- [keyboard.spec.js](keyboard.spec.js) checks the actual image-editor shortcut
router using minimal DOM owners, independent stores and editable controls.
It covers scoped keys, text undo, nested editors and listener/lock teardown.
- [launcher.spec.js](launcher.spec.js) uses Puppeteer and real React to check the
horizontal/vertical bubble animation, persistent options, keyboard focus and dismissal,
the always-visible ellipsis, desktop-expanded versus mobile/tablet-collapsed
startup without autofocus, the 1199/1200px strip breakpoint, and preserving a mounted draft.
Explicit desktop disclosure choices survive orientation changes; the separate
991/992px breakpoint sets the default visibility. Desktop click-away closes the
panel without hiding the bubbles. Small-desktop
checks retain desktop header/footer spacing and both resize grips, with the
width grip only hiding at the separate 991px tablet breakpoint. It compiles the production
LESS with test theme variables and checks RTL, reduced-motion behavior, banner
offsets, ellipsis alignment, speech-bubble pointers, and panel dismissal via
the ellipsis, outside controls, keyboard focus and the simulator iframe.
Pin regressions cover click-away/iframe focus, explicit collapse and reopening,
manual unpin, example defaults, and switching tabs/viewports.
Resize tests cover side/bottom grips, pointer and keyboard input, RTL, retained
dimensions and viewport/banner limits. Grip dots are included in theme contrast checks.
Default-size tests compare the combined desktop panel/bubble footprint with
legacy sidedocs, exercise breakpoint transitions, and verify target overrides
and preserved manual sizes.
The whiteboard is stubbed; no development server is needed. Puppeteer's browser
must be installed.
- [whiteboard.spec.js](whiteboard.spec.js) loads the actual image editor, reducer,
whiteboard controller and header menu. Storage and game-asset access are mocked,
not the controls. It covers new and saved notes, named-board operations, independent
images/text/undo, confirmed deletion and last-board protection, save errors/retry,
desktop hide/reveal with retained notes/drawing/undo/pin, all 16 colors on mobile
and footer spacing.
Contrast checks use the production stylesheet order (shared menu styles load
after project-tools) and the actual theme manager. They check icons, text and
keyboard focus through idle/hover/pressed/expanded states on mobile and desktop,
plus system forced colors. Light/dark regression palettes and shared high
contrast always run. With the sibling Arcade checkout (or `PXT_ARCADE_PATH`),
every Arcade color theme and its override CSS is included automatically.
`testprojecttools` rebuilds pxtlib/webapp and is included in `gulp test`. Asset tests
also need fresh Blockly/simulator output; run a full `gulp` build after changing
those modules. Browser tests require Puppeteer's Chromium, not a development server.

Bitmap/schema checks and the startup experiment guard are also covered by
[the editor suite](../pxt-editor-test/editorrunner.ts), run with `gulp testpxteditor`.
See the [feature notes](../../docs/project-tools.md) for manual UI/privacy checks.
- Storage suites cover persistence, account isolation and sharing privacy.
- Project/clipboard suites cover dependency consent, conflicts and editor integration.
- Block/asset suites exercise serialization, pixels and undo.
- UI/launcher/whiteboard suites cover representative interaction and accessibility.

Test shared behavior once, with representative cases rather than viewport/theme
or failure-stage matrices. Use [browser.js](browser.js), await actual effects and
transactions, and unmount before closing pages. Network/account boundaries are
mocked; check cross-device sync, keyboard/touch and screen readers manually
before release. See [feature usage](../../docs/project-tools.md).
Comment on lines +20 to +23
Loading
Loading