Files
frontend/src/composables/document/editor/rich-text-editor/useRichTextLinkOverlay.js
T
2026-08-11 15:03:02 +08:00

275 lines
6.9 KiB
JavaScript

import { computed, onBeforeUnmount, ref, watch } from 'vue';
const HOVER_HIDE_DELAY = 120;
const normalizeLinkUrl = (value = '') => {
const input = String(value || '').trim();
if (!input) {
return '';
}
if (/^(https?:\/\/|mailto:|tel:)/i.test(input)) {
return input;
}
return `https://${input}`;
};
export function useRichTextLinkOverlay(options = {}) {
const {
editorRef,
scrollContainerRef,
setLink,
unsetLink
} = options;
const linkDialogVisible = ref(false);
const linkDialogValue = ref('');
const linkHoverVisible = ref(false);
const linkHoverStyle = ref({ top: '0px', left: '0px' });
const linkHoverHref = ref('');
const linkHoveringCard = ref(false);
const hoverAnchorPos = ref(null);
const dialogSource = ref({
type: 'selection',
pos: null
});
let mouseHost = null;
let editorDom = null;
let hideTimer = 0;
const enabled = computed(() => Boolean(editorRef.value?.view && scrollContainerRef.value));
const clearHideTimer = () => {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = 0;
}
};
const hideHoverCard = () => {
linkHoverVisible.value = false;
linkHoverHref.value = '';
hoverAnchorPos.value = null;
};
const scheduleHideHoverCard = () => {
clearHideTimer();
hideTimer = window.setTimeout(() => {
hideTimer = 0;
if (linkHoveringCard.value || linkDialogVisible.value) {
return;
}
hideHoverCard();
}, HOVER_HIDE_DELAY);
};
const getEditorDom = () => editorRef.value?.view?.dom || null;
const resolveAnchorPos = (anchorEl) => {
const view = editorRef.value?.view;
const size = editorRef.value?.state?.doc?.content?.size;
if (!view || !anchorEl || !Number.isFinite(size)) {
return null;
}
try {
const pos = view.posAtDOM(anchorEl, 0);
const safePos = Math.max(1, Math.min(pos + 1, size));
return safePos;
} catch (_) {
return null;
}
};
const updateHoverCardFromAnchor = (anchorEl) => {
const container = scrollContainerRef.value;
if (!anchorEl || !container) {
hideHoverCard();
return;
}
const href = String(anchorEl.getAttribute('href') || '').trim();
if (!href) {
hideHoverCard();
return;
}
const rect = anchorEl.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const cardWidth = 280;
const cardHeight = 108;
const rawLeft = rect.left - containerRect.left + container.scrollLeft;
const rawTop = rect.bottom - containerRect.top + container.scrollTop + 8;
const minLeft = container.scrollLeft + 8;
const maxLeft = container.scrollLeft + container.clientWidth - cardWidth - 8;
const minTop = container.scrollTop + 8;
const maxTop = container.scrollTop + container.clientHeight - cardHeight - 8;
linkHoverStyle.value = {
left: `${Math.max(minLeft, Math.min(rawLeft, maxLeft))}px`,
top: `${Math.max(minTop, Math.min(rawTop, maxTop))}px`
};
linkHoverHref.value = href;
hoverAnchorPos.value = resolveAnchorPos(anchorEl);
linkHoverVisible.value = true;
};
const handleMouseMove = (event) => {
if (linkDialogVisible.value) {
return;
}
clearHideTimer();
const root = getEditorDom();
const target = event?.target;
const source = target?.nodeType === Node.TEXT_NODE ? target.parentElement : target;
if (!(source instanceof Element) || !root?.contains(source)) {
if (!linkHoveringCard.value) {
scheduleHideHoverCard();
}
return;
}
const anchor = source.closest('a[href]');
if (!anchor || !root.contains(anchor)) {
if (!linkHoveringCard.value) {
scheduleHideHoverCard();
}
return;
}
updateHoverCardFromAnchor(anchor);
};
const handleMouseLeave = () => {
if (linkHoveringCard.value || linkDialogVisible.value) {
return;
}
scheduleHideHoverCard();
};
const bindEvents = () => {
editorDom = getEditorDom();
mouseHost = scrollContainerRef.value;
if (!editorDom || !mouseHost) {
return;
}
mouseHost.addEventListener('mousemove', handleMouseMove);
mouseHost.addEventListener('mouseleave', handleMouseLeave);
if (editorDom !== mouseHost) {
editorDom.addEventListener('mousemove', handleMouseMove);
editorDom.addEventListener('mouseleave', handleMouseLeave);
}
};
const unbindEvents = () => {
clearHideTimer();
if (mouseHost) {
mouseHost.removeEventListener('mousemove', handleMouseMove);
mouseHost.removeEventListener('mouseleave', handleMouseLeave);
mouseHost = null;
}
if (editorDom) {
editorDom.removeEventListener('mousemove', handleMouseMove);
editorDom.removeEventListener('mouseleave', handleMouseLeave);
editorDom = null;
}
hideHoverCard();
};
const openSelectionLinkDialog = () => {
const currentHref = String(editorRef.value?.getAttributes?.('link')?.href || '').trim();
linkDialogValue.value = currentHref;
dialogSource.value = { type: 'selection', pos: null };
linkDialogVisible.value = true;
};
const openHoverLinkDialog = () => {
linkDialogValue.value = linkHoverHref.value;
dialogSource.value = { type: 'hover', pos: hoverAnchorPos.value };
linkDialogVisible.value = true;
};
const closeLinkDialog = () => {
linkDialogVisible.value = false;
};
const applyLinkDialog = () => {
const source = dialogSource.value;
const normalized = normalizeLinkUrl(linkDialogValue.value);
if (!normalized) {
unsetLink?.({ pos: source.pos });
linkDialogVisible.value = false;
if (source.type === 'hover') {
hideHoverCard();
}
return;
}
setLink?.(normalized, { pos: source.pos });
linkDialogVisible.value = false;
if (source.type === 'hover') {
linkHoverHref.value = normalized;
}
};
const removeHoverLink = () => {
unsetLink?.({ pos: hoverAnchorPos.value });
hideHoverCard();
};
const handleLinkCardEnter = () => {
clearHideTimer();
linkHoveringCard.value = true;
};
const handleLinkCardLeave = () => {
linkHoveringCard.value = false;
if (!linkDialogVisible.value) {
scheduleHideHoverCard();
}
};
watch(
enabled,
(next) => {
unbindEvents();
if (next) {
bindEvents();
}
},
{ immediate: true }
);
watch(
() => editorRef.value,
() => {
if (!enabled.value) {
return;
}
unbindEvents();
bindEvents();
}
);
watch(linkDialogVisible, (next) => {
if (!next && !linkHoveringCard.value) {
scheduleHideHoverCard();
}
});
onBeforeUnmount(() => {
unbindEvents();
});
return {
linkDialogVisible,
linkDialogValue,
linkHoverVisible,
linkHoverStyle,
linkHoverHref,
openSelectionLinkDialog,
openHoverLinkDialog,
closeLinkDialog,
applyLinkDialog,
removeHoverLink,
handleLinkCardEnter,
handleLinkCardLeave
};
}