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:
9
packages/core/src/content/converters/index.ts
Normal file
9
packages/core/src/content/converters/index.ts
Normal 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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
120
packages/core/src/content/converters/types.ts
Normal file
120
packages/core/src/content/converters/types.ts
Normal 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[];
|
||||
}
|
||||
5
packages/core/src/content/index.ts
Normal file
5
packages/core/src/content/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Content utilities
|
||||
*/
|
||||
|
||||
export * from "./converters/index.js";
|
||||
Reference in New Issue
Block a user