feat: enhance image handling and synchronization in ArticleEditorCanvas
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user