feat: add image upload functionality for articles
- Implemented image upload API in the article service, allowing users to upload images associated with articles. - Added validation for image size and type, ensuring only supported formats (PNG, JPG, GIF, WebP) are accepted. - Created a new Aliyun object storage client to handle image storage and retrieval. - Updated the article handler to include an endpoint for image uploads. - Enhanced error handling for image upload failures and added relevant error messages. - Introduced public URL generation for stored images, allowing access via signed tokens. - Updated localization files to include new messages related to image upload functionality. - Added tests for image upload and retrieval processes.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
UndoOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
|
||||
import {
|
||||
insertHrCommand,
|
||||
@@ -30,13 +31,16 @@ import { Crepe, CrepeFeature } from "@milkdown/crepe";
|
||||
import "@milkdown/crepe/theme/common/style.css";
|
||||
import "@milkdown/crepe/theme/frame.css";
|
||||
import { Milkdown, useEditor, useInstance } from "@milkdown/vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { formatError } from "@/lib/errors";
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
modelValue: string;
|
||||
disabled?: boolean;
|
||||
uploadImage?: (file: File) => Promise<string>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -47,6 +51,17 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n();
|
||||
const crepeRef = ref<Crepe | null>(null);
|
||||
const syncingExternalMarkdown = ref(false);
|
||||
const tablePickerRef = ref<HTMLElement | null>(null);
|
||||
const tableGridRef = ref<HTMLElement | null>(null);
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const TABLE_PICKER_CELL_SIZE = 22;
|
||||
const TABLE_PICKER_GAP = 6;
|
||||
const TABLE_PICKER_INITIAL_ROWS = 4;
|
||||
const TABLE_PICKER_INITIAL_COLS = 10;
|
||||
const TABLE_PICKER_MAX_ROWS = 15;
|
||||
const TABLE_PICKER_MAX_COLS = 15;
|
||||
const TABLE_PICKER_EXPAND_STEP = 2;
|
||||
|
||||
const { loading } = useEditor((root) => {
|
||||
const crepe = new Crepe({
|
||||
@@ -78,6 +93,37 @@ const { loading } = useEditor((root) => {
|
||||
|
||||
const [instanceLoading, getEditor] = useInstance();
|
||||
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
|
||||
const tablePickerOpen = ref(false);
|
||||
const tablePickerRows = ref(TABLE_PICKER_INITIAL_ROWS);
|
||||
const tablePickerCols = ref(TABLE_PICKER_INITIAL_COLS);
|
||||
const tablePreviewRow = ref(0);
|
||||
const tablePreviewCol = ref(0);
|
||||
const tablePointerActive = ref(false);
|
||||
const imageUploading = ref(false);
|
||||
|
||||
const tablePickerGridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${tablePickerCols.value}, ${TABLE_PICKER_CELL_SIZE}px)`,
|
||||
}));
|
||||
|
||||
const tablePickerCells = computed(() => {
|
||||
const cells: Array<{ key: string; row: number; col: number }> = [];
|
||||
|
||||
for (let row = 1; row <= tablePickerRows.value; row += 1) {
|
||||
for (let col = 1; col <= tablePickerCols.value; col += 1) {
|
||||
cells.push({ key: `${row}-${col}`, row, col });
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
});
|
||||
|
||||
const tableSelectionLabel = computed(() => {
|
||||
if (!tablePreviewRow.value || !tablePreviewCol.value) {
|
||||
return t("article.editor.tablePicker.empty");
|
||||
}
|
||||
|
||||
return `${tablePreviewRow.value} × ${tablePreviewCol.value}`;
|
||||
});
|
||||
|
||||
function syncMarkdownFromProps(nextValue: string): void {
|
||||
const editor = getEditor();
|
||||
@@ -102,6 +148,9 @@ watch(
|
||||
() => props.disabled,
|
||||
(value) => {
|
||||
crepeRef.value?.setReadonly(!!value);
|
||||
if (value) {
|
||||
closeTablePicker();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -119,9 +168,18 @@ watch(
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("pointerdown", handleWindowPointerDown);
|
||||
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||
crepeRef.value = null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("pointerdown", handleWindowPointerDown);
|
||||
window.addEventListener("pointermove", handleWindowPointerMove);
|
||||
window.addEventListener("pointerup", handleWindowPointerUp);
|
||||
});
|
||||
|
||||
function runCommand(command: { key: unknown }, payload?: unknown): void {
|
||||
if (editorDisabled.value) {
|
||||
return;
|
||||
@@ -140,6 +198,11 @@ function insertImage(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.uploadImage) {
|
||||
imageInputRef.value?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const src = window.prompt(t("article.editor.imagePrompt"));
|
||||
if (!src?.trim()) {
|
||||
return;
|
||||
@@ -148,8 +211,167 @@ function insertImage(): void {
|
||||
runCommand(insertImageCommand, { src: src.trim() });
|
||||
}
|
||||
|
||||
function insertTable(): void {
|
||||
runCommand(insertTableCommand, { row: 3, col: 3 });
|
||||
async function handleImageFileChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
|
||||
if (!file || !props.uploadImage || editorDisabled.value || imageUploading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
imageUploading.value = true;
|
||||
try {
|
||||
const src = (await props.uploadImage(file)).trim();
|
||||
if (!src) {
|
||||
throw new Error("article_image_url_unavailable");
|
||||
}
|
||||
|
||||
runCommand(insertImageCommand, {
|
||||
src,
|
||||
alt: resolveImageAltText(file.name),
|
||||
});
|
||||
} catch (error) {
|
||||
message.error(formatError(error));
|
||||
} finally {
|
||||
imageUploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImageAltText(fileName: string): string {
|
||||
return fileName.replace(/\.[^.]+$/, "").trim() || "image";
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function resetTablePicker(): void {
|
||||
tablePickerRows.value = TABLE_PICKER_INITIAL_ROWS;
|
||||
tablePickerCols.value = TABLE_PICKER_INITIAL_COLS;
|
||||
tablePreviewRow.value = 0;
|
||||
tablePreviewCol.value = 0;
|
||||
tablePointerActive.value = false;
|
||||
}
|
||||
|
||||
function closeTablePicker(): void {
|
||||
tablePickerOpen.value = false;
|
||||
resetTablePicker();
|
||||
}
|
||||
|
||||
function ensureTablePickerCapacity(row: number, col: number): void {
|
||||
while (row > tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) {
|
||||
tablePickerRows.value = Math.min(TABLE_PICKER_MAX_ROWS, tablePickerRows.value + TABLE_PICKER_EXPAND_STEP);
|
||||
}
|
||||
|
||||
while (col > tablePickerCols.value && tablePickerCols.value < TABLE_PICKER_MAX_COLS) {
|
||||
tablePickerCols.value = Math.min(TABLE_PICKER_MAX_COLS, tablePickerCols.value + TABLE_PICKER_EXPAND_STEP);
|
||||
}
|
||||
|
||||
if (row === tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) {
|
||||
tablePickerRows.value = Math.min(TABLE_PICKER_MAX_ROWS, tablePickerRows.value + TABLE_PICKER_EXPAND_STEP);
|
||||
}
|
||||
|
||||
if (col === tablePickerCols.value && tablePickerCols.value < TABLE_PICKER_MAX_COLS) {
|
||||
tablePickerCols.value = Math.min(TABLE_PICKER_MAX_COLS, tablePickerCols.value + TABLE_PICKER_EXPAND_STEP);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTablePreview(row: number, col: number): void {
|
||||
const nextRow = clamp(row, 1, TABLE_PICKER_MAX_ROWS);
|
||||
const nextCol = clamp(col, 1, TABLE_PICKER_MAX_COLS);
|
||||
|
||||
ensureTablePickerCapacity(nextRow, nextCol);
|
||||
|
||||
tablePreviewRow.value = nextRow;
|
||||
tablePreviewCol.value = nextCol;
|
||||
}
|
||||
|
||||
function resolveTableSizeFromPointer(clientX: number, clientY: number): { row: number; col: number } | null {
|
||||
const grid = tableGridRef.value;
|
||||
if (!grid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = grid.getBoundingClientRect();
|
||||
const stepX = TABLE_PICKER_CELL_SIZE + TABLE_PICKER_GAP;
|
||||
const stepY = TABLE_PICKER_CELL_SIZE + TABLE_PICKER_GAP;
|
||||
const relativeX = clientX - rect.left;
|
||||
const relativeY = clientY - rect.top;
|
||||
|
||||
return {
|
||||
row: clamp(Math.ceil((relativeY + TABLE_PICKER_GAP) / stepY), 1, TABLE_PICKER_MAX_ROWS),
|
||||
col: clamp(Math.ceil((relativeX + TABLE_PICKER_GAP) / stepX), 1, TABLE_PICKER_MAX_COLS),
|
||||
};
|
||||
}
|
||||
|
||||
function insertTableFromSelection(row: number, col: number): void {
|
||||
runCommand(insertTableCommand, { row, col });
|
||||
closeTablePicker();
|
||||
}
|
||||
|
||||
function toggleTablePicker(): void {
|
||||
if (editorDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tablePickerOpen.value) {
|
||||
closeTablePicker();
|
||||
return;
|
||||
}
|
||||
|
||||
resetTablePicker();
|
||||
tablePickerOpen.value = true;
|
||||
}
|
||||
|
||||
function beginTableSelection(row: number, col: number): void {
|
||||
if (editorDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
tablePointerActive.value = true;
|
||||
updateTablePreview(row, col);
|
||||
}
|
||||
|
||||
function handleWindowPointerDown(event: PointerEvent): void {
|
||||
if (!tablePickerOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (target instanceof Node && tablePickerRef.value?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeTablePicker();
|
||||
}
|
||||
|
||||
function handleWindowPointerMove(event: PointerEvent): void {
|
||||
if (!tablePickerOpen.value || !tablePointerActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSize = resolveTableSizeFromPointer(event.clientX, event.clientY);
|
||||
if (!nextSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateTablePreview(nextSize.row, nextSize.col);
|
||||
}
|
||||
|
||||
function handleWindowPointerUp(): void {
|
||||
if (!tablePointerActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
tablePointerActive.value = false;
|
||||
|
||||
if (!tablePreviewRow.value || !tablePreviewCol.value) {
|
||||
closeTablePicker();
|
||||
return;
|
||||
}
|
||||
|
||||
insertTableFromSelection(tablePreviewRow.value, tablePreviewCol.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -190,12 +412,58 @@ function insertTable(): void {
|
||||
<a-button size="small" type="text" @click="runCommand(wrapInOrderedListCommand)">
|
||||
<template #icon><OrderedListOutlined /></template>
|
||||
</a-button>
|
||||
|
||||
<a-button size="small" type="text" @click="insertTable">
|
||||
<template #icon><TableOutlined /></template>
|
||||
</a-button>
|
||||
|
||||
<div ref="tablePickerRef" class="article-editor-canvas__table-trigger">
|
||||
<a-button
|
||||
size="small"
|
||||
type="text"
|
||||
class="article-editor-canvas__toolbar-button"
|
||||
:class="{ 'article-editor-canvas__toolbar-button--active': tablePickerOpen }"
|
||||
@click="toggleTablePicker"
|
||||
>
|
||||
<template #icon><TableOutlined /></template>
|
||||
</a-button>
|
||||
|
||||
<div v-if="tablePickerOpen" class="article-editor-canvas__table-panel" @pointerdown.stop>
|
||||
<div class="article-editor-canvas__table-panel-status">{{ tableSelectionLabel }}</div>
|
||||
<div
|
||||
ref="tableGridRef"
|
||||
class="article-editor-canvas__table-grid"
|
||||
:style="tablePickerGridStyle"
|
||||
>
|
||||
<button
|
||||
v-for="cell in tablePickerCells"
|
||||
:key="cell.key"
|
||||
type="button"
|
||||
class="article-editor-canvas__table-cell"
|
||||
:class="{ 'article-editor-canvas__table-cell--active': cell.row <= tablePreviewRow && cell.col <= tablePreviewCol }"
|
||||
@mouseenter="updateTablePreview(cell.row, cell.col)"
|
||||
@focus="updateTablePreview(cell.row, cell.col)"
|
||||
@pointerdown.prevent.stop="beginTableSelection(cell.row, cell.col)"
|
||||
@keydown.enter.prevent="insertTableFromSelection(cell.row, cell.col)"
|
||||
@keydown.space.prevent="insertTableFromSelection(cell.row, cell.col)"
|
||||
></button>
|
||||
</div>
|
||||
<div class="article-editor-canvas__table-panel-hint">{{ t("article.editor.tablePicker.hint") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="article-editor-canvas__divider"></span>
|
||||
<a-button size="small" type="text" @click="insertImage">
|
||||
<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"
|
||||
>
|
||||
<template #icon><PictureOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -241,6 +509,8 @@ function insertTable(): void {
|
||||
}
|
||||
|
||||
.article-editor-canvas__toolbar {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -258,8 +528,86 @@ function insertTable(): void {
|
||||
background: #e5ebf5;
|
||||
}
|
||||
|
||||
.article-editor-canvas__table-trigger {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.article-editor-canvas__toolbar :deep(.article-editor-canvas__toolbar-button--active) {
|
||||
color: #245bdb;
|
||||
background: rgba(47, 107, 255, 0.1);
|
||||
}
|
||||
|
||||
.article-editor-canvas__table-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 12px);
|
||||
left: -18px;
|
||||
z-index: 24;
|
||||
width: max-content;
|
||||
max-width: min(calc(100vw - 48px), 520px);
|
||||
padding: 18px;
|
||||
border: 1px solid #e3e9f4;
|
||||
border-radius: 20px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 255, 0.98) 100%);
|
||||
box-shadow:
|
||||
0 18px 40px rgba(15, 23, 42, 0.14),
|
||||
0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.article-editor-canvas__table-panel-status {
|
||||
margin-bottom: 14px;
|
||||
color: #101828;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.article-editor-canvas__table-grid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.article-editor-canvas__table-cell {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid #d5dbe7;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
cursor: crosshair;
|
||||
transition:
|
||||
border-color 0.14s ease,
|
||||
background-color 0.14s ease,
|
||||
transform 0.14s ease,
|
||||
box-shadow 0.14s ease;
|
||||
}
|
||||
|
||||
.article-editor-canvas__table-cell:hover,
|
||||
.article-editor-canvas__table-cell--active {
|
||||
border-color: #2f6bff;
|
||||
background: rgba(47, 107, 255, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba(47, 107, 255, 0.08);
|
||||
}
|
||||
|
||||
.article-editor-canvas__table-cell:focus-visible {
|
||||
outline: 2px solid rgba(47, 107, 255, 0.36);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.article-editor-canvas__table-panel-hint {
|
||||
margin-top: 12px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.article-editor-canvas__surface {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -267,7 +615,7 @@ function insertTable(): void {
|
||||
|
||||
.article-editor-canvas__title-row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: 0;
|
||||
padding: 18px 28px 0;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user