Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
/**
* Portable Text Converters
*
* Bidirectional conversion between Portable Text and ProseMirror JSON.
*/
export { prosemirrorToPortableText } from "./prosemirror-to-portable-text.js";
export { portableTextToProsemirror } from "./portable-text-to-prosemirror.js";
export * from "./types.js";

View File

@@ -0,0 +1,433 @@
/**
* Portable Text to ProseMirror Converter
*
* Converts Portable Text to TipTap's ProseMirror JSON format for editing.
*/
import type {
ProseMirrorDocument,
ProseMirrorNode,
ProseMirrorMark,
PortableTextBlock,
PortableTextTextBlock,
PortableTextSpan,
PortableTextMarkDef,
PortableTextImageBlock,
PortableTextCodeBlock,
} from "./types.js";
/**
* Convert Portable Text to ProseMirror document
*/
export function portableTextToProsemirror(blocks: PortableTextBlock[]): ProseMirrorDocument {
if (!blocks || blocks.length === 0) {
return {
type: "doc",
content: [{ type: "paragraph" }],
};
}
const content: ProseMirrorNode[] = [];
let i = 0;
while (i < blocks.length) {
const block = blocks[i];
// Check for list items
if (isTextBlock(block) && block.listItem) {
// Collect consecutive list items
const listBlocks: PortableTextTextBlock[] = [];
const listType = block.listItem;
while (i < blocks.length) {
const current = blocks[i];
if (isTextBlock(current) && current.listItem === listType) {
listBlocks.push(current);
i++;
} else {
break;
}
}
content.push(convertList(listBlocks, listType));
} else {
const converted = convertBlock(block);
if (converted) {
content.push(converted);
}
i++;
}
}
return {
type: "doc",
content: content.length > 0 ? content : [{ type: "paragraph" }],
};
}
/**
* Type guard for text blocks
*/
function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock {
return block._type === "block";
}
/**
* Type guard for image blocks.
* Checks both `_type` and that `asset` is a valid object — image blocks
* without an `asset` wrapper (e.g. `{ _type: "image", url: "..." }`) are
* malformed and should not be cast to `PortableTextImageBlock`.
*/
function isImageBlock(block: PortableTextBlock): block is PortableTextImageBlock {
return (
block._type === "image" &&
"asset" in block &&
typeof block.asset === "object" &&
block.asset !== null
);
}
/**
* Type guard for code blocks
*/
function isCodeBlock(block: PortableTextBlock): block is PortableTextCodeBlock {
return block._type === "code";
}
/**
* Convert a single Portable Text block to ProseMirror node
*/
function convertBlock(block: PortableTextBlock): ProseMirrorNode | null {
if (isTextBlock(block)) {
return convertTextBlock(block);
}
if (isImageBlock(block)) {
return convertImage(block);
}
if (block._type === "image") {
// Malformed image block (no asset wrapper) — extract url from top level
return convertMalformedImage(block);
}
if (isCodeBlock(block)) {
return convertCodeBlock(block);
}
if (block._type === "break") {
return { type: "horizontalRule" };
}
// Unknown block - wrap in a div or preserve as placeholder
return {
type: "paragraph",
content: [
{
type: "text",
text: `[Unknown block type: ${block._type}]`,
marks: [{ type: "code" }],
},
],
};
}
/**
* Convert text block to ProseMirror paragraph or heading
*/
function convertTextBlock(block: PortableTextTextBlock): ProseMirrorNode | null {
const { style = "normal", children, markDefs = [] } = block;
// Convert children to ProseMirror nodes
const content = convertSpans(children, markDefs);
// Determine node type based on style
switch (style) {
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6": {
const level = parseInt(style.substring(1), 10);
return {
type: "heading",
attrs: { level },
content: content.length > 0 ? content : undefined,
};
}
case "blockquote":
return {
type: "blockquote",
content: [
{
type: "paragraph",
content: content.length > 0 ? content : undefined,
},
],
};
case "normal":
default:
return {
type: "paragraph",
content: content.length > 0 ? content : undefined,
};
}
}
/**
* Convert list items to ProseMirror list
*/
function convertList(
items: PortableTextTextBlock[],
listType: "bullet" | "number",
): ProseMirrorNode {
// Group items by level
const rootItems: ProseMirrorNode[] = [];
let i = 0;
while (i < items.length) {
const item = items[i];
const level = item.level || 1;
if (level === 1) {
// Collect nested items for this root item
const nestedItems: PortableTextTextBlock[] = [];
i++;
while (i < items.length && (items[i].level || 1) > 1) {
nestedItems.push(items[i]);
i++;
}
rootItems.push(convertListItem(item, nestedItems, listType));
} else {
// Orphan nested item - treat as root
rootItems.push(convertListItem(item, [], listType));
i++;
}
}
return {
type: listType === "bullet" ? "bulletList" : "orderedList",
content: rootItems,
};
}
/**
* Convert a single list item to ProseMirror
*/
function convertListItem(
item: PortableTextTextBlock,
nestedItems: PortableTextTextBlock[],
parentListType: "bullet" | "number",
): ProseMirrorNode {
const content: ProseMirrorNode[] = [];
// Add paragraph content
const spans = convertSpans(item.children, item.markDefs || []);
content.push({
type: "paragraph",
content: spans.length > 0 ? spans : undefined,
});
// Handle nested items
if (nestedItems.length > 0) {
// Group nested items by their list type
let j = 0;
while (j < nestedItems.length) {
const nestedListType = nestedItems[j].listItem || parentListType;
const nestedGroup: PortableTextTextBlock[] = [];
while (
j < nestedItems.length &&
(nestedItems[j].listItem || parentListType) === nestedListType
) {
nestedGroup.push(nestedItems[j]);
j++;
}
if (nestedGroup.length > 0) {
// Decrease level for nested conversion
const adjustedGroup = nestedGroup.map((ni) => ({
...ni,
level: (ni.level || 2) - 1,
}));
content.push(convertList(adjustedGroup, nestedListType));
}
}
}
return {
type: "listItem",
content,
};
}
/**
* Convert Portable Text spans to ProseMirror text nodes
*/
function convertSpans(
spans: PortableTextSpan[],
markDefs: PortableTextMarkDef[],
): ProseMirrorNode[] {
const nodes: ProseMirrorNode[] = [];
const markDefsMap = new Map(markDefs.map((md) => [md._key, md]));
for (const span of spans) {
if (span._type !== "span") continue;
// Handle newlines in text
const parts = span.text.split("\n");
for (let i = 0; i < parts.length; i++) {
const text = parts[i];
// Add text node
if (text.length > 0) {
const marks = convertMarks(span.marks || [], markDefsMap);
const node: ProseMirrorNode = {
type: "text",
text,
};
if (marks.length > 0) {
node.marks = marks;
}
nodes.push(node);
}
// Add hard break between parts (not after last)
if (i < parts.length - 1) {
nodes.push({ type: "hardBreak" });
}
}
}
return nodes;
}
/**
* Convert Portable Text marks to ProseMirror marks
*/
function convertMarks(
marks: string[],
markDefs: Map<string, PortableTextMarkDef>,
): ProseMirrorMark[] {
const pmMarks: ProseMirrorMark[] = [];
for (const mark of marks) {
switch (mark) {
case "strong":
pmMarks.push({ type: "bold" });
break;
case "em":
pmMarks.push({ type: "italic" });
break;
case "underline":
pmMarks.push({ type: "underline" });
break;
case "strike-through":
pmMarks.push({ type: "strike" });
break;
case "code":
pmMarks.push({ type: "code" });
break;
default: {
// Check if it's a mark definition reference
const markDef = markDefs.get(mark);
if (markDef) {
if (markDef._type === "link") {
pmMarks.push({
type: "link",
attrs: {
href: markDef.href,
target: markDef.blank ? "_blank" : null,
},
});
} else {
// Unknown mark def type - preserve attrs
pmMarks.push({
type: markDef._type,
attrs: markDef as Record<string, unknown>,
});
}
}
break;
}
}
}
return pmMarks;
}
/**
* Convert image block to ProseMirror
*/
function convertImage(block: PortableTextImageBlock): ProseMirrorNode {
return {
type: "image",
attrs: {
src: block.asset.url || block.asset._ref,
alt: block.alt || "",
title: block.caption || "",
mediaId: block.asset._ref,
provider: block.asset.provider,
width: block.width,
height: block.height,
displayWidth: block.displayWidth,
displayHeight: block.displayHeight,
},
};
}
/**
* Convert a malformed image block (missing `asset` wrapper) to ProseMirror.
* Handles blocks like `{ _type: "image", url: "...", alt: "..." }` that may
* originate from migrations or third-party imports.
*/
function convertMalformedImage(block: PortableTextBlock): ProseMirrorNode {
// PortableTextUnknownBlock allows indexed access via [key: string]: unknown
const url = "url" in block && typeof block.url === "string" ? block.url : "";
const alt = "alt" in block && typeof block.alt === "string" ? block.alt : "";
const caption = "caption" in block && typeof block.caption === "string" ? block.caption : "";
const width = "width" in block && typeof block.width === "number" ? block.width : undefined;
const height = "height" in block && typeof block.height === "number" ? block.height : undefined;
const displayWidth =
"displayWidth" in block && typeof block.displayWidth === "number"
? block.displayWidth
: undefined;
const displayHeight =
"displayHeight" in block && typeof block.displayHeight === "number"
? block.displayHeight
: undefined;
return {
type: "image",
attrs: {
src: url,
alt,
title: caption,
mediaId: undefined,
provider: undefined,
width,
height,
displayWidth,
displayHeight,
},
};
}
/**
* Convert code block to ProseMirror
*/
function convertCodeBlock(block: PortableTextCodeBlock): ProseMirrorNode {
return {
type: "codeBlock",
attrs: {
language: block.language || null,
},
content: block.code ? [{ type: "text", text: block.code }] : undefined,
};
}

View File

@@ -0,0 +1,413 @@
/**
* ProseMirror to Portable Text Converter
*
* Converts TipTap's ProseMirror JSON format to Portable Text for storage.
*/
import type {
ProseMirrorDocument,
ProseMirrorNode,
ProseMirrorMark,
PortableTextBlock,
PortableTextTextBlock,
PortableTextSpan,
PortableTextMarkDef,
PortableTextImageBlock,
PortableTextCodeBlock,
} from "./types.js";
/**
* Generate a unique key for Portable Text blocks
*/
function generateKey(): string {
return Math.random().toString(36).substring(2, 11);
}
/**
* Convert ProseMirror document to Portable Text
*/
export function prosemirrorToPortableText(doc: ProseMirrorDocument): PortableTextBlock[] {
if (!doc || doc.type !== "doc" || !doc.content) {
return [];
}
const blocks: PortableTextBlock[] = [];
for (const node of doc.content) {
const converted = convertNode(node);
if (converted) {
if (Array.isArray(converted)) {
blocks.push(...converted);
} else {
blocks.push(converted);
}
}
}
return blocks;
}
/**
* Convert a single ProseMirror node to Portable Text block(s)
*/
function convertNode(node: ProseMirrorNode): PortableTextBlock | PortableTextBlock[] | null {
switch (node.type) {
case "paragraph":
return convertParagraph(node);
case "heading":
return convertHeading(node);
case "bulletList":
return convertList(node, "bullet");
case "orderedList":
return convertList(node, "number");
case "blockquote":
return convertBlockquote(node);
case "codeBlock":
return convertCodeBlock(node);
case "image":
return convertImage(node);
case "horizontalRule":
return {
_type: "break",
_key: generateKey(),
style: "lineBreak",
};
default:
// Preserve unknown blocks
return {
_type: node.type,
_key: generateKey(),
...node.attrs,
_pmContent: node.content,
};
}
}
/**
* Convert paragraph to Portable Text block
*/
function convertParagraph(node: ProseMirrorNode): PortableTextTextBlock | null {
const { children, markDefs } = convertInlineContent(node.content || []);
// Skip empty paragraphs
if (children.length === 0) {
return null;
}
return {
_type: "block",
_key: generateKey(),
style: "normal",
children,
markDefs: markDefs.length > 0 ? markDefs : undefined,
};
}
/** Map heading level number to Portable Text style */
function headingLevelToStyle(level: number): PortableTextTextBlock["style"] {
switch (level) {
case 1:
return "h1";
case 2:
return "h2";
case 3:
return "h3";
case 4:
return "h4";
case 5:
return "h5";
case 6:
return "h6";
default:
return "h1";
}
}
/**
* Convert heading to Portable Text block
*/
function convertHeading(node: ProseMirrorNode): PortableTextTextBlock | null {
const { children, markDefs } = convertInlineContent(node.content || []);
const rawLevel = typeof node.attrs?.level === "number" ? node.attrs.level : 1;
const style = headingLevelToStyle(rawLevel);
if (children.length === 0) {
return null;
}
return {
_type: "block",
_key: generateKey(),
style,
children,
markDefs: markDefs.length > 0 ? markDefs : undefined,
};
}
/**
* Convert list to Portable Text blocks
*/
function convertList(
node: ProseMirrorNode,
listItem: "bullet" | "number",
): PortableTextTextBlock[] {
const blocks: PortableTextTextBlock[] = [];
for (const item of node.content || []) {
if (item.type === "listItem") {
const itemBlocks = convertListItem(item, listItem, 1);
blocks.push(...itemBlocks);
}
}
return blocks;
}
/**
* Convert list item to Portable Text blocks
*/
function convertListItem(
item: ProseMirrorNode,
listItem: "bullet" | "number",
level: number,
): PortableTextTextBlock[] {
const blocks: PortableTextTextBlock[] = [];
for (const child of item.content || []) {
if (child.type === "paragraph") {
const { children, markDefs } = convertInlineContent(child.content || []);
if (children.length > 0) {
blocks.push({
_type: "block",
_key: generateKey(),
style: "normal",
listItem,
level,
children,
markDefs: markDefs.length > 0 ? markDefs : undefined,
});
}
} else if (child.type === "bulletList") {
blocks.push(...convertListItemNested(child, "bullet", level + 1));
} else if (child.type === "orderedList") {
blocks.push(...convertListItemNested(child, "number", level + 1));
}
}
return blocks;
}
/**
* Convert nested list
*/
function convertListItemNested(
node: ProseMirrorNode,
listItem: "bullet" | "number",
level: number,
): PortableTextTextBlock[] {
const blocks: PortableTextTextBlock[] = [];
for (const item of node.content || []) {
if (item.type === "listItem") {
blocks.push(...convertListItem(item, listItem, level));
}
}
return blocks;
}
/**
* Convert blockquote to Portable Text blocks
*/
function convertBlockquote(
node: ProseMirrorNode,
): PortableTextTextBlock | PortableTextTextBlock[] | null {
// Blockquotes in PT are just blocks with style: "blockquote"
const blocks: PortableTextTextBlock[] = [];
for (const child of node.content || []) {
if (child.type === "paragraph") {
const { children, markDefs } = convertInlineContent(child.content || []);
if (children.length > 0) {
blocks.push({
_type: "block",
_key: generateKey(),
style: "blockquote",
children,
markDefs: markDefs.length > 0 ? markDefs : undefined,
});
}
}
}
return blocks.length === 1 ? blocks[0] : blocks.length > 0 ? blocks : null;
}
/**
* Convert code block to Portable Text
*/
function convertCodeBlock(node: ProseMirrorNode): PortableTextCodeBlock {
const code = node.content?.map((n) => n.text || "").join("") || "";
const language = typeof node.attrs?.language === "string" ? node.attrs.language : undefined;
return {
_type: "code",
_key: generateKey(),
code,
language: language || undefined,
};
}
/**
* Convert image to Portable Text
*/
function convertImage(node: ProseMirrorNode): PortableTextImageBlock {
const attrs = node.attrs;
const provider = typeof attrs?.provider === "string" ? attrs.provider : undefined;
const mediaId = typeof attrs?.mediaId === "string" ? attrs.mediaId : undefined;
const src = typeof attrs?.src === "string" ? attrs.src : "";
const alt = typeof attrs?.alt === "string" ? attrs.alt : undefined;
const title = typeof attrs?.title === "string" ? attrs.title : undefined;
const width = typeof attrs?.width === "number" ? attrs.width : undefined;
const height = typeof attrs?.height === "number" ? attrs.height : undefined;
const displayWidth = typeof attrs?.displayWidth === "number" ? attrs.displayWidth : undefined;
const displayHeight = typeof attrs?.displayHeight === "number" ? attrs.displayHeight : undefined;
return {
_type: "image",
_key: generateKey(),
asset: {
// Use mediaId as _ref if available (for proper provider lookups)
_ref: mediaId || src || "",
// Store URL for admin preview and fallback rendering
url: src || "",
// Store provider for external media
provider: provider && provider !== "local" ? provider : undefined,
},
alt: alt || undefined,
caption: title || undefined,
width: width || undefined,
height: height || undefined,
displayWidth: displayWidth || undefined,
displayHeight: displayHeight || undefined,
};
}
/**
* Convert inline content (text nodes with marks) to Portable Text spans
*/
function convertInlineContent(nodes: ProseMirrorNode[]): {
children: PortableTextSpan[];
markDefs: PortableTextMarkDef[];
} {
const children: PortableTextSpan[] = [];
const markDefs: PortableTextMarkDef[] = [];
const markDefMap = new Map<string, string>(); // href -> key
for (const node of nodes) {
if (node.type === "text" && node.text) {
const marks: string[] = [];
for (const mark of node.marks || []) {
const markType = convertMark(mark, markDefs, markDefMap);
if (markType) {
marks.push(markType);
}
}
children.push({
_type: "span",
_key: generateKey(),
text: node.text,
marks: marks.length > 0 ? marks : undefined,
});
} else if (node.type === "hardBreak") {
// Hard breaks become newlines in the text
if (children.length > 0) {
const lastChild = children.at(-1)!;
lastChild.text += "\n";
} else {
children.push({
_type: "span",
_key: generateKey(),
text: "\n",
});
}
}
}
// Ensure at least one span exists
if (children.length === 0) {
children.push({
_type: "span",
_key: generateKey(),
text: "",
});
}
return { children, markDefs };
}
/**
* Convert a ProseMirror mark to Portable Text mark
*/
function convertMark(
mark: ProseMirrorMark,
markDefs: PortableTextMarkDef[],
markDefMap: Map<string, string>,
): string | null {
switch (mark.type) {
case "bold":
case "strong":
return "strong";
case "italic":
case "em":
return "em";
case "underline":
return "underline";
case "strike":
case "strikethrough":
return "strike-through";
case "code":
return "code";
case "link": {
const href = (typeof mark.attrs?.href === "string" ? mark.attrs.href : "") || "";
// Check if we already have a mark def for this link
if (markDefMap.has(href)) {
return markDefMap.get(href)!;
}
// Create new mark def
const key = generateKey();
markDefs.push({
_type: "link",
_key: key,
href,
blank: mark.attrs?.target === "_blank",
});
markDefMap.set(href, key);
return key;
}
default:
// Unknown mark - preserve as-is
return mark.type;
}
}

View File

@@ -0,0 +1,120 @@
/**
* Portable Text Types
*
* Defines the structure of Portable Text blocks used in EmDash.
*/
/**
* Base span (inline text)
*/
export interface PortableTextSpan {
_type: "span";
_key: string;
text: string;
marks?: string[];
}
/**
* Mark definition (bold, italic, link, etc.)
*/
export interface PortableTextMarkDef {
_type: string;
_key: string;
[key: string]: unknown;
}
/**
* Link mark definition
*/
export interface PortableTextLinkMark extends PortableTextMarkDef {
_type: "link";
href: string;
blank?: boolean;
}
/**
* Text block (paragraph, heading, etc.)
*/
export interface PortableTextTextBlock {
_type: "block";
_key: string;
style?: "normal" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote";
listItem?: "bullet" | "number";
level?: number;
children: PortableTextSpan[];
markDefs?: PortableTextMarkDef[];
}
/**
* Image block
*/
export interface PortableTextImageBlock {
_type: "image";
_key: string;
asset: {
_ref: string;
url?: string;
/** Provider ID for external media (e.g., "cloudflare-images") */
provider?: string;
};
alt?: string;
caption?: string;
/** Original image width */
width?: number;
/** Original image height */
height?: number;
/** Display width for this instance (overrides original) */
displayWidth?: number;
/** Display height for this instance (overrides original) */
displayHeight?: number;
}
/**
* Code block
*/
export interface PortableTextCodeBlock {
_type: "code";
_key: string;
code: string;
language?: string;
filename?: string;
}
/**
* Unknown/custom block (preserved for plugin compatibility)
*/
export interface PortableTextUnknownBlock {
_type: string;
_key: string;
[key: string]: unknown;
}
/**
* Any Portable Text block
*/
export type PortableTextBlock =
| PortableTextTextBlock
| PortableTextImageBlock
| PortableTextCodeBlock
| PortableTextUnknownBlock;
/**
* ProseMirror JSON types (simplified for TipTap)
*/
export interface ProseMirrorMark {
type: string;
attrs?: Record<string, unknown>;
}
export interface ProseMirrorNode {
type: string;
attrs?: Record<string, unknown>;
content?: ProseMirrorNode[];
marks?: ProseMirrorMark[];
text?: string;
}
export interface ProseMirrorDocument {
type: "doc";
content: ProseMirrorNode[];
}

View File

@@ -0,0 +1,5 @@
/**
* Content utilities
*/
export * from "./converters/index.js";