The AI image editor used inside Bloom Editor, plus a reusable React component.
This repo produces two outputs, and they are consumed in different ways:
| Output | Built by | What it is | Consumed by |
|---|---|---|---|
dist/ (library) |
build:lib (tsup) |
The ImageToolsWorkspace React component, as an importable npm package. |
Any app that wants to import { ImageToolsWorkspace }. |
dist-app/ (hosted app) |
build:app (Vite) |
The whole standalone editor app (index.html + assets). |
Bloom Editor, which loads it in an iframe overlay. |
Bloom does not import the React component — it loads the prebuilt dist-app/ app by URL into an iframe. See How Bloom hosts this editor.
Prerequisites: Vite+ (vp). On Windows install it with irm https://vite.plus/ps1 | iex, then restart your terminal or VS Code so vp is on PATH.
Vite+ manages the Node.js runtime from .node-version and the pnpm version from packageManager in package.json.
- Install dependencies:
vp install - Run the dev demo:
vp dev - Build the library:
vp run build:lib→dist/ - Build the hosted app:
vp run build:app→dist-app/(this is what Bloom ships) - Build the demo bundle (optional):
vp build→demo-dist/
Not published to npm. The library build works and is importable from a local checkout or via
yarn/pnpm link, but there is no registry release yet, so thepnpm addbelow is aspirational. Bloom does not use this path — see How Bloom hosts this editor.
pnpm add bloom-ai-image-toolsimport { ImageToolsWorkspace } from "bloom-ai-image-tools";
function Example() {
return <ImageToolsWorkspace persistence={...} envApiKey={...} />;
}See App.tsx for a concrete integration example.
Art-style preview thumbnails rely on bundlers that support
import.meta.glob(Vite/Rollup). Other bundlers fall back to text-only style selection.
A string to be translated goes through l10n(id, english), the same shape
bloom-image-gallery uses. A host that
can translate passes getLocalizations, which is called once on mount with every string ID
and its English default and returns whatever translations it has; anything missing falls
back to the English. With no getLocalizations, the editor is in English.
<ImageToolsWorkspace
persistence={...}
getLocalizations={(strings) => bloomApi.getLocalizations(strings)}
/>IDs are namespaced AiImageEditor.*, except where Bloom already has the string (Common.Close,
EditTab.PasteButton, …). ALL_IMAGE_EDITOR_STRINGS is the whole set, exported for a host that
wants to pre-fetch or inspect it; a tool's or art style's text is keyed by its own id (e.g.
AiImageEditor.Tool.make_gif.Title), read from the registry so the English is written once.
After adding or changing an l10n() call, run node dev/generateStaticStrings.mjs to update
lib/staticStrings.ts; lib/__tests__/staticStrings.test.ts fails when the two disagree.
Every ID the editor asks for must have a <trans-unit> in one of Bloom's English XLF files, or
Bloom reports it as missing. So text that is not ready to be translated stays a plain string, and
is wrapped in l10n() when its <trans-unit> is added. Tool text in
components/tools/tools-registry.ts has no call site to wrap; its IDs that stay English are
listed in lib/untranslated.ts.
Two things stay English on purpose: a select option's stored value (it is what the tool's
prompt sends to the model, so only the menu text is translated) and the messages thrown by the
service layer (services/*), which have no React context to read a translation from.
Crowdin can show a translator a screenshot with the string they are translating outlined on
it. Three commands keep those screenshots current; nothing they produce is committed
(screenshots-out/ is ignored).
pnpm screenshots:capturedrives the fake-Bloom harness headlessly through the scenes intests/screenshots/scenes.ts, saves a PNG per scene, and works out where each string id is by matching the visible text againstALL_IMAGE_EDITOR_STRINGS. It ends withscreenshots-out/coverage.md, which lists the strings no screenshot shows yet.pnpm screenshots:upload:drysays what an upload would do without touching Crowdin.pnpm screenshots:uploadneedsBLOOM_CROWDIN_TOKEN: the SILCrowdinBot token, so no developer's name is attached to the uploads (the same rule as for translations in BloomDesktop'sDistFiles/localization/README.md). It uploads each PNG asAiImageEditor/<scene>.png, lets Crowdin's OCR tag what it can, then adds our positioned tags for the rest. Strings not yet in Crowdin (they arrive when BloomDesktop'sDistFiles/localization/en/*.xlfchanges reach master and sync) are skipped and listed inscreenshots-out/upload-report.json; run again with--refresh-idsonce they have landed. Unchanged screenshots are skipped on later runs;--forceredoes them and--scene <name>limits a run to one.--prunedeletes theAiImageEditor/screenshots on Crowdin that the current scenes no longer account for, so a translator is never shown a screen the editor does not have any more, and drops them fromscreenshots-out/upload-manifest.json. It needs the whole set of scenes, so it cannot be combined with--scene; add--dry-runto see what it would delete.
A string on a screen that is already a scene needs nothing. A string that only shows in a
new state needs a new entry in scenes.ts. SCREENSHOT_SCENES=name1,name2 captures only
those scenes while you work on one.
Bloom embeds the editor as an iframe overlay inside its existing edit-tab WebView2 —
it is not a separate window, and Bloom never bundles this repo's source. The editor app
runs in the iframe and talks to its host over window.postMessage (channel
bloom-ai-image-tools); file I/O and image bytes go over HTTP to Bloom's local server.
On the Bloom side this lives in AiImageEditorApi.cs and CanvasElementContextControls.tsx.
The editor decides how it's running from the URL (App.tsx): ?mode=bloom-iframe →
BloomEmbeddedShell over createIframeBloomHostBridge(); ?mode=bloom-harness → the
fake-host BloomHostHarness (dev/e2e); no mode → the plain StandaloneShell. The host
plumbing all hides behind services/host/BloomHostBridge.ts.
Bloom's GetEditorUrl() returns http://localhost:3000/ in a DEBUG build, so the
overlay iframe loads this repo's running Vite dev server:
vp devhere (serves the editor onhttp://localhost:3000).- Run a DEBUG build of Bloom and choose "Edit with AI…" on an image. Editor edits hot-reload inside Bloom; only Bloom C# changes need a Bloom rebuild.
A Windows junction (BloomEditor → the Bloom worktree) is sometimes used to view/edit
both repos in one place; it's git-ignored and not part of the consumption path.
In a Release build GetEditorUrl() returns {ServerUrl}/bloom/aiImageEditor/index.html,
i.e. the editor served same-origin from Bloom's own server (no CORS).
Bloom gets that build from a git tag, not npm. Bloom cannot build this project on
install (it would need Vite+ on the build machine — prepare runs vp config), so it
consumes a prebuilt dist-app/. Each dist-v* tag holds dist-app/ plus a minimal,
script-free package.json, so the package manager installs it as static files with no
build step. The tag content is an orphan commit; master stays clean. Tags are
immutable, and different Bloom branches can pin different editor builds. See the
header comment in .github/workflows/release.yml.
To wire that up:
- Publish a tag — see Versioning & Releases. That gives you
dist-v<version>. The app build bakes in--base=/bloom/aiImageEditor/so its asset URLs resolve at that mount. - Point Bloom at the tag (
src/BloomBrowserUI/package.json), pinning the exact ref — not a semver range, since the tag is the version: - Copy the app into Bloom's served output at build time, exactly like the existing
bp-to-outputstep forbloom-player, e.g.:During dev you can link this package instead of pinning a tag.// src/BloomBrowserUI/package.json scripts "aiimageeditor-to-output": "cpx \"./node_modules/bloom-ai-image-tools/dist-app/**/*\" ../../output/browser/aiImageEditor -v --clean"
To get a new editor build into Bloom, publish a new tag and bump the pinned ref. Tags never move, so re-installing against an unchanged ref can never change what Bloom gets.
Releasing is on demand — nothing publishes when you merge. Push your work to master,
then run the Release workflow (Actions -> Release -> Run workflow) and pick patch,
minor or major. One click does the whole thing:
- bumps the version in
package.jsonand commits that tomaster - builds
dist-app/ - publishes the immutable tag
dist-v<new version> - writes a GitHub Release whose notes are the commit subjects since the last release
The run's summary prints the exact line to paste into Bloom. Publishing is refused if
dist-v<version> already exists — tags are immutable, so choose a larger bump or pass an
exact version input.
Then point Bloom at the new tag — step 2 of Production.
npm: this package is not on the registry and Bloom does not import it as a library —
Bloom loads dist-app/ as static files in an iframe. Nothing publishes to npm.
- Unit tests:
vp test - E2E (Playwright): set
BLOOM_OPENROUTER_KEY_FOR_PLAYWRIGHT_TESTSto your OpenRouter API key, then runvp run e2e.