feat: enhance image handling and synchronization in ArticleEditorCanvas
This commit is contained in:
@@ -74,6 +74,7 @@ const emit = defineEmits<{
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const crepeRef = ref<Crepe | null>(null);
|
const crepeRef = ref<Crepe | null>(null);
|
||||||
const syncingExternalMarkdown = ref(false);
|
const syncingExternalMarkdown = ref(false);
|
||||||
|
const surfaceRef = ref<HTMLElement | null>(null);
|
||||||
const tablePickerRef = ref<HTMLElement | null>(null);
|
const tablePickerRef = ref<HTMLElement | null>(null);
|
||||||
const tableGridRef = ref<HTMLElement | null>(null);
|
const tableGridRef = ref<HTMLElement | null>(null);
|
||||||
const tableContextMenuRef = 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 selectedImageWrapperElement: HTMLElement | null = null;
|
||||||
let selectedImageElement: HTMLImageElement | null = null;
|
let selectedImageElement: HTMLImageElement | null = null;
|
||||||
let selectedImageSyncFrame = 0;
|
let selectedImageSyncFrame = 0;
|
||||||
|
let imageDragActive = false;
|
||||||
|
let selectedImageResizeObserver: ResizeObserver | null = null;
|
||||||
|
let selectedImageLoadHandler: (() => void) | null = null;
|
||||||
|
|
||||||
const tablePickerGridStyle = computed(() => ({
|
const tablePickerGridStyle = computed(() => ({
|
||||||
gridTemplateColumns: `repeat(${tablePickerCols.value}, ${TABLE_PICKER_CELL_SIZE}px)`,
|
gridTemplateColumns: `repeat(${tablePickerCols.value}, ${TABLE_PICKER_CELL_SIZE}px)`,
|
||||||
@@ -336,6 +340,7 @@ onBeforeUnmount(() => {
|
|||||||
cancelAnimationFrame(selectedImageSyncFrame);
|
cancelAnimationFrame(selectedImageSyncFrame);
|
||||||
selectedImageSyncFrame = 0;
|
selectedImageSyncFrame = 0;
|
||||||
}
|
}
|
||||||
|
detachSelectedImageDomSync();
|
||||||
window.removeEventListener("pointerdown", handleWindowPointerDown);
|
window.removeEventListener("pointerdown", handleWindowPointerDown);
|
||||||
window.removeEventListener("pointermove", handleWindowPointerMove);
|
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||||
window.removeEventListener("pointerup", handleWindowPointerUp);
|
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||||
@@ -470,7 +475,67 @@ function closeTableContextMenu(): void {
|
|||||||
tableContextMenu.value = createDefaultTableContextMenuState();
|
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 {
|
function clearSelectedImageState(): void {
|
||||||
|
detachSelectedImageDomSync();
|
||||||
selectedImage.value = createDefaultSelectedImageState();
|
selectedImage.value = createDefaultSelectedImageState();
|
||||||
selectedImageHostElement = null;
|
selectedImageHostElement = null;
|
||||||
selectedImageWrapperElement = null;
|
selectedImageWrapperElement = null;
|
||||||
@@ -512,7 +577,7 @@ function resolveSelectedImageSnapshot(targetSelection?: Selection | null): Selec
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = wrapper.getBoundingClientRect();
|
const rect = image.getBoundingClientRect();
|
||||||
if (!rect.width || !rect.height) {
|
if (!rect.width || !rect.height) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -541,16 +606,24 @@ function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): voi
|
|||||||
selectedImageHostElement = snapshot.host;
|
selectedImageHostElement = snapshot.host;
|
||||||
selectedImageWrapperElement = snapshot.wrapper;
|
selectedImageWrapperElement = snapshot.wrapper;
|
||||||
selectedImageElement = snapshot.image;
|
selectedImageElement = snapshot.image;
|
||||||
|
const surfaceRect = resolveSurfaceRelativeRect(snapshot.rect);
|
||||||
|
if (!surfaceRect) {
|
||||||
|
clearSelectedImageState();
|
||||||
|
closeImageContextMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
selectedImage.value = {
|
selectedImage.value = {
|
||||||
open: true,
|
open: true,
|
||||||
x: snapshot.rect.left,
|
x: surfaceRect.x,
|
||||||
y: snapshot.rect.top,
|
y: surfaceRect.y,
|
||||||
width: snapshot.rect.width,
|
width: surfaceRect.width,
|
||||||
height: snapshot.rect.height,
|
height: surfaceRect.height,
|
||||||
nodePos: snapshot.nodePos,
|
nodePos: snapshot.nodePos,
|
||||||
nodeSize: snapshot.nodeSize,
|
nodeSize: snapshot.nodeSize,
|
||||||
src: snapshot.src,
|
src: snapshot.src,
|
||||||
};
|
};
|
||||||
|
attachSelectedImageDomSync();
|
||||||
|
|
||||||
if (imageContextMenu.value.open && imageContextMenu.value.nodePos !== snapshot.nodePos) {
|
if (imageContextMenu.value.open && imageContextMenu.value.nodePos !== snapshot.nodePos) {
|
||||||
closeImageContextMenu();
|
closeImageContextMenu();
|
||||||
@@ -558,31 +631,41 @@ function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncSelectedImageBoundsFromDom(): void {
|
function syncSelectedImageBoundsFromDom(): void {
|
||||||
if (!selectedImage.value.open || !selectedImageWrapperElement) {
|
const frameElement = selectedImageElement ?? selectedImageWrapperElement;
|
||||||
|
if (!selectedImage.value.open || !frameElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!document.body.contains(selectedImageWrapperElement)) {
|
if (!document.body.contains(frameElement)) {
|
||||||
clearSelectedImageState();
|
clearSelectedImageState();
|
||||||
closeImageContextMenu();
|
closeImageContextMenu();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = selectedImageWrapperElement.getBoundingClientRect();
|
const rect = frameElement.getBoundingClientRect();
|
||||||
if (!rect.width || !rect.height) {
|
if (!rect.width || !rect.height) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const surfaceRect = resolveSurfaceRelativeRect(rect);
|
||||||
|
if (!surfaceRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
selectedImage.value = {
|
selectedImage.value = {
|
||||||
...selectedImage.value,
|
...selectedImage.value,
|
||||||
x: rect.left,
|
x: surfaceRect.x,
|
||||||
y: rect.top,
|
y: surfaceRect.y,
|
||||||
width: rect.width,
|
width: surfaceRect.width,
|
||||||
height: rect.height,
|
height: surfaceRect.height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleSelectedImageSync(selection?: Selection | null): void {
|
function scheduleSelectedImageSync(selection?: Selection | null): void {
|
||||||
|
if (imageDragActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedImageSyncFrame) {
|
if (selectedImageSyncFrame) {
|
||||||
cancelAnimationFrame(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 {
|
function ensureTablePickerCapacity(row: number, col: number): void {
|
||||||
while (row > tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) {
|
while (row > tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) {
|
||||||
tablePickerRows.value = Math.min(TABLE_PICKER_MAX_ROWS, tablePickerRows.value + TABLE_PICKER_EXPAND_STEP);
|
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,
|
host,
|
||||||
wrapper,
|
wrapper,
|
||||||
image,
|
image,
|
||||||
rect: wrapper.getBoundingClientRect(),
|
rect: image.getBoundingClientRect(),
|
||||||
nodePos,
|
nodePos,
|
||||||
nodeSize: node.nodeSize,
|
nodeSize: node.nodeSize,
|
||||||
src: String(node.attrs.src ?? ""),
|
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 {
|
function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | number): void {
|
||||||
if (!selectedImage.value.open) {
|
if (!selectedImage.value.open) {
|
||||||
return;
|
return;
|
||||||
@@ -1269,7 +1392,15 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
|||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</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">
|
<div class="article-editor-canvas__title-row">
|
||||||
<a-input
|
<a-input
|
||||||
:value="title"
|
:value="title"
|
||||||
@@ -1284,15 +1415,6 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
|||||||
|
|
||||||
<Milkdown />
|
<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
|
<div
|
||||||
v-if="selectedImage.open"
|
v-if="selectedImage.open"
|
||||||
class="article-editor-canvas__image-selection"
|
class="article-editor-canvas__image-selection"
|
||||||
@@ -1319,7 +1441,14 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
|||||||
@pointerdown="beginSelectedImageResize($event, 'se')"
|
@pointerdown="beginSelectedImageResize($event, 'se')"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</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">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
@@ -1669,8 +1798,8 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-canvas__image-selection {
|
.article-editor-canvas__image-selection {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
z-index: 1100;
|
z-index: 1;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 2px solid #2f6bff;
|
border: 2px solid #2f6bff;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
|||||||
@@ -220,6 +220,14 @@ html, body, #app {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Premium Modal Overrides */
|
/* 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 {
|
.ant-modal-content {
|
||||||
border-radius: 16px !important;
|
border-radius: 16px !important;
|
||||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08) !important;
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08) !important;
|
||||||
|
|||||||
Reference in New Issue
Block a user