import Image, { type ImageOptions } from "@tiptap/extension-image";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
/**
* The injectable async upload contract for the shared image block.
*
* An app provides this to turn a picked / pasted / dropped {@link File} into a
* hosted image. It returns the final `src` (a hosted URL) plus an optional
* `alt`. Plans wire it to the framework `upload-image` action; Content keeps its
* own richer image block and does not use this contract.
*
* When NO upload function is supplied, the shared block still renders images and
* round-trips `` markdown — it just cannot ingest local files (paste
* of an image URL / markdown still works via the base node's input rules).
*/
export type ImageUploadFn = (
file: File,
) => Promise<{ src: string; alt?: string }>;
export interface SharedImageOptions extends ImageOptions {
/**
* App-injected uploader. When present, the shared block accepts pasted /
* dropped image files and the `/image` slash command, uploading each file
* through this function and patching the node's `src` on resolve.
*/
onImageUpload?: ImageUploadFn | null;
}
const sharedImageUploadPluginKey = new PluginKey("an-shared-image-upload");
/** A monotonically increasing id so concurrent uploads patch the right node. */
let uploadCounter = 0;
function nextUploadId(): string {
uploadCounter += 1;
return `an-img-${Date.now()}-${uploadCounter}`;
}
/** Image files only — never ingest non-image clipboard/drop payloads. */
function imageFilesFrom(data: DataTransfer | null | undefined): File[] {
if (!data) return [];
return Array.from(data.files ?? []).filter((file) =>
file.type.startsWith("image/"),
);
}
/**
* Insert a placeholder `image` node (empty `src`, transient `uploadId`) at
* `pos` for every file, then resolve each upload and patch the matching node's
* `src`. Mirrors Content's optimistic upload flow but is self-contained in the
* shared extension so it works for ANY editor wrapper (the plan's
* `SharedRichEditor` and Content's hand-rolled `useEditor` alike) without a new
* `editorProps` seam.
*/
function uploadAndInsertImages(
view: EditorView,
files: File[],
pos: number,
upload: ImageUploadFn,
): void {
if (files.length === 0) return;
const pending: Array<{ uploadId: string; file: File }> = [];
// Insert all placeholders first, top-down from `pos`, so multi-file
// paste/drop keeps source order.
let insertAt = pos;
for (const file of files) {
const uploadId = nextUploadId();
const node = view.state.schema.nodes.image?.create({
src: "",
alt: "",
uploadId,
});
if (!node) continue;
const tr = view.state.tr.insert(insertAt, node);
view.dispatch(tr);
pending.push({ uploadId, file });
// Advance past the inserted atom for the next placeholder.
insertAt += node.nodeSize;
}
for (const item of pending) {
void (async () => {
try {
const { src, alt } = await upload(item.file);
if (!view.dom.isConnected || view.isDestroyed) return;
patchUploadNode(view, item.uploadId, { src, alt: alt ?? "" });
} catch (error) {
console.error("Image upload failed:", error);
// Drop the placeholder so a failed upload does not leave an empty box.
if (view.dom.isConnected && !view.isDestroyed) {
removeUploadNode(view, item.uploadId);
}
}
})();
}
}
/** Find a placeholder node by its transient `uploadId` and return its position. */
function findUploadNode(
view: EditorView,
uploadId: string,
): { pos: number; nodeSize: number } | null {
let found: { pos: number; nodeSize: number } | null = null;
view.state.doc.descendants((node, pos) => {
if (found) return false;
if (node.type.name === "image" && node.attrs.uploadId === uploadId) {
found = { pos, nodeSize: node.nodeSize };
return false;
}
return true;
});
return found;
}
/** Patch the resolved `src`/`alt` onto the placeholder and clear `uploadId`. */
function patchUploadNode(
view: EditorView,
uploadId: string,
attrs: { src: string; alt: string },
): void {
const target = findUploadNode(view, uploadId);
if (!target) return;
const node = view.state.doc.nodeAt(target.pos);
if (!node) return;
const tr = view.state.tr.setNodeMarkup(target.pos, undefined, {
...node.attrs,
src: attrs.src,
alt: attrs.alt,
uploadId: null,
});
view.dispatch(tr);
}
/** Remove a placeholder whose upload failed. */
function removeUploadNode(view: EditorView, uploadId: string): void {
const target = findUploadNode(view, uploadId);
if (!target) return;
const tr = view.state.tr.delete(target.pos, target.pos + target.nodeSize);
view.dispatch(tr);
}
/**
* The SHARED block-level image node. Built on `@tiptap/extension-image` (a
* node named `image`), so:
*
* - **GFM serialization** emits pure `` markdown via a block-aware
* `addStorage().markdown.serialize` (it calls `closeBlock` so a block image
* keeps its blank-line separator, byte-stable). It emits NO `
`
* HTML, so plans stay source-syncable under the GFM `html:false` contract.
* - **Paste / drop** of local image files is handled by a self-contained
* ProseMirror plugin that calls the injected {@link ImageUploadFn}. Pasting
* an image URL or `` markdown works through the base node's input
* rules even when no uploader is supplied.
*
* The node adds one transient `uploadId` attribute (never parsed/rendered to
* HTML, never serialized to markdown) used to track an in-flight upload.
*
* Content keeps its own richer `ImageNode` (Assets picker, AI alt-text, resize,
* NFM `{color}` serialization) and does NOT use this shared node — both nodes
* are named `image` but never coexist in the same editor because Content leaves
* `features.image` off and injects its own via `extraExtensions`.
*/
export const SharedImage = Image.extend({
// Block-level atom (Content's image is also a block atom). The base extension
// is block when `inline:false`.
inline: false,
group: "block",
addOptions() {
return {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...this.parent!(),
onImageUpload: null,
};
},
addAttributes() {
return {
...this.parent?.(),
// Transient: marks an in-flight upload so the plugin can patch `src` on
// resolve. Never written to HTML or markdown.
uploadId: {
default: null,
parseHTML: () => null,
renderHTML: () => ({}),
},
};
},
addStorage() {
return {
...this.parent?.(),
// A BLOCK-aware markdown serializer. tiptap-markdown's built-in fallback
// for the `image` node is prosemirror-markdown's INLINE image serializer,
// which omits the trailing block separator — so an image immediately
// followed by a paragraph loses its blank line on round-trip. This
// node-level spec (merged OVER the fallback by tiptap-markdown's
// `getMarkdownSpec`) emits the same pure `` markdown but calls
// `closeBlock`, so a block image stays byte-stable and source-syncable.
// No `
` / HTML is emitted, so the GFM `html:false` contract and
// the plan round-trip corpus are preserved.
markdown: {
serialize(
state: {
esc: (s: string) => string;
write: (s: string) => void;
closeBlock: (n: unknown) => void;
},
node: { attrs: { src?: string; alt?: string; title?: string } },
) {
const src = node.attrs.src ?? "";
const alt = node.attrs.alt ?? "";
const title = node.attrs.title ?? "";
const titleSuffix = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
state.write(`}${titleSuffix})`);
state.closeBlock(node);
},
parse: {
// Parsing `` is handled by markdown-it + the base node's
// markdown input rule.
},
},
};
},
addProseMirrorPlugins() {
const upload = this.options.onImageUpload;
const parentPlugins = this.parent?.() ?? [];
if (!upload) return parentPlugins;
return [
...parentPlugins,
new Plugin({
key: sharedImageUploadPluginKey,
props: {
handlePaste(view, event) {
const files = imageFilesFrom(event.clipboardData);
if (files.length === 0) return false;
event.preventDefault();
uploadAndInsertImages(
view,
files,
view.state.selection.from,
upload,
);
return true;
},
handleDrop(view, event) {
const files = imageFilesFrom(event.dataTransfer);
if (files.length === 0) return false;
event.preventDefault();
const coords = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
const pos = coords?.pos ?? view.state.selection.from;
uploadAndInsertImages(view, files, pos, upload);
return true;
},
},
}),
];
},
});
/**
* Build the shared image extension, optionally wired with an app uploader.
*
* @example
* createImageExtension({ onImageUpload: uploadEditorImage })
*/
export function createImageExtension(
options: { onImageUpload?: ImageUploadFn | null } = {},
) {
return SharedImage.configure({
onImageUpload: options.onImageUpload ?? null,
HTMLAttributes: { class: "an-rich-md-image" },
});
}
/**
* Open a native file picker, then upload + insert the chosen image(s) through
* the same flow as paste/drop. Used by the `/image` slash command.
*/
export function pickAndInsertImage(
view: EditorView,
upload: ImageUploadFn,
): void {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => {
const files = Array.from(input.files ?? []).filter((file) =>
file.type.startsWith("image/"),
);
input.remove();
if (files.length === 0) return;
uploadAndInsertImages(view, files, view.state.selection.from, upload);
});
document.body.appendChild(input);
input.click();
}