This is a toolkit for building UXP extensions for Adobe Photoshop. It has been created because the experience building extensions for Adobe Photoshop is pretty terrible: nothing works as expected and the documentation is lacking.
With the code in this repo, we fix a few things:
uxp and photoshop modules.pnpm add @bubblydoo/uxp-toolkit
pnpm add -D @adobe-uxp-types/uxp @adobe-uxp-types/photoshop
We were very hopeful when in 2021 Adobe announced they would revamp their Photoshop API. However, after 2 years they seem to have given up, and the API is barely updated.
There are a lot of issues with the API:
batchPlay, and in order to figure out what to do, you need to record the action with Alchemist and convert it into code. But even then, that code often does not work.document.layers[0].name, it looks synchronous, but under the hood it makes several IPC calls to the Photoshop process. If you have a lot of layers, this can get very slow.We made functions for building typesafe UXP projects. Instead of just running batchPlay, and trusting what the output is, we verify what the output is.
createCommand and batchPlayCommand// Before
// ActionDescriptor (any)
// ^
const result = await batchPlay([
{
_obj: "get",
_target: [
{ _ref: "layer", _id: 123 }
]
}
]);
// After
// { name: string }
// ^
const result = await batchPlayCommand(
createCommand({
modifying: false,
descriptor: {
_obj: "get",
_target: [
{ _ref: "layer", _id: 123 }
]
},
schema: z.object({
name: z.string(),
}),
})
);
This is only possible with non-modifiying commands. If you want to run a modifying command, this needs to happen in a modal context:
await executeAsModal(commandName, async (ctx) => {
await ctx.batchPlayCommand(createCommand({
modifying: true,
descriptor: {
_obj: "set",
_target: [
{ _ref: "layer", _id: 123 }
],
name: "New Layer Name",
},
schema: z.unknown(),
}));
});
There is also batchPlayCommands for running multiple commands at once, with correct typing.
executeAsModalFor better ergonomics, we put the name first, and then the function. The ctx has new attributes:
signalbatchPlayCommand and batchPlayCommands for running commandsawait executeAsModal("Do something cancellable", async (ctx) => {
ctx.signal.throwIfAborted();
});
suspendHistoryFor better ergonomics, we put the document first, then the name, and then the function.
await suspendHistory(document, "Action that suspends history", async (ctx) => {
await ctx.batchPlayCommand(createRenameLayerCommand({ id: 123, docId: document.id }, "New Name"));
});
executeAsModalAndSuspendHistoryA combination of the two above:
await executeAsModalAndSuspendHistory("Combined action", document, async (ctx, suspendHistoryContext) => {
ctx.reportProgress({ value: 0.5 });
});
UTLayerAs document.layers can get slow, we provide a parser for a layer tree, built on batchPlay commands.
// LayerDescriptor[]
// ^
const descriptors = await getDocumentLayerDescriptors(document.id);
// UTLayer[]
// ^
const layers = photoshopLayerDescriptorsToUTLayers(descriptors);
utLayersToTextConvert a UTLayer tree to a human-readable text representation. This is useful for debugging, logging, and AI/LLM use cases where you need to represent the layer structure as text.
import { utLayersToText } from '@bubblydoo/uxp-toolkit';
const text = utLayersToText(layers);
console.log(text);
Output:
◯ Background
◯ ▾ Header Group
◯ Logo ƒ
◯ ⬐ Title
⊘ Subtitle ⁕
◯ ▾ Content
◯ Image
Icons:
◯ visible / ⊘ hidden▾ group⬐ clipping maskƒ has layer effects⁕ non-default blend modeWe publish our own types for the uxp and photoshop modules, which are based on other community efforts but adapted to be more accurate:
pnpm add -D @adobe-uxp-types/uxp @adobe-uxp-types/photoshop
tsconfig.json:
{
"compilerOptions": {
"types": ["photoshop", "uxp"],
"typeRoots": [
"./node_modules/@adobe-uxp-types",
"./node_modules/@types"
]
}
}
We have a library of commands for common actions in Photoshop, which you can import from @bubblydoo/uxp-toolkit/commands.
Examples are createRenameLayerCommand and createSelectLayerCommand.
See the commands library index file for a complete list.
We have a function to parse error sourcemaps, which is very useful for debugging errors in your code.
function throwError() {
throw new Error("Test error");
}
try {
throwError();
} catch (error) {
// [{ fileName: "test.ts", lineNumber: 2, columnNumber: 8 }]
// ^
const parsedError = await parseUxpErrorSourcemaps(error);
// "/Users/you/project/src/test.ts:2:8"
// ^
const absolutePath = await getBasicStackFrameAbsoluteFilePath(parsedError[0]);
await copyToClipboard(absolutePath);
}
We now support Vitest for unit testing TypeScript code without Photoshop:
pnpm test
The test suite includes:
.test.ts) for pure TypeScript functions.test-d.ts) for compile-time type checkingphotoshop or uxp modulesTests run in CI via GitHub Actions with JUnit reporting.
pnpm add @bubblydoo/uxp-test-framework
For integration tests that require Photoshop, we have developed a plugin specifically for testing UXP plugins. It allows you to run tests inside of Photoshop, and see the results in a panel.
You can run tests using the create-uxp-test-plugin command.
uxp-tests.json:
{
"$schema": "./node_modules/@bubblydoo/uxp-test-framework/uxp-tests-json-schema.json",
"testsFile": "test/index.ts",
"testFixturesDir": "test/fixtures",
"plugin": {
"id": "co.bubblydoo.uxp-toolkit-test-plugin",
"name": "UXP Toolkit Tests"
}
}
test/index.ts:
import type { Test } from "@bubblydoo/uxp-test-framework";
import { expect } from "chai";
import { app } from "photoshop";
const renameLayerTest: Test = {
name: "Should rename layer",
async run() {
const doc = await openFileByPath("plugin:/fixtures/one-layer.psd");
const descriptors = await getDocumentLayerDescriptors(doc.id);
const firstLayer = descriptors[0];
expect(firstLayer.name).to.equal("Layer 1");
await executeAsModal("Rename Layer", async (ctx) => {
await ctx.batchPlayCommand(
createRenameLayerCommand({ id: firstLayer.layerID, docId: firstLayer.docId }, "New Name")
);
});
const updatedDescriptors = await getDocumentLayerDescriptors(doc.id);
expect(updatedDescriptors[0].name).to.equal("New Name");
},
};
export const tests: Test[] = [
renameLayerTest,
];
{
"name": "your-plugin",
"scripts": {
"uxp-test:build": "uxp-test build",
"uxp-test:dev": "uxp-test dev"
}
}
Then you can run:
pnpm uxp-test:dev
And you will get a plugin in the uxp-tests-plugin directory, which you can load using UXP Developer Tools, and then you can run the tests inside of Photoshop.
In the future, we'd like to get Vitest to work natively with a UXP runner or pool, but for now, this is a good compromise.
The plugin also sourcemaps the errors, so you can find the error much more easily:
pnpm add @bubblydoo/uxp-toolkit-react
We have a React integration for the toolkit. It allows you to use the toolkit in a React application. Many functions use React Query under the hood.
import { useActiveDocument } from "@bubblydoo/uxp-toolkit-react";
function App() {
const activeDocument = useActiveDocument();
return <div>Active document: {activeDocument.name}</div>;
}
This package provides the following hooks:
useActiveDocument – Sync external store for the current active documentuseOnDocumentEdited – Run a callback when the given document is edited (select, delete, make, set, move, close, show, hide, etc.)useOnDocumentLayersEdited – Run a callback when layers change (delete, make, set, move, close)useOnDocumentLayersSelection – Run a callback when layer selection changes (select, deselect)useOnEvent – Run a callback for arbitrary Photoshop action events on a given documentuseOpenDocuments – Sync external store for the list of open documentsuseIsPluginPanelVisible and useIsAnyPluginPanelVisible – Whether a plugin panel is visibleuseApplicationInfoQuery – React Query for Photoshop application info (e.g. panel list)useEventListenerSkippable - Generic hook to subscribe to events with optional skip/filter so triggers can be queued or ignoredWe fixed the official devtools package, which had a lot of issues. You can find the fixed repo here.
Based on this, we created our own CLI. You can run this without installing anything, just pnpm.
This can replace UXP Developer Tools.
Open devtools with a "fake" plugin (doesn't have any functionality)
pnpm --allow-build=@adobe-fixed-uxp/uxp-devtools-helper dlx @bubblydoo/uxp-cli open-devtools
Open devtools with a custom plugin
pnpm --allow-build=@adobe-fixed-uxp/uxp-devtools-helper dlx @bubblydoo/uxp-cli open-devtools --plugin-path ./my-plugin
You can also just install it:
pnpm add -D @bubblydoo/uxp-cli
If you're using approved builds in pnpm, make sure to add @adobe-fixed-uxp/uxp-devtools-helper to the onlyBuiltDependencies in your pnpm-workspace.yaml. The postinstall script just unzips some binary proprietary Adobe files.
We have a MCP server for Photoshop automation via Chrome DevTools Protocol. It allows AI assistants to execute JavaScript code directly in Adobe Photoshop's UXP environment, but it also has access to UXP Toolkit and its commands, to the TypeScript schemas and these readmes.
pnpm --allow-build=@adobe-fixed-uxp/uxp-devtools-helper dlx @bubblydoo/photoshop-mcp