diff --git a/apps/admin-web/src/components/ArticleEditorCanvas.vue b/apps/admin-web/src/components/ArticleEditorCanvas.vue index de68d29..2467157 100644 --- a/apps/admin-web/src/components/ArticleEditorCanvas.vue +++ b/apps/admin-web/src/components/ArticleEditorCanvas.vue @@ -74,6 +74,7 @@ const emit = defineEmits<{ const { t } = useI18n(); const crepeRef = ref(null); const syncingExternalMarkdown = ref(false); +const surfaceRef = ref(null); const tablePickerRef = ref(null); const tableGridRef = ref(null); const tableContextMenuRef = ref(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 { -
+
-
- -
-
- {{ t("article.editor.messages.locked") }} -
-
- -
-
+ +
+ +
+
+ {{ t("article.editor.messages.locked") }} +
+