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:
@@ -60,8 +60,10 @@ import EditorActionMenu from "@/components/editor-common/EditorActionMenu.vue";
|
||||
import EditorAiAssistPanel from "@/components/editor-common/EditorAiAssistPanel.vue";
|
||||
import EditorGridSizePicker from "@/components/editor-common/EditorGridSizePicker.vue";
|
||||
import EditorSelectionFrame from "@/components/editor-common/EditorSelectionFrame.vue";
|
||||
import CoverPickerModal from "@/components/CoverPickerModal.vue";
|
||||
import MarkdownPreview from "@/components/MarkdownPreview.vue";
|
||||
import {
|
||||
imagesApi,
|
||||
streamArticleSelectionOptimize,
|
||||
type ArticleSelectionOptimizeStreamEvent,
|
||||
} from "@/lib/api";
|
||||
@@ -90,7 +92,6 @@ const crepeRef = ref<Crepe | null>(null);
|
||||
const syncingExternalMarkdown = ref(false);
|
||||
const surfaceRef = ref<HTMLElement | null>(null);
|
||||
const tablePickerRef = ref<HTMLElement | null>(null);
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const AI_OPTIMIZE_PANEL_GAP = 18;
|
||||
const AI_OPTIMIZE_SELECTION_HIGHLIGHT_NAME = "article-editor-ai-optimize-selection";
|
||||
@@ -130,6 +131,8 @@ type ImageContextMenuAction =
|
||||
| "align-right"
|
||||
| "delete-image";
|
||||
|
||||
type ImagePickerAction = "insert" | "replace";
|
||||
|
||||
type AiOptimizeStatus = "idle" | "generating" | "completed" | "error";
|
||||
|
||||
type TableContextMenuState = {
|
||||
@@ -152,6 +155,7 @@ type SelectedImageState = {
|
||||
nodePos: number;
|
||||
nodeSize: number;
|
||||
src: string;
|
||||
assetId: number | null;
|
||||
align: RichImageAlign;
|
||||
};
|
||||
|
||||
@@ -189,6 +193,7 @@ type SelectedImageSnapshot = {
|
||||
nodePos: number;
|
||||
nodeSize: number;
|
||||
src: string;
|
||||
assetId: number | null;
|
||||
align: RichImageAlign;
|
||||
};
|
||||
|
||||
@@ -215,6 +220,7 @@ function createDefaultSelectedImageState(): SelectedImageState {
|
||||
nodePos: 0,
|
||||
nodeSize: 0,
|
||||
src: "",
|
||||
assetId: null,
|
||||
align: "center",
|
||||
};
|
||||
}
|
||||
@@ -297,8 +303,10 @@ const { loading } = useEditor((root) => {
|
||||
const [instanceLoading, getEditor] = useInstance();
|
||||
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
|
||||
const tablePickerOpen = ref(false);
|
||||
const imageUploading = ref(false);
|
||||
const imageInputAction = ref<"insert" | "replace">("insert");
|
||||
const imagePickerOpen = ref(false);
|
||||
const imagePickerAction = ref<ImagePickerAction>("insert");
|
||||
const imagePickerCurrentUrl = ref("");
|
||||
const imagePickerCurrentAssetId = ref<number | null>(null);
|
||||
const tableContextMenu = ref<TableContextMenuState>(createDefaultTableContextMenuState());
|
||||
const selectedImage = ref<SelectedImageState>(createDefaultSelectedImageState());
|
||||
const imageContextMenu = ref<ImageContextMenuState>(createDefaultImageContextMenuState());
|
||||
@@ -512,6 +520,7 @@ watch(
|
||||
closeTableContextMenu();
|
||||
clearSelectedImageState();
|
||||
closeImageContextMenu();
|
||||
imagePickerOpen.value = false;
|
||||
closeAiOptimizePanel();
|
||||
}
|
||||
},
|
||||
@@ -575,8 +584,16 @@ function runEditorAction(action: (ctx: Ctx) => void): void {
|
||||
editor.action(action);
|
||||
}
|
||||
|
||||
function insertImageBlockAtCursor(src: string): void {
|
||||
const resolvedSrc = src.trim();
|
||||
function resolveImageAssetId(value: unknown): number | null {
|
||||
const resolved = Number.parseInt(String(value ?? "").trim(), 10);
|
||||
if (!Number.isInteger(resolved) || resolved <= 0) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function insertImageBlockAtCursor(payload: { src: string; assetId?: number | null }): void {
|
||||
const resolvedSrc = payload.src.trim();
|
||||
if (!resolvedSrc) {
|
||||
return;
|
||||
}
|
||||
@@ -588,6 +605,7 @@ function insertImageBlockAtCursor(src: string): void {
|
||||
attrs: {
|
||||
src: resolvedSrc,
|
||||
caption: "",
|
||||
assetId: payload.assetId ?? 0,
|
||||
ratio: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -606,54 +624,44 @@ function insertImage(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.uploadImage) {
|
||||
imageInputAction.value = "insert";
|
||||
imageInputRef.value?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const src = window.prompt(t("article.editor.imagePrompt"));
|
||||
if (!src?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
insertImageBlockAtCursor(src);
|
||||
closeTablePicker();
|
||||
closeTableContextMenu();
|
||||
closeImageContextMenu();
|
||||
imagePickerAction.value = "insert";
|
||||
imagePickerCurrentUrl.value = "";
|
||||
imagePickerCurrentAssetId.value = null;
|
||||
imagePickerOpen.value = true;
|
||||
}
|
||||
|
||||
async function handleImageFileChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
const action = imageInputAction.value;
|
||||
imageInputAction.value = "insert";
|
||||
async function uploadBodyImageToLibrary(file: File): Promise<{ url: string; fileName: string; assetId: number }> {
|
||||
const uploaded = await imagesApi.upload(file);
|
||||
return {
|
||||
url: uploaded.url,
|
||||
fileName: uploaded.name,
|
||||
assetId: uploaded.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (!file || !props.uploadImage || editorDisabled.value || imageUploading.value) {
|
||||
return;
|
||||
function handleImagePicked(payload: { url: string; fileName: string; assetId?: number | null }): void {
|
||||
const assetId = payload.assetId ?? null;
|
||||
if (imagePickerAction.value === "replace" && selectedImage.value.open) {
|
||||
updateSelectedImageAttributes({
|
||||
src: payload.url,
|
||||
assetId: assetId ?? 0,
|
||||
ratio: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
} else {
|
||||
insertImageBlockAtCursor({
|
||||
src: payload.url,
|
||||
assetId,
|
||||
});
|
||||
}
|
||||
|
||||
imageUploading.value = true;
|
||||
try {
|
||||
const src = (await props.uploadImage(file)).trim();
|
||||
if (!src) {
|
||||
throw new Error("article_image_url_unavailable");
|
||||
}
|
||||
|
||||
if (action === "replace" && selectedImage.value.open) {
|
||||
updateSelectedImageAttributes({
|
||||
src,
|
||||
ratio: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
insertImageBlockAtCursor(src);
|
||||
} catch (error) {
|
||||
message.error(formatError(error));
|
||||
} finally {
|
||||
imageUploading.value = false;
|
||||
}
|
||||
imagePickerAction.value = "insert";
|
||||
imagePickerCurrentUrl.value = "";
|
||||
imagePickerCurrentAssetId.value = null;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
@@ -1119,6 +1127,7 @@ function resolveSelectedImageSnapshot(targetSelection?: Selection | null): Selec
|
||||
nodePos: selection.from,
|
||||
nodeSize: selection.node.nodeSize,
|
||||
src: String(selection.node.attrs.src ?? ""),
|
||||
assetId: resolveImageAssetId(selection.node.attrs.assetId),
|
||||
align: resolveImageAlign(selection.node.attrs.align),
|
||||
};
|
||||
});
|
||||
@@ -1152,6 +1161,7 @@ function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): voi
|
||||
nodePos: snapshot.nodePos,
|
||||
nodeSize: snapshot.nodeSize,
|
||||
src: snapshot.src,
|
||||
assetId: snapshot.assetId,
|
||||
align: snapshot.align,
|
||||
};
|
||||
attachSelectedImageDomSync();
|
||||
@@ -1396,6 +1406,7 @@ function resolveImageContextFromTarget(target: EventTarget | null): SelectedImag
|
||||
nodePos,
|
||||
nodeSize: node.nodeSize,
|
||||
src: String(node.attrs.src ?? ""),
|
||||
assetId: resolveImageAssetId(node.attrs.assetId),
|
||||
align: resolveImageAlign(node.attrs.align),
|
||||
};
|
||||
});
|
||||
@@ -1456,7 +1467,7 @@ function handleEditorDrop(): void {
|
||||
}
|
||||
|
||||
function updateSelectedImageAttributes(
|
||||
attrs: Partial<Record<"src" | "ratio" | "width" | "height" | "align", string | number>>,
|
||||
attrs: Partial<Record<"src" | "assetId" | "ratio" | "width" | "height" | "align", string | number>>,
|
||||
): void {
|
||||
if (!selectedImage.value.open) {
|
||||
return;
|
||||
@@ -1478,7 +1489,7 @@ function updateSelectedImageAttributes(
|
||||
}
|
||||
|
||||
function updateSelectedImageAttribute(
|
||||
attr: "src" | "ratio" | "width" | "height" | "align",
|
||||
attr: "src" | "assetId" | "ratio" | "width" | "height" | "align",
|
||||
value: string | number,
|
||||
): void {
|
||||
updateSelectedImageAttributes({ [attr]: value });
|
||||
@@ -1519,24 +1530,10 @@ function replaceSelectedImage(): void {
|
||||
}
|
||||
|
||||
closeImageContextMenu();
|
||||
|
||||
if (props.uploadImage) {
|
||||
imageInputAction.value = "replace";
|
||||
imageInputRef.value?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const src = window.prompt(t("article.editor.imagePrompt"), selectedImage.value.src);
|
||||
if (!src?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSelectedImageAttributes({
|
||||
src: src.trim(),
|
||||
ratio: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
imagePickerAction.value = "replace";
|
||||
imagePickerCurrentUrl.value = selectedImage.value.src;
|
||||
imagePickerCurrentAssetId.value = selectedImage.value.assetId;
|
||||
imagePickerOpen.value = true;
|
||||
}
|
||||
|
||||
function runImageContextAction(action: ImageContextMenuAction): void {
|
||||
@@ -1853,18 +1850,9 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
||||
</div>
|
||||
|
||||
<span class="article-editor-canvas__divider"></span>
|
||||
<input
|
||||
ref="imageInputRef"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
hidden
|
||||
@change="handleImageFileChange"
|
||||
/>
|
||||
<a-button
|
||||
size="small"
|
||||
type="text"
|
||||
:loading="imageUploading"
|
||||
:disabled="imageUploading"
|
||||
@mousedown.prevent
|
||||
@click="insertImage"
|
||||
>
|
||||
@@ -1958,6 +1946,19 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
||||
<MarkdownPreview :content="aiOptimizePreview" />
|
||||
</template>
|
||||
</EditorAiAssistPanel>
|
||||
|
||||
<CoverPickerModal
|
||||
v-model:open="imagePickerOpen"
|
||||
:article-id="articleId"
|
||||
:platform-ids="[]"
|
||||
:current-url="imagePickerCurrentUrl"
|
||||
:current-asset-id="imagePickerCurrentAssetId"
|
||||
:title="t('article.editor.imagePicker.title')"
|
||||
:empty-upload-title="t('article.editor.imagePicker.emptyTitle')"
|
||||
:empty-upload-hint="t('article.editor.imagePicker.emptyHint')"
|
||||
:upload-handler="uploadBodyImageToLibrary"
|
||||
@confirmed="handleImagePicked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user