migrate from github

This commit is contained in:
2026-07-15 22:51:56 +08:00
commit a1cccc8068
215 changed files with 38318 additions and 0 deletions
@@ -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)];
}
@@ -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
};
}