feat: add image management functionality
- Implemented image folder repository with CRUD operations. - Added image reference repository for managing image references to articles. - Created image repository for handling image assets, including listing, inserting, updating, and deleting images. - Introduced image usage repository to track storage usage and quotas for tenants. - Added SQL queries for image assets, folders, references, and usage. - Developed image handler for HTTP endpoints to manage images and folders. - Created database migration scripts for image-related tables and structures.
This commit is contained in:
@@ -71,6 +71,14 @@ import type {
|
||||
TemplateGenerateResponse,
|
||||
TemplateListItem,
|
||||
TemplateCard,
|
||||
ImageFolder,
|
||||
ImageAssetItem,
|
||||
ImageAssetListResponse,
|
||||
ImageUploadResponse,
|
||||
ImageReferenceDetail,
|
||||
FolderDeletePreview,
|
||||
ImageStorageUsage,
|
||||
ImageUpdateRequest,
|
||||
UpdateQuestionRequest,
|
||||
UpdateArticleRequest,
|
||||
UserInfo,
|
||||
@@ -83,6 +91,12 @@ import {
|
||||
getStoredRefreshToken,
|
||||
setStoredTokens,
|
||||
} from "./session";
|
||||
import {
|
||||
convertImageFileToWebp,
|
||||
markImageUploadRequestFailed,
|
||||
markImageUploadRequestFinished,
|
||||
markImageUploadRequestStarted,
|
||||
} from "./image-webp";
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED === "true";
|
||||
@@ -242,18 +256,27 @@ export const articlesApi = {
|
||||
return apiClient.put<ArticleDetail, UpdateArticleRequest>(`/api/tenant/articles/${id}`, payload);
|
||||
},
|
||||
async uploadImage(id: number, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const normalizedFile = await convertImageFileToWebp(file);
|
||||
const progressToken = markImageUploadRequestStarted(normalizedFile.name);
|
||||
|
||||
const response = await apiClient.raw.post<ApiEnvelope<ArticleImageUploadResponse>>(
|
||||
`/api/tenant/articles/${id}/images`,
|
||||
formData,
|
||||
);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", normalizedFile, normalizedFile.name);
|
||||
|
||||
return {
|
||||
...response.data.data,
|
||||
url: resolveApiURL(response.data.data.url),
|
||||
};
|
||||
const response = await apiClient.raw.post<ApiEnvelope<ArticleImageUploadResponse>>(
|
||||
`/api/tenant/articles/${id}/images`,
|
||||
formData,
|
||||
);
|
||||
|
||||
markImageUploadRequestFinished(progressToken);
|
||||
return {
|
||||
...response.data.data,
|
||||
url: resolveApiURL(response.data.data.url),
|
||||
};
|
||||
} catch (error) {
|
||||
markImageUploadRequestFailed(progressToken);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
versions(id: number) {
|
||||
return apiClient.get<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`);
|
||||
@@ -320,6 +343,65 @@ export const knowledgeApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export const imagesApi = {
|
||||
listFolders() {
|
||||
return apiClient.get<ImageFolder[]>("/api/tenant/images/folders");
|
||||
},
|
||||
createFolder(name: string) {
|
||||
return apiClient.post<ImageFolder, { name: string }>("/api/tenant/images/folders", { name });
|
||||
},
|
||||
updateFolder(id: number, name: string) {
|
||||
return apiClient.put<null, { name: string }>(`/api/tenant/images/folders/${id}`, { name });
|
||||
},
|
||||
folderDeletePreview(id: number) {
|
||||
return apiClient.get<FolderDeletePreview>(`/api/tenant/images/folders/${id}/delete-preview`);
|
||||
},
|
||||
deleteFolder(id: number, force?: boolean) {
|
||||
return apiClient.remove<null>(`/api/tenant/images/folders/${id}`, { params: force ? { force: 1 } : undefined });
|
||||
},
|
||||
list(params?: { folder_id?: number | null; q?: string; page?: number; page_size?: number }) {
|
||||
return apiClient.get<ImageAssetListResponse>("/api/tenant/images", { params });
|
||||
},
|
||||
async upload(file: File, folderId?: number | null) {
|
||||
const normalizedFile = await convertImageFileToWebp(file);
|
||||
const progressToken = markImageUploadRequestStarted(normalizedFile.name);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", normalizedFile, normalizedFile.name);
|
||||
if (folderId != null) {
|
||||
formData.append("folder_id", String(folderId));
|
||||
}
|
||||
|
||||
const response = await apiClient.raw.post<ApiEnvelope<ImageUploadResponse>>(
|
||||
"/api/tenant/images",
|
||||
formData,
|
||||
);
|
||||
|
||||
markImageUploadRequestFinished(progressToken);
|
||||
return {
|
||||
...response.data.data,
|
||||
url: resolveApiURL(response.data.data.url),
|
||||
};
|
||||
} catch (error) {
|
||||
markImageUploadRequestFailed(progressToken);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update(id: number, payload: ImageUpdateRequest) {
|
||||
return apiClient.put<null, ImageUpdateRequest>(`/api/tenant/images/${id}`, payload);
|
||||
},
|
||||
getReferences(id: number) {
|
||||
return apiClient.get<ImageReferenceDetail>(`/api/tenant/images/${id}/references`);
|
||||
},
|
||||
delete(id: number, force?: boolean) {
|
||||
return apiClient.remove<null>(`/api/tenant/images/${id}`, { params: force ? { force: 1 } : undefined });
|
||||
},
|
||||
storageUsage() {
|
||||
return apiClient.get<ImageStorageUsage>("/api/tenant/images/storage-usage");
|
||||
},
|
||||
};
|
||||
|
||||
export interface ArticleGenerationStreamEvent {
|
||||
type: string;
|
||||
article_id: number;
|
||||
|
||||
@@ -31,6 +31,26 @@ export function formatDateTime(value?: string | null, pattern = "YYYY-MM-DD HH:m
|
||||
return parsed.isValid() ? parsed.format(pattern) : value;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function formatPercentage(value?: number | null, decimals = 2): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return value.toFixed(Math.max(0, decimals));
|
||||
}
|
||||
|
||||
export function getGenerateStatusMeta(status?: string | null): { label: string; color: string } {
|
||||
if (!status) {
|
||||
return { label: "--", color: "default" };
|
||||
|
||||
@@ -28,6 +28,10 @@ const errorMessageMap: Record<string, string> = {
|
||||
draft_not_editable: "只有草稿或生成失败的文章才可继续在向导中编辑",
|
||||
article_title_required: "请输入文章标题",
|
||||
invalid_image: "请选择图片文件",
|
||||
image_upload_too_large: "图片不能超过 10MB",
|
||||
image_upload_type_not_supported: "仅支持 PNG、JPG、JPEG、GIF、WebP、BMP、SVG、AVIF 图片",
|
||||
image_canvas_context_unavailable: "当前浏览器暂不支持该图片处理能力,请更换浏览器后重试",
|
||||
image_load_failed: "图片解析失败,请重新选择图片",
|
||||
article_image_too_large: "图片不能超过 10MB",
|
||||
article_image_type_not_supported: "仅支持 PNG、JPG、GIF、WebP 图片",
|
||||
article_image_upload_failed: "图片上传失败",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { encode } from "@jsquash/webp";
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
// upload limit set to 10MB
|
||||
export const MAX_IMAGE_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const WEBP_MIME_TYPE = "image/webp";
|
||||
const SUPPORTED_IMAGE_UPLOAD_FORMATS = [
|
||||
{ mimeTypes: ["image/png"], extensions: ["png"] },
|
||||
{ mimeTypes: ["image/jpeg"], extensions: ["jpg", "jpeg"] },
|
||||
{ mimeTypes: ["image/gif"], extensions: ["gif"] },
|
||||
{ mimeTypes: ["image/webp"], extensions: ["webp"] },
|
||||
{ mimeTypes: ["image/bmp", "image/x-ms-bmp"], extensions: ["bmp"] },
|
||||
{ mimeTypes: ["image/svg+xml"], extensions: ["svg"] },
|
||||
{ mimeTypes: ["image/avif"], extensions: ["avif"] },
|
||||
] as const;
|
||||
|
||||
export const IMAGE_UPLOAD_ACCEPT = SUPPORTED_IMAGE_UPLOAD_FORMATS
|
||||
.flatMap((format) => format.mimeTypes)
|
||||
.join(",");
|
||||
|
||||
export type ImageUploadProgressStage =
|
||||
| "idle"
|
||||
| "validating"
|
||||
| "decoding"
|
||||
| "encoding"
|
||||
| "uploading"
|
||||
| "finishing";
|
||||
|
||||
type ImageUploadProgressState = {
|
||||
visible: boolean;
|
||||
fileName: string;
|
||||
percent: number;
|
||||
stage: ImageUploadProgressStage;
|
||||
};
|
||||
|
||||
type ImageUploadProgressUpdate = {
|
||||
percent: number;
|
||||
stage: Exclude<ImageUploadProgressStage, "idle">;
|
||||
};
|
||||
|
||||
const supportedImageUploadMimeTypeSet: ReadonlySet<string> = new Set(
|
||||
SUPPORTED_IMAGE_UPLOAD_FORMATS.flatMap((format) => format.mimeTypes),
|
||||
);
|
||||
const supportedImageUploadExtensionSet: ReadonlySet<string> = new Set(
|
||||
SUPPORTED_IMAGE_UPLOAD_FORMATS.flatMap((format) => format.extensions),
|
||||
);
|
||||
|
||||
const imageUploadProgressState = reactive<ImageUploadProgressState>({
|
||||
visible: false,
|
||||
fileName: "",
|
||||
percent: 0,
|
||||
stage: "idle",
|
||||
});
|
||||
|
||||
let activeProgressToken = 0;
|
||||
|
||||
function ensureWebpFileName(fileName: string, fallbackBaseName = "image"): string {
|
||||
const trimmed = fileName.trim();
|
||||
const normalized = trimmed.replace(/\.[^.]+$/, "").trim();
|
||||
const baseName = normalized || fallbackBaseName;
|
||||
return `${baseName}.webp`;
|
||||
}
|
||||
|
||||
function resolveFileExtension(fileName: string): string {
|
||||
return fileName.includes(".") ? fileName.split(".").pop()?.trim().toLowerCase() ?? "" : "";
|
||||
}
|
||||
|
||||
function resetImageUploadProgressState(): void {
|
||||
imageUploadProgressState.visible = false;
|
||||
imageUploadProgressState.fileName = "";
|
||||
imageUploadProgressState.percent = 0;
|
||||
imageUploadProgressState.stage = "idle";
|
||||
}
|
||||
|
||||
function updateImageUploadProgress(token: number, update: ImageUploadProgressUpdate): void {
|
||||
if (token !== activeProgressToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
imageUploadProgressState.visible = true;
|
||||
imageUploadProgressState.percent = Math.max(0, Math.min(100, Math.round(update.percent)));
|
||||
imageUploadProgressState.stage = update.stage;
|
||||
}
|
||||
|
||||
export function useImageUploadProgress() {
|
||||
return readonly(imageUploadProgressState);
|
||||
}
|
||||
|
||||
export function validateImageUploadFile(file: File): void {
|
||||
if (!(file instanceof File) || !file.name.trim()) {
|
||||
throw new Error("invalid_image");
|
||||
}
|
||||
|
||||
if (file.size <= 0) {
|
||||
throw new Error("invalid_image");
|
||||
}
|
||||
|
||||
if (file.size > MAX_IMAGE_UPLOAD_SIZE_BYTES) {
|
||||
throw new Error("image_upload_too_large");
|
||||
}
|
||||
|
||||
const normalizedMimeType = file.type.trim().toLowerCase();
|
||||
const extension = resolveFileExtension(file.name);
|
||||
const mimeSupported = normalizedMimeType ? supportedImageUploadMimeTypeSet.has(normalizedMimeType) : false;
|
||||
const extensionSupported = extension ? supportedImageUploadExtensionSet.has(extension) : false;
|
||||
|
||||
if (!mimeSupported && !extensionSupported) {
|
||||
throw new Error("image_upload_type_not_supported");
|
||||
}
|
||||
}
|
||||
|
||||
function startImageUploadProgress(fileName: string): number {
|
||||
const token = ++activeProgressToken;
|
||||
imageUploadProgressState.visible = true;
|
||||
imageUploadProgressState.fileName = fileName;
|
||||
imageUploadProgressState.percent = 0;
|
||||
imageUploadProgressState.stage = "validating";
|
||||
return token;
|
||||
}
|
||||
|
||||
function finishImageUploadProgress(token: number): void {
|
||||
updateImageUploadProgress(token, { percent: 100, stage: "finishing" });
|
||||
window.setTimeout(() => {
|
||||
if (token === activeProgressToken) {
|
||||
resetImageUploadProgressState();
|
||||
}
|
||||
}, 280);
|
||||
}
|
||||
|
||||
function cancelImageUploadProgress(token: number): void {
|
||||
window.setTimeout(() => {
|
||||
if (token === activeProgressToken) {
|
||||
resetImageUploadProgressState();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function createImageDataFromBlob(
|
||||
blob: Blob,
|
||||
token: number,
|
||||
): Promise<ImageData> {
|
||||
updateImageUploadProgress(token, { percent: 28, stage: "decoding" });
|
||||
|
||||
if (typeof createImageBitmap === "function") {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) {
|
||||
throw new Error("image_canvas_context_unavailable");
|
||||
}
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return context.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const nextImage = new Image();
|
||||
nextImage.onload = () => resolve(nextImage);
|
||||
nextImage.onerror = () => reject(new Error("image_load_failed"));
|
||||
nextImage.src = objectUrl;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth || image.width;
|
||||
canvas.height = image.naturalHeight || image.height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) {
|
||||
throw new Error("image_canvas_context_unavailable");
|
||||
}
|
||||
context.drawImage(image, 0, 0);
|
||||
return context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertImageFileToWebp(file: File): Promise<File> {
|
||||
validateImageUploadFile(file);
|
||||
|
||||
const token = startImageUploadProgress(file.name);
|
||||
|
||||
try {
|
||||
updateImageUploadProgress(token, { percent: 12, stage: "validating" });
|
||||
|
||||
const normalizedName = ensureWebpFileName(file.name);
|
||||
if (file.type.trim().toLowerCase() === WEBP_MIME_TYPE) {
|
||||
updateImageUploadProgress(token, { percent: 86, stage: "finishing" });
|
||||
return new File([file], normalizedName, {
|
||||
type: WEBP_MIME_TYPE,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
}
|
||||
|
||||
const imageData = await createImageDataFromBlob(file, token);
|
||||
updateImageUploadProgress(token, { percent: 72, stage: "encoding" });
|
||||
const encoded = await encode(imageData, { quality: 82 });
|
||||
|
||||
updateImageUploadProgress(token, { percent: 88, stage: "finishing" });
|
||||
return new File([encoded], normalizedName, {
|
||||
type: WEBP_MIME_TYPE,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
} catch (error) {
|
||||
cancelImageUploadProgress(token);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function markImageUploadRequestStarted(fileName: string): number {
|
||||
const token = activeProgressToken || startImageUploadProgress(fileName);
|
||||
imageUploadProgressState.fileName = fileName;
|
||||
updateImageUploadProgress(token, { percent: 94, stage: "uploading" });
|
||||
return token;
|
||||
}
|
||||
|
||||
export function markImageUploadRequestFinished(token: number): void {
|
||||
finishImageUploadProgress(token);
|
||||
}
|
||||
|
||||
export function markImageUploadRequestFailed(token: number): void {
|
||||
cancelImageUploadProgress(token);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type RichImageBlockFeatureConfig = {
|
||||
type ParsedImageMarkup = {
|
||||
src: string;
|
||||
caption: string;
|
||||
assetId: number;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: number;
|
||||
@@ -42,6 +43,7 @@ type RichImageBlockNode = {
|
||||
url: string;
|
||||
title: string;
|
||||
alt: string;
|
||||
assetId: number;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: number;
|
||||
@@ -74,6 +76,14 @@ function parseRatio(value: unknown): number {
|
||||
return Number(parsed.toFixed(2));
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown): number {
|
||||
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
@@ -119,6 +129,7 @@ function parseImageMarkup(markup: string): ParsedImageMarkup | null {
|
||||
return {
|
||||
src,
|
||||
caption: (image.getAttribute("title") || image.getAttribute("alt") || "").trim(),
|
||||
assetId: parsePositiveInteger(image.getAttribute("data-asset-id")),
|
||||
width: parsePositiveNumber(image.getAttribute("width")),
|
||||
height: parsePositiveNumber(image.getAttribute("height")),
|
||||
ratio: parseRatio(image.getAttribute("data-ratio")),
|
||||
@@ -132,12 +143,14 @@ function serializeImageHtml(attrs: Record<string, unknown>): string {
|
||||
const width = parsePositiveNumber(attrs.width);
|
||||
const height = parsePositiveNumber(attrs.height);
|
||||
const ratio = parseRatio(attrs.ratio);
|
||||
const assetId = parsePositiveInteger(attrs.assetId);
|
||||
const align = clampImageAlign(attrs.align);
|
||||
const encodedCaption = escapeHtmlAttribute(caption);
|
||||
|
||||
const widthAttribute = width > 0 ? ` width="${Math.round(width)}"` : "";
|
||||
const heightAttribute = height > 0 ? ` height="${Math.round(height)}"` : "";
|
||||
const ratioAttribute = width === 0 && height === 0 && ratio !== 1 ? ` data-ratio="${ratio.toFixed(2)}"` : "";
|
||||
const assetIdAttribute = assetId > 0 ? ` data-asset-id="${assetId}"` : "";
|
||||
|
||||
return (
|
||||
`<p class="article-editor-image article-editor-image--${align}" align="${align}">` +
|
||||
@@ -147,6 +160,7 @@ function serializeImageHtml(attrs: Record<string, unknown>): string {
|
||||
widthAttribute +
|
||||
heightAttribute +
|
||||
ratioAttribute +
|
||||
assetIdAttribute +
|
||||
" />" +
|
||||
"</p>"
|
||||
);
|
||||
@@ -155,8 +169,9 @@ function serializeImageHtml(attrs: Record<string, unknown>): string {
|
||||
function needsHtmlSerialization(node: { attrs: Record<string, unknown> }): boolean {
|
||||
const width = parsePositiveNumber(node.attrs.width);
|
||||
const height = parsePositiveNumber(node.attrs.height);
|
||||
const assetId = parsePositiveInteger(node.attrs.assetId);
|
||||
const align = clampImageAlign(node.attrs.align);
|
||||
return width > 0 || height > 0 || align !== "center";
|
||||
return width > 0 || height > 0 || align !== "center" || assetId > 0;
|
||||
}
|
||||
|
||||
function toRichImageBlockNode(parsed: ParsedImageMarkup): RichImageBlockNode {
|
||||
@@ -165,6 +180,7 @@ function toRichImageBlockNode(parsed: ParsedImageMarkup): RichImageBlockNode {
|
||||
url: parsed.src,
|
||||
title: parsed.caption,
|
||||
alt: parsed.ratio === 1 ? "" : parsed.ratio.toFixed(2),
|
||||
assetId: parsed.assetId,
|
||||
width: parsed.width,
|
||||
height: parsed.height,
|
||||
ratio: parsed.ratio,
|
||||
@@ -181,6 +197,7 @@ function transformRichImageNode(node: Record<string, unknown>): Record<string, u
|
||||
url: String(firstChild.url ?? ""),
|
||||
title: String(firstChild.title ?? ""),
|
||||
alt: String(firstChild.alt ?? ""),
|
||||
assetId: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
ratio: parseRatio(firstChild.alt),
|
||||
@@ -226,6 +243,7 @@ export const richImageBlockSchema = $nodeSchema("image-block", () => ({
|
||||
attrs: {
|
||||
src: { default: "", validate: "string" },
|
||||
caption: { default: "", validate: "string" },
|
||||
assetId: { default: 0, validate: "number" },
|
||||
ratio: { default: 1, validate: "number" },
|
||||
width: { default: 0, validate: "number" },
|
||||
height: { default: 0, validate: "number" },
|
||||
@@ -242,6 +260,7 @@ export const richImageBlockSchema = $nodeSchema("image-block", () => ({
|
||||
return {
|
||||
src: dom.getAttribute("src") || "",
|
||||
caption: dom.getAttribute("title") || dom.getAttribute("alt") || "",
|
||||
assetId: parsePositiveInteger(dom.getAttribute("data-asset-id")),
|
||||
ratio: parseRatio(dom.getAttribute("data-ratio") || dom.getAttribute("ratio")),
|
||||
width: parsePositiveNumber(dom.getAttribute("width")),
|
||||
height: parsePositiveNumber(dom.getAttribute("height")),
|
||||
@@ -258,6 +277,7 @@ export const richImageBlockSchema = $nodeSchema("image-block", () => ({
|
||||
alt: node.attrs.caption,
|
||||
title: node.attrs.caption,
|
||||
align: node.attrs.align,
|
||||
"data-asset-id": parsePositiveInteger(node.attrs.assetId) || undefined,
|
||||
width: node.attrs.width || undefined,
|
||||
height: node.attrs.height || undefined,
|
||||
"data-ratio": node.attrs.ratio,
|
||||
@@ -269,6 +289,7 @@ export const richImageBlockSchema = $nodeSchema("image-block", () => ({
|
||||
state.addNode(type, {
|
||||
src: String((node as RichImageBlockNode).url ?? ""),
|
||||
caption: String((node as RichImageBlockNode).title ?? ""),
|
||||
assetId: parsePositiveInteger((node as RichImageBlockNode).assetId),
|
||||
ratio: parseRatio((node as RichImageBlockNode).ratio || (node as RichImageBlockNode).alt),
|
||||
width: parsePositiveNumber((node as RichImageBlockNode).width),
|
||||
height: parsePositiveNumber((node as RichImageBlockNode).height),
|
||||
|
||||
Reference in New Issue
Block a user