migrate from github
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
export function useInlineCommentAnchor({
|
||||
commentList,
|
||||
onAnchorClick,
|
||||
onAnchorHover,
|
||||
onAnchorRemove,
|
||||
createTempComment,
|
||||
deleteRemoteComment
|
||||
}) {
|
||||
const handleContentClick = (item) => {
|
||||
if (item?.anchor_id && typeof onAnchorClick === 'function') {
|
||||
onAnchorClick(item.anchor_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHover = (item, hovering) => {
|
||||
if (item?.anchor_id && typeof onAnchorHover === 'function') {
|
||||
onAnchorHover(item.anchor_id, hovering);
|
||||
}
|
||||
};
|
||||
|
||||
const maybeRemoveAnchor = (anchorId, list = commentList.value) => {
|
||||
const id = `${anchorId || ''}`.trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const hasAnyComment = (list || []).some((entry) => `${entry?.anchor_id || ''}`.trim() === id);
|
||||
if (hasAnyComment) {
|
||||
return;
|
||||
}
|
||||
if (typeof onAnchorRemove === 'function') {
|
||||
onAnchorRemove([id]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInlineAnchorAdd = (payload) => {
|
||||
const rawId = typeof payload === 'string' ? payload : payload?.id;
|
||||
const id = `${rawId || ''}`.trim();
|
||||
if (!id) return;
|
||||
if (commentList.value.some((item) => item.anchor_id === id && item.editing)) return;
|
||||
commentList.value.unshift(createTempComment({ parent: null, anchorId: id }));
|
||||
};
|
||||
|
||||
const handleInlineAnchorRemove = async (ids) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
const idSet = new Set(ids);
|
||||
const targets = commentList.value.filter((item) => idSet.has(item.anchor_id));
|
||||
commentList.value = commentList.value.filter((item) => !idSet.has(item.anchor_id));
|
||||
await Promise.all(
|
||||
targets
|
||||
.filter((item) => item.external_id)
|
||||
.map((item) => deleteRemoteComment(item.external_id).catch(() => {}))
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
handleContentClick,
|
||||
handleHover,
|
||||
maybeRemoveAnchor,
|
||||
handleInlineAnchorAdd,
|
||||
handleInlineAnchorRemove
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
listDocumentComments,
|
||||
createDocumentComment,
|
||||
deleteDocumentComment,
|
||||
updateDocumentComment
|
||||
} from '@/services/api';
|
||||
import { resolveFileUrl } from '@/utils/fileUrl';
|
||||
|
||||
export function useInlineCommentCrud({
|
||||
t,
|
||||
getDocId,
|
||||
getInlineEnabled,
|
||||
getUserInfo,
|
||||
commentList,
|
||||
userDisplayName,
|
||||
maybeRemoveAnchor,
|
||||
onCommentMutated
|
||||
}) {
|
||||
const buildLocalId = () => `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const createTempComment = ({ parent, anchorId }) => {
|
||||
const userInfo = getUserInfo?.();
|
||||
return {
|
||||
local_id: buildLocalId(),
|
||||
external_id: '',
|
||||
parent_external_id: parent?.external_id || '',
|
||||
content: '',
|
||||
created_at: '',
|
||||
creator_name: userDisplayName.value,
|
||||
creator_user_external_id: userInfo?.external_id || '',
|
||||
creator_avatar: resolveFileUrl(userInfo?.avatar || ''),
|
||||
anchor_id: `${anchorId || parent?.anchor_id || ''}`.trim(),
|
||||
editing: true,
|
||||
draft: '',
|
||||
saving: false
|
||||
};
|
||||
};
|
||||
|
||||
const canModify = (item) => {
|
||||
if (!item) return false;
|
||||
if (!item.external_id) return true;
|
||||
const userInfo = getUserInfo?.();
|
||||
const currentExternalId = userInfo?.external_id;
|
||||
if (!currentExternalId) return false;
|
||||
if (item.creator_user_external_id === currentExternalId) {
|
||||
return true;
|
||||
}
|
||||
return userInfo?.username === 'admin';
|
||||
};
|
||||
|
||||
const getActions = (item) => {
|
||||
const editable = canModify(item);
|
||||
return [
|
||||
{ key: 'copy', label: t('common.copy') },
|
||||
{ key: 'edit', label: t('common.edit'), disabled: !editable },
|
||||
{ key: 'delete', label: t('document.commentDelete'), danger: true, disabled: !editable }
|
||||
];
|
||||
};
|
||||
|
||||
const insertAfter = (list, predicate, item) => {
|
||||
const index = list.findIndex(predicate);
|
||||
if (index === -1) {
|
||||
list.unshift(item);
|
||||
return;
|
||||
}
|
||||
list.splice(index + 1, 0, item);
|
||||
};
|
||||
|
||||
const handleReply = (item) => {
|
||||
const temp = createTempComment({ parent: item });
|
||||
insertAfter(commentList.value, (entry) => entry.external_id === item.external_id, temp);
|
||||
};
|
||||
|
||||
const startEdit = (item) => {
|
||||
if (!item || item.editing) return;
|
||||
item.draft = item.content || '';
|
||||
item.editing = true;
|
||||
};
|
||||
|
||||
const cancelEdit = (item) => {
|
||||
if (!item) return;
|
||||
if (!item.external_id) {
|
||||
const nextList = commentList.value.filter((entry) => entry.local_id !== item.local_id);
|
||||
commentList.value = nextList;
|
||||
maybeRemoveAnchor(item.anchor_id, nextList);
|
||||
return;
|
||||
}
|
||||
item.editing = false;
|
||||
item.draft = '';
|
||||
};
|
||||
|
||||
const saveEdit = async (item) => {
|
||||
if (!item) return;
|
||||
const content = (item.draft || '').trim();
|
||||
if (!content) {
|
||||
ElMessage.error(t('document.inlineCommentContentRequired'));
|
||||
return;
|
||||
}
|
||||
if (!item.anchor_id) {
|
||||
ElMessage.error(t('document.inlineCommentAnchorMissing'));
|
||||
return;
|
||||
}
|
||||
if (item.saving) return;
|
||||
|
||||
const docId = getDocId?.();
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.saving = true;
|
||||
try {
|
||||
if (item.external_id) {
|
||||
const resp = await updateDocumentComment({
|
||||
external_id: item.external_id,
|
||||
content
|
||||
});
|
||||
const saved = resp.comment;
|
||||
item.content = saved?.content || content;
|
||||
item.created_at = saved?.created_at || item.created_at;
|
||||
item.creator_name = saved?.creator_name || item.creator_name;
|
||||
item.creator_avatar = saved?.creator_avatar || item.creator_avatar;
|
||||
item.creator_user_external_id = saved?.creator_user_external_id || item.creator_user_external_id;
|
||||
item.editing = false;
|
||||
item.draft = '';
|
||||
if (typeof onCommentMutated === 'function') {
|
||||
onCommentMutated({ type: 'update', comment_id: item.external_id, anchor_id: item.anchor_id });
|
||||
}
|
||||
} else {
|
||||
const resp = await createDocumentComment({
|
||||
document_external_id: docId,
|
||||
content,
|
||||
parent_external_id: item.parent_external_id || undefined,
|
||||
anchor_id: item.anchor_id,
|
||||
mentioned_user_external_ids: item.mentionedUserExternalIds || []
|
||||
});
|
||||
const saved = resp.comment;
|
||||
item.content = saved?.content || content;
|
||||
item.external_id = saved?.external_id || item.external_id;
|
||||
item.local_id = item.external_id || item.local_id;
|
||||
item.created_at = saved?.created_at || new Date().toISOString();
|
||||
item.creator_name = saved?.creator_name || item.creator_name;
|
||||
item.creator_avatar = saved?.creator_avatar || item.creator_avatar;
|
||||
item.creator_user_external_id = saved?.creator_user_external_id || item.creator_user_external_id;
|
||||
item.anchor_id = saved?.anchor_id || item.anchor_id;
|
||||
item.editing = false;
|
||||
item.draft = '';
|
||||
item.mentionedUserExternalIds = [];
|
||||
if (typeof onCommentMutated === 'function') {
|
||||
onCommentMutated({ type: 'create', comment_id: item.external_id, anchor_id: item.anchor_id });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(t('common.requestFailed'));
|
||||
} finally {
|
||||
item.saving = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCommentItem = async (item) => {
|
||||
if (!item) return;
|
||||
const deletedAnchorId = item.anchor_id;
|
||||
|
||||
if (item.external_id) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('document.commentDeleteConfirm'),
|
||||
t('document.commentDelete'),
|
||||
{
|
||||
confirmButtonText: t('button.confirm'),
|
||||
cancelButtonText: t('button.cancel'),
|
||||
type: 'warning'
|
||||
}
|
||||
);
|
||||
await deleteDocumentComment(item.external_id);
|
||||
if (typeof onCommentMutated === 'function') {
|
||||
onCommentMutated({ type: 'delete', comment_id: item.external_id, anchor_id: deletedAnchorId });
|
||||
}
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextList = commentList.value.filter((entry) => entry.local_id !== item.local_id);
|
||||
commentList.value = nextList;
|
||||
maybeRemoveAnchor(item.anchor_id, nextList);
|
||||
};
|
||||
|
||||
const copyComment = async (item) => {
|
||||
const text = item?.content || '';
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ElMessage.success(t('common.copySuccess'));
|
||||
} catch (error) {
|
||||
ElMessage.error(t('common.copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (item, action) => {
|
||||
if (action === 'copy') {
|
||||
copyComment(item);
|
||||
return;
|
||||
}
|
||||
if (action === 'edit') {
|
||||
if (!canModify(item)) return;
|
||||
startEdit(item);
|
||||
return;
|
||||
}
|
||||
if (action === 'delete') {
|
||||
if (!canModify(item)) return;
|
||||
deleteCommentItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCommentTime = (value) => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const loadComments = async (loadVersionRef) => {
|
||||
const requestVersion = ++loadVersionRef.value;
|
||||
const requestDocId = getDocId?.();
|
||||
const inlineEnabled = !!getInlineEnabled?.();
|
||||
const pendingEditingDrafts = commentList.value
|
||||
.filter((item) => item && !item.external_id && item.editing)
|
||||
.map((item) => ({ ...item }));
|
||||
|
||||
if (!requestDocId || !inlineEnabled) {
|
||||
commentList.value = [];
|
||||
if (requestVersion === loadVersionRef.value) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await listDocumentComments({
|
||||
document_external_id: requestDocId,
|
||||
page: 1,
|
||||
page_size: 100
|
||||
});
|
||||
if (requestVersion !== loadVersionRef.value || requestDocId !== getDocId?.()) {
|
||||
return false;
|
||||
}
|
||||
const remoteComments = (resp.comments || [])
|
||||
.filter((item) => item.anchor_id)
|
||||
.map((item) => ({
|
||||
local_id: item.external_id || item.anchor_id,
|
||||
anchor_id: item.anchor_id,
|
||||
external_id: item.external_id,
|
||||
parent_external_id: item.parent_external_id || '',
|
||||
content: item.content,
|
||||
created_at: item.created_at,
|
||||
creator_name: item.creator_name,
|
||||
creator_user_external_id: item.creator_user_external_id,
|
||||
creator_avatar: item.creator_avatar,
|
||||
editing: false,
|
||||
draft: '',
|
||||
saving: false
|
||||
}));
|
||||
commentList.value = [...pendingEditingDrafts, ...remoteComments];
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (requestVersion !== loadVersionRef.value || requestDocId !== getDocId?.()) {
|
||||
return false;
|
||||
}
|
||||
commentList.value = pendingEditingDrafts;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createTempComment,
|
||||
getActions,
|
||||
handleReply,
|
||||
handleAction,
|
||||
saveEdit,
|
||||
cancelEdit,
|
||||
formatCommentTime,
|
||||
loadComments,
|
||||
deleteRemoteComment: deleteDocumentComment
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export const flattenCommentTree = (items) => {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const childrenMap = new Map();
|
||||
const idSet = new Set();
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item?.external_id) {
|
||||
idSet.add(item.external_id);
|
||||
}
|
||||
});
|
||||
|
||||
items.forEach((item) => {
|
||||
const parentId = item.parent_external_id || '';
|
||||
if (!childrenMap.has(parentId)) {
|
||||
childrenMap.set(parentId, []);
|
||||
}
|
||||
childrenMap.get(parentId).push(item);
|
||||
});
|
||||
|
||||
const result = [];
|
||||
const walk = (node, level) => {
|
||||
node.level = level;
|
||||
result.push(node);
|
||||
if (!node.external_id) {
|
||||
return;
|
||||
}
|
||||
const children = childrenMap.get(node.external_id) || [];
|
||||
children.forEach((child) => walk(child, level + 1));
|
||||
};
|
||||
|
||||
const roots = [];
|
||||
items.forEach((item) => {
|
||||
const parentId = item.parent_external_id || '';
|
||||
if (!parentId || !idSet.has(parentId)) {
|
||||
roots.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
roots.forEach((root) => walk(root, 0));
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getIndentStyle = (item) => ({
|
||||
paddingLeft: `${Math.min(item?.level || 0, 3) * 16}px`
|
||||
});
|
||||
|
||||
export const buildReplyLabel = (item, comments, t) => {
|
||||
if (!item?.parent_external_id) {
|
||||
return '';
|
||||
}
|
||||
const parent = comments.find((entry) => entry.external_id === item.parent_external_id);
|
||||
return t('document.commentReplyTo', { name: parent?.creator_name || t('document.commentUnknown') });
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { flattenCommentTree, getIndentStyle as buildIndentStyle, buildReplyLabel } from './inline-comments/tree';
|
||||
import { useInlineCommentAnchor } from './inline-comments/anchor';
|
||||
import { useInlineCommentCrud } from './inline-comments/crud';
|
||||
|
||||
export function useInlineComments(options = {}) {
|
||||
const {
|
||||
t,
|
||||
getDocId,
|
||||
getInlineEnabled,
|
||||
getUserInfo,
|
||||
onAnchorClick,
|
||||
onAnchorHover,
|
||||
onAnchorRemove,
|
||||
onCommentMutated
|
||||
} = options;
|
||||
|
||||
const commentList = ref([]);
|
||||
const loading = ref(false);
|
||||
const loadVersion = ref(0);
|
||||
|
||||
const inlineEmptyText = computed(() => t('document.inlineCommentEmpty'));
|
||||
const userDisplayName = computed(() => {
|
||||
const userInfo = getUserInfo?.();
|
||||
return userInfo?.nickname || userInfo?.username || t('common.me');
|
||||
});
|
||||
const titleWithCount = computed(() => {
|
||||
const count = commentList.value.length;
|
||||
return count > 0 ? `${count}` : '';
|
||||
});
|
||||
const displayComments = computed(() => flattenCommentTree(commentList.value));
|
||||
const getIndentStyle = (item) => buildIndentStyle(item);
|
||||
const replyLabel = (item) => buildReplyLabel(item, commentList.value, t);
|
||||
|
||||
let maybeRemoveAnchor = () => {};
|
||||
|
||||
const commentCrud = useInlineCommentCrud({
|
||||
t,
|
||||
getDocId,
|
||||
getInlineEnabled,
|
||||
getUserInfo,
|
||||
commentList,
|
||||
userDisplayName,
|
||||
maybeRemoveAnchor: (...args) => maybeRemoveAnchor(...args),
|
||||
onCommentMutated
|
||||
});
|
||||
|
||||
const anchorBridge = useInlineCommentAnchor({
|
||||
commentList,
|
||||
onAnchorClick,
|
||||
onAnchorHover,
|
||||
onAnchorRemove,
|
||||
createTempComment: commentCrud.createTempComment,
|
||||
deleteRemoteComment: commentCrud.deleteRemoteComment
|
||||
});
|
||||
maybeRemoveAnchor = anchorBridge.maybeRemoveAnchor;
|
||||
|
||||
const loadComments = async () => {
|
||||
const requestVersion = loadVersion.value + 1;
|
||||
loading.value = true;
|
||||
try {
|
||||
await commentCrud.loadComments(loadVersion);
|
||||
} finally {
|
||||
if (requestVersion === loadVersion.value) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => getDocId?.(),
|
||||
async () => {
|
||||
commentList.value = [];
|
||||
loading.value = false;
|
||||
await loadComments();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => !!getInlineEnabled?.(),
|
||||
async (enabled) => {
|
||||
if (!enabled) {
|
||||
commentList.value = [];
|
||||
return;
|
||||
}
|
||||
await loadComments();
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
inlineEmptyText,
|
||||
titleWithCount,
|
||||
displayComments,
|
||||
getIndentStyle,
|
||||
getActions: commentCrud.getActions,
|
||||
replyLabel,
|
||||
handleAction: commentCrud.handleAction,
|
||||
handleReply: commentCrud.handleReply,
|
||||
handleContentClick: anchorBridge.handleContentClick,
|
||||
handleHover: anchorBridge.handleHover,
|
||||
saveEdit: commentCrud.saveEdit,
|
||||
cancelEdit: commentCrud.cancelEdit,
|
||||
formatCommentTime: commentCrud.formatCommentTime,
|
||||
handleInlineAnchorAdd: anchorBridge.handleInlineAnchorAdd,
|
||||
handleInlineAnchorRemove: anchorBridge.handleInlineAnchorRemove,
|
||||
reload: loadComments
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export const YJS_CONTENT_FIELD = 'content';
|
||||
export const YJS_COMMENT_META_FIELD = 'comment_meta';
|
||||
|
||||
export const YJS_CONTENT_CARRIER = Object.freeze({
|
||||
TEXT: 'text',
|
||||
XML_FRAGMENT: 'xml-fragment'
|
||||
});
|
||||
|
||||
const createTextBridge = (doc, field) => {
|
||||
const target = doc.getText(field);
|
||||
const toString = () => target.toString();
|
||||
|
||||
return {
|
||||
target,
|
||||
isEmpty: () => target.length === 0,
|
||||
toString,
|
||||
replace: (value = '') => {
|
||||
target.delete(0, target.length);
|
||||
if (value) {
|
||||
target.insert(0, value);
|
||||
}
|
||||
},
|
||||
insertIfEmpty: (value = '') => {
|
||||
if (!value || target.length > 0) {
|
||||
return;
|
||||
}
|
||||
target.insert(0, value);
|
||||
},
|
||||
observeRemote: (onRemoteChange) => {
|
||||
const observer = (event) => {
|
||||
if (event?.transaction?.local) {
|
||||
return;
|
||||
}
|
||||
onRemoteChange?.(toString());
|
||||
};
|
||||
target.observe(observer);
|
||||
return () => target.unobserve(observer);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const createXmlFragmentBridge = (doc, field) => {
|
||||
const target = doc.getXmlFragment(field);
|
||||
return {
|
||||
target,
|
||||
isEmpty: () => {
|
||||
if (typeof target.length === 'number' && target.length > 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const json = target.toJSON?.();
|
||||
return !Array.isArray(json) || json.length === 0;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const bridgeFactories = {
|
||||
[YJS_CONTENT_CARRIER.TEXT]: createTextBridge,
|
||||
[YJS_CONTENT_CARRIER.XML_FRAGMENT]: createXmlFragmentBridge
|
||||
};
|
||||
|
||||
export const createYjsContentBridge = ({
|
||||
doc,
|
||||
carrier = YJS_CONTENT_CARRIER.TEXT,
|
||||
field = YJS_CONTENT_FIELD
|
||||
} = {}) => {
|
||||
const factory = bridgeFactories[carrier] || bridgeFactories[YJS_CONTENT_CARRIER.TEXT];
|
||||
return factory(doc, field);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { YJS_COMMENT_META_FIELD } from '@/composables/document/editor/collab/useYjsContentBridge';
|
||||
|
||||
export function useCommentBridge({ props, emit, vditorRef, ydocRef, syncEditorToYjs }) {
|
||||
const getCommentOptions = () => {
|
||||
if (!props.commentEnabled) {
|
||||
return { enable: false };
|
||||
}
|
||||
return {
|
||||
enable: true,
|
||||
add: (id, text, commentsData) => {
|
||||
emit('comment-add', { id, text, commentsData });
|
||||
queueMicrotask(() => {
|
||||
syncEditorToYjs();
|
||||
});
|
||||
},
|
||||
remove: (ids) => {
|
||||
emit('comment-remove', ids);
|
||||
queueMicrotask(() => {
|
||||
syncEditorToYjs();
|
||||
});
|
||||
},
|
||||
scroll: (top) => {
|
||||
emit('comment-scroll', top);
|
||||
},
|
||||
adjustTop: (commentsData) => {
|
||||
emit('comment-adjust', commentsData);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const removeCommentIds = (ids = []) => {
|
||||
const vditor = vditorRef.value;
|
||||
if (!vditor || typeof vditor.removeCommentIds !== 'function') {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
vditor.removeCommentIds(ids);
|
||||
queueMicrotask(() => {
|
||||
syncEditorToYjs();
|
||||
});
|
||||
};
|
||||
|
||||
const broadcastCommentChange = () => {
|
||||
if (!props.collabEnabled || !ydocRef.value) {
|
||||
return;
|
||||
}
|
||||
const commentMeta = ydocRef.value.getMap(YJS_COMMENT_META_FIELD);
|
||||
commentMeta.set('tick', `${Date.now()}_${Math.random().toString(36).slice(2)}`);
|
||||
};
|
||||
|
||||
return {
|
||||
getCommentOptions,
|
||||
removeCommentIds,
|
||||
broadcastCommentChange
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Vditor from 'vditor';
|
||||
|
||||
const DEFAULT_TOOLBAR = [
|
||||
'headings',
|
||||
'bold',
|
||||
'italic',
|
||||
'strike',
|
||||
'|',
|
||||
'list',
|
||||
'ordered-list',
|
||||
'check',
|
||||
'|',
|
||||
'link',
|
||||
'quote',
|
||||
'code',
|
||||
'table',
|
||||
'|',
|
||||
'edit-mode',
|
||||
'undo',
|
||||
'redo',
|
||||
'outline'
|
||||
];
|
||||
|
||||
export function useVditorCore({
|
||||
props,
|
||||
emit,
|
||||
editorRef,
|
||||
vditorRef,
|
||||
isVditorReady,
|
||||
suppressInput,
|
||||
getCommentOptions,
|
||||
onEditorInput
|
||||
}) {
|
||||
let themeObserver = null;
|
||||
|
||||
const getEditableElement = () => {
|
||||
const vditor = vditorRef.value;
|
||||
if (!vditor) {
|
||||
return null;
|
||||
}
|
||||
return vditor?.vditor?.wysiwyg?.element || vditor?.vditor?.ir?.element || editorRef.value;
|
||||
};
|
||||
|
||||
const getValueSafely = (fallback = '') => {
|
||||
const vditor = vditorRef.value;
|
||||
if (!vditor || typeof vditor.getValue !== 'function') {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return vditor.getValue();
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const setValueSafely = (value = '') => {
|
||||
const vditor = vditorRef.value;
|
||||
if (!vditor || typeof vditor.setValue !== 'function') {
|
||||
return;
|
||||
}
|
||||
suppressInput.value = true;
|
||||
vditor.setValue(value);
|
||||
suppressInput.value = false;
|
||||
};
|
||||
|
||||
const applyVditorTheme = () => {
|
||||
const vditor = vditorRef.value;
|
||||
if (!vditor || typeof vditor.setTheme !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const isDarkMode = document.documentElement.classList.contains('dark-mode');
|
||||
vditor.setTheme(
|
||||
isDarkMode ? 'dark' : 'classic',
|
||||
isDarkMode ? 'dark' : 'light',
|
||||
isDarkMode ? 'dark' : 'github'
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore theme apply errors during init/destroy
|
||||
}
|
||||
};
|
||||
|
||||
const initVditor = () => {
|
||||
const initIsDarkMode = document.documentElement.classList.contains('dark-mode');
|
||||
vditorRef.value = new Vditor(editorRef.value, {
|
||||
height: props.height,
|
||||
value: props.modelValue,
|
||||
placeholder: props.placeholder,
|
||||
mode: 'wysiwyg',
|
||||
theme: initIsDarkMode ? 'dark' : 'classic',
|
||||
icon: 'ant',
|
||||
toolbar: DEFAULT_TOOLBAR,
|
||||
toolbarConfig: {
|
||||
hide: false
|
||||
},
|
||||
customWysiwygToolbar: () => [],
|
||||
counter: {
|
||||
enable: true
|
||||
},
|
||||
cache: {
|
||||
enable: false
|
||||
},
|
||||
comment: getCommentOptions(),
|
||||
upload: {
|
||||
handler: () => Promise.reject('上传功能暂未实现')
|
||||
},
|
||||
after: () => {
|
||||
setValueSafely(props.modelValue);
|
||||
isVditorReady.value = true;
|
||||
applyVditorTheme();
|
||||
emit('ready', vditorRef.value);
|
||||
},
|
||||
input: (value) => {
|
||||
if (suppressInput.value) {
|
||||
return;
|
||||
}
|
||||
onEditorInput?.(value);
|
||||
}
|
||||
});
|
||||
|
||||
themeObserver = new MutationObserver(() => {
|
||||
applyVditorTheme();
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
};
|
||||
|
||||
const destroyVditor = () => {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect();
|
||||
themeObserver = null;
|
||||
}
|
||||
if (vditorRef.value) {
|
||||
vditorRef.value.destroy();
|
||||
vditorRef.value = null;
|
||||
}
|
||||
isVditorReady.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
getEditableElement,
|
||||
getValueSafely,
|
||||
setValueSafely,
|
||||
initVditor,
|
||||
destroyVditor
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ref } from 'vue';
|
||||
import * as Y from 'yjs';
|
||||
import { WebsocketProvider } from 'y-websocket';
|
||||
import {
|
||||
createYjsContentBridge,
|
||||
YJS_CONTENT_CARRIER,
|
||||
YJS_COMMENT_META_FIELD
|
||||
} from '@/composables/document/editor/collab/useYjsContentBridge';
|
||||
import { resolveCollabUrl, getAwarenessPeerCount } from '@/utils/collabUrl';
|
||||
|
||||
export function useYjsCollaboration({
|
||||
props,
|
||||
emit,
|
||||
editorRef,
|
||||
vditorRef,
|
||||
isVditorReady,
|
||||
suppressInput,
|
||||
getEditableElement,
|
||||
getValueSafely,
|
||||
setValueSafely
|
||||
}) {
|
||||
const ydocRef = ref(null);
|
||||
const providerRef = ref(null);
|
||||
const contentBridgeRef = ref(null);
|
||||
const contentObserverCleanupRef = ref(null);
|
||||
const ycommentMetaRef = ref(null);
|
||||
const commentMetaObserverRef = ref(null);
|
||||
const activeRoomRef = ref('');
|
||||
const collabSyncedRef = ref(false);
|
||||
const pendingSeedRef = ref('');
|
||||
|
||||
const getCaretOffset = (container) => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!container.contains(range.startContainer)) {
|
||||
return null;
|
||||
}
|
||||
const preRange = range.cloneRange();
|
||||
preRange.selectNodeContents(container);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
return preRange.toString().length;
|
||||
};
|
||||
|
||||
const restoreCaretOffset = (container, offset) => {
|
||||
if (offset === null || offset === undefined) {
|
||||
return;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
||||
let currentOffset = 0;
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
const nodeLength = node.nodeValue ? node.nodeValue.length : 0;
|
||||
if (currentOffset + nodeLength >= offset) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, Math.max(0, offset - currentOffset));
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return;
|
||||
}
|
||||
currentOffset += nodeLength;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
if (container.lastChild) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(container);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
};
|
||||
|
||||
const syncContentToYjs = (content = '') => {
|
||||
if (!props.collabEnabled || !contentBridgeRef.value) {
|
||||
return;
|
||||
}
|
||||
contentBridgeRef.value.replace(content);
|
||||
};
|
||||
|
||||
const applyRemoteValue = (remoteValue) => {
|
||||
const vditor = vditorRef.value;
|
||||
if (!vditor || !isVditorReady.value || typeof vditor.setValue !== 'function') {
|
||||
return;
|
||||
}
|
||||
if (suppressInput.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = getValueSafely('');
|
||||
if (currentValue === remoteValue) {
|
||||
emit('update:modelValue', remoteValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = getEditableElement() || editorRef.value;
|
||||
const hasFocus = element && document.activeElement && element.contains(document.activeElement);
|
||||
const caretOffset = hasFocus ? getCaretOffset(element) : null;
|
||||
|
||||
suppressInput.value = true;
|
||||
setValueSafely(remoteValue);
|
||||
emit('update:modelValue', remoteValue);
|
||||
suppressInput.value = false;
|
||||
|
||||
if (hasFocus && element) {
|
||||
requestAnimationFrame(() => {
|
||||
restoreCaretOffset(element, caretOffset);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setupCollaboration = () => {
|
||||
if (!props.collabEnabled || !props.collabRoom) {
|
||||
return;
|
||||
}
|
||||
const url = resolveCollabUrl(props.collabUrl);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
ydocRef.value = new Y.Doc();
|
||||
contentBridgeRef.value = createYjsContentBridge({
|
||||
doc: ydocRef.value,
|
||||
carrier: YJS_CONTENT_CARRIER.TEXT
|
||||
});
|
||||
ycommentMetaRef.value = ydocRef.value.getMap(YJS_COMMENT_META_FIELD);
|
||||
commentMetaObserverRef.value = (event) => {
|
||||
if (event?.transaction?.local) {
|
||||
return;
|
||||
}
|
||||
emit('comment-changed');
|
||||
};
|
||||
ycommentMetaRef.value.observe(commentMetaObserverRef.value);
|
||||
activeRoomRef.value = props.collabRoom;
|
||||
|
||||
providerRef.value = new WebsocketProvider(url, props.collabRoom, ydocRef.value, {
|
||||
params: props.collabToken ? { token: props.collabToken } : {}
|
||||
});
|
||||
|
||||
providerRef.value.on('sync', (isSynced) => {
|
||||
collabSyncedRef.value = isSynced;
|
||||
emit('collab-sync', isSynced);
|
||||
|
||||
const bridge = contentBridgeRef.value;
|
||||
if (!isSynced || !bridge) {
|
||||
return;
|
||||
}
|
||||
const remote = bridge.toString();
|
||||
if (bridge.isEmpty()) {
|
||||
const peerCount = getAwarenessPeerCount(providerRef.value);
|
||||
if (peerCount > 1) {
|
||||
return;
|
||||
}
|
||||
const seed = pendingSeedRef.value || props.modelValue || '';
|
||||
bridge.insertIfEmpty(seed);
|
||||
} else if (remote && remote !== props.modelValue) {
|
||||
applyRemoteValue(remote);
|
||||
}
|
||||
pendingSeedRef.value = '';
|
||||
});
|
||||
|
||||
contentObserverCleanupRef.value = contentBridgeRef.value.observeRemote((remoteValue) => {
|
||||
applyRemoteValue(remoteValue || '');
|
||||
});
|
||||
};
|
||||
|
||||
const teardownCollaboration = () => {
|
||||
if (contentObserverCleanupRef.value) {
|
||||
contentObserverCleanupRef.value();
|
||||
contentObserverCleanupRef.value = null;
|
||||
}
|
||||
|
||||
if (ycommentMetaRef.value && commentMetaObserverRef.value) {
|
||||
ycommentMetaRef.value.unobserve(commentMetaObserverRef.value);
|
||||
}
|
||||
ycommentMetaRef.value = null;
|
||||
commentMetaObserverRef.value = null;
|
||||
|
||||
if (providerRef.value) {
|
||||
providerRef.value.destroy();
|
||||
providerRef.value = null;
|
||||
}
|
||||
|
||||
if (ydocRef.value) {
|
||||
ydocRef.value.destroy();
|
||||
ydocRef.value = null;
|
||||
}
|
||||
|
||||
contentBridgeRef.value = null;
|
||||
activeRoomRef.value = '';
|
||||
collabSyncedRef.value = false;
|
||||
pendingSeedRef.value = '';
|
||||
emit('collab-sync', false);
|
||||
};
|
||||
|
||||
const handleModelValueChange = (newValue) => {
|
||||
const bridge = contentBridgeRef.value;
|
||||
if (props.collabEnabled && bridge) {
|
||||
if (bridge.toString() === newValue) {
|
||||
return;
|
||||
}
|
||||
if (!collabSyncedRef.value) {
|
||||
pendingSeedRef.value = newValue || '';
|
||||
return;
|
||||
}
|
||||
if (bridge.isEmpty()) {
|
||||
const peerCount = getAwarenessPeerCount(providerRef.value);
|
||||
if (peerCount > 1) {
|
||||
return;
|
||||
}
|
||||
const seed = newValue || pendingSeedRef.value || '';
|
||||
pendingSeedRef.value = '';
|
||||
bridge.insertIfEmpty(seed);
|
||||
return;
|
||||
}
|
||||
if (activeRoomRef.value === props.collabRoom) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const vditor = vditorRef.value;
|
||||
if (!vditor || !isVditorReady.value || typeof vditor.getValue !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = getValueSafely('');
|
||||
if (currentValue !== newValue) {
|
||||
setValueSafely(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomChange = () => {
|
||||
if (!props.collabEnabled) {
|
||||
return;
|
||||
}
|
||||
teardownCollaboration();
|
||||
setupCollaboration();
|
||||
};
|
||||
|
||||
return {
|
||||
ydocRef,
|
||||
syncContentToYjs,
|
||||
setupCollaboration,
|
||||
teardownCollaboration,
|
||||
handleModelValueChange,
|
||||
handleRoomChange
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Extract all unique mentioned user external IDs from the Tiptap editor state.
|
||||
*
|
||||
* @param {import('@tiptap/vue-3').Editor} editor
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function extractMentionIds(editor) {
|
||||
if (!editor) return [];
|
||||
const ids = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'mention' && node.attrs.id) {
|
||||
ids.push(node.attrs.id);
|
||||
}
|
||||
});
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
export const BLOCK_SELECTOR = [
|
||||
'p',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'pre',
|
||||
'li',
|
||||
'table',
|
||||
'hr',
|
||||
'.mindmap-node',
|
||||
'.drawio-node',
|
||||
'.rich-table-node',
|
||||
'yoresee-mindmap',
|
||||
'yoresee-drawio',
|
||||
'yoresee-table'
|
||||
].join(',');
|
||||
|
||||
const PREFERRED_BLOCK_NODE_NAMES = new Set([
|
||||
'listItem',
|
||||
'paragraph',
|
||||
'heading',
|
||||
'blockquote',
|
||||
'codeBlock',
|
||||
'horizontalRule',
|
||||
'table',
|
||||
'tableBlock',
|
||||
'mindmapBlock',
|
||||
'drawioBlock'
|
||||
]);
|
||||
|
||||
const NON_EMPTY_BLOCK_TYPES = new Set([
|
||||
'horizontalRule',
|
||||
'table',
|
||||
'tableBlock',
|
||||
'mindmapBlock',
|
||||
'drawioBlock'
|
||||
]);
|
||||
|
||||
export const HANDLE_SIZE = 22;
|
||||
|
||||
const normalizeBlockType = (value) => {
|
||||
switch (value) {
|
||||
case 'heading':
|
||||
return 'heading';
|
||||
case 'listItem':
|
||||
return 'list';
|
||||
case 'blockquote':
|
||||
return 'quote';
|
||||
case 'codeBlock':
|
||||
return 'code';
|
||||
case 'table':
|
||||
case 'tableBlock':
|
||||
return 'table';
|
||||
case 'horizontalRule':
|
||||
return 'divider';
|
||||
case 'mindmapBlock':
|
||||
return 'mindmap';
|
||||
case 'drawioBlock':
|
||||
return 'drawio';
|
||||
default:
|
||||
return 'paragraph';
|
||||
}
|
||||
};
|
||||
|
||||
export const dedupeNumberList = (list = []) => {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
list.forEach((item) => {
|
||||
const value = Number(item);
|
||||
if (!Number.isFinite(value) || seen.has(value)) {
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const resolveHoveredDomBlock = ({ target, editorDom }) => {
|
||||
if (!editorDom) {
|
||||
return null;
|
||||
}
|
||||
const sourceTarget = target?.nodeType === Node.TEXT_NODE ? target.parentElement : target;
|
||||
if (!(sourceTarget instanceof Element) || !editorDom.contains(sourceTarget)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let domBlock = sourceTarget.closest(BLOCK_SELECTOR);
|
||||
if (!domBlock || !editorDom.contains(domBlock)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (domBlock.tagName === 'P' && domBlock.parentElement?.tagName === 'LI') {
|
||||
domBlock = domBlock.parentElement;
|
||||
}
|
||||
|
||||
return domBlock;
|
||||
};
|
||||
|
||||
export const resolveRangeFromDomBlock = ({ view, state, domBlock }) => {
|
||||
if (!view || !state || !domBlock) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const rawPos = view.posAtDOM(domBlock, 0);
|
||||
const size = state.doc.content.size;
|
||||
const candidates = dedupeNumberList([
|
||||
rawPos - 1,
|
||||
rawPos,
|
||||
rawPos + 1
|
||||
]).map((pos) => Math.max(0, Math.min(pos, size)));
|
||||
|
||||
for (const safePos of candidates) {
|
||||
const directNode = state.doc.nodeAt(safePos);
|
||||
if (directNode && (directNode.isBlock || PREFERRED_BLOCK_NODE_NAMES.has(directNode.type?.name))) {
|
||||
return {
|
||||
from: safePos,
|
||||
to: safePos + directNode.nodeSize
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const safePos = Math.max(0, Math.min(rawPos, size));
|
||||
return {
|
||||
from: safePos,
|
||||
to: Math.max(0, Math.min(safePos + domBlock.textContent.length + 2, state.doc.content.size))
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveRangeByMouseCoords = ({ view, state, event }) => {
|
||||
if (!view || !state || typeof event?.clientX !== 'number' || typeof event?.clientY !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const coords = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY
|
||||
});
|
||||
if (!coords || typeof coords.pos !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pos = Math.max(0, Math.min(coords.pos, state.doc.content.size));
|
||||
return { from: pos, to: pos };
|
||||
};
|
||||
|
||||
export const resolveMetaFromRange = ({ state, range }) => {
|
||||
if (!state || !range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolveDirectMetaByPos = (pos) => {
|
||||
const directNode = state.doc.nodeAt(pos);
|
||||
if (!directNode || (!directNode.isBlock && !PREFERRED_BLOCK_NODE_NAMES.has(directNode.type?.name))) {
|
||||
return null;
|
||||
}
|
||||
const nodeType = String(directNode.type?.name || 'paragraph');
|
||||
const normalizedType = normalizeBlockType(nodeType);
|
||||
const text = String(directNode.textContent || '').replace(/\u200b/g, '').trim();
|
||||
const isEmpty = !NON_EMPTY_BLOCK_TYPES.has(nodeType)
|
||||
&& text.length === 0
|
||||
&& Number(directNode.content?.size || 0) === 0;
|
||||
return {
|
||||
from: pos,
|
||||
to: pos + directNode.nodeSize,
|
||||
topFrom: pos,
|
||||
topTo: pos + directNode.nodeSize,
|
||||
nodeType,
|
||||
type: normalizedType,
|
||||
isEmpty
|
||||
};
|
||||
};
|
||||
|
||||
const directCandidates = dedupeNumberList([
|
||||
range.from,
|
||||
range.from - 1,
|
||||
range.from + 1
|
||||
]).map((pos) => Math.max(0, Math.min(pos, state.doc.content.size)));
|
||||
|
||||
for (const pos of directCandidates) {
|
||||
const directMeta = resolveDirectMetaByPos(pos);
|
||||
if (directMeta) {
|
||||
return directMeta;
|
||||
}
|
||||
}
|
||||
|
||||
const probePos = Math.max(0, Math.min(range.from + 1, state.doc.content.size));
|
||||
const $probe = state.doc.resolve(probePos);
|
||||
|
||||
for (let depth = $probe.depth; depth > 0; depth -= 1) {
|
||||
const node = $probe.node(depth);
|
||||
if (!node || (!node.isBlock && !PREFERRED_BLOCK_NODE_NAMES.has(node.type?.name))) {
|
||||
continue;
|
||||
}
|
||||
const from = $probe.before(depth);
|
||||
const to = $probe.after(depth);
|
||||
const topFrom = $probe.before(1);
|
||||
const topTo = $probe.after(1);
|
||||
const nodeType = String(node.type?.name || 'paragraph');
|
||||
const normalizedType = normalizeBlockType(nodeType);
|
||||
const text = String(node.textContent || '').replace(/\u200b/g, '').trim();
|
||||
const isEmpty = !NON_EMPTY_BLOCK_TYPES.has(nodeType)
|
||||
&& text.length === 0
|
||||
&& Number(node.content?.size || 0) === 0;
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
topFrom,
|
||||
topTo,
|
||||
nodeType,
|
||||
type: normalizedType,
|
||||
isEmpty
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolvePositionFromMeta = ({ view, meta }) => {
|
||||
if (!view || !meta) {
|
||||
return null;
|
||||
}
|
||||
const upperBound = Math.max(1, view.state.doc.content.size);
|
||||
const candidatePositions = dedupeNumberList([
|
||||
meta.from + 1,
|
||||
meta.from,
|
||||
Math.max(meta.to - 1, 1)
|
||||
]).map((pos) => Math.max(1, Math.min(pos, upperBound)));
|
||||
|
||||
for (const pos of candidatePositions) {
|
||||
try {
|
||||
const coords = view.coordsAtPos(pos);
|
||||
if (coords?.left != null && coords?.top != null) {
|
||||
return coords;
|
||||
}
|
||||
} catch (_) {
|
||||
// continue fallback candidates
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveDomBlockFromMeta = ({ view, editorRoot, meta }) => {
|
||||
if (!view || !editorRoot || !meta) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const dom = view.nodeDOM(meta.from);
|
||||
if (!dom) {
|
||||
return null;
|
||||
}
|
||||
const base = dom.nodeType === Node.TEXT_NODE ? dom.parentElement : dom;
|
||||
if (!(base instanceof Element)) {
|
||||
return null;
|
||||
}
|
||||
if (base.matches(BLOCK_SELECTOR)) {
|
||||
return base;
|
||||
}
|
||||
const closest = base.closest(BLOCK_SELECTOR);
|
||||
if (closest && editorRoot.contains(closest)) {
|
||||
return closest;
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveHandleStyle = ({ container, blockRect, handleSize = HANDLE_SIZE }) => {
|
||||
if (!container || !blockRect) {
|
||||
return null;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rawTop = blockRect.top - containerRect.top + container.scrollTop - handleSize;
|
||||
const rawLeft = blockRect.left - containerRect.left + container.scrollLeft - handleSize;
|
||||
const minTop = container.scrollTop + 2;
|
||||
const minLeft = container.scrollLeft + 2;
|
||||
const maxTop = container.scrollTop + container.clientHeight - handleSize - 2;
|
||||
const maxLeft = container.scrollLeft + container.clientWidth - handleSize - 2;
|
||||
const top = Math.max(minTop, Math.min(rawTop, Math.max(minTop, maxTop)));
|
||||
const left = Math.max(minLeft, Math.min(rawLeft, Math.max(minLeft, maxLeft)));
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ref } from 'vue';
|
||||
import { common, createLowlight } from 'lowlight';
|
||||
|
||||
const BUILTIN_CODE_LANGUAGES = Object.freeze([
|
||||
{ value: 'plaintext', label: 'Plain Text' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'yaml', label: 'YAML' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'rust', label: 'Rust' }
|
||||
]);
|
||||
|
||||
const CODE_LANGUAGE_ALIAS_MAP = Object.freeze({
|
||||
text: 'plaintext',
|
||||
plain: 'plaintext',
|
||||
txt: 'plaintext',
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
yml: 'yaml',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
py: 'python',
|
||||
md: 'markdown'
|
||||
});
|
||||
|
||||
const builtinCodeLanguageSet = new Set(BUILTIN_CODE_LANGUAGES.map((item) => item.value));
|
||||
|
||||
export function useRichTextCodeLanguageOverlay(options = {}) {
|
||||
const { editorRef, scrollContainerRef } = options;
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
const codeLanguageDraft = ref('plaintext');
|
||||
const codeLanguageFloatingVisible = ref(false);
|
||||
const codeLanguageFloatingStyle = ref({ top: '0px', left: '0px' });
|
||||
const codeLanguageInteracting = ref(false);
|
||||
const codeBlockAnchorPos = ref(null);
|
||||
|
||||
const sanitizeCodeLanguage = (value) => {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
const aliased = CODE_LANGUAGE_ALIAS_MAP[raw] || raw;
|
||||
return aliased.replace(/[^a-z0-9_+-]/g, '');
|
||||
};
|
||||
|
||||
const isSupportedHighlightLanguage = (language) => {
|
||||
if (!language) {
|
||||
return false;
|
||||
}
|
||||
if (builtinCodeLanguageSet.has(language)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof lowlight.registered === 'function') {
|
||||
return Boolean(lowlight.registered(language));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const resolveCodeBlockLanguage = (value = codeLanguageDraft.value) => {
|
||||
const normalized = sanitizeCodeLanguage(value);
|
||||
if (!normalized) {
|
||||
return 'plaintext';
|
||||
}
|
||||
return isSupportedHighlightLanguage(normalized) ? normalized : 'plaintext';
|
||||
};
|
||||
|
||||
const resolveSelectionCodeBlockMeta = () => {
|
||||
const instance = editorRef.value;
|
||||
if (!instance) {
|
||||
return null;
|
||||
}
|
||||
const { $from } = instance.state.selection;
|
||||
for (let depth = $from.depth; depth >= 0; depth -= 1) {
|
||||
const node = $from.node(depth);
|
||||
if (node?.type?.name !== 'codeBlock') {
|
||||
continue;
|
||||
}
|
||||
const pos = depth > 0 ? $from.before(depth) : 0;
|
||||
const rawDom = instance.view.nodeDOM(pos);
|
||||
let element = rawDom instanceof HTMLElement ? rawDom : null;
|
||||
if (!element && rawDom && typeof Node !== 'undefined' && rawDom.nodeType === Node.TEXT_NODE) {
|
||||
element = rawDom.parentElement;
|
||||
}
|
||||
if (!element) {
|
||||
return {
|
||||
pos,
|
||||
node
|
||||
};
|
||||
}
|
||||
if (element.tagName.toLowerCase() !== 'pre') {
|
||||
element = element.querySelector?.('pre') || element;
|
||||
}
|
||||
return {
|
||||
pos,
|
||||
node,
|
||||
element
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveAnchorCodeBlockMeta = () => {
|
||||
const instance = editorRef.value;
|
||||
const pos = Number(codeBlockAnchorPos.value);
|
||||
if (!instance || !Number.isFinite(pos)) {
|
||||
return null;
|
||||
}
|
||||
const node = instance.state.doc.nodeAt(pos);
|
||||
if (!node || node.type?.name !== 'codeBlock') {
|
||||
return null;
|
||||
}
|
||||
const rawDom = instance.view.nodeDOM(pos);
|
||||
let element = rawDom instanceof HTMLElement ? rawDom : null;
|
||||
if (!element && rawDom && typeof Node !== 'undefined' && rawDom.nodeType === Node.TEXT_NODE) {
|
||||
element = rawDom.parentElement;
|
||||
}
|
||||
if (element && element.tagName.toLowerCase() !== 'pre') {
|
||||
element = element.querySelector?.('pre') || element;
|
||||
}
|
||||
return {
|
||||
pos,
|
||||
node,
|
||||
element: element || null
|
||||
};
|
||||
};
|
||||
|
||||
const syncCodeLanguageDraft = () => {
|
||||
const selected = resolveSelectionCodeBlockMeta();
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
codeBlockAnchorPos.value = selected.pos;
|
||||
const current = sanitizeCodeLanguage(selected.node?.attrs?.language);
|
||||
codeLanguageDraft.value = isSupportedHighlightLanguage(current) ? current : 'plaintext';
|
||||
};
|
||||
|
||||
const resolveActiveCodeBlockDom = () => {
|
||||
const selected = resolveSelectionCodeBlockMeta();
|
||||
if (selected) {
|
||||
codeBlockAnchorPos.value = selected.pos;
|
||||
return selected.element || null;
|
||||
}
|
||||
const anchored = resolveAnchorCodeBlockMeta();
|
||||
return anchored?.element || null;
|
||||
};
|
||||
|
||||
const updateCodeLanguageFloating = () => {
|
||||
const container = scrollContainerRef.value;
|
||||
const blockElement = resolveActiveCodeBlockDom();
|
||||
if (!container || !blockElement) {
|
||||
if (!codeLanguageInteracting.value) {
|
||||
codeLanguageFloatingVisible.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const blockRect = blockElement.getBoundingClientRect();
|
||||
const floatingWidth = 132;
|
||||
const floatingHeight = 30;
|
||||
const rawLeft = blockRect.right - containerRect.left + container.scrollLeft - floatingWidth - 8;
|
||||
const rawTop = blockRect.top - containerRect.top + container.scrollTop + 8;
|
||||
const minLeft = container.scrollLeft + 8;
|
||||
const maxLeft = container.scrollLeft + container.clientWidth - floatingWidth - 8;
|
||||
const minTop = container.scrollTop + 8;
|
||||
const maxTop = container.scrollTop + container.clientHeight - floatingHeight - 8;
|
||||
codeLanguageFloatingStyle.value = {
|
||||
left: `${Math.min(maxLeft, Math.max(minLeft, rawLeft))}px`,
|
||||
top: `${Math.min(maxTop, Math.max(minTop, rawTop))}px`
|
||||
};
|
||||
codeLanguageFloatingVisible.value = true;
|
||||
};
|
||||
|
||||
const queryCodeLanguageSuggestions = (queryString, cb) => {
|
||||
const keyword = String(queryString || '').trim().toLowerCase();
|
||||
const next = !keyword
|
||||
? BUILTIN_CODE_LANGUAGES
|
||||
: BUILTIN_CODE_LANGUAGES.filter(
|
||||
(item) => item.value.includes(keyword) || item.label.toLowerCase().includes(keyword)
|
||||
);
|
||||
cb(next);
|
||||
};
|
||||
|
||||
const applyCodeLanguage = (value) => {
|
||||
const instance = editorRef.value;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
const nextLanguage = resolveCodeBlockLanguage(value);
|
||||
if (!nextLanguage) {
|
||||
return;
|
||||
}
|
||||
const selected = resolveSelectionCodeBlockMeta() || resolveAnchorCodeBlockMeta();
|
||||
if (!selected || !Number.isFinite(selected.pos)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNode = instance.state.doc.nodeAt(selected.pos);
|
||||
if (!currentNode || currentNode.type?.name !== 'codeBlock') {
|
||||
return;
|
||||
}
|
||||
const nextAttrs = {
|
||||
...currentNode.attrs,
|
||||
language: nextLanguage
|
||||
};
|
||||
instance.view.dispatch(
|
||||
instance.state.tr.setNodeMarkup(selected.pos, currentNode.type, nextAttrs)
|
||||
);
|
||||
codeBlockAnchorPos.value = selected.pos;
|
||||
codeLanguageDraft.value = nextLanguage;
|
||||
updateCodeLanguageFloating();
|
||||
};
|
||||
|
||||
const applyCodeLanguageFromDraft = () => {
|
||||
applyCodeLanguage(codeLanguageDraft.value);
|
||||
};
|
||||
|
||||
const handleCodeLanguageSelect = (item) => {
|
||||
const value = item?.value || item?.label || codeLanguageDraft.value;
|
||||
applyCodeLanguage(value);
|
||||
};
|
||||
|
||||
const handleCodeLanguageInputBlur = () => {
|
||||
setTimeout(() => {
|
||||
if (!codeLanguageInteracting.value) {
|
||||
updateCodeLanguageFloating();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const hideCodeLanguageFloating = () => {
|
||||
codeBlockAnchorPos.value = null;
|
||||
codeLanguageFloatingVisible.value = false;
|
||||
};
|
||||
|
||||
const handleEditorBlur = () => {
|
||||
if (!codeLanguageInteracting.value) {
|
||||
hideCodeLanguageFloating();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lowlight,
|
||||
codeLanguageDraft,
|
||||
codeLanguageFloatingVisible,
|
||||
codeLanguageFloatingStyle,
|
||||
codeLanguageInteracting,
|
||||
queryCodeLanguageSuggestions,
|
||||
applyCodeLanguageFromDraft,
|
||||
handleCodeLanguageSelect,
|
||||
handleCodeLanguageInputBlur,
|
||||
syncCodeLanguageDraft,
|
||||
updateCodeLanguageFloating,
|
||||
hideCodeLanguageFloating,
|
||||
handleEditorBlur,
|
||||
resolveCodeBlockLanguage
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { COMMENT_ANCHOR_ATTR } from '@/components/document/rich-text/extensions/commentAnchorExtension';
|
||||
|
||||
const createCommentAnchorId = () => `rt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
export function useRichTextCommentBridge(options = {}) {
|
||||
const {
|
||||
editorRef,
|
||||
scrollContainerRef,
|
||||
commentEnabledRef,
|
||||
onCommentAdd,
|
||||
onCommentRemove,
|
||||
onCommentChanged
|
||||
} = options;
|
||||
|
||||
const selectionCommentVisible = ref(false);
|
||||
const selectionCommentStyle = ref({ top: '0px', left: '0px' });
|
||||
const selectionCommentHovering = ref(false);
|
||||
const selectionUpdateRaf = ref(0);
|
||||
|
||||
const isSelectionInsideEditor = () => {
|
||||
const root = editorRef.value?.view?.dom;
|
||||
const selection = window.getSelection?.();
|
||||
if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return false;
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
const common = range.commonAncestorContainer;
|
||||
const commonElement = common?.nodeType === Node.ELEMENT_NODE ? common : common?.parentElement;
|
||||
return Boolean(commonElement && root.contains(commonElement));
|
||||
};
|
||||
|
||||
const addInlineComment = () => {
|
||||
if (!commentEnabledRef.value || !editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const { from, to, empty } = editorRef.value.state.selection;
|
||||
if (empty || from === to) {
|
||||
return;
|
||||
}
|
||||
const selectedText = editorRef.value.state.doc.textBetween(from, to, ' ');
|
||||
const anchorId = createCommentAnchorId();
|
||||
const chain = editorRef.value.chain().focus();
|
||||
if (typeof chain.setCommentAnchor === 'function') {
|
||||
chain.setCommentAnchor(anchorId).run();
|
||||
} else {
|
||||
chain.setMark('commentAnchor', { anchorId }).run();
|
||||
}
|
||||
onCommentAdd?.({
|
||||
id: anchorId,
|
||||
text: selectedText || ''
|
||||
});
|
||||
onCommentChanged?.();
|
||||
selectionCommentVisible.value = false;
|
||||
};
|
||||
|
||||
const updateSelectionCommentTrigger = () => {
|
||||
if (!editorRef.value || !scrollContainerRef.value) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
if (!isSelectionInsideEditor()) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const instance = editorRef.value;
|
||||
const selection = instance.state.selection;
|
||||
const { from, to, empty } = selection;
|
||||
if (empty || from === to) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const selectedText = instance.state.doc.textBetween(from, to, ' ').trim();
|
||||
if (!selectedText) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
let fromCoords;
|
||||
let toCoords;
|
||||
try {
|
||||
fromCoords = instance.view.coordsAtPos(from);
|
||||
toCoords = instance.view.coordsAtPos(to);
|
||||
} catch (_) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const container = scrollContainerRef.value;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const triggerWidth = commentEnabledRef.value ? 308 : 278;
|
||||
const triggerHeight = 36;
|
||||
const rawLeft = toCoords.right - containerRect.left + container.scrollLeft + 8;
|
||||
const rawTop = Math.min(fromCoords.top, toCoords.top) - containerRect.top + container.scrollTop - triggerHeight - 6;
|
||||
const minLeft = container.scrollLeft + 8;
|
||||
const maxLeft = container.scrollLeft + container.clientWidth - triggerWidth - 8;
|
||||
const minTop = container.scrollTop + 8;
|
||||
const maxTop = container.scrollTop + container.clientHeight - triggerHeight - 8;
|
||||
selectionCommentStyle.value = {
|
||||
left: `${Math.min(maxLeft, Math.max(minLeft, rawLeft))}px`,
|
||||
top: `${Math.min(maxTop, Math.max(minTop, rawTop))}px`
|
||||
};
|
||||
selectionCommentVisible.value = true;
|
||||
};
|
||||
|
||||
const requestSelectionCommentTriggerUpdate = () => {
|
||||
if (selectionUpdateRaf.value) {
|
||||
cancelAnimationFrame(selectionUpdateRaf.value);
|
||||
}
|
||||
selectionUpdateRaf.value = requestAnimationFrame(() => {
|
||||
updateSelectionCommentTrigger();
|
||||
selectionUpdateRaf.value = 0;
|
||||
});
|
||||
};
|
||||
|
||||
const getCommentAnchorElements = (ids = null) => {
|
||||
const root = editorRef.value?.view?.dom;
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
const all = Array.from(root.querySelectorAll(`span[${COMMENT_ANCHOR_ATTR}]`));
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return all;
|
||||
}
|
||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||
return all.filter((element) => idSet.has(String(element.getAttribute(COMMENT_ANCHOR_ATTR) || '').trim()));
|
||||
};
|
||||
|
||||
const getCommentIds = () => {
|
||||
const elements = getCommentAnchorElements();
|
||||
const container = scrollContainerRef.value;
|
||||
const containerRect = container?.getBoundingClientRect?.();
|
||||
const resultMap = new Map();
|
||||
|
||||
elements.forEach((element) => {
|
||||
const anchorId = String(element.getAttribute(COMMENT_ANCHOR_ATTR) || '').trim();
|
||||
if (!anchorId) {
|
||||
return;
|
||||
}
|
||||
let top = 0;
|
||||
if (container && containerRect) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
top = rect.top - containerRect.top + container.scrollTop;
|
||||
}
|
||||
if (!resultMap.has(anchorId) || top < resultMap.get(anchorId)) {
|
||||
resultMap.set(anchorId, top);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(resultMap.entries()).map(([id, top]) => ({ id, top }));
|
||||
};
|
||||
|
||||
const hlCommentIds = (ids = []) => {
|
||||
getCommentAnchorElements(ids).forEach((element) => {
|
||||
element.classList.add('comment-anchor-highlight');
|
||||
});
|
||||
};
|
||||
|
||||
const unHlCommentIds = (ids = []) => {
|
||||
getCommentAnchorElements(ids).forEach((element) => {
|
||||
element.classList.remove('comment-anchor-highlight');
|
||||
});
|
||||
};
|
||||
|
||||
const removeCommentIds = (ids = []) => {
|
||||
if (!editorRef.value || !Array.isArray(ids) || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||
if (idSet.size === 0) {
|
||||
return;
|
||||
}
|
||||
let transaction = editorRef.value.state.tr;
|
||||
editorRef.value.state.doc.descendants((node, pos) => {
|
||||
if (!node.isText || !Array.isArray(node.marks) || node.marks.length === 0) {
|
||||
return;
|
||||
}
|
||||
node.marks.forEach((mark) => {
|
||||
if (mark.type.name !== 'commentAnchor') {
|
||||
return;
|
||||
}
|
||||
const anchorId = String(mark.attrs?.anchorId || '').trim();
|
||||
if (!idSet.has(anchorId)) {
|
||||
return;
|
||||
}
|
||||
transaction = transaction.removeMark(pos, pos + node.nodeSize, mark);
|
||||
});
|
||||
});
|
||||
if (!transaction.docChanged) {
|
||||
return;
|
||||
}
|
||||
editorRef.value.view.dispatch(transaction);
|
||||
onCommentRemove?.(Array.from(idSet));
|
||||
onCommentChanged?.();
|
||||
};
|
||||
|
||||
const scrollToCommentId = async (id) => {
|
||||
const targetId = String(id || '').trim();
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const container = scrollContainerRef.value;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const target = getCommentAnchorElements([targetId])[0];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const top = Math.max(targetRect.top - containerRect.top + container.scrollTop - 24, 0);
|
||||
container.scrollTo({ top, behavior: 'smooth' });
|
||||
hlCommentIds([targetId]);
|
||||
};
|
||||
|
||||
const clearSelectionRaf = () => {
|
||||
if (selectionUpdateRaf.value) {
|
||||
cancelAnimationFrame(selectionUpdateRaf.value);
|
||||
selectionUpdateRaf.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorBlur = () => {
|
||||
if (!selectionCommentHovering.value) {
|
||||
selectionCommentVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectionCommentVisible,
|
||||
selectionCommentStyle,
|
||||
selectionCommentHovering,
|
||||
addInlineComment,
|
||||
updateSelectionCommentTrigger,
|
||||
requestSelectionCommentTriggerUpdate,
|
||||
handleEditorBlur,
|
||||
clearSelectionRaf,
|
||||
getCommentIds,
|
||||
hlCommentIds,
|
||||
unHlCommentIds,
|
||||
removeCommentIds,
|
||||
scrollToCommentId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import { Editor } from '@tiptap/vue-3';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import Color from '@tiptap/extension-color';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import { resolveRichTextComponentSystem } from '@/components/document/rich-text/components/registry';
|
||||
import { CommentAnchorExtension } from '@/components/document/rich-text/extensions/commentAnchorExtension';
|
||||
|
||||
export function useRichTextEditorRuntime(options = {}) {
|
||||
const {
|
||||
editorRef,
|
||||
bodyScrollRef,
|
||||
modelValueRef,
|
||||
valueFormatIsJsonRef,
|
||||
placeholderTextRef,
|
||||
commentEnabledRef,
|
||||
enabledComponentsRef,
|
||||
externalExtensionsRef,
|
||||
additionalExtensionsRef,
|
||||
reservedBridgeOptionsRef,
|
||||
collaborationBridgeRef,
|
||||
componentToolbarItemsRef,
|
||||
lowlight,
|
||||
applyingModelValueRef,
|
||||
lastEmittedValueRef,
|
||||
serializeModelValue,
|
||||
modelValueFromEditor,
|
||||
resolveInitialEditorContent,
|
||||
applyModelValueToEditor,
|
||||
syncLastEmittedValue,
|
||||
syncCodeLanguageDraft,
|
||||
updateCodeLanguageFloating,
|
||||
updateSelectionCommentTrigger,
|
||||
handleSelectionCommentEditorBlur,
|
||||
handleCodeLanguageEditorBlur,
|
||||
requestSelectionCommentTriggerUpdate,
|
||||
clearSelectionRaf,
|
||||
selectionCommentVisibleRef,
|
||||
onUpdateModelValue,
|
||||
onCommit,
|
||||
onReady
|
||||
} = options;
|
||||
|
||||
const buildReservedBridge = () => ({
|
||||
...reservedBridgeOptionsRef.value,
|
||||
comments: null,
|
||||
collaboration: collaborationBridgeRef?.value || null
|
||||
});
|
||||
|
||||
const handleSelectionOverlayScrollOrResize = () => {
|
||||
if (selectionCommentVisibleRef.value) {
|
||||
updateSelectionCommentTrigger();
|
||||
}
|
||||
updateCodeLanguageFloating();
|
||||
};
|
||||
|
||||
const handleKeyDown = (view, event) => {
|
||||
if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return false;
|
||||
}
|
||||
const instance = editorRef.value;
|
||||
if (instance?.isActive('listItem')) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
instance.chain().focus().liftListItem('listItem').run();
|
||||
} else {
|
||||
instance.chain().focus().sinkListItem('listItem').run();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.tr.insertText(' '));
|
||||
return true;
|
||||
};
|
||||
|
||||
const createEditor = () => {
|
||||
const componentSystem = resolveRichTextComponentSystem(enabledComponentsRef.value);
|
||||
const extraExtensions = Array.isArray(additionalExtensionsRef?.value)
|
||||
? additionalExtensionsRef.value
|
||||
: [];
|
||||
componentToolbarItemsRef.value = componentSystem.toolbarItems;
|
||||
|
||||
editorRef.value = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
blockquote: true,
|
||||
heading: { levels: [1, 2, 3] }
|
||||
}),
|
||||
Underline,
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight.configure({
|
||||
multicolor: true
|
||||
}),
|
||||
Link.configure({
|
||||
autolink: true,
|
||||
openOnClick: false,
|
||||
linkOnPaste: true
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholderTextRef.value
|
||||
}),
|
||||
...extraExtensions,
|
||||
...(commentEnabledRef.value ? [CommentAnchorExtension] : []),
|
||||
...componentSystem.extensions,
|
||||
...externalExtensionsRef.value
|
||||
],
|
||||
content: resolveInitialEditorContent(modelValueRef.value),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'yoresee-rich-text-content'
|
||||
},
|
||||
handleKeyDown
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
if (applyingModelValueRef.value) {
|
||||
return;
|
||||
}
|
||||
const nextValue = modelValueFromEditor(instance);
|
||||
const serialized = serializeModelValue(nextValue);
|
||||
if (serialized === lastEmittedValueRef.value) {
|
||||
return;
|
||||
}
|
||||
lastEmittedValueRef.value = serialized;
|
||||
onUpdateModelValue(nextValue);
|
||||
syncCodeLanguageDraft();
|
||||
updateCodeLanguageFloating();
|
||||
updateSelectionCommentTrigger();
|
||||
},
|
||||
onSelectionUpdate: () => {
|
||||
syncCodeLanguageDraft();
|
||||
updateCodeLanguageFloating();
|
||||
updateSelectionCommentTrigger();
|
||||
},
|
||||
onBlur: () => {
|
||||
handleSelectionCommentEditorBlur();
|
||||
handleCodeLanguageEditorBlur();
|
||||
onCommit();
|
||||
}
|
||||
});
|
||||
|
||||
onReady({
|
||||
editor: editorRef.value,
|
||||
reservedBridge: buildReservedBridge()
|
||||
});
|
||||
|
||||
syncCodeLanguageDraft();
|
||||
updateCodeLanguageFloating();
|
||||
};
|
||||
|
||||
const destroyEditor = () => {
|
||||
editorRef.value?.destroy();
|
||||
editorRef.value = null;
|
||||
};
|
||||
|
||||
const recreateEditor = () => {
|
||||
destroyEditor();
|
||||
createEditor();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const initialValue = valueFormatIsJsonRef.value
|
||||
? modelValueRef.value
|
||||
: String(modelValueRef.value || '');
|
||||
syncLastEmittedValue(initialValue);
|
||||
|
||||
createEditor();
|
||||
|
||||
nextTick(() => {
|
||||
bodyScrollRef.value?.addEventListener('scroll', handleSelectionOverlayScrollOrResize, {
|
||||
passive: true
|
||||
});
|
||||
bodyScrollRef.value?.addEventListener('mouseup', requestSelectionCommentTriggerUpdate);
|
||||
bodyScrollRef.value?.addEventListener('keyup', requestSelectionCommentTriggerUpdate);
|
||||
});
|
||||
document.addEventListener('selectionchange', requestSelectionCommentTriggerUpdate);
|
||||
window.addEventListener('resize', handleSelectionOverlayScrollOrResize);
|
||||
});
|
||||
|
||||
watch(modelValueRef, (nextValue) => {
|
||||
applyModelValueToEditor(nextValue);
|
||||
syncCodeLanguageDraft();
|
||||
nextTick(() => {
|
||||
updateCodeLanguageFloating();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSelectionRaf();
|
||||
bodyScrollRef.value?.removeEventListener('mouseup', requestSelectionCommentTriggerUpdate);
|
||||
bodyScrollRef.value?.removeEventListener('keyup', requestSelectionCommentTriggerUpdate);
|
||||
bodyScrollRef.value?.removeEventListener('scroll', handleSelectionOverlayScrollOrResize);
|
||||
document.removeEventListener('selectionchange', requestSelectionCommentTriggerUpdate);
|
||||
window.removeEventListener('resize', handleSelectionOverlayScrollOrResize);
|
||||
destroyEditor();
|
||||
});
|
||||
|
||||
return {
|
||||
recreateEditor
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
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, Math.max(minLeft, maxLeft)))}px`,
|
||||
top: `${Math.max(minTop, Math.min(rawTop, Math.max(minTop, 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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
export function useRichTextParagraphActions({
|
||||
editorRef,
|
||||
componentToolbarItemsRef,
|
||||
runComponentCommand,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleBlockquote,
|
||||
toggleCodeBlock,
|
||||
undo,
|
||||
redo
|
||||
}) {
|
||||
const focusForParagraphAction = (ctx, position = 'start') => {
|
||||
if (typeof ctx?.focusBlock === 'function') {
|
||||
ctx.focusBlock(position);
|
||||
return;
|
||||
}
|
||||
editorRef.value?.commands?.focus?.();
|
||||
};
|
||||
|
||||
const insertComponentByHandle = (ctx, item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
focusForParagraphAction(ctx);
|
||||
runComponentCommand(item);
|
||||
};
|
||||
|
||||
const emptyParagraphActions = computed(() => {
|
||||
const actions = [
|
||||
{
|
||||
key: 'set-paragraph',
|
||||
label: '普通文本',
|
||||
iconKey: 'paragraph',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
editorRef.value?.chain().focus().setParagraph().run();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'heading-1',
|
||||
label: '一级标题',
|
||||
iconKey: 'heading-1',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
editorRef.value?.chain().focus().setHeading({ level: 1 }).run();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'heading-2',
|
||||
label: '二级标题',
|
||||
iconKey: 'heading-2',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
editorRef.value?.chain().focus().setHeading({ level: 2 }).run();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'bullet-list',
|
||||
label: '无序列表',
|
||||
iconKey: 'bullet-list',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
toggleBulletList();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'ordered-list',
|
||||
label: '有序列表',
|
||||
iconKey: 'ordered-list',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
toggleOrderedList();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'blockquote',
|
||||
label: '引用',
|
||||
iconKey: 'blockquote',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
toggleBlockquote();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const insertActions = [
|
||||
{
|
||||
key: 'code-block',
|
||||
label: '代码块',
|
||||
iconKey: 'code-block',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
toggleCodeBlock();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
componentToolbarItemsRef.value.forEach((item) => {
|
||||
insertActions.push({
|
||||
key: `component-${item.key}`,
|
||||
label: item.label,
|
||||
iconKey: item.key,
|
||||
handler: (ctx) => {
|
||||
insertComponentByHandle(ctx, item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (insertActions.length > 0) {
|
||||
actions.push({
|
||||
key: 'insert',
|
||||
label: '插入',
|
||||
iconKey: 'insert',
|
||||
children: insertActions
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'undo',
|
||||
label: '撤销',
|
||||
handler: () => {
|
||||
undo();
|
||||
}
|
||||
});
|
||||
|
||||
actions.push({
|
||||
key: 'redo',
|
||||
label: '重做',
|
||||
handler: () => {
|
||||
redo();
|
||||
}
|
||||
});
|
||||
|
||||
return actions;
|
||||
});
|
||||
|
||||
return {
|
||||
emptyParagraphActions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import {
|
||||
dedupeNumberList,
|
||||
resolveDomBlockFromMeta,
|
||||
resolveHandleStyle,
|
||||
resolveHoveredDomBlock,
|
||||
resolveMetaFromRange,
|
||||
resolvePositionFromMeta,
|
||||
resolveRangeByMouseCoords,
|
||||
resolveRangeFromDomBlock
|
||||
} from './paragraph-handle/paragraphHandleUtils';
|
||||
|
||||
export function useRichTextParagraphHandle({
|
||||
editorRef,
|
||||
scrollContainerRef,
|
||||
labels = {},
|
||||
resolveActions = null,
|
||||
onMutated
|
||||
}) {
|
||||
const visible = ref(false);
|
||||
const style = ref({ top: '0px', left: '0px' });
|
||||
const hoveredDomBlock = ref(null);
|
||||
const hoveredMeta = ref(null);
|
||||
const hoveringHandle = ref(false);
|
||||
|
||||
let editorDom = null;
|
||||
let mouseHost = null;
|
||||
let scrollContainer = null;
|
||||
let hideTimer = 0;
|
||||
const HIDE_DELAY_MS = 240;
|
||||
const TRANSITION_HIDE_DELAY_MS = 320;
|
||||
|
||||
const enabled = computed(() => Boolean(editorRef.value?.view && scrollContainerRef.value));
|
||||
const paragraphType = computed(() => hoveredMeta.value?.type || 'paragraph');
|
||||
const paragraphIsEmpty = computed(() => Boolean(hoveredMeta.value?.isEmpty));
|
||||
const paragraphHandleMode = computed(() => (paragraphIsEmpty.value ? 'empty' : 'normal'));
|
||||
|
||||
const buildDefaultActions = () => ([
|
||||
{
|
||||
key: 'insert-empty-paragraph',
|
||||
label: labels.insertEmptyParagraph || '插入空段落',
|
||||
iconKey: 'insert',
|
||||
children: [
|
||||
{
|
||||
key: 'add-above',
|
||||
label: labels.addAbove || '在上方',
|
||||
iconKey: 'add-above'
|
||||
},
|
||||
{
|
||||
key: 'add-below',
|
||||
label: labels.addBelow || '在下方',
|
||||
iconKey: 'add-below'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: labels.delete || 'Delete Paragraph',
|
||||
iconKey: 'delete',
|
||||
danger: true
|
||||
}
|
||||
]);
|
||||
|
||||
const paragraphActions = computed(() => {
|
||||
const defaults = buildDefaultActions();
|
||||
if (typeof resolveActions !== 'function') {
|
||||
return defaults;
|
||||
}
|
||||
const resolved = resolveActions({
|
||||
blockType: paragraphType.value,
|
||||
isEmpty: paragraphIsEmpty.value,
|
||||
defaults: defaults.slice()
|
||||
});
|
||||
if (!Array.isArray(resolved) || resolved.length === 0) {
|
||||
return defaults;
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
visible.value = false;
|
||||
hoveredDomBlock.value = null;
|
||||
hoveredMeta.value = null;
|
||||
};
|
||||
|
||||
const clearHideTimer = () => {
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReset = (delay = HIDE_DELAY_MS) => {
|
||||
clearHideTimer();
|
||||
hideTimer = window.setTimeout(() => {
|
||||
hideTimer = 0;
|
||||
if (!hoveringHandle.value) {
|
||||
reset();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const getEditorDom = () => editorRef.value?.view?.dom || null;
|
||||
const getEditorView = () => editorRef.value?.view || null;
|
||||
const getEditorState = () => getEditorView()?.state || null;
|
||||
|
||||
const updatePosition = (nextMeta = hoveredMeta.value, nextDomBlock = hoveredDomBlock.value) => {
|
||||
const container = scrollContainerRef.value;
|
||||
if (!container || !nextMeta) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
let blockRect = null;
|
||||
if (nextDomBlock?.isConnected) {
|
||||
blockRect = nextDomBlock.getBoundingClientRect();
|
||||
}
|
||||
|
||||
if (!blockRect) {
|
||||
const coords = resolvePositionFromMeta({
|
||||
view: getEditorView(),
|
||||
meta: nextMeta
|
||||
});
|
||||
if (coords) {
|
||||
blockRect = {
|
||||
top: coords.top,
|
||||
left: coords.left
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!blockRect) {
|
||||
if (!hoveringHandle.value) {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStyle = resolveHandleStyle({
|
||||
container,
|
||||
blockRect
|
||||
});
|
||||
if (!nextStyle) {
|
||||
if (!hoveringHandle.value) {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
style.value = nextStyle;
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const updateHoveredBlock = (domBlock, event) => {
|
||||
if (!domBlock) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const view = getEditorView();
|
||||
const state = getEditorState();
|
||||
const range = resolveRangeFromDomBlock({ view, state, domBlock })
|
||||
|| resolveRangeByMouseCoords({ view, state, event });
|
||||
const meta = resolveMetaFromRange({ state, range });
|
||||
|
||||
if (!meta || meta.from >= meta.to) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedDomBlock = domBlock || resolveDomBlockFromMeta({
|
||||
view,
|
||||
editorRoot: getEditorDom(),
|
||||
meta
|
||||
});
|
||||
|
||||
hoveredDomBlock.value = resolvedDomBlock;
|
||||
hoveredMeta.value = meta;
|
||||
updatePosition(meta, resolvedDomBlock);
|
||||
};
|
||||
|
||||
const handleMouseMove = (event) => {
|
||||
clearHideTimer();
|
||||
if (event?.target instanceof Element && event.target.closest('.rich-text-paragraph-handle')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domBlock = resolveHoveredDomBlock({
|
||||
target: event?.target,
|
||||
editorDom: getEditorDom()
|
||||
});
|
||||
|
||||
if (!domBlock) {
|
||||
if (hoveringHandle.value) {
|
||||
return;
|
||||
}
|
||||
if (hoveredMeta.value) {
|
||||
// Keep a short grace period when cursor moves from tiny/empty paragraphs to the handle.
|
||||
scheduleReset(TRANSITION_HIDE_DELAY_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateHoveredBlock(domBlock, event);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (event) => {
|
||||
const relatedTarget = event?.relatedTarget;
|
||||
if (relatedTarget instanceof Element && relatedTarget.closest('.rich-text-paragraph-handle')) {
|
||||
return;
|
||||
}
|
||||
if (hoveringHandle.value) {
|
||||
return;
|
||||
}
|
||||
scheduleReset(TRANSITION_HIDE_DELAY_MS);
|
||||
};
|
||||
|
||||
const handleScrollOrResize = () => {
|
||||
if (!hoveredMeta.value) {
|
||||
return;
|
||||
}
|
||||
updatePosition(hoveredMeta.value, hoveredDomBlock.value);
|
||||
};
|
||||
|
||||
const bindEvents = () => {
|
||||
editorDom = getEditorDom();
|
||||
scrollContainer = scrollContainerRef.value;
|
||||
mouseHost = scrollContainerRef.value;
|
||||
|
||||
if (!editorDom || !scrollContainer || !mouseHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
mouseHost.addEventListener('mousemove', handleMouseMove);
|
||||
mouseHost.addEventListener('mouseleave', handleMouseLeave);
|
||||
if (editorDom !== mouseHost) {
|
||||
editorDom.addEventListener('mousemove', handleMouseMove);
|
||||
editorDom.addEventListener('mouseleave', handleMouseLeave);
|
||||
}
|
||||
scrollContainer.addEventListener('scroll', handleScrollOrResize, { passive: true });
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
if (scrollContainer) {
|
||||
scrollContainer.removeEventListener('scroll', handleScrollOrResize);
|
||||
scrollContainer = null;
|
||||
}
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
reset();
|
||||
};
|
||||
|
||||
const tryInsertParagraphAt = (positions = []) => {
|
||||
const editor = editorRef.value;
|
||||
const view = editor?.view;
|
||||
const state = view?.state;
|
||||
const paragraphNodeType = state?.schema?.nodes?.paragraph;
|
||||
if (!editor || !view || !state || !paragraphNodeType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidates = dedupeNumberList(positions)
|
||||
.map((pos) => Math.max(0, Math.min(pos, state.doc.content.size)));
|
||||
|
||||
for (const pos of candidates) {
|
||||
try {
|
||||
const tr = view.state.tr.insert(pos, paragraphNodeType.create());
|
||||
view.dispatch(tr.scrollIntoView());
|
||||
editor.commands.focus(Math.max(0, Math.min(pos + 1, view.state.doc.content.size)));
|
||||
onMutated?.({ type: 'insert', position: pos, blockType: paragraphType.value });
|
||||
return true;
|
||||
} catch (_) {
|
||||
// fallback to next candidate
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const addParagraphAbove = () => {
|
||||
const meta = hoveredMeta.value;
|
||||
if (!meta) {
|
||||
return false;
|
||||
}
|
||||
const inserted = tryInsertParagraphAt([meta.from, meta.topFrom]);
|
||||
if (inserted) {
|
||||
reset();
|
||||
}
|
||||
return inserted;
|
||||
};
|
||||
|
||||
const addParagraphBelow = () => {
|
||||
const meta = hoveredMeta.value;
|
||||
if (!meta) {
|
||||
return false;
|
||||
}
|
||||
const inserted = tryInsertParagraphAt([meta.to, meta.topTo]);
|
||||
if (inserted) {
|
||||
reset();
|
||||
}
|
||||
return inserted;
|
||||
};
|
||||
|
||||
const deleteHoveredParagraph = () => {
|
||||
const editor = editorRef.value;
|
||||
const view = editor?.view;
|
||||
const meta = hoveredMeta.value;
|
||||
if (!editor || !view || !meta || meta.from >= meta.to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = view.state;
|
||||
if (state.doc.childCount <= 1) {
|
||||
editor.commands.clearContent(true);
|
||||
onMutated?.({ type: 'delete', blockType: paragraphType.value });
|
||||
reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
view.dispatch(state.tr.deleteRange(meta.from, meta.to).scrollIntoView());
|
||||
editor.commands.focus();
|
||||
onMutated?.({ type: 'delete', blockType: paragraphType.value });
|
||||
reset();
|
||||
return true;
|
||||
};
|
||||
|
||||
const focusHoveredBlock = (position = 'start') => {
|
||||
const editor = editorRef.value;
|
||||
const meta = hoveredMeta.value;
|
||||
const size = editor?.state?.doc?.content?.size;
|
||||
if (!editor || !meta || !Number.isFinite(size)) {
|
||||
return false;
|
||||
}
|
||||
const targetPos = position === 'end'
|
||||
? Math.max(meta.from + 1, meta.to - 1)
|
||||
: meta.from + 1;
|
||||
const safePos = Math.max(1, Math.min(targetPos, size));
|
||||
editor.commands.focus(safePos);
|
||||
return true;
|
||||
};
|
||||
|
||||
const runParagraphAction = (key) => {
|
||||
const findActionByKey = (actions, targetKey) => {
|
||||
if (!Array.isArray(actions) || !targetKey) {
|
||||
return null;
|
||||
}
|
||||
for (const item of actions) {
|
||||
if (item?.key === targetKey) {
|
||||
return item;
|
||||
}
|
||||
if (Array.isArray(item?.children) && item.children.length) {
|
||||
const childMatched = findActionByKey(item.children, targetKey);
|
||||
if (childMatched) {
|
||||
return childMatched;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const action = findActionByKey(paragraphActions.value, key);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const ctx = {
|
||||
blockType: paragraphType.value,
|
||||
isEmpty: paragraphIsEmpty.value,
|
||||
editor: editorRef.value,
|
||||
meta: hoveredMeta.value,
|
||||
focusBlock: focusHoveredBlock,
|
||||
addParagraphAbove,
|
||||
addParagraphBelow,
|
||||
deleteParagraph: deleteHoveredParagraph
|
||||
};
|
||||
if (typeof action.handler === 'function') {
|
||||
action.handler(ctx);
|
||||
return;
|
||||
}
|
||||
if (key === 'add-above') {
|
||||
addParagraphAbove();
|
||||
return;
|
||||
}
|
||||
if (key === 'add-below') {
|
||||
addParagraphBelow();
|
||||
return;
|
||||
}
|
||||
if (key === 'delete') {
|
||||
deleteHoveredParagraph();
|
||||
}
|
||||
};
|
||||
|
||||
const handleHandleEnter = () => {
|
||||
clearHideTimer();
|
||||
hoveringHandle.value = true;
|
||||
};
|
||||
|
||||
const handleHandleLeave = () => {
|
||||
hoveringHandle.value = false;
|
||||
scheduleReset(HIDE_DELAY_MS);
|
||||
};
|
||||
|
||||
watch(
|
||||
enabled,
|
||||
(next) => {
|
||||
unbindEvents();
|
||||
if (next) {
|
||||
bindEvents();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => editorRef.value,
|
||||
() => {
|
||||
if (!enabled.value) {
|
||||
return;
|
||||
}
|
||||
unbindEvents();
|
||||
bindEvents();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHideTimer();
|
||||
unbindEvents();
|
||||
});
|
||||
|
||||
return {
|
||||
paragraphHandleVisible: visible,
|
||||
paragraphHandleStyle: style,
|
||||
paragraphHandleMode,
|
||||
paragraphType,
|
||||
paragraphIsEmpty,
|
||||
paragraphActions,
|
||||
runParagraphAction,
|
||||
addParagraphAbove,
|
||||
addParagraphBelow,
|
||||
deleteHoveredParagraph,
|
||||
handleHandleEnter,
|
||||
handleHandleLeave
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
const normalizeColorForPicker = (value, fallback) => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(normalized) ? normalized : fallback;
|
||||
};
|
||||
|
||||
export function useRichTextSelectionColors({ editorRef }) {
|
||||
const selectionTextColor = computed(() => {
|
||||
const value = editorRef.value?.getAttributes('textStyle')?.color;
|
||||
return normalizeColorForPicker(value, '#1f2937');
|
||||
});
|
||||
|
||||
const selectionBackgroundColor = computed(() => {
|
||||
const value = editorRef.value?.getAttributes('highlight')?.color;
|
||||
return normalizeColorForPicker(value, '');
|
||||
});
|
||||
|
||||
return {
|
||||
selectionTextColor,
|
||||
selectionBackgroundColor
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export function useRichTextToolbarActions(options = {}) {
|
||||
const {
|
||||
editorRef,
|
||||
codeLanguageDraftRef,
|
||||
resolveCodeBlockLanguage,
|
||||
hideCodeLanguageFloating,
|
||||
updateCodeLanguageFloating
|
||||
} = options;
|
||||
|
||||
const runCommand = (command) => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
command(editorRef.value.chain().focus()).run();
|
||||
};
|
||||
|
||||
const isActive = (name) => editorRef.value?.isActive(name) ?? false;
|
||||
const isHeadingActive = (level) => editorRef.value?.isActive('heading', { level }) ?? false;
|
||||
|
||||
const toggleBold = () => runCommand((chain) => chain.toggleBold());
|
||||
const toggleUnderline = () => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const chain = editorRef.value.chain().focus();
|
||||
if (typeof chain.toggleUnderline !== 'function') {
|
||||
return;
|
||||
}
|
||||
chain.toggleUnderline().run();
|
||||
};
|
||||
const toggleItalic = () => runCommand((chain) => chain.toggleItalic());
|
||||
const toggleStrike = () => runCommand((chain) => chain.toggleStrike());
|
||||
const toggleInlineCode = () => runCommand((chain) => chain.toggleCode());
|
||||
const toggleHeading = (level) => runCommand((chain) => chain.toggleHeading({ level }));
|
||||
const toggleBulletList = () => runCommand((chain) => chain.toggleBulletList());
|
||||
const toggleOrderedList = () => runCommand((chain) => chain.toggleOrderedList());
|
||||
const toggleBlockquote = () => runCommand((chain) => chain.toggleBlockquote());
|
||||
const resolveLinkChain = (pos) => {
|
||||
const chain = editorRef.value.chain();
|
||||
if (typeof pos === 'number' && Number.isFinite(pos)) {
|
||||
return chain.focus(pos).extendMarkRange('link');
|
||||
}
|
||||
return chain.focus().extendMarkRange('link');
|
||||
};
|
||||
|
||||
const setLink = (href = '', options = {}) => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const url = String(href || '').trim();
|
||||
if (!url) {
|
||||
resolveLinkChain(options.pos).unsetLink().run();
|
||||
return;
|
||||
}
|
||||
resolveLinkChain(options.pos).setLink({ href: url }).run();
|
||||
};
|
||||
const unsetLink = (options = {}) => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
resolveLinkChain(options.pos).unsetLink().run();
|
||||
};
|
||||
const setTextColor = (color = '') => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const value = String(color || '').trim();
|
||||
if (!value) {
|
||||
editorRef.value.chain().focus().unsetColor().run();
|
||||
return;
|
||||
}
|
||||
editorRef.value.chain().focus().setColor(value).run();
|
||||
};
|
||||
const setBackgroundColor = (color = '') => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const value = String(color || '').trim();
|
||||
if (!value) {
|
||||
editorRef.value.chain().focus().unsetHighlight().run();
|
||||
return;
|
||||
}
|
||||
editorRef.value.chain().focus().setHighlight({ color: value }).run();
|
||||
};
|
||||
|
||||
const toggleCodeBlock = () => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const chain = editorRef.value.chain().focus();
|
||||
if (editorRef.value.isActive('codeBlock')) {
|
||||
chain.toggleCodeBlock().run();
|
||||
hideCodeLanguageFloating?.();
|
||||
return;
|
||||
}
|
||||
const nextLanguage = resolveCodeBlockLanguage?.(codeLanguageDraftRef.value) || 'plaintext';
|
||||
codeLanguageDraftRef.value = nextLanguage;
|
||||
chain.setCodeBlock({ language: nextLanguage }).run();
|
||||
updateCodeLanguageFloating?.();
|
||||
};
|
||||
|
||||
const undo = () => runCommand((chain) => chain.undo());
|
||||
const redo = () => runCommand((chain) => chain.redo());
|
||||
|
||||
const runComponentCommand = (item) => {
|
||||
if (!editorRef.value || !item?.command) {
|
||||
return;
|
||||
}
|
||||
const chain = editorRef.value.chain().focus();
|
||||
const commandMethod = chain[item.command];
|
||||
if (typeof commandMethod !== 'function') {
|
||||
return;
|
||||
}
|
||||
commandMethod.call(chain, item.commandArgs || {});
|
||||
chain.run();
|
||||
};
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isHeadingActive,
|
||||
toggleBold,
|
||||
toggleUnderline,
|
||||
toggleItalic,
|
||||
toggleStrike,
|
||||
toggleInlineCode,
|
||||
toggleHeading,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleBlockquote,
|
||||
toggleCodeBlock,
|
||||
setLink,
|
||||
unsetLink,
|
||||
setTextColor,
|
||||
setBackgroundColor,
|
||||
undo,
|
||||
redo,
|
||||
runComponentCommand
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { createRichTextTurndown, htmlToMarkdown, markdownToHtml } from './value-bridge/markdownBridge';
|
||||
import { cloneJsonDoc, normalizeJsonDoc, serializeJsonDoc } from './value-bridge/jsonDocBridge';
|
||||
|
||||
export function useRichTextValueBridge(options = {}) {
|
||||
const { editorRef, valueFormatRef } = options;
|
||||
const applyingModelValue = ref(false);
|
||||
const lastEmittedValue = ref('');
|
||||
const isJsonValueMode = computed(() => valueFormatRef.value === 'json');
|
||||
const turndown = createRichTextTurndown();
|
||||
|
||||
const serializeModelValue = (value) => {
|
||||
if (isJsonValueMode.value) {
|
||||
return serializeJsonDoc(value);
|
||||
}
|
||||
return String(value || '');
|
||||
};
|
||||
const modelValueFromEditor = (instance) => {
|
||||
if (isJsonValueMode.value) {
|
||||
return cloneJsonDoc(instance.getJSON());
|
||||
}
|
||||
return htmlToMarkdown(instance.getHTML(), turndown);
|
||||
};
|
||||
const resolveInitialEditorContent = (modelValue) => {
|
||||
if (isJsonValueMode.value) {
|
||||
return normalizeJsonDoc(modelValue);
|
||||
}
|
||||
return markdownToHtml(String(modelValue || ''));
|
||||
};
|
||||
const applyModelValueToEditor = (modelValue) => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const serialized = serializeModelValue(modelValue);
|
||||
if (serialized === lastEmittedValue.value) {
|
||||
return;
|
||||
}
|
||||
applyingModelValue.value = true;
|
||||
if (isJsonValueMode.value) {
|
||||
editorRef.value.commands.setContent(normalizeJsonDoc(modelValue), false);
|
||||
} else {
|
||||
editorRef.value.commands.setContent(markdownToHtml(String(modelValue || '')), false, {
|
||||
preserveWhitespace: true
|
||||
});
|
||||
}
|
||||
lastEmittedValue.value = serialized;
|
||||
applyingModelValue.value = false;
|
||||
};
|
||||
const syncLastEmittedValue = (value) => {
|
||||
lastEmittedValue.value = serializeModelValue(value);
|
||||
};
|
||||
|
||||
return {
|
||||
applyingModelValue,
|
||||
isJsonValueMode,
|
||||
lastEmittedValue,
|
||||
serializeModelValue,
|
||||
modelValueFromEditor,
|
||||
resolveInitialEditorContent,
|
||||
applyModelValueToEditor,
|
||||
syncLastEmittedValue
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import * as Y from 'yjs';
|
||||
import { WebsocketProvider } from 'y-websocket';
|
||||
import Collaboration from '@tiptap/extension-collaboration';
|
||||
import {
|
||||
createYjsContentBridge,
|
||||
YJS_CONTENT_CARRIER,
|
||||
YJS_CONTENT_FIELD
|
||||
} from '@/composables/document/editor/collab/useYjsContentBridge';
|
||||
|
||||
import { resolveCollabUrl, getAwarenessPeerCount } from '@/utils/collabUrl';
|
||||
|
||||
const hasSeedContent = (content) => {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
if (!content || typeof content !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(content.content)) {
|
||||
return content.content.length > 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export function useRichTextYjsCollaboration(options = {}) {
|
||||
const {
|
||||
collabEnabledRef,
|
||||
collabRoomRef,
|
||||
collabUrlRef,
|
||||
collabTokenRef,
|
||||
modelValueRef,
|
||||
editorRef,
|
||||
resolveInitialEditorContent,
|
||||
onSync
|
||||
} = options;
|
||||
|
||||
const ydocRef = ref(null);
|
||||
const contentBridgeRef = ref(null);
|
||||
const providerRef = ref(null);
|
||||
const collabSyncedRef = ref(false);
|
||||
const pendingSeedRef = ref(false);
|
||||
|
||||
const emitSync = (isSynced) => {
|
||||
collabSyncedRef.value = Boolean(isSynced);
|
||||
onSync?.(Boolean(isSynced));
|
||||
};
|
||||
|
||||
const seedEditorFromModelValue = () => {
|
||||
const instance = editorRef.value;
|
||||
if (!instance) {
|
||||
return false;
|
||||
}
|
||||
const seedContent = resolveInitialEditorContent(modelValueRef.value);
|
||||
if (!hasSeedContent(seedContent)) {
|
||||
return false;
|
||||
}
|
||||
instance.commands.setContent(seedContent, false, {
|
||||
preserveWhitespace: true
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const maybeSeedAfterSync = () => {
|
||||
const bridge = contentBridgeRef.value;
|
||||
if (!collabSyncedRef.value) {
|
||||
return;
|
||||
}
|
||||
if (!bridge || !bridge.isEmpty()) {
|
||||
pendingSeedRef.value = false;
|
||||
return;
|
||||
}
|
||||
if (getAwarenessPeerCount(providerRef.value) > 1) {
|
||||
pendingSeedRef.value = false;
|
||||
return;
|
||||
}
|
||||
if (!editorRef.value) {
|
||||
pendingSeedRef.value = true;
|
||||
return;
|
||||
}
|
||||
pendingSeedRef.value = false;
|
||||
seedEditorFromModelValue();
|
||||
};
|
||||
|
||||
const teardownCollaboration = () => {
|
||||
if (providerRef.value) {
|
||||
providerRef.value.destroy();
|
||||
providerRef.value = null;
|
||||
}
|
||||
if (ydocRef.value) {
|
||||
ydocRef.value.destroy();
|
||||
ydocRef.value = null;
|
||||
}
|
||||
contentBridgeRef.value = null;
|
||||
pendingSeedRef.value = false;
|
||||
emitSync(false);
|
||||
};
|
||||
|
||||
const setupCollaboration = () => {
|
||||
teardownCollaboration();
|
||||
|
||||
if (!collabEnabledRef.value || !collabRoomRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = resolveCollabUrl(collabUrlRef.value);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
ydocRef.value = new Y.Doc();
|
||||
contentBridgeRef.value = createYjsContentBridge({
|
||||
doc: ydocRef.value,
|
||||
carrier: YJS_CONTENT_CARRIER.XML_FRAGMENT
|
||||
});
|
||||
|
||||
providerRef.value = new WebsocketProvider(url, collabRoomRef.value, ydocRef.value, {
|
||||
params: collabTokenRef.value ? { token: collabTokenRef.value } : {}
|
||||
});
|
||||
|
||||
providerRef.value.on('sync', (isSynced) => {
|
||||
emitSync(isSynced);
|
||||
if (!isSynced) {
|
||||
return;
|
||||
}
|
||||
maybeSeedAfterSync();
|
||||
});
|
||||
};
|
||||
|
||||
const collaborationExtensionsRef = computed(() => {
|
||||
if (!collabEnabledRef.value || !ydocRef.value) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
Collaboration.configure({
|
||||
document: ydocRef.value,
|
||||
field: YJS_CONTENT_FIELD
|
||||
})
|
||||
];
|
||||
});
|
||||
|
||||
const collaborationBridgeRef = computed(() => {
|
||||
if (!ydocRef.value || !providerRef.value) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ydoc: ydocRef.value,
|
||||
provider: providerRef.value
|
||||
};
|
||||
});
|
||||
|
||||
watch(editorRef, (instance) => {
|
||||
if (!instance || !pendingSeedRef.value) {
|
||||
return;
|
||||
}
|
||||
maybeSeedAfterSync();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setupCollaboration();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
teardownCollaboration();
|
||||
});
|
||||
|
||||
return {
|
||||
collabSyncedRef,
|
||||
collaborationExtensionsRef,
|
||||
collaborationBridgeRef,
|
||||
setupCollaboration,
|
||||
teardownCollaboration
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
decodeRichTableModelFromAttr,
|
||||
decodeRichTableModelFromMarkdownBlock,
|
||||
encodeRichTableModelForAttr,
|
||||
encodeRichTableModelToMarkdownBlock
|
||||
} from '@/components/document/rich-text/components/table/richTableModel';
|
||||
|
||||
export {
|
||||
decodeRichTableModelFromAttr,
|
||||
decodeRichTableModelFromMarkdownBlock,
|
||||
encodeRichTableModelForAttr,
|
||||
encodeRichTableModelToMarkdownBlock
|
||||
};
|
||||
|
||||
export const encodeDrawioSourceToBase64 = (value) => {
|
||||
const source = String(value || '');
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
if (typeof TextEncoder !== 'undefined') {
|
||||
const bytes = new TextEncoder().encode(source);
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
const chunk = bytes.subarray(index, index + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
return btoa(unescape(encodeURIComponent(source)));
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const decodeDrawioSourceFromBase64 = (value) => {
|
||||
const source = String(value || '').trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const binary = atob(source);
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
return decodeURIComponent(escape(binary));
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeDrawioSource = (value) => {
|
||||
const source = String(value || '').trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
if (source.startsWith('base64:')) {
|
||||
const decoded = decodeDrawioSourceFromBase64(source.slice('base64:'.length));
|
||||
return decoded || '';
|
||||
}
|
||||
return source;
|
||||
};
|
||||
|
||||
export const resolveMindmapSource = (node) => {
|
||||
if (!node || typeof node.getAttribute !== 'function') {
|
||||
return '';
|
||||
}
|
||||
const rawSource = node.getAttribute('data-source') || node.getAttribute('source') || '';
|
||||
if (rawSource) {
|
||||
try {
|
||||
return decodeURIComponent(String(rawSource));
|
||||
} catch (_) {
|
||||
return String(rawSource);
|
||||
}
|
||||
}
|
||||
if (typeof node.querySelector === 'function') {
|
||||
const textarea = node.querySelector('textarea');
|
||||
if (textarea?.value) {
|
||||
return String(textarea.value);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const resolveDrawioSource = (node) => {
|
||||
if (!node || typeof node.getAttribute !== 'function') {
|
||||
return '';
|
||||
}
|
||||
const source = node.getAttribute('data-diagram') || node.getAttribute('diagram') || '';
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(String(source));
|
||||
} catch (_) {
|
||||
return String(source);
|
||||
}
|
||||
};
|
||||
|
||||
export const extractDrawioBlocksFromHtml = (sourceHtml) => {
|
||||
const source = String(sourceHtml || '');
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks = [];
|
||||
const pattern = /<([a-z0-9-]+)\b([^>]*)>/gi;
|
||||
let matched = pattern.exec(source);
|
||||
while (matched) {
|
||||
const tagName = String(matched[1] || '').toLowerCase();
|
||||
const attrsSource = String(matched[2] || '');
|
||||
const dataTypeMatch = attrsSource.match(/\bdata-type=["']([^"']+)["']/i);
|
||||
const dataType = String(dataTypeMatch?.[1] || '').toLowerCase();
|
||||
const hasDrawioType = tagName === 'yoresee-drawio' || dataType.includes('drawio');
|
||||
if (!hasDrawioType) {
|
||||
matched = pattern.exec(source);
|
||||
continue;
|
||||
}
|
||||
const dataDiagramMatch = attrsSource.match(/\b(?:data-diagram|diagram)=["']([^"']+)["']/i);
|
||||
if (!dataDiagramMatch?.[1]) {
|
||||
matched = pattern.exec(source);
|
||||
continue;
|
||||
}
|
||||
|
||||
let decodedSource = '';
|
||||
try {
|
||||
decodedSource = decodeURIComponent(String(dataDiagramMatch[1]));
|
||||
} catch (_) {
|
||||
decodedSource = String(dataDiagramMatch[1]);
|
||||
}
|
||||
decodedSource = decodedSource.trim();
|
||||
if (!decodedSource) {
|
||||
matched = pattern.exec(source);
|
||||
continue;
|
||||
}
|
||||
|
||||
const encoded = encodeDrawioSourceToBase64(decodedSource);
|
||||
if (encoded) {
|
||||
blocks.push(`\`\`\`drawio\nbase64:${encoded}\n\`\`\``);
|
||||
}
|
||||
|
||||
matched = pattern.exec(source);
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const normalizeLanguageToken = (value = '') =>
|
||||
String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_+-]/g, '');
|
||||
|
||||
export const extractLanguageFromCodeClass = (className = '') => {
|
||||
const source = String(className || '').trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
const matched = source.match(/(?:^|\s)language-([a-z0-9_+-]+)(?:\s|$)/i);
|
||||
if (!matched?.[1]) {
|
||||
return '';
|
||||
}
|
||||
return normalizeLanguageToken(matched[1]);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
const EMPTY_DOC = Object.freeze({ type: 'doc', content: [] });
|
||||
const EMPTY_DOC_JSON = '{"type":"doc","content":[]}';
|
||||
|
||||
const safeClone = (value) => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch (_) {
|
||||
return { ...EMPTY_DOC };
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeJsonDoc = (value) => {
|
||||
if (value && typeof value === 'object' && value.type === 'doc') {
|
||||
return safeClone(value);
|
||||
}
|
||||
return { ...EMPTY_DOC };
|
||||
};
|
||||
|
||||
export const serializeJsonDoc = (value) => {
|
||||
try {
|
||||
return JSON.stringify(normalizeJsonDoc(value));
|
||||
} catch (_) {
|
||||
return EMPTY_DOC_JSON;
|
||||
}
|
||||
};
|
||||
|
||||
export const cloneJsonDoc = safeClone;
|
||||
@@ -0,0 +1,186 @@
|
||||
import TurndownService from 'turndown';
|
||||
import { marked } from 'marked';
|
||||
import { COMMENT_ANCHOR_ATTR } from '@/components/document/rich-text/extensions/commentAnchorExtension';
|
||||
import {
|
||||
decodeRichTableModelFromAttr,
|
||||
decodeRichTableModelFromMarkdownBlock,
|
||||
encodeRichTableModelForAttr,
|
||||
encodeRichTableModelToMarkdownBlock,
|
||||
encodeDrawioSourceToBase64,
|
||||
extractDrawioBlocksFromHtml,
|
||||
extractLanguageFromCodeClass,
|
||||
normalizeDrawioSource,
|
||||
resolveDrawioSource,
|
||||
resolveMindmapSource
|
||||
} from './componentCodec';
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true
|
||||
});
|
||||
|
||||
export const createRichTextTurndown = () => {
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
bulletListMarker: '-',
|
||||
codeBlockStyle: 'fenced',
|
||||
emDelimiter: '*'
|
||||
});
|
||||
|
||||
turndown.addRule('fencedCodeBlockWithLanguage', {
|
||||
filter: (node) => {
|
||||
if (!node || node.nodeName !== 'PRE' || !node.firstChild) {
|
||||
return false;
|
||||
}
|
||||
return node.firstChild.nodeName === 'CODE';
|
||||
},
|
||||
replacement: (_, node) => {
|
||||
const codeNode = node.firstChild;
|
||||
const className = typeof codeNode.getAttribute === 'function' ? codeNode.getAttribute('class') || '' : '';
|
||||
const language = extractLanguageFromCodeClass(className);
|
||||
const rawText = String(codeNode.textContent || '').replace(/\n+$/, '');
|
||||
const languageSuffix = language ? language : '';
|
||||
return `\n\n\`\`\`${languageSuffix}\n${rawText}\n\`\`\`\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
turndown.addRule('yoreseeMindmap', {
|
||||
filter: (node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodeName = String(node.nodeName || '').toLowerCase();
|
||||
if (nodeName === 'yoresee-mindmap') {
|
||||
return true;
|
||||
}
|
||||
if (typeof node.getAttribute !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const dataType = String(node.getAttribute('data-type') || '').toLowerCase();
|
||||
const hasDataSource = Boolean(node.getAttribute('data-source') || node.getAttribute('source'));
|
||||
return hasDataSource && dataType.includes('mindmap');
|
||||
},
|
||||
replacement: (_, node) => {
|
||||
const source = resolveMindmapSource(node).trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
return `\n\n\`\`\`mindmap\n${source}\n\`\`\`\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
turndown.addRule('yoreseeDrawio', {
|
||||
filter: (node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodeName = String(node.nodeName || '').toLowerCase();
|
||||
if (nodeName === 'yoresee-drawio') {
|
||||
return true;
|
||||
}
|
||||
if (typeof node.getAttribute !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const dataType = String(node.getAttribute('data-type') || '').toLowerCase();
|
||||
const hasData = Boolean(node.getAttribute('data-diagram') || node.getAttribute('diagram'));
|
||||
return hasData && dataType.includes('drawio');
|
||||
},
|
||||
replacement: (_, node) => {
|
||||
const source = resolveDrawioSource(node).trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
const encoded = encodeDrawioSourceToBase64(source);
|
||||
if (!encoded) {
|
||||
return '';
|
||||
}
|
||||
return `\n\n\`\`\`drawio\nbase64:${encoded}\n\`\`\`\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
turndown.addRule('yoreseeTable', {
|
||||
filter: (node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodeName = String(node.nodeName || '').toLowerCase();
|
||||
if (nodeName === 'yoresee-table') {
|
||||
return true;
|
||||
}
|
||||
if (typeof node.getAttribute !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const dataType = String(node.getAttribute('data-type') || '').toLowerCase();
|
||||
const hasData = Boolean(node.getAttribute('data-table') || node.getAttribute('table'));
|
||||
return hasData && dataType.includes('table');
|
||||
},
|
||||
replacement: (_, node) => {
|
||||
const tableModel = decodeRichTableModelFromAttr(
|
||||
node.getAttribute('data-table') || node.getAttribute('table')
|
||||
);
|
||||
const encoded = encodeRichTableModelToMarkdownBlock(tableModel);
|
||||
if (!encoded) {
|
||||
return '';
|
||||
}
|
||||
return `\n\n\`\`\`table\n${encoded}\n\`\`\`\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
turndown.addRule('yoreseeCommentAnchor', {
|
||||
filter: (node) => node.nodeName === 'SPAN' && node.getAttribute(COMMENT_ANCHOR_ATTR),
|
||||
replacement: (content, node) => {
|
||||
const anchorId = String(node.getAttribute(COMMENT_ANCHOR_ATTR) || '').trim();
|
||||
if (!anchorId) {
|
||||
return content || '';
|
||||
}
|
||||
return `<span ${COMMENT_ANCHOR_ATTR}="${anchorId}">${content || ''}</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
return turndown;
|
||||
};
|
||||
|
||||
export const markdownToHtml = (value) => {
|
||||
const markdownSource = String(value || '');
|
||||
if (!markdownSource.trim()) {
|
||||
return '<p></p>';
|
||||
}
|
||||
|
||||
const sourceWithComponentBlocks = markdownSource
|
||||
.replace(/```drawio\s*\n([\s\S]*?)```/gi, (_, drawioSource) => {
|
||||
const normalized = normalizeDrawioSource(drawioSource);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
const encoded = encodeURIComponent(normalized);
|
||||
return `\n<yoresee-drawio data-diagram="${encoded}" data-type="drawio"></yoresee-drawio>\n`;
|
||||
})
|
||||
.replace(/```mindmap\s*\n([\s\S]*?)```/gi, (_, mindmapSource) => {
|
||||
const encoded = encodeURIComponent(String(mindmapSource || '').trim());
|
||||
return `\n<yoresee-mindmap data-source="${encoded}"></yoresee-mindmap>\n`;
|
||||
})
|
||||
.replace(/```table\s*\n([\s\S]*?)```/gi, (_, tableSource) => {
|
||||
const tableModel = decodeRichTableModelFromMarkdownBlock(tableSource);
|
||||
if (!tableModel) {
|
||||
return '';
|
||||
}
|
||||
const encoded = encodeRichTableModelForAttr(tableModel);
|
||||
return `\n<yoresee-table data-table="${encoded}" data-type="table"></yoresee-table>\n`;
|
||||
});
|
||||
|
||||
const parsed = marked.parse(sourceWithComponentBlocks, { async: false });
|
||||
return typeof parsed === 'string' ? parsed : markdownSource;
|
||||
};
|
||||
|
||||
export const htmlToMarkdown = (value, turndown) => {
|
||||
const source = String(value || '');
|
||||
if (!source.trim()) {
|
||||
return '';
|
||||
}
|
||||
const drawioFallbackBlocks = extractDrawioBlocksFromHtml(source);
|
||||
let markdown = turndown.turndown(source);
|
||||
if (drawioFallbackBlocks.length > 0 && !/```drawio[\s\S]*?```/i.test(markdown)) {
|
||||
markdown = `${markdown.trimEnd()}\n\n${drawioFallbackBlocks.join('\n\n')}`.trim();
|
||||
}
|
||||
return markdown.replace(/\n{3,}/g, '\n\n').trimEnd();
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { normalizeDocumentType } from '@/utils/documentType';
|
||||
import { useApiAction } from '@/composables/actions/useApiAction';
|
||||
|
||||
export function useTypedDocumentPersistence(options = {}) {
|
||||
const {
|
||||
type,
|
||||
docId,
|
||||
currentDocType,
|
||||
editorContent,
|
||||
t,
|
||||
getDocumentContent,
|
||||
updateDocument,
|
||||
saveContext = 'saveTypedDocument',
|
||||
loadContext = 'loadTypedDocument',
|
||||
rerender = null
|
||||
} = options;
|
||||
|
||||
const normalizedType = normalizeDocumentType(type, '');
|
||||
const { runSilent } = useApiAction({ t });
|
||||
|
||||
const isCurrentType = computed(() => normalizeDocumentType(currentDocType.value, '1') === normalizedType);
|
||||
const dirty = ref(false);
|
||||
const saveTimer = ref(null);
|
||||
const saveInFlight = ref(false);
|
||||
const saveSeq = ref(0);
|
||||
const lastSaved = ref('');
|
||||
const skipWatcher = ref(false);
|
||||
const loadSeq = ref(0);
|
||||
|
||||
const clearSaveTimer = () => {
|
||||
if (!saveTimer.value) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(saveTimer.value);
|
||||
saveTimer.value = null;
|
||||
};
|
||||
|
||||
const persistContent = async (content, requestSeq) => {
|
||||
if (!docId.value || !isCurrentType.value) {
|
||||
return;
|
||||
}
|
||||
if (saveInFlight.value) {
|
||||
scheduleSave();
|
||||
return;
|
||||
}
|
||||
if (content === lastSaved.value) {
|
||||
dirty.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
saveInFlight.value = true;
|
||||
await runSilent(
|
||||
() => updateDocument(docId.value, { content }),
|
||||
{
|
||||
context: saveContext,
|
||||
onSuccess: () => {
|
||||
if (requestSeq !== saveSeq.value) {
|
||||
return;
|
||||
}
|
||||
lastSaved.value = content;
|
||||
dirty.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
saveInFlight.value = false;
|
||||
};
|
||||
|
||||
const scheduleSave = () => {
|
||||
if (!isCurrentType.value || !dirty.value) {
|
||||
return;
|
||||
}
|
||||
clearSaveTimer();
|
||||
saveTimer.value = setTimeout(() => {
|
||||
saveSeq.value += 1;
|
||||
const seq = saveSeq.value;
|
||||
persistContent(editorContent.value, seq);
|
||||
}, 900);
|
||||
};
|
||||
|
||||
const flushSave = async () => {
|
||||
if (!isCurrentType.value || !dirty.value) {
|
||||
return;
|
||||
}
|
||||
clearSaveTimer();
|
||||
saveSeq.value += 1;
|
||||
const seq = saveSeq.value;
|
||||
await persistContent(editorContent.value, seq);
|
||||
};
|
||||
|
||||
const loadContent = async () => {
|
||||
if (!docId.value || !isCurrentType.value) {
|
||||
return;
|
||||
}
|
||||
const nextLoadSeq = ++loadSeq.value;
|
||||
const response = await runSilent(
|
||||
() => getDocumentContent(docId.value),
|
||||
{ context: loadContext }
|
||||
);
|
||||
if (!response || nextLoadSeq !== loadSeq.value) {
|
||||
return;
|
||||
}
|
||||
skipWatcher.value = true;
|
||||
editorContent.value = response.content || '';
|
||||
lastSaved.value = editorContent.value;
|
||||
dirty.value = false;
|
||||
skipWatcher.value = false;
|
||||
};
|
||||
|
||||
const rerenderEditor = () => {
|
||||
if (!isCurrentType.value || typeof rerender !== 'function') {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
rerender();
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [docId.value, currentDocType.value],
|
||||
async () => {
|
||||
clearSaveTimer();
|
||||
dirty.value = false;
|
||||
lastSaved.value = '';
|
||||
if (!isCurrentType.value) {
|
||||
return;
|
||||
}
|
||||
await loadContent();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => editorContent.value,
|
||||
() => {
|
||||
if (!isCurrentType.value || skipWatcher.value) {
|
||||
return;
|
||||
}
|
||||
dirty.value = true;
|
||||
scheduleSave();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isCurrentType.value,
|
||||
(enabled) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
rerenderEditor();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSaveTimer();
|
||||
});
|
||||
|
||||
return {
|
||||
isCurrentType,
|
||||
flushSave,
|
||||
rerenderEditor
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTypedDocumentPersistence } from '@/composables/document/editor/shared/useTypedDocumentPersistence';
|
||||
|
||||
export function useSlideDocumentPersistence(options = {}) {
|
||||
const {
|
||||
docId,
|
||||
currentDocType,
|
||||
editorContent,
|
||||
slideEditorRef,
|
||||
t,
|
||||
getDocumentContent,
|
||||
updateDocument
|
||||
} = options;
|
||||
|
||||
const {
|
||||
isCurrentType,
|
||||
flushSave,
|
||||
rerenderEditor
|
||||
} = useTypedDocumentPersistence({
|
||||
type: '3',
|
||||
docId,
|
||||
currentDocType,
|
||||
editorContent,
|
||||
t,
|
||||
getDocumentContent,
|
||||
updateDocument,
|
||||
saveContext: 'saveSlideDocument',
|
||||
loadContext: 'loadSlideDocument',
|
||||
rerender: () => slideEditorRef.value?.reRender?.()
|
||||
});
|
||||
|
||||
return {
|
||||
isSlideDocument: isCurrentType,
|
||||
flushSlideSave: flushSave,
|
||||
rerenderSlideEditor: rerenderEditor
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTypedDocumentPersistence } from '@/composables/document/editor/shared/useTypedDocumentPersistence';
|
||||
|
||||
export function useTableDocumentPersistence(options = {}) {
|
||||
const {
|
||||
docId,
|
||||
currentDocType,
|
||||
editorContent,
|
||||
tableEditorRef,
|
||||
t,
|
||||
getDocumentContent,
|
||||
updateDocument
|
||||
} = options;
|
||||
|
||||
const {
|
||||
isCurrentType,
|
||||
flushSave,
|
||||
rerenderEditor
|
||||
} = useTypedDocumentPersistence({
|
||||
type: '2',
|
||||
docId,
|
||||
currentDocType,
|
||||
editorContent,
|
||||
t,
|
||||
getDocumentContent,
|
||||
updateDocument,
|
||||
saveContext: 'saveTableDocument',
|
||||
loadContext: 'loadTableDocument',
|
||||
rerender: () => tableEditorRef.value?.reRender?.()
|
||||
});
|
||||
|
||||
return {
|
||||
isTableDocument: isCurrentType,
|
||||
flushTableSave: flushSave,
|
||||
rerenderTableEditor: rerenderEditor
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
createDocument as createDocumentApi,
|
||||
createTemplate as createTemplateApi,
|
||||
deleteDocument as deleteDocumentApi,
|
||||
updateDocumentMeta
|
||||
} from '@/services/api';
|
||||
import { useApiAction } from '@/composables/actions/useApiAction';
|
||||
import { DEFAULT_DOCUMENT_TYPE, normalizeDocumentType } from '@/utils/documentType';
|
||||
|
||||
export function useDocumentEditorActions({
|
||||
t,
|
||||
router,
|
||||
kbId,
|
||||
docId,
|
||||
currentDocType,
|
||||
currentDocTitle,
|
||||
editorContent,
|
||||
directoryTree,
|
||||
updateTreeNodeTitle,
|
||||
fetchDocuments
|
||||
}) {
|
||||
const { runWithLoading } = useApiAction({ t });
|
||||
|
||||
const isEditingTitle = ref(false);
|
||||
const pendingTitle = ref('');
|
||||
const savingTitle = ref(false);
|
||||
const renamingNode = ref(false);
|
||||
|
||||
const showCreateDialog = ref(false);
|
||||
const creatingLoading = ref(false);
|
||||
const pendingParentId = ref(null);
|
||||
const selectedDocumentType = ref(DEFAULT_DOCUMENT_TYPE);
|
||||
|
||||
const savingTemplate = ref(false);
|
||||
const showTemplateDialog = ref(false);
|
||||
const templateDialogInit = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
scope: 'own',
|
||||
tags: '',
|
||||
content: ''
|
||||
});
|
||||
|
||||
const openCreateDocumentDialog = (parentId = null, documentType = DEFAULT_DOCUMENT_TYPE) => {
|
||||
pendingParentId.value = parentId;
|
||||
selectedDocumentType.value = normalizeDocumentType(documentType);
|
||||
showCreateDialog.value = true;
|
||||
};
|
||||
|
||||
const cancelCreateDocument = () => {
|
||||
showCreateDialog.value = false;
|
||||
};
|
||||
|
||||
const createDocument = async (payload) => {
|
||||
const title = payload?.title?.trim() || t('document.untitledDefaultTitle');
|
||||
await runWithLoading(
|
||||
creatingLoading,
|
||||
async () => {
|
||||
const isPersonal = kbId.value === 'personal';
|
||||
const requestBody = {
|
||||
title,
|
||||
type: normalizeDocumentType(payload?.type || selectedDocumentType.value),
|
||||
container_type: isPersonal ? 'own' : 'knowledge_base',
|
||||
is_public: false
|
||||
};
|
||||
if (!isPersonal) {
|
||||
requestBody.knowledge_base_external_id = kbId.value;
|
||||
}
|
||||
if (payload?.parent_external_id) {
|
||||
requestBody.parent_external_id = payload.parent_external_id;
|
||||
} else if (pendingParentId.value) {
|
||||
requestBody.parent_external_id = pendingParentId.value;
|
||||
}
|
||||
if (payload?.template) {
|
||||
requestBody.template_id = payload.template;
|
||||
}
|
||||
return createDocumentApi(requestBody);
|
||||
},
|
||||
{
|
||||
context: 'createDocument',
|
||||
errorMessage: t('knowledgeBase.createDocumentError'),
|
||||
onSuccess: async (response) => {
|
||||
const isPersonal = kbId.value === 'personal';
|
||||
showCreateDialog.value = false;
|
||||
pendingParentId.value = null;
|
||||
await fetchDocuments();
|
||||
if (response?.external_id) {
|
||||
if (isPersonal) {
|
||||
router.push(`/mydocument/${response.external_id}`);
|
||||
} else {
|
||||
router.push(`/knowledge-base/${kbId.value}/document/${response.external_id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateFromTree = (payload) => {
|
||||
if (!payload) {
|
||||
openCreateDocumentDialog(null, DEFAULT_DOCUMENT_TYPE);
|
||||
return;
|
||||
}
|
||||
if (typeof payload === 'object' && ('target' in payload || 'type' in payload)) {
|
||||
openCreateDocumentDialog(
|
||||
payload?.target?.id || null,
|
||||
payload?.type || DEFAULT_DOCUMENT_TYPE
|
||||
);
|
||||
return;
|
||||
}
|
||||
openCreateDocumentDialog(payload?.id || null, DEFAULT_DOCUMENT_TYPE);
|
||||
};
|
||||
|
||||
const deletingDocument = ref(false);
|
||||
|
||||
const handleDeleteDocument = async (target) => {
|
||||
const targetId = target?.id || docId.value;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(t('document.deleteDocumentConfirm'), t('document.deleteDocument'), {
|
||||
confirmButtonText: t('button.confirm'),
|
||||
cancelButtonText: t('button.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await runWithLoading(
|
||||
deletingDocument,
|
||||
() => deleteDocumentApi(targetId),
|
||||
{
|
||||
context: 'deleteDocument',
|
||||
successMessage: t('document.deleteSuccess'),
|
||||
errorMessage: t('common.requestFailed'),
|
||||
onSuccess: async () => {
|
||||
await fetchDocuments();
|
||||
if (String(targetId) === String(docId.value)) {
|
||||
const isPersonal = kbId.value === 'personal';
|
||||
if (isPersonal) {
|
||||
router.push('/mydocuments');
|
||||
} else {
|
||||
router.push(`/knowledge-base/${kbId.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleRenameFromTree = async (payload) => {
|
||||
const targetNode = payload?.data;
|
||||
const nextTitle = payload?.title?.trim?.() || '';
|
||||
if (!targetNode?.id || !nextTitle) {
|
||||
ElMessage.error(t('knowledgeBase.titleRequired'));
|
||||
return;
|
||||
}
|
||||
const targetId = String(targetNode.id);
|
||||
const currentTitle = String(targetNode.label || '').trim();
|
||||
if (nextTitle === currentTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runWithLoading(
|
||||
renamingNode,
|
||||
() => updateDocumentMeta(targetId, { title: nextTitle }),
|
||||
{
|
||||
context: 'renameDocumentFromTree',
|
||||
errorMessage: t('common.requestFailed'),
|
||||
onSuccess: () => {
|
||||
updateTreeNodeTitle(directoryTree.value, targetId, nextTitle);
|
||||
if (String(docId.value || '') === targetId) {
|
||||
currentDocTitle.value = nextTitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const startEditTitle = async () => {
|
||||
if (!docId.value) {
|
||||
return;
|
||||
}
|
||||
isEditingTitle.value = true;
|
||||
pendingTitle.value = currentDocTitle.value || '';
|
||||
};
|
||||
|
||||
const cancelEditTitle = () => {
|
||||
isEditingTitle.value = false;
|
||||
pendingTitle.value = '';
|
||||
};
|
||||
|
||||
const commitTitle = async () => {
|
||||
if (!isEditingTitle.value) {
|
||||
return;
|
||||
}
|
||||
const nextTitle = pendingTitle.value.trim();
|
||||
if (!nextTitle) {
|
||||
ElMessage.error(t('knowledgeBase.titleRequired'));
|
||||
return;
|
||||
}
|
||||
if (nextTitle === currentDocTitle.value) {
|
||||
cancelEditTitle();
|
||||
return;
|
||||
}
|
||||
if (!docId.value) {
|
||||
cancelEditTitle();
|
||||
return;
|
||||
}
|
||||
await runWithLoading(
|
||||
savingTitle,
|
||||
() => updateDocumentMeta(docId.value, { title: nextTitle }),
|
||||
{
|
||||
context: 'updateDocumentMeta',
|
||||
errorMessage: t('common.requestFailed'),
|
||||
onSuccess: () => {
|
||||
currentDocTitle.value = nextTitle;
|
||||
updateTreeNodeTitle(directoryTree.value, docId.value, nextTitle);
|
||||
cancelEditTitle();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const openCreateTemplateDialog = () => {
|
||||
const defaultScope = kbId.value && kbId.value !== 'personal' ? 'knowledge_base' : 'own';
|
||||
templateDialogInit.value = {
|
||||
name: currentDocTitle.value || t('templates.untitled'),
|
||||
description: '',
|
||||
scope: defaultScope,
|
||||
tags: '',
|
||||
content: editorContent.value || ''
|
||||
};
|
||||
showTemplateDialog.value = true;
|
||||
};
|
||||
|
||||
const handleHeaderCommand = (command) => {
|
||||
if (command === 'create_template') {
|
||||
openCreateTemplateDialog();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const submitCreateTemplate = async (payload) => {
|
||||
if (!editorContent.value || !editorContent.value.trim()) {
|
||||
ElMessage.error(t('templates.emptyContent'));
|
||||
return;
|
||||
}
|
||||
|
||||
await runWithLoading(
|
||||
savingTemplate,
|
||||
async () => {
|
||||
const requestBody = {
|
||||
target_container: payload.scope,
|
||||
type: normalizeDocumentType(currentDocType?.value || DEFAULT_DOCUMENT_TYPE),
|
||||
template_content: JSON.stringify({
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
content: editorContent.value,
|
||||
tags: payload.tags || []
|
||||
})
|
||||
};
|
||||
if (payload.scope === 'knowledge_base' && kbId.value && kbId.value !== 'personal') {
|
||||
requestBody.knowledge_base_id = kbId.value;
|
||||
}
|
||||
await createTemplateApi(requestBody);
|
||||
},
|
||||
{
|
||||
context: 'createTemplate',
|
||||
successMessage: t('templates.saveSuccess'),
|
||||
errorMessage: t('templates.saveFailed'),
|
||||
onSuccess: () => {
|
||||
showTemplateDialog.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
isEditingTitle,
|
||||
pendingTitle,
|
||||
showCreateDialog,
|
||||
creatingLoading,
|
||||
pendingParentId,
|
||||
selectedDocumentType,
|
||||
savingTemplate,
|
||||
showTemplateDialog,
|
||||
templateDialogInit,
|
||||
openCreateDocumentDialog,
|
||||
cancelCreateDocument,
|
||||
createDocument,
|
||||
handleCreateFromTree,
|
||||
handleDeleteDocument,
|
||||
handleRenameFromTree,
|
||||
startEditTitle,
|
||||
cancelEditTitle,
|
||||
commitTitle,
|
||||
handleHeaderCommand,
|
||||
submitCreateTemplate
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { onMounted, watch } from 'vue';
|
||||
|
||||
export function useDocumentEditorLifecycle({
|
||||
props,
|
||||
route,
|
||||
initLanguage,
|
||||
fetchSystemInfo,
|
||||
kbId,
|
||||
docId,
|
||||
activeMenu,
|
||||
resolveActiveMenu,
|
||||
collabEnabled,
|
||||
collabReady,
|
||||
lastSyncedDocId,
|
||||
editorContent,
|
||||
currentDocTitle,
|
||||
knowledgeBaseName,
|
||||
fetchDocuments,
|
||||
updateCurrentDocTitle,
|
||||
expandToCurrentDoc,
|
||||
commentSidebarRef,
|
||||
isCommentCollapsed,
|
||||
cancelEditTitle,
|
||||
recordRecentDocument
|
||||
}) {
|
||||
const toggleCommentSidebar = () => {
|
||||
isCommentCollapsed.value = !isCommentCollapsed.value;
|
||||
};
|
||||
|
||||
const handleCollabSync = (isSynced) => {
|
||||
if (!collabEnabled.value) {
|
||||
collabReady.value = true;
|
||||
return;
|
||||
}
|
||||
collabReady.value = isSynced;
|
||||
if (isSynced) {
|
||||
lastSyncedDocId.value = docId.value || '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
initLanguage();
|
||||
activeMenu.value = resolveActiveMenu(kbId.value);
|
||||
|
||||
await fetchDocuments();
|
||||
if (docId.value) {
|
||||
recordRecentDocument(docId.value).catch(() => {});
|
||||
}
|
||||
if (lastSyncedDocId.value !== docId.value) {
|
||||
collabReady.value = !collabEnabled.value;
|
||||
}
|
||||
|
||||
await fetchSystemInfo();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.docId || route.params.docId,
|
||||
async (newDocId) => {
|
||||
docId.value = newDocId;
|
||||
editorContent.value = '';
|
||||
currentDocTitle.value = '';
|
||||
cancelEditTitle();
|
||||
await commentSidebarRef.value?.reload?.();
|
||||
if (docId.value) {
|
||||
recordRecentDocument(docId.value).catch(() => {});
|
||||
}
|
||||
await expandToCurrentDoc();
|
||||
updateCurrentDocTitle();
|
||||
if (lastSyncedDocId.value !== docId.value) {
|
||||
collabReady.value = !collabEnabled.value;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.kbId || route.params.kbId,
|
||||
async (newKbId) => {
|
||||
if (!newKbId) {
|
||||
return;
|
||||
}
|
||||
kbId.value = newKbId;
|
||||
activeMenu.value = resolveActiveMenu(kbId.value);
|
||||
cancelEditTitle();
|
||||
await fetchDocuments();
|
||||
if (lastSyncedDocId.value !== docId.value) {
|
||||
collabReady.value = !collabEnabled.value;
|
||||
}
|
||||
updateCurrentDocTitle();
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
toggleCommentSidebar,
|
||||
handleCollabSync
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { computed } from 'vue';
|
||||
import { normalizeDocumentType } from '@/utils/documentType';
|
||||
|
||||
export function useDocumentEditorPolicy(options = {}) {
|
||||
const {
|
||||
kbId,
|
||||
docId,
|
||||
currentDocType
|
||||
} = options;
|
||||
|
||||
const isPersonalDocument = computed(() => kbId.value === 'personal');
|
||||
const hasDocument = computed(() => Boolean(docId.value));
|
||||
const normalizedDocType = computed(() => normalizeDocumentType(currentDocType.value, '1'));
|
||||
const isMarkdownDocument = computed(() => normalizedDocType.value === '1');
|
||||
const isTableDocument = computed(() => normalizedDocType.value === '2');
|
||||
const isSlideDocument = computed(() => normalizedDocType.value === '3');
|
||||
const isRichTextDocument = computed(() => normalizedDocType.value === '4');
|
||||
|
||||
const canManageAttachments = computed(() => hasDocument.value);
|
||||
const canManageSettings = computed(() => hasDocument.value);
|
||||
const collabEnabled = computed(
|
||||
() => hasDocument.value && (isMarkdownDocument.value || isRichTextDocument.value)
|
||||
);
|
||||
const inlineCommentEnabled = computed(
|
||||
() => hasDocument.value && (isMarkdownDocument.value || isRichTextDocument.value)
|
||||
);
|
||||
const createDialogKnowledgeBaseId = computed(() => (isPersonalDocument.value ? '' : kbId.value));
|
||||
const showTemplateDialogKbScope = computed(() => !isPersonalDocument.value);
|
||||
|
||||
return {
|
||||
isPersonalDocument,
|
||||
hasDocument,
|
||||
normalizedDocType,
|
||||
isMarkdownDocument,
|
||||
isTableDocument,
|
||||
isSlideDocument,
|
||||
isRichTextDocument,
|
||||
canManageAttachments,
|
||||
canManageSettings,
|
||||
collabEnabled,
|
||||
inlineCommentEnabled,
|
||||
createDialogKnowledgeBaseId,
|
||||
showTemplateDialogKbScope
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export function useDocumentHeaderRouting(options = {}) {
|
||||
const {
|
||||
router,
|
||||
kbId,
|
||||
docId,
|
||||
onCommand
|
||||
} = options;
|
||||
|
||||
const pushDocumentSubRoute = (segment) => {
|
||||
if (!docId.value) {
|
||||
return;
|
||||
}
|
||||
if (kbId.value === 'personal') {
|
||||
router.push(`/mydocument/${docId.value}/${segment}`);
|
||||
return;
|
||||
}
|
||||
router.push(`/knowledge-base/${kbId.value}/document/${docId.value}/${segment}`);
|
||||
};
|
||||
|
||||
const handleHeaderCommand = (command) => {
|
||||
if (typeof onCommand === 'function' && onCommand(command)) {
|
||||
return;
|
||||
}
|
||||
if (command === 'document_settings') {
|
||||
pushDocumentSubRoute('setting');
|
||||
return;
|
||||
}
|
||||
if (command === 'manage_attachments') {
|
||||
pushDocumentSubRoute('attachments');
|
||||
return;
|
||||
}
|
||||
if (command === 'show_history') {
|
||||
pushDocumentSubRoute('history');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleHeaderCommand
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export function useDocumentRouteContext({ props, route }) {
|
||||
const kbId = ref(props.kbId || route.params.kbId);
|
||||
const docId = ref(props.docId || route.params.docId);
|
||||
|
||||
const resolveActiveMenu = (currentKbId) => {
|
||||
if (currentKbId === 'personal') return 'documents';
|
||||
if (currentKbId) return 'knowledge-base';
|
||||
return 'home';
|
||||
};
|
||||
|
||||
const collabEnabled = computed(() => !!docId.value);
|
||||
const collabRoom = computed(() => (docId.value ? `${docId.value}` : ''));
|
||||
const collabUrl = computed(() => '/ws/doc');
|
||||
const collabToken = computed(() => localStorage.getItem('token') || '');
|
||||
const collabReady = ref(false);
|
||||
const lastSyncedDocId = ref('');
|
||||
|
||||
return {
|
||||
kbId,
|
||||
docId,
|
||||
resolveActiveMenu,
|
||||
collabEnabled,
|
||||
collabRoom,
|
||||
collabUrl,
|
||||
collabToken,
|
||||
collabReady,
|
||||
lastSyncedDocId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
|
||||
export function useEditorCommentBridge({
|
||||
isCommentCollapsed,
|
||||
markdownEditorRef,
|
||||
richTextEditorRef,
|
||||
isMarkdownDocument,
|
||||
isRichTextDocument,
|
||||
commentSidebarRef
|
||||
}) {
|
||||
const remoteCommentReloadTimer = ref(null);
|
||||
|
||||
const editorCandidates = [
|
||||
{
|
||||
active: isRichTextDocument,
|
||||
ref: richTextEditorRef
|
||||
},
|
||||
{
|
||||
active: isMarkdownDocument,
|
||||
ref: markdownEditorRef
|
||||
}
|
||||
];
|
||||
|
||||
const getActiveEditor = () => {
|
||||
const activeEditor = editorCandidates
|
||||
.find((candidate) => candidate.active?.value && candidate.ref?.value)
|
||||
?.ref?.value;
|
||||
if (activeEditor) {
|
||||
return activeEditor;
|
||||
}
|
||||
return editorCandidates
|
||||
.map((candidate) => candidate.ref?.value)
|
||||
.find(Boolean) || null;
|
||||
};
|
||||
|
||||
const callEditorMethod = (method, ...args) => {
|
||||
const editor = getActiveEditor();
|
||||
if (!editor || typeof editor[method] !== 'function') {
|
||||
return false;
|
||||
}
|
||||
editor[method](...args);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleInlineCommentAdd = (payload) => {
|
||||
if (isCommentCollapsed.value) {
|
||||
isCommentCollapsed.value = false;
|
||||
}
|
||||
commentSidebarRef.value?.handleInlineAnchorAdd?.(payload);
|
||||
};
|
||||
|
||||
const handleInlineCommentRemove = (ids) => {
|
||||
commentSidebarRef.value?.handleInlineAnchorRemove?.(ids);
|
||||
};
|
||||
|
||||
const handleAnchorHover = (id, hovering) => {
|
||||
const targetMethod = hovering ? 'hlCommentIds' : 'unHlCommentIds';
|
||||
callEditorMethod(targetMethod, [id]);
|
||||
};
|
||||
|
||||
const handleAnchorRemove = (ids) => {
|
||||
const targetIds = Array.isArray(ids) ? ids : [ids];
|
||||
callEditorMethod('removeCommentIds', targetIds);
|
||||
};
|
||||
|
||||
const handleCommentMutated = () => {
|
||||
callEditorMethod('broadcastCommentChange');
|
||||
};
|
||||
|
||||
const handleRemoteCommentChanged = () => {
|
||||
if (remoteCommentReloadTimer.value) {
|
||||
clearTimeout(remoteCommentReloadTimer.value);
|
||||
}
|
||||
remoteCommentReloadTimer.value = setTimeout(() => {
|
||||
commentSidebarRef.value?.reload?.();
|
||||
remoteCommentReloadTimer.value = null;
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const scrollToInlineAnchor = (id) => {
|
||||
callEditorMethod('scrollToCommentId', id);
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (remoteCommentReloadTimer.value) {
|
||||
clearTimeout(remoteCommentReloadTimer.value);
|
||||
remoteCommentReloadTimer.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
handleInlineCommentAdd,
|
||||
handleInlineCommentRemove,
|
||||
handleAnchorHover,
|
||||
handleAnchorRemove,
|
||||
handleCommentMutated,
|
||||
handleRemoteCommentChanged,
|
||||
scrollToInlineAnchor
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, unref } from 'vue';
|
||||
|
||||
export function useEditorFullscreen(options = {}) {
|
||||
const {
|
||||
editorLayoutRef,
|
||||
docId,
|
||||
onChange = null
|
||||
} = options;
|
||||
|
||||
const isEditorFullscreen = ref(false);
|
||||
const canToggleEditorFullscreen = computed(() => Boolean(unref(docId)));
|
||||
let fullscreenHandler = null;
|
||||
|
||||
const syncEditorFullscreenState = () => {
|
||||
const target = unref(editorLayoutRef);
|
||||
isEditorFullscreen.value = Boolean(target) && document.fullscreenElement === target;
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(isEditorFullscreen.value);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEditorFullscreen = async () => {
|
||||
const target = unref(editorLayoutRef);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (document.fullscreenElement === target) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await target.requestFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore unsupported browser fullscreen errors
|
||||
} finally {
|
||||
syncEditorFullscreenState();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fullscreenHandler = () => syncEditorFullscreenState();
|
||||
document.addEventListener('fullscreenchange', fullscreenHandler);
|
||||
syncEditorFullscreenState();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!fullscreenHandler) {
|
||||
return;
|
||||
}
|
||||
document.removeEventListener('fullscreenchange', fullscreenHandler);
|
||||
fullscreenHandler = null;
|
||||
});
|
||||
|
||||
return {
|
||||
isEditorFullscreen,
|
||||
canToggleEditorFullscreen,
|
||||
toggleEditorFullscreen,
|
||||
syncEditorFullscreenState
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import { usePanelSidebar } from '@/composables/layout/usePanelSidebar';
|
||||
|
||||
export function useEditorPanelConstraints(options = {}) {
|
||||
const {
|
||||
editorLayoutRef,
|
||||
commentSidebarRef,
|
||||
isCommentCollapsed,
|
||||
onLayoutChange = null,
|
||||
sidebarWidthStorageKey = 'docSidebarWidth',
|
||||
sidebarCollapsedStorageKey = 'sidebarCollapsed',
|
||||
minEditorMainWidth = 520
|
||||
} = options;
|
||||
const COLLAPSE_ANIMATION_MS = 320;
|
||||
const COMMENT_RESIZE_DEBOUNCE_MS = 140;
|
||||
let layoutSettleTimer = null;
|
||||
|
||||
const emitLayoutChange = (delay = 0) => {
|
||||
if (layoutSettleTimer) {
|
||||
clearTimeout(layoutSettleTimer);
|
||||
layoutSettleTimer = null;
|
||||
}
|
||||
layoutSettleTimer = window.setTimeout(() => {
|
||||
layoutSettleTimer = null;
|
||||
if (typeof onLayoutChange === 'function') {
|
||||
onLayoutChange();
|
||||
}
|
||||
}, Math.max(0, delay));
|
||||
};
|
||||
|
||||
const getVisibleCommentWidth = () => {
|
||||
if (isCommentCollapsed.value) {
|
||||
return 0;
|
||||
}
|
||||
const exposedWidth = Number(commentSidebarRef.value?.getCurrentWidth?.());
|
||||
if (Number.isFinite(exposedWidth) && exposedWidth > 0) {
|
||||
return exposedWidth;
|
||||
}
|
||||
const storedWidth = Number(localStorage.getItem('commentSidebarWidth'));
|
||||
if (Number.isFinite(storedWidth) && storedWidth > 0) {
|
||||
return Math.min(Math.max(storedWidth, 280), 560);
|
||||
}
|
||||
return 320;
|
||||
};
|
||||
|
||||
const computeSidebarMaxWidth = () => {
|
||||
const layoutRect = editorLayoutRef.value?.getBoundingClientRect();
|
||||
if (!layoutRect) {
|
||||
return 520;
|
||||
}
|
||||
const available = layoutRect.width - getVisibleCommentWidth() - minEditorMainWidth - 8;
|
||||
return Math.min(520, Math.max(220, available));
|
||||
};
|
||||
|
||||
const {
|
||||
width: sidebarWidth,
|
||||
collapsed: isSidebarCollapsed,
|
||||
resizing: isSidebarResizing,
|
||||
setCollapsed: setSidebarCollapsed,
|
||||
toggleCollapsed: toggleSidebar,
|
||||
startResize
|
||||
} = usePanelSidebar({
|
||||
defaultWidth: 280,
|
||||
minWidth: 180,
|
||||
maxWidth: 520,
|
||||
resizeEdge: 'right',
|
||||
collapsedStorageKey: sidebarCollapsedStorageKey,
|
||||
widthStorageKey: sidebarWidthStorageKey,
|
||||
getMaxWidth: computeSidebarMaxWidth,
|
||||
onWidthChange: (value) => {
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${value}px`);
|
||||
if (!isSidebarResizing.value) {
|
||||
emitLayoutChange(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const clampSidebarWidth = () => {
|
||||
const maxWidth = computeSidebarMaxWidth();
|
||||
if (sidebarWidth.value > maxWidth) {
|
||||
sidebarWidth.value = maxWidth;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentWidthChange = () => {
|
||||
clampSidebarWidth();
|
||||
emitLayoutChange(COMMENT_RESIZE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => isSidebarCollapsed.value,
|
||||
() => {
|
||||
requestAnimationFrame(() => {
|
||||
clampSidebarWidth();
|
||||
emitLayoutChange(COLLAPSE_ANIMATION_MS);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isSidebarResizing.value,
|
||||
(resizing) => {
|
||||
if (!resizing) {
|
||||
emitLayoutChange(0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isCommentCollapsed.value,
|
||||
() => {
|
||||
requestAnimationFrame(() => {
|
||||
clampSidebarWidth();
|
||||
emitLayoutChange(COLLAPSE_ANIMATION_MS);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Always start with directory sidebar expanded when entering editor.
|
||||
setSidebarCollapsed(false);
|
||||
window.addEventListener('resize', clampSidebarWidth);
|
||||
clampSidebarWidth();
|
||||
emitLayoutChange(0);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', clampSidebarWidth);
|
||||
if (layoutSettleTimer) {
|
||||
clearTimeout(layoutSettleTimer);
|
||||
layoutSettleTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isSidebarCollapsed,
|
||||
isSidebarResizing,
|
||||
toggleSidebar,
|
||||
startResize,
|
||||
handleCommentWidthChange,
|
||||
clampSidebarWidth
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { normalizeDocumentType } from '@/utils/documentType';
|
||||
import { normalizeRows } from '@/utils/tableUtils';
|
||||
|
||||
const MARKDOWN_DOC_TYPE = '1';
|
||||
const TABLE_DOC_TYPE = '2';
|
||||
const MARKDOWN_SLIDE_DOC_TYPE = '3';
|
||||
const YORESEE_RICH_TEXT_DOC_TYPE = '4';
|
||||
|
||||
const toText = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const tryParseJson = (value) => {
|
||||
const text = toText(value).trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const unwrapTemplateContent = (value) => {
|
||||
const rawText = toText(value);
|
||||
const parsed = tryParseJson(rawText);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return rawText;
|
||||
}
|
||||
if (typeof parsed.content === 'string') {
|
||||
return parsed.content;
|
||||
}
|
||||
return rawText;
|
||||
};
|
||||
|
||||
const normalizeSource = (value, { isTemplate = false } = {}) =>
|
||||
isTemplate ? unwrapTemplateContent(value) : toText(value);
|
||||
|
||||
const stripCommentAnchorNoise = (value) =>
|
||||
toText(value)
|
||||
.replace(/<span\b[^>]*\bdata-comment-anchor-id=(["'])[^"']*\1[^>]*>([\s\S]*?)<\/span>/gi, '$2')
|
||||
.replace(/<\/?span\b[^>]*\bdata-comment-anchor-id=(["'])[^"']*\1[^>]*\/?>/gi, '');
|
||||
|
||||
const parseRowsFromParsedPayload = (parsed) => {
|
||||
if (Array.isArray(parsed)) {
|
||||
return normalizeRows(parsed);
|
||||
}
|
||||
if (parsed && Array.isArray(parsed.rows)) {
|
||||
return normalizeRows(parsed.rows);
|
||||
}
|
||||
if (parsed && typeof parsed.content === 'string') {
|
||||
return parseTableRows(parsed.content, { isTemplate: false });
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const parseTableRows = (value, options = {}) => {
|
||||
const source = normalizeSource(value, options);
|
||||
const parsed = tryParseJson(source);
|
||||
return parseRowsFromParsedPayload(parsed);
|
||||
};
|
||||
|
||||
const escapeMarkdownCell = (value) =>
|
||||
String(value || '')
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\n/g, '<br />')
|
||||
.trim();
|
||||
|
||||
const rowsToMarkdownTable = (rows) => {
|
||||
const normalizedRows = normalizeRows(rows);
|
||||
if (normalizedRows.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const columnCount = Math.max(1, normalizedRows[0]?.length || 0);
|
||||
const firstRow = normalizedRows[0] || [];
|
||||
const hasHeader = firstRow.some((item) => String(item || '').trim() !== '');
|
||||
const header = hasHeader
|
||||
? firstRow
|
||||
: Array.from({ length: columnCount }, (_, index) => `Col ${index + 1}`);
|
||||
const bodyRows = hasHeader ? normalizedRows.slice(1) : normalizedRows;
|
||||
|
||||
const headerLine = `| ${header.map(escapeMarkdownCell).join(' | ')} |`;
|
||||
const separatorLine = `| ${Array.from({ length: columnCount }, () => '---').join(' | ')} |`;
|
||||
const bodyLines = bodyRows.map((row) => `| ${row.map(escapeMarkdownCell).join(' | ')} |`);
|
||||
return [headerLine, separatorLine, ...bodyLines].join('\n');
|
||||
};
|
||||
|
||||
const rowsToDiffText = (rows) =>
|
||||
normalizeRows(rows)
|
||||
.map((row, index) => `[R${index + 1}] ${row.map((cell) => toText(cell).replace(/\t/g, ' ')).join('\t')}`)
|
||||
.join('\n');
|
||||
|
||||
const parseSlideSections = (value, options = {}) => {
|
||||
const source = normalizeSource(value, options);
|
||||
const parsed = tryParseJson(source);
|
||||
if (parsed && Array.isArray(parsed.slides)) {
|
||||
return parsed.slides.map((slide, index) => {
|
||||
if (typeof slide === 'string') {
|
||||
return slide.trim();
|
||||
}
|
||||
if (slide && typeof slide.content === 'string') {
|
||||
return slide.content.trim();
|
||||
}
|
||||
if (slide && typeof slide.markdown === 'string') {
|
||||
return slide.markdown.trim();
|
||||
}
|
||||
return `# Slide ${index + 1}`;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
return source
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split(/\n-{3,}\n/g)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const slidesToPreviewMarkdown = (sections) => {
|
||||
if (!Array.isArray(sections) || sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return sections
|
||||
.map((section, index) => `## Slide ${index + 1}\n\n${section}`)
|
||||
.join('\n\n---\n\n');
|
||||
};
|
||||
|
||||
const slidesToDiffText = (sections) => {
|
||||
if (!Array.isArray(sections) || sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return sections
|
||||
.map((section, index) => `[Slide ${index + 1}]\n${section}`)
|
||||
.join('\n\n---\n\n');
|
||||
};
|
||||
|
||||
const buildRendererRegistry = () => ({
|
||||
[MARKDOWN_DOC_TYPE]: {
|
||||
toPreviewMarkdown: (value, options) => normalizeSource(value, options),
|
||||
toDiffText: (value) => toText(value)
|
||||
},
|
||||
[YORESEE_RICH_TEXT_DOC_TYPE]: {
|
||||
toPreviewMarkdown: (value, options) => stripCommentAnchorNoise(normalizeSource(value, options)),
|
||||
toDiffText: (value) => stripCommentAnchorNoise(toText(value))
|
||||
},
|
||||
[TABLE_DOC_TYPE]: {
|
||||
toPreviewMarkdown: (value, options) => rowsToMarkdownTable(parseTableRows(value, options)),
|
||||
toDiffText: (value) => rowsToDiffText(parseTableRows(value, { isTemplate: false }))
|
||||
},
|
||||
[MARKDOWN_SLIDE_DOC_TYPE]: {
|
||||
toPreviewMarkdown: (value, options) => slidesToPreviewMarkdown(parseSlideSections(value, options)),
|
||||
toDiffText: (value) => slidesToDiffText(parseSlideSections(value, { isTemplate: false }))
|
||||
}
|
||||
});
|
||||
|
||||
const documentRenderRegistry = buildRendererRegistry();
|
||||
|
||||
const resolveRenderer = (documentType) => {
|
||||
const normalized = normalizeDocumentType(documentType, MARKDOWN_DOC_TYPE);
|
||||
return documentRenderRegistry[normalized] || documentRenderRegistry[MARKDOWN_DOC_TYPE];
|
||||
};
|
||||
|
||||
export const resolveDocumentPreviewKind = (documentType) => {
|
||||
const normalized = normalizeDocumentType(documentType, MARKDOWN_DOC_TYPE);
|
||||
if (normalized === TABLE_DOC_TYPE) {
|
||||
return 'table';
|
||||
}
|
||||
if (normalized === MARKDOWN_SLIDE_DOC_TYPE) {
|
||||
return 'slide';
|
||||
}
|
||||
return 'markdown';
|
||||
};
|
||||
|
||||
export const resolveTablePreviewRows = ({
|
||||
content = '',
|
||||
isTemplate = false
|
||||
} = {}) => parseTableRows(content, { isTemplate });
|
||||
|
||||
export const resolveSlidePreviewSections = ({
|
||||
content = '',
|
||||
isTemplate = false
|
||||
} = {}) => parseSlideSections(content, { isTemplate });
|
||||
|
||||
export const resolveDocumentPreviewContent = ({
|
||||
content = '',
|
||||
documentType = MARKDOWN_DOC_TYPE,
|
||||
isTemplate = false
|
||||
} = {}) => {
|
||||
const renderer = resolveRenderer(documentType);
|
||||
return renderer.toPreviewMarkdown(content, { isTemplate });
|
||||
};
|
||||
|
||||
export const resolveDocumentDiffContentPair = ({
|
||||
leftContent = '',
|
||||
rightContent = '',
|
||||
documentType = MARKDOWN_DOC_TYPE
|
||||
} = {}) => {
|
||||
const renderer = resolveRenderer(documentType);
|
||||
return {
|
||||
// Final guard: strip comment-anchor wrappers for every doc type before diff rendering.
|
||||
// This prevents noisy diffs when content accidentally contains inline anchor markup.
|
||||
leftText: stripCommentAnchorNoise(renderer.toDiffText(leftContent)),
|
||||
rightText: stripCommentAnchorNoise(renderer.toDiffText(rightContent))
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveTableDiffRowsPair = ({
|
||||
leftContent = '',
|
||||
rightContent = ''
|
||||
} = {}) => ({
|
||||
leftRows: parseTableRows(leftContent, { isTemplate: false }),
|
||||
rightRows: parseTableRows(rightContent, { isTemplate: false })
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { normalizeDocumentType } from '@/utils/documentType';
|
||||
|
||||
export function useDirectoryTreeState({
|
||||
t,
|
||||
router,
|
||||
kbId,
|
||||
docId,
|
||||
treeComponentRef,
|
||||
getKnowledgeBaseDocuments,
|
||||
getMyDocuments
|
||||
}) {
|
||||
const treeLoading = ref(false);
|
||||
const directoryTree = ref([]);
|
||||
const knowledgeBaseName = ref('');
|
||||
const currentDocTitle = ref('');
|
||||
const currentDocType = ref('1');
|
||||
const isAllExpanded = ref(true);
|
||||
|
||||
const treeRef = computed(() => treeComponentRef.value?.getTreeRef());
|
||||
|
||||
const transformDocumentsToTree = (documents, parentId = null) => {
|
||||
const tree = [];
|
||||
documents.forEach((doc) => {
|
||||
const treeNode = {
|
||||
id: doc.external_id,
|
||||
label: doc.title,
|
||||
isFolder: !!doc.has_children,
|
||||
isLeaf: !doc.has_children,
|
||||
type: normalizeDocumentType(doc.type, '1'),
|
||||
parentId,
|
||||
children: []
|
||||
};
|
||||
if (doc.children && doc.children.length > 0) {
|
||||
treeNode.children = transformDocumentsToTree(doc.children, treeNode.id);
|
||||
}
|
||||
tree.push(treeNode);
|
||||
});
|
||||
return tree;
|
||||
};
|
||||
|
||||
const findNodeById = (nodes, targetId) => {
|
||||
for (const node of nodes) {
|
||||
if (String(node.id) === String(targetId)) {
|
||||
return node;
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = findNodeById(node.children, targetId);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateCurrentDocTitle = () => {
|
||||
if (!docId.value) {
|
||||
currentDocType.value = '1';
|
||||
return;
|
||||
}
|
||||
const node = findNodeById(directoryTree.value, docId.value);
|
||||
if (node) {
|
||||
currentDocTitle.value = node.label;
|
||||
currentDocType.value = normalizeDocumentType(node.type, '1');
|
||||
return;
|
||||
}
|
||||
currentDocType.value = '1';
|
||||
};
|
||||
|
||||
const updateTreeNodeTitle = (nodes, targetId, title) => {
|
||||
for (const node of nodes) {
|
||||
if (String(node.id) === String(targetId)) {
|
||||
node.label = title;
|
||||
return true;
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
if (updateTreeNodeTitle(node.children, targetId, title)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const findPathToNode = (tree, targetId, path = []) => {
|
||||
for (const node of tree) {
|
||||
if (node.id === targetId) {
|
||||
return [...path, node];
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const result = findPathToNode(node.children, targetId, [...path, node]);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const expandToCurrentDoc = async () => {
|
||||
if (!treeRef.value || !docId.value || directoryTree.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
const path = findPathToNode(directoryTree.value, docId.value);
|
||||
if (path && path.length > 0) {
|
||||
await nextTick();
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
const node = treeRef.value.getNode(path[i].id);
|
||||
if (node) {
|
||||
node.expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDocuments = async () => {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
if (kbId.value === 'personal') {
|
||||
const response = await getMyDocuments({ page: 1, page_size: 1000, directory_only: true });
|
||||
knowledgeBaseName.value = t('home.myDocuments');
|
||||
directoryTree.value = transformDocumentsToTree(response.documents || []);
|
||||
} else {
|
||||
const response = await getKnowledgeBaseDocuments(kbId.value, { directory_only: true });
|
||||
knowledgeBaseName.value = response.knowledge_base.name;
|
||||
directoryTree.value = transformDocumentsToTree(response.documents);
|
||||
}
|
||||
await expandToCurrentDoc();
|
||||
updateCurrentDocTitle();
|
||||
} catch (error) {
|
||||
console.error('获取文档列表失败:', error);
|
||||
ElMessage.error(t('knowledgeBase.fetchError'));
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (kbId.value === 'personal') {
|
||||
router.push('/mydocuments');
|
||||
} else {
|
||||
router.push(`/knowledge-base/${kbId.value}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTreeNodeClick = (data) => {
|
||||
if (data?.isCreating) return;
|
||||
if (!data?.id) return;
|
||||
if (kbId.value === 'personal') {
|
||||
router.push(`/mydocument/${data.id}`);
|
||||
} else {
|
||||
router.push(`/knowledge-base/${kbId.value}/document/${data.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpandAll = () => {
|
||||
isAllExpanded.value = !isAllExpanded.value;
|
||||
if (!treeRef.value) {
|
||||
return;
|
||||
}
|
||||
const nodes = treeRef.value.store?.nodesMap;
|
||||
if (!nodes) {
|
||||
return;
|
||||
}
|
||||
Object.values(nodes).forEach((node) => {
|
||||
node.expanded = isAllExpanded.value;
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
if (treeComponentRef.value) {
|
||||
treeComponentRef.value.closeContextMenu?.();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
treeLoading,
|
||||
directoryTree,
|
||||
knowledgeBaseName,
|
||||
currentDocTitle,
|
||||
currentDocType,
|
||||
isAllExpanded,
|
||||
fetchDocuments,
|
||||
updateCurrentDocTitle,
|
||||
updateTreeNodeTitle,
|
||||
expandToCurrentDoc,
|
||||
goBack,
|
||||
handleTreeNodeClick,
|
||||
toggleExpandAll,
|
||||
closeContextMenu
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
const resolveBool = (valueOrGetterOrRef, fallback = false) => {
|
||||
if (typeof valueOrGetterOrRef === 'function') {
|
||||
return valueOrGetterOrRef();
|
||||
}
|
||||
if (valueOrGetterOrRef && typeof valueOrGetterOrRef === 'object' && 'value' in valueOrGetterOrRef) {
|
||||
return valueOrGetterOrRef.value;
|
||||
}
|
||||
if (valueOrGetterOrRef === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return Boolean(valueOrGetterOrRef);
|
||||
};
|
||||
|
||||
export function useDocumentTreeContextMenu(options = {}) {
|
||||
const { enabled = true } = options;
|
||||
|
||||
const contextMenu = ref({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
data: null
|
||||
});
|
||||
|
||||
const closeContextMenu = () => {
|
||||
if (!contextMenu.value.visible) {
|
||||
return;
|
||||
}
|
||||
contextMenu.value.visible = false;
|
||||
};
|
||||
|
||||
const openContextMenu = (event, data) => {
|
||||
if (!resolveBool(enabled, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const menuWidth = 150;
|
||||
const menuHeight = 120;
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
if (x + menuWidth > window.innerWidth) {
|
||||
x = window.innerWidth - menuWidth - 8;
|
||||
}
|
||||
if (y + menuHeight > window.innerHeight) {
|
||||
y = window.innerHeight - menuHeight - 8;
|
||||
}
|
||||
|
||||
contextMenu.value = {
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
data
|
||||
};
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
openContextMenu,
|
||||
closeContextMenu
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
const resolveBool = (valueOrGetterOrRef, fallback = false) => {
|
||||
if (typeof valueOrGetterOrRef === 'function') {
|
||||
return valueOrGetterOrRef();
|
||||
}
|
||||
if (valueOrGetterOrRef && typeof valueOrGetterOrRef === 'object' && 'value' in valueOrGetterOrRef) {
|
||||
return valueOrGetterOrRef.value;
|
||||
}
|
||||
if (valueOrGetterOrRef === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return Boolean(valueOrGetterOrRef);
|
||||
};
|
||||
|
||||
export function useInlineRename(options = {}) {
|
||||
const { revertRename = true, onConfirm = null } = options;
|
||||
|
||||
const renamingInputRef = ref(null);
|
||||
|
||||
const setRenamingInputRef = (el, data) => {
|
||||
if (el && data?.isRenaming) {
|
||||
renamingInputRef.value = el;
|
||||
}
|
||||
};
|
||||
|
||||
const focusRenameInput = () => {
|
||||
const inputEl = renamingInputRef.value?.input || renamingInputRef.value;
|
||||
if (!inputEl || typeof inputEl.focus !== 'function') {
|
||||
return;
|
||||
}
|
||||
inputEl.focus();
|
||||
if (typeof inputEl.select === 'function') {
|
||||
inputEl.select();
|
||||
}
|
||||
};
|
||||
|
||||
const startInlineRename = (node) => {
|
||||
if (!node || node.isRenaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.isRenaming = true;
|
||||
node.originalLabel = node.label;
|
||||
node.renameValue = node.label;
|
||||
|
||||
nextTick(() => {
|
||||
focusRenameInput();
|
||||
});
|
||||
};
|
||||
|
||||
const clearRenameState = (node) => {
|
||||
node.renameValue = '';
|
||||
node.isRenaming = false;
|
||||
};
|
||||
|
||||
const cancelInlineRename = (node) => {
|
||||
if (!node?.isRenaming) {
|
||||
return;
|
||||
}
|
||||
node.label = node.originalLabel || node.label;
|
||||
clearRenameState(node);
|
||||
};
|
||||
|
||||
const confirmInlineRename = (node) => {
|
||||
if (!node?.isRenaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextName = node.renameValue?.trim();
|
||||
if (!nextName) {
|
||||
cancelInlineRename(node);
|
||||
return;
|
||||
}
|
||||
|
||||
clearRenameState(node);
|
||||
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm({
|
||||
node,
|
||||
title: nextName
|
||||
});
|
||||
}
|
||||
|
||||
if (resolveBool(revertRename, true)) {
|
||||
node.label = node.originalLabel || node.label;
|
||||
} else {
|
||||
node.label = nextName;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
renamingInputRef,
|
||||
setRenamingInputRef,
|
||||
startInlineRename,
|
||||
cancelInlineRename,
|
||||
confirmInlineRename
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user