Image studio
The Bild-Studio is a block, not a mode. It holds the text that describes the picture (a placeholder until you write one), opens into a dialog with a chat, and keeps every reference image and every previous generation. One of them is selected and is the block's picture.
| Part | What it is |
|---|---|
| Description | The text describing the image. Empty shows „Noch keine Beschreibung“ in the block and „Beschreibe das Bild …“ in the dialog. |
| Prompt blocks | A list of style, brand, … texts that the host manages (label, text, order). Each can be switched on or off per block. |
| References | Uploaded or picked images. An active one goes into the next generation. |
| Previous generations | Every image the chat produced. An active one goes into the next generation as a reference. |
| Selected image | The one reference or generation that is the block's picture (src of the node). |
| Chat | The log of instructions, each with the image it produced or its problem. |
Removing a reference or a generation removes it from this block only; the host keeps the file. The package never deletes files.
This guide covers the feature, the node and its Markdown form, the state and its reducers, the backend port, and the dialog for a host that owns the state itself. The framework-free and Vue entries ship since 3.5.0, the React entry (dialog and node view) since 3.5.1.
| Entry | Contents |
|---|---|
@skaile/forge-editor/image-studio/vue | imageStudioFeature(), ImageStudioDialog, createImageStudioNodeStore, the state and reducers |
@skaile/forge-editor/image-studio/react | imageStudioFeature(), ImageStudioDialog, useImageStudioNodeStore, ImageStudioReactStore, the state and reducers |
@skaile/forge-editor/image-studio | Framework-free: imageStudioFeatureCore, the state and reducers |
@skaile/forge-editor/image-studio/schema | Vue-free imageStudioSchemaExtensions() and ImageStudioNode for servers |
Enabling it
Add imageStudioFeature() to defineEditor({ features }) next to the core features. The feature has no other dependency and imports no other feature.
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { imageStudioFeature } from '@skaile/forge-editor/image-studio/vue'
const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), imageStudioFeature()] })
On the React surface (shadcn) import imageStudioFeature from @skaile/forge-editor/image-studio/react instead; everything else, the block, the Markdown form, the backend port and the behaviour described below, is the same.
The feature adds:
- the
imageStudioblock (an atom, draggable; it revokes the text abilities, so no marks or text commands apply inside it), - the
/entry „Bild-Studio“ (command idimageStudio, slash idbild-studio), offered where the caret has the abilityinsert.imageStudio(ABILITY.imageStudio), and the commandinsertImageStudio(attrs?), - the block action
image-studio.open(„Im Bild-Studio öffnen“, in the block menu and on the block's chrome), which asks the block's view to open the dialog. The view also opens it on double-click and from its „Bild-Studio öffnen“ button.
A server that validates or stores documents containing the node builds its schema from the schema entry:
import { getSchema } from '@tiptap/core'
import { coreSchemaExtensions } from '@skaile/forge-editor/core/schema'
import { imageStudioSchemaExtensions } from '@skaile/forge-editor/image-studio/schema'
const schema = getSchema([...coreSchemaExtensions(), ...imageStudioSchemaExtensions()])
The node and its Markdown form
The node imageStudio has the attributes src (the url of the selected image, null without one), alt and studio (the whole ImageStudioState). setImageStudioState(pos, state) replaces the state of the block at pos in one transaction and sets src from the selected image, so undo takes both back together.
| Form | Shape |
|---|---|
| HTML | <img data-image-studio src alt data-studio="{json}"> |
| Markdown | [Bild-Studio: <alt>](<src>) <!-- {"studio": …} --> on its own line |
[Bild-Studio: Segelboot im Abendlicht](/files/a1.png) <!-- {"studio":{"description":"Ein Segelboot im Abendlicht","promptBlockIds":["style"],"references":[],"generations":[{"id":"a1","url":"/files/a1.png","title":"Version 1","active":false}],"selected":"a1","turns":[{"id":"t1","instruction":"","imageId":"a1","createdAt":"2026-10-21T09:00:00.000Z"}]}} -->
The label is the literal Bild-Studio in every UI language, because parsing looks for it. The comment is left out while the studio is empty (no description, prompt block, image or turn). Reading a document normalises the state: a missing or mistyped field falls back to its empty value, an entry without an id is dropped and a selected that points at no image becomes null. A valid state round-trips unchanged.
The state and its reducers
The state is plain JSON and knows nothing about the host: image ids are opaque (a host uses its own asset ids).
interface ImageStudioImage { id: string, url: string, title: string, active: boolean }
interface ImageStudioTurn { id: string, instruction: string, imageId: string | null, createdAt: string, problem?: string }
interface ImageStudioState {
description: string // '' shows the placeholder
promptBlockIds: string[] // active prompt blocks, by the host's ids
references: ImageStudioImage[]
generations: ImageStudioImage[]
selected: string | null // id of the generation (or reference) that is the block's image
turns: ImageStudioTurn[] // the chat log: instruction → generation
}
EMPTY_IMAGE_STUDIO_STATE is the state of a new block. The reducers are pure: each returns a new state and never touches its argument.
| Reducer | Effect |
|---|---|
setDescription(state, text) | Replaces the description. |
togglePromptBlock(state, id) | Adds the prompt block to the active ones, or removes it when it is active. |
toggleImage(state, id) | Flips active of the reference or generation; an unknown id changes nothing. |
select(state, id | null) | Makes the image the block's picture; null clears the choice; an unknown id changes nothing. |
removeImage(state, id) | Drops the image from references and generations; clears selected when it was selected. The chat log keeps its turns. |
addReference(state, image) | Appends a reference; one with the same id is replaced where it stands. |
adoptImage(state, image) | Makes the image the block's picture: unchanged when it already is; an image the block holds is only selected; any other is added as a reference first. |
addGeneration(state, image, turn) | Appends the generation and its turn (pointing at the image) and selects it: a chat generate means „this is the picture now“; an earlier one can be selected again. |
Helpers: selectedImage(state) (the selected image, if the selection still points at one), isEmptyImageStudioState(state), normalizeImageStudioState(value) (for stored JSON from outside). All of them, with the types, are exported from @skaile/forge-editor/image-studio and /image-studio/vue.
The backend port
Generation, uploads and the list of prompt blocks go through the images capability of the EditorBackend, the last entry of BACKEND_CAPABILITIES:
interface ImagesPort { // EditorBackend['images']
promptBlocks: () => Promise<ImageStudioPromptBlock[]> // { id, label, text }, in the host's order
generate: (body: ImageStudioGenerateBody, signal?: AbortSignal) => Promise<ImageStudioImage> // resolves with active: false
upload: (documentId: string, file: File) => Promise<ImageStudioImage> // resolves with active: true
}
interface ImageStudioGenerateBody {
description: string
instruction: string // '' generates from the description alone
promptBlockIds: string[] // the active prompt blocks
referenceUrls: string[] // urls of the active references and generations
}
The HTTP backend (createHttpBackend()) maps the three members to routes. With probe: true it drops images when the server does not list the capability.
| Member | Route | Body | Answer |
|---|---|---|---|
promptBlocks() | GET /api/editor/images/prompt-blocks | none | ImageStudioPromptBlock[] |
generate(body, signal) | POST /api/editor/images/generate | JSON ImageStudioGenerateBody (description ≤ 10 000, instruction ≤ 4 000 characters, ≤ 50 promptBlockIds, ≤ 20 referenceUrls) | ImageStudioImage |
upload(documentId, file) | POST /api/editor/images/upload | multipart: documentId (a document id or scratch) and file | ImageStudioImage |
The routes authenticate like every other editor route. 400 answers a malformed body, a bad documentId or a missing file; 413 a file over the image limit (MAX_MEDIA_BYTES.image); 415 a file that is no image; 501 any of the three when the server has no images port.
The memory backend (createMemoryBackend()) has no images member: image generation needs a server that holds the model key and stores the files. In such a client the block still works as a picture holder, but opening the studio (the block action, double-click or the button) shows the notice „Bildgenerierung ist nicht eingerichtet“ instead of the dialog.
The server port
On the server the host supplies EditorPorts.images, an ImageProvider. The package owns authentication, validation and the routes; the provider owns the prompt, the model and the storage. user is what identity.authenticate returned.
import { provideEditorServer } from '@skaile/forge-editor/server/nitro'
provideEditorServer({
ports: {
identity,
documents,
images: {
// The managed list (label, text, order), e.g. rows of your own table.
promptBlocks: user => listPromptBlocks(user),
// Build the prompt from description + instruction + the texts of body.promptBlockIds,
// pass body.referenceUrls to the model, store the result and return it with active: false.
generate: async (body, user) => {
const file = await runImageModel(buildPrompt(body), body.referenceUrls)
const stored = await storeImage(file, user)
return { id: stored.id, url: stored.url, title: stored.title, active: false }
},
// The route has already checked documentId, size and that the file is an image.
upload: async (documentId, file, user) => {
const stored = await storeUpload(documentId, file, user)
return { id: stored.id, url: stored.url, title: stored.title, active: true }
},
},
},
})
images maps to the capability images (PORT_CAPABILITY), so serverCapabilities(ports) and GET /api/editor/capabilities list it only when the port is given. Without it the three routes answer 501. The port is described with the others in EditorPorts.
The dialog and your own store
ImageStudioDialog is exported for hosts; the block's view uses the same component. The Vue dialog is a modal (useUiKit().Modal), the React one a shadcn Dialog; both have three columns: description and prompt blocks on the left (a switch per prompt block, its text underneath), references and previous generations in the middle, the selected image and the chat on the right. They use the same texts (imageStudio.*).
| Prop / event | Meaning |
|---|---|
open: boolean (v-model:open; React: open and onOpenChange(open)) | Whether the dialog is shown; Vue emits update:open, React calls onOpenChange. |
store: ImageStudioStore (React: ImageStudioReactStore) | What the dialog drives, see below. |
title?: string | Dialog title; default „Bild-Studio“. |
In the middle column a click on a tile toggles active (a ring marks it), „Als Bild verwenden“ selects it and „Entfernen“ removes it from the block. A reference is added by the upload button or by dropping files on the zone. In the chat, Enter sends the instruction, Shift+Enter adds a line, and „Erzeugen“ is disabled while a generation runs. Closing the dialog aborts a running generation; an aborted generation adds nothing, a failed one adds a turn with problem set. The description is committed 500 ms after the last keystroke and at once on blur, on „Erzeugen“ and on close, so a prompt is never built from a stale text.
The dialog applies the reducers itself and hands every resulting state to store.update. The store only persists, generates and uploads:
interface ImageStudioStore {
readonly state: Ref<ImageStudioState>
readonly promptBlocks: Ref<readonly ImageStudioPromptBlock[]> // the managed list, loaded by the host
update: (next: ImageStudioState) => void // after a reducer; the host persists it
generate: (instruction: string, signal: AbortSignal) => Promise<ImageStudioImage> // the host builds the prompt
upload: (file: File) => Promise<ImageStudioImage> // the dialog then calls addReference
readonly problem: Ref<string | undefined> // a problem of the store itself, shown as a notice
readonly library?: ImageStudioLibrary // the host's gallery; a «Galerie» tab appears only when set
readonly placeholderUrl?: string // shown in the preview while no image is selected
}
generate reads the description, the active prompt blocks and the active images from state and returns one image; the dialog calls addGeneration with it. Its store methods never write state themselves.
For a studio inside the editor, createImageStudioNodeStore({ editor, getPos, backend, documentId }) builds the store of one imageStudio node: state follows the node's studio attribute (so undo, collaboration and other writers show), update writes it back with setImageStudioState, and the rest goes to backend.images. Call its dispose() when the view goes.
A host that keeps the state outside the document (a sidecar next to the block, a database row) writes its own store and uses the dialog directly:
<script setup lang="ts">
import { ImageStudioDialog, type ImageStudioStore, normalizeImageStudioState } from '@skaile/forge-editor/image-studio/vue'
import { ref, shallowRef } from 'vue'
const open = ref(false)
// The block's studio state lives in the host's own record.
const state = shallowRef(normalizeImageStudioState(record.studio))
const promptBlocks = shallowRef(await fetchPromptBlocks())
const problem = shallowRef<string>()
const store: ImageStudioStore = {
state,
promptBlocks,
problem,
update(next) {
state.value = next
void saveStudio(record.id, next) // persist; also mirror the selected image into the block here
},
async generate(instruction, signal) {
const { description, promptBlockIds, references, generations } = state.value
const referenceIds = [...references, ...generations].filter(image => image.active).map(image => image.id)
return await requestImage({ description, instruction, promptBlockIds, referenceIds }, signal)
},
upload: file => uploadReference(file),
}
</script>
<template>
<UButton label="Bild-Studio" @click="open = true" />
<ImageStudioDialog v-model:open="open" :store="store" />
</template>
React
The React dialog takes the same contract with plain values instead of refs, ImageStudioReactStore: state, promptBlocks and problem are values, update, generate and upload are the same functions. The host re-renders the dialog with the new state after update; until state changes the dialog keeps building on the state it handed to update, so several uploads that finish together all land. The identity of update is the identity of the store: keep it stable while the dialog edits one image (useCallback), and hand the open dialog another update only with another image, so a pending description edit is committed to the image it was typed for. A host that keeps the state in a record writes it with useState:
import { ImageStudioDialog, type ImageStudioPromptBlock, type ImageStudioReactStore, type ImageStudioState, normalizeImageStudioState } from '@skaile/forge-editor/image-studio/react'
import { useCallback, useMemo, useState } from 'react'
function StudioButton({ record, promptBlocks }: { record: { id: string, studio: unknown }, promptBlocks: ImageStudioPromptBlock[] }) {
const [open, setOpen] = useState(false)
const [state, setState] = useState(() => normalizeImageStudioState(record.studio))
// Stable per record: a new `update` tells the dialog that another image is open.
const update = useCallback((next: ImageStudioState) => {
setState(next)
void saveStudio(record.id, next) // persist; also mirror the selected image into the block here
}, [record.id])
const store: ImageStudioReactStore = useMemo(() => ({
state,
promptBlocks,
problem: undefined,
update,
async generate(instruction, signal) {
const { description, promptBlockIds, references, generations } = state
const referenceIds = [...references, ...generations].filter(image => image.active).map(image => image.id)
return await requestImage({ description, instruction, promptBlockIds, referenceIds }, signal)
},
upload: file => uploadReference(file),
}), [state, promptBlocks, update])
return (
<>
<button onClick={() => setOpen(true)}>Bild-Studio</button>
<ImageStudioDialog open={open} onOpenChange={setOpen} store={store} />
</>
)
}
For the node's own store use useImageStudioNodeStore({ editor, getPos, backend, documentId }), which wraps createImageStudioNodeCore (the framework-free observable the Vue store wraps as well: get(), subscribe(listener), update, generate, upload, dispose()). It returns undefined on the first render, because the core is built in an effect; render the dialog once there is a store.
Image ids can be the host's own ids (an asset id, say) and url any url the page can show; the library only compares ids. The host decides what a removed image means for its files, and whether selected is also written to wherever the block's picture is read from.
The gallery tab
With store.library the middle column gets two tabs: „Dieser Block“ (references and previous generations, as above) and „Galerie“ (the host's images). The tab re-reads facets() each time it opens, lists with the first option of every facet and an empty search, and lists again after a facet change and 300 ms after the last keystroke of the search; a newer read aborts the running one. A tile offers „Als Bild verwenden“ (adoptImage), „Als Referenz“ (addReference, disabled once the block holds the image), and, when the host gives rename / remove, a pencil (Enter commits, Escape or blur cancels) and a trash button. A refused remove shows the error's message in the tab and keeps the tile; a granted one also drops the image from the block when it holds it.
interface ImageStudioLibrary {
facets: () => readonly ImageStudioLibraryFacet[] // { id, label, options: { value, label }[] }; the first option is the default
list: (query: ImageStudioLibraryQuery, signal: AbortSignal) => Promise<readonly ImageStudioLibraryItem[]> // query: { search, facets: { [facetId]: value } }; item: an ImageStudioImage plus an optional subtitle
rename?: (id: string, title: string) => Promise<ImageStudioLibraryItem>
remove?: (id: string) => Promise<void> // reject with an Error whose message the dialog shows
}
placeholderUrl replaces the text „Noch kein Bild“ in the preview with that picture while no image is selected. The node store sets neither member.
See also
- Public exports lists the three entries.
- EditorPorts explains the ports, the capability probe and the capability classes.
- Media, mentions and transcript covers the plain image block and uploads through the
assetsport.
Block properties and Property definitions
Define Block properties with Property definitions, give them to a Block type with the properties capability and read and write them.
Prüfung and Freigabestatus
Record that a user checked a Typed block at one content state, derive the Freigabestatus, optionally lock checked blocks, and add your own chips and inspector sections.