feat: enhance image handling and synchronization in ArticleEditorCanvas

This commit is contained in:
2026-04-05 23:01:30 +08:00
parent 0e2ad4ed16
commit 1ab405c451
2 changed files with 163 additions and 26 deletions
@@ -74,6 +74,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const crepeRef = ref<Crepe | null>(null);
const syncingExternalMarkdown = ref(false);
const surfaceRef = ref<HTMLElement | null>(null);
const tablePickerRef = ref<HTMLElement | null>(null);
const tableGridRef = ref<HTMLElement | null>(null);
const tableContextMenuRef = ref<HTMLElement | null>(null);
@@ -240,6 +241,9 @@ let selectedImageHostElement: HTMLElement | null = null;
let selectedImageWrapperElement: HTMLElement | null = null;
let selectedImageElement: HTMLImageElement | null = null;
let selectedImageSyncFrame = 0;
let imageDragActive = false;
let selectedImageResizeObserver: ResizeObserver | null = null;
let selectedImageLoadHandler: (() => void) | null = null;
const tablePickerGridStyle = computed(() => ({
gridTemplateColumns: `repeat(${tablePickerCols.value}, ${TABLE_PICKER_CELL_SIZE}px)`,
@@ -336,6 +340,7 @@ onBeforeUnmount(() => {
cancelAnimationFrame(selectedImageSyncFrame);
selectedImageSyncFrame = 0;
}
detachSelectedImageDomSync();
window.removeEventListener("pointerdown", handleWindowPointerDown);
window.removeEventListener("pointermove", handleWindowPointerMove);
window.removeEventListener("pointerup", handleWindowPointerUp);
@@ -470,7 +475,67 @@ function closeTableContextMenu(): void {
tableContextMenu.value = createDefaultTableContextMenuState();
}
function resolveSurfaceRelativeRect(rect: DOMRect): { x: number; y: number; width: number; height: number } | null {
const surface = surfaceRef.value;
if (!surface) {
return null;
}
const surfaceRect = surface.getBoundingClientRect();
return {
x: rect.left - surfaceRect.left + surface.scrollLeft,
y: rect.top - surfaceRect.top + surface.scrollTop,
width: rect.width,
height: rect.height,
};
}
function detachSelectedImageDomSync(): void {
selectedImageResizeObserver?.disconnect();
selectedImageResizeObserver = null;
if (selectedImageElement && selectedImageLoadHandler) {
selectedImageElement.removeEventListener("load", selectedImageLoadHandler);
}
selectedImageLoadHandler = null;
}
function queueSelectedImageBoundsSync(): void {
requestAnimationFrame(() => {
syncSelectedImageBoundsFromDom();
requestAnimationFrame(() => {
syncSelectedImageBoundsFromDom();
adjustImageContextMenuPosition();
});
});
}
function attachSelectedImageDomSync(): void {
detachSelectedImageDomSync();
if (!selectedImageElement) {
return;
}
selectedImageLoadHandler = () => {
queueSelectedImageBoundsSync();
};
selectedImageElement.addEventListener("load", selectedImageLoadHandler);
if (typeof ResizeObserver !== "undefined") {
selectedImageResizeObserver = new ResizeObserver(() => {
syncSelectedImageBoundsFromDom();
adjustImageContextMenuPosition();
});
selectedImageResizeObserver.observe(selectedImageElement);
}
queueSelectedImageBoundsSync();
}
function clearSelectedImageState(): void {
detachSelectedImageDomSync();
selectedImage.value = createDefaultSelectedImageState();
selectedImageHostElement = null;
selectedImageWrapperElement = null;
@@ -512,7 +577,7 @@ function resolveSelectedImageSnapshot(targetSelection?: Selection | null): Selec
return;
}
const rect = wrapper.getBoundingClientRect();
const rect = image.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
@@ -541,16 +606,24 @@ function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): voi
selectedImageHostElement = snapshot.host;
selectedImageWrapperElement = snapshot.wrapper;
selectedImageElement = snapshot.image;
const surfaceRect = resolveSurfaceRelativeRect(snapshot.rect);
if (!surfaceRect) {
clearSelectedImageState();
closeImageContextMenu();
return;
}
selectedImage.value = {
open: true,
x: snapshot.rect.left,
y: snapshot.rect.top,
width: snapshot.rect.width,
height: snapshot.rect.height,
x: surfaceRect.x,
y: surfaceRect.y,
width: surfaceRect.width,
height: surfaceRect.height,
nodePos: snapshot.nodePos,
nodeSize: snapshot.nodeSize,
src: snapshot.src,
};
attachSelectedImageDomSync();
if (imageContextMenu.value.open && imageContextMenu.value.nodePos !== snapshot.nodePos) {
closeImageContextMenu();
@@ -558,31 +631,41 @@ function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): voi
}
function syncSelectedImageBoundsFromDom(): void {
if (!selectedImage.value.open || !selectedImageWrapperElement) {
const frameElement = selectedImageElement ?? selectedImageWrapperElement;
if (!selectedImage.value.open || !frameElement) {
return;
}
if (!document.body.contains(selectedImageWrapperElement)) {
if (!document.body.contains(frameElement)) {
clearSelectedImageState();
closeImageContextMenu();
return;
}
const rect = selectedImageWrapperElement.getBoundingClientRect();
const rect = frameElement.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
const surfaceRect = resolveSurfaceRelativeRect(rect);
if (!surfaceRect) {
return;
}
selectedImage.value = {
...selectedImage.value,
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
x: surfaceRect.x,
y: surfaceRect.y,
width: surfaceRect.width,
height: surfaceRect.height,
};
}
function scheduleSelectedImageSync(selection?: Selection | null): void {
if (imageDragActive) {
return;
}
if (selectedImageSyncFrame) {
cancelAnimationFrame(selectedImageSyncFrame);
}
@@ -593,6 +676,14 @@ function scheduleSelectedImageSync(selection?: Selection | null): void {
});
}
function queueSelectedImageSync(): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scheduleSelectedImageSync();
});
});
}
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);
@@ -871,7 +962,7 @@ function resolveImageContextFromTarget(target: EventTarget | null): SelectedImag
host,
wrapper,
image,
rect: wrapper.getBoundingClientRect(),
rect: image.getBoundingClientRect(),
nodePos,
nodeSize: node.nodeSize,
src: String(node.attrs.src ?? ""),
@@ -905,6 +996,38 @@ function openImageContextMenu(event: MouseEvent): void {
});
}
function handleEditorDragStart(event: DragEvent): void {
const target = event.target;
if (!(target instanceof HTMLElement) || !target.closest(".milkdown-image-block")) {
return;
}
imageDragActive = true;
if (selectedImageSyncFrame) {
cancelAnimationFrame(selectedImageSyncFrame);
selectedImageSyncFrame = 0;
}
closeImageContextMenu();
clearSelectedImageState();
}
function finishEditorImageDrag(): void {
if (!imageDragActive) {
return;
}
imageDragActive = false;
queueSelectedImageSync();
}
function handleEditorDragEnd(): void {
finishEditorImageDrag();
}
function handleEditorDrop(): void {
finishEditorImageDrag();
}
function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | number): void {
if (!selectedImage.value.open) {
return;
@@ -1269,7 +1392,15 @@ function runTableContextAction(action: TableContextMenuAction): void {
</a-button>
</div>
<div class="article-editor-canvas__surface" @contextmenu.capture="handleEditorContextMenu" @scroll="handleSurfaceScroll">
<div
ref="surfaceRef"
class="article-editor-canvas__surface"
@contextmenu.capture="handleEditorContextMenu"
@dragstart.capture="handleEditorDragStart"
@dragend.capture="handleEditorDragEnd"
@drop.capture="handleEditorDrop"
@scroll="handleSurfaceScroll"
>
<div class="article-editor-canvas__title-row">
<a-input
:value="title"
@@ -1284,15 +1415,6 @@ function runTableContextAction(action: TableContextMenuAction): void {
<Milkdown />
<div v-if="loading" class="article-editor-canvas__loading">
<a-skeleton active :paragraph="{ rows: 10 }" />
</div>
<div v-if="disabled" class="article-editor-canvas__mask">
{{ t("article.editor.messages.locked") }}
</div>
</div>
<Teleport to="body">
<div
v-if="selectedImage.open"
class="article-editor-canvas__image-selection"
@@ -1319,7 +1441,14 @@ function runTableContextAction(action: TableContextMenuAction): void {
@pointerdown="beginSelectedImageResize($event, 'se')"
></button>
</div>
</Teleport>
<div v-if="loading" class="article-editor-canvas__loading">
<a-skeleton active :paragraph="{ rows: 10 }" />
</div>
<div v-if="disabled" class="article-editor-canvas__mask">
{{ t("article.editor.messages.locked") }}
</div>
</div>
<Teleport to="body">
<div
@@ -1669,8 +1798,8 @@ function runTableContextAction(action: TableContextMenuAction): void {
}
.article-editor-canvas__image-selection {
position: fixed;
z-index: 1100;
position: absolute;
z-index: 1;
box-sizing: border-box;
border: 2px solid #2f6bff;
border-radius: 18px;
+8
View File
@@ -220,6 +220,14 @@ html, body, #app {
}
/* Premium Modal Overrides */
.ant-modal-root .ant-modal-mask {
z-index: 3000 !important;
}
.ant-modal-root .ant-modal-wrap {
z-index: 3001 !important;
}
.ant-modal-content {
border-radius: 16px !important;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08) !important;