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,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();
};