migrate from github
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Extract all unique mentioned user external IDs from the Tiptap editor state.
|
||||
*
|
||||
* @param {import('@tiptap/vue-3').Editor} editor
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function extractMentionIds(editor) {
|
||||
if (!editor) return [];
|
||||
const ids = [];
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'mention' && node.attrs.id) {
|
||||
ids.push(node.attrs.id);
|
||||
}
|
||||
});
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
export const BLOCK_SELECTOR = [
|
||||
'p',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'blockquote',
|
||||
'pre',
|
||||
'li',
|
||||
'table',
|
||||
'hr',
|
||||
'.mindmap-node',
|
||||
'.drawio-node',
|
||||
'.rich-table-node',
|
||||
'yoresee-mindmap',
|
||||
'yoresee-drawio',
|
||||
'yoresee-table'
|
||||
].join(',');
|
||||
|
||||
const PREFERRED_BLOCK_NODE_NAMES = new Set([
|
||||
'listItem',
|
||||
'paragraph',
|
||||
'heading',
|
||||
'blockquote',
|
||||
'codeBlock',
|
||||
'horizontalRule',
|
||||
'table',
|
||||
'tableBlock',
|
||||
'mindmapBlock',
|
||||
'drawioBlock'
|
||||
]);
|
||||
|
||||
const NON_EMPTY_BLOCK_TYPES = new Set([
|
||||
'horizontalRule',
|
||||
'table',
|
||||
'tableBlock',
|
||||
'mindmapBlock',
|
||||
'drawioBlock'
|
||||
]);
|
||||
|
||||
export const HANDLE_SIZE = 22;
|
||||
|
||||
const normalizeBlockType = (value) => {
|
||||
switch (value) {
|
||||
case 'heading':
|
||||
return 'heading';
|
||||
case 'listItem':
|
||||
return 'list';
|
||||
case 'blockquote':
|
||||
return 'quote';
|
||||
case 'codeBlock':
|
||||
return 'code';
|
||||
case 'table':
|
||||
case 'tableBlock':
|
||||
return 'table';
|
||||
case 'horizontalRule':
|
||||
return 'divider';
|
||||
case 'mindmapBlock':
|
||||
return 'mindmap';
|
||||
case 'drawioBlock':
|
||||
return 'drawio';
|
||||
default:
|
||||
return 'paragraph';
|
||||
}
|
||||
};
|
||||
|
||||
export const dedupeNumberList = (list = []) => {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
list.forEach((item) => {
|
||||
const value = Number(item);
|
||||
if (!Number.isFinite(value) || seen.has(value)) {
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const resolveHoveredDomBlock = ({ target, editorDom }) => {
|
||||
if (!editorDom) {
|
||||
return null;
|
||||
}
|
||||
const sourceTarget = target?.nodeType === Node.TEXT_NODE ? target.parentElement : target;
|
||||
if (!(sourceTarget instanceof Element) || !editorDom.contains(sourceTarget)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let domBlock = sourceTarget.closest(BLOCK_SELECTOR);
|
||||
if (!domBlock || !editorDom.contains(domBlock)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (domBlock.tagName === 'P' && domBlock.parentElement?.tagName === 'LI') {
|
||||
domBlock = domBlock.parentElement;
|
||||
}
|
||||
|
||||
return domBlock;
|
||||
};
|
||||
|
||||
export const resolveRangeFromDomBlock = ({ view, state, domBlock }) => {
|
||||
if (!view || !state || !domBlock) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const rawPos = view.posAtDOM(domBlock, 0);
|
||||
const size = state.doc.content.size;
|
||||
const candidates = dedupeNumberList([
|
||||
rawPos - 1,
|
||||
rawPos,
|
||||
rawPos + 1
|
||||
]).map((pos) => Math.max(0, Math.min(pos, size)));
|
||||
|
||||
for (const safePos of candidates) {
|
||||
const directNode = state.doc.nodeAt(safePos);
|
||||
if (directNode && (directNode.isBlock || PREFERRED_BLOCK_NODE_NAMES.has(directNode.type?.name))) {
|
||||
return {
|
||||
from: safePos,
|
||||
to: safePos + directNode.nodeSize
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const safePos = Math.max(0, Math.min(rawPos, size));
|
||||
return {
|
||||
from: safePos,
|
||||
to: Math.max(0, Math.min(safePos + domBlock.textContent.length + 2, state.doc.content.size))
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveRangeByMouseCoords = ({ view, state, event }) => {
|
||||
if (!view || !state || typeof event?.clientX !== 'number' || typeof event?.clientY !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const coords = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY
|
||||
});
|
||||
if (!coords || typeof coords.pos !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pos = Math.max(0, Math.min(coords.pos, state.doc.content.size));
|
||||
return { from: pos, to: pos };
|
||||
};
|
||||
|
||||
export const resolveMetaFromRange = ({ state, range }) => {
|
||||
if (!state || !range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolveDirectMetaByPos = (pos) => {
|
||||
const directNode = state.doc.nodeAt(pos);
|
||||
if (!directNode || (!directNode.isBlock && !PREFERRED_BLOCK_NODE_NAMES.has(directNode.type?.name))) {
|
||||
return null;
|
||||
}
|
||||
const nodeType = String(directNode.type?.name || 'paragraph');
|
||||
const normalizedType = normalizeBlockType(nodeType);
|
||||
const text = String(directNode.textContent || '').replace(/\u200b/g, '').trim();
|
||||
const isEmpty = !NON_EMPTY_BLOCK_TYPES.has(nodeType)
|
||||
&& text.length === 0
|
||||
&& Number(directNode.content?.size || 0) === 0;
|
||||
return {
|
||||
from: pos,
|
||||
to: pos + directNode.nodeSize,
|
||||
topFrom: pos,
|
||||
topTo: pos + directNode.nodeSize,
|
||||
nodeType,
|
||||
type: normalizedType,
|
||||
isEmpty
|
||||
};
|
||||
};
|
||||
|
||||
const directCandidates = dedupeNumberList([
|
||||
range.from,
|
||||
range.from - 1,
|
||||
range.from + 1
|
||||
]).map((pos) => Math.max(0, Math.min(pos, state.doc.content.size)));
|
||||
|
||||
for (const pos of directCandidates) {
|
||||
const directMeta = resolveDirectMetaByPos(pos);
|
||||
if (directMeta) {
|
||||
return directMeta;
|
||||
}
|
||||
}
|
||||
|
||||
const probePos = Math.max(0, Math.min(range.from + 1, state.doc.content.size));
|
||||
const $probe = state.doc.resolve(probePos);
|
||||
|
||||
for (let depth = $probe.depth; depth > 0; depth -= 1) {
|
||||
const node = $probe.node(depth);
|
||||
if (!node || (!node.isBlock && !PREFERRED_BLOCK_NODE_NAMES.has(node.type?.name))) {
|
||||
continue;
|
||||
}
|
||||
const from = $probe.before(depth);
|
||||
const to = $probe.after(depth);
|
||||
const topFrom = $probe.before(1);
|
||||
const topTo = $probe.after(1);
|
||||
const nodeType = String(node.type?.name || 'paragraph');
|
||||
const normalizedType = normalizeBlockType(nodeType);
|
||||
const text = String(node.textContent || '').replace(/\u200b/g, '').trim();
|
||||
const isEmpty = !NON_EMPTY_BLOCK_TYPES.has(nodeType)
|
||||
&& text.length === 0
|
||||
&& Number(node.content?.size || 0) === 0;
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
topFrom,
|
||||
topTo,
|
||||
nodeType,
|
||||
type: normalizedType,
|
||||
isEmpty
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolvePositionFromMeta = ({ view, meta }) => {
|
||||
if (!view || !meta) {
|
||||
return null;
|
||||
}
|
||||
const upperBound = Math.max(1, view.state.doc.content.size);
|
||||
const candidatePositions = dedupeNumberList([
|
||||
meta.from + 1,
|
||||
meta.from,
|
||||
Math.max(meta.to - 1, 1)
|
||||
]).map((pos) => Math.max(1, Math.min(pos, upperBound)));
|
||||
|
||||
for (const pos of candidatePositions) {
|
||||
try {
|
||||
const coords = view.coordsAtPos(pos);
|
||||
if (coords?.left != null && coords?.top != null) {
|
||||
return coords;
|
||||
}
|
||||
} catch (_) {
|
||||
// continue fallback candidates
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveDomBlockFromMeta = ({ view, editorRoot, meta }) => {
|
||||
if (!view || !editorRoot || !meta) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const dom = view.nodeDOM(meta.from);
|
||||
if (!dom) {
|
||||
return null;
|
||||
}
|
||||
const base = dom.nodeType === Node.TEXT_NODE ? dom.parentElement : dom;
|
||||
if (!(base instanceof Element)) {
|
||||
return null;
|
||||
}
|
||||
if (base.matches(BLOCK_SELECTOR)) {
|
||||
return base;
|
||||
}
|
||||
const closest = base.closest(BLOCK_SELECTOR);
|
||||
if (closest && editorRoot.contains(closest)) {
|
||||
return closest;
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveHandleStyle = ({ container, blockRect, handleSize = HANDLE_SIZE }) => {
|
||||
if (!container || !blockRect) {
|
||||
return null;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rawTop = blockRect.top - containerRect.top + container.scrollTop - handleSize;
|
||||
const rawLeft = blockRect.left - containerRect.left + container.scrollLeft - handleSize;
|
||||
const minTop = container.scrollTop + 2;
|
||||
const minLeft = container.scrollLeft + 2;
|
||||
const maxTop = container.scrollTop + container.clientHeight - handleSize - 2;
|
||||
const maxLeft = container.scrollLeft + container.clientWidth - handleSize - 2;
|
||||
const top = Math.max(minTop, Math.min(rawTop, Math.max(minTop, maxTop)));
|
||||
const left = Math.max(minLeft, Math.min(rawLeft, Math.max(minLeft, maxLeft)));
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import { ref } from 'vue';
|
||||
import { common, createLowlight } from 'lowlight';
|
||||
|
||||
const BUILTIN_CODE_LANGUAGES = Object.freeze([
|
||||
{ value: 'plaintext', label: 'Plain Text' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'yaml', label: 'YAML' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'rust', label: 'Rust' }
|
||||
]);
|
||||
|
||||
const CODE_LANGUAGE_ALIAS_MAP = Object.freeze({
|
||||
text: 'plaintext',
|
||||
plain: 'plaintext',
|
||||
txt: 'plaintext',
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
yml: 'yaml',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
py: 'python',
|
||||
md: 'markdown'
|
||||
});
|
||||
|
||||
const builtinCodeLanguageSet = new Set(BUILTIN_CODE_LANGUAGES.map((item) => item.value));
|
||||
|
||||
export function useRichTextCodeLanguageOverlay(options = {}) {
|
||||
const { editorRef, scrollContainerRef } = options;
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
const codeLanguageDraft = ref('plaintext');
|
||||
const codeLanguageFloatingVisible = ref(false);
|
||||
const codeLanguageFloatingStyle = ref({ top: '0px', left: '0px' });
|
||||
const codeLanguageInteracting = ref(false);
|
||||
const codeBlockAnchorPos = ref(null);
|
||||
|
||||
const sanitizeCodeLanguage = (value) => {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
const aliased = CODE_LANGUAGE_ALIAS_MAP[raw] || raw;
|
||||
return aliased.replace(/[^a-z0-9_+-]/g, '');
|
||||
};
|
||||
|
||||
const isSupportedHighlightLanguage = (language) => {
|
||||
if (!language) {
|
||||
return false;
|
||||
}
|
||||
if (builtinCodeLanguageSet.has(language)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof lowlight.registered === 'function') {
|
||||
return Boolean(lowlight.registered(language));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const resolveCodeBlockLanguage = (value = codeLanguageDraft.value) => {
|
||||
const normalized = sanitizeCodeLanguage(value);
|
||||
if (!normalized) {
|
||||
return 'plaintext';
|
||||
}
|
||||
return isSupportedHighlightLanguage(normalized) ? normalized : 'plaintext';
|
||||
};
|
||||
|
||||
const resolveSelectionCodeBlockMeta = () => {
|
||||
const instance = editorRef.value;
|
||||
if (!instance) {
|
||||
return null;
|
||||
}
|
||||
const { $from } = instance.state.selection;
|
||||
for (let depth = $from.depth; depth >= 0; depth -= 1) {
|
||||
const node = $from.node(depth);
|
||||
if (node?.type?.name !== 'codeBlock') {
|
||||
continue;
|
||||
}
|
||||
const pos = depth > 0 ? $from.before(depth) : 0;
|
||||
const rawDom = instance.view.nodeDOM(pos);
|
||||
let element = rawDom instanceof HTMLElement ? rawDom : null;
|
||||
if (!element && rawDom && typeof Node !== 'undefined' && rawDom.nodeType === Node.TEXT_NODE) {
|
||||
element = rawDom.parentElement;
|
||||
}
|
||||
if (!element) {
|
||||
return {
|
||||
pos,
|
||||
node
|
||||
};
|
||||
}
|
||||
if (element.tagName.toLowerCase() !== 'pre') {
|
||||
element = element.querySelector?.('pre') || element;
|
||||
}
|
||||
return {
|
||||
pos,
|
||||
node,
|
||||
element
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveAnchorCodeBlockMeta = () => {
|
||||
const instance = editorRef.value;
|
||||
const pos = Number(codeBlockAnchorPos.value);
|
||||
if (!instance || !Number.isFinite(pos)) {
|
||||
return null;
|
||||
}
|
||||
const node = instance.state.doc.nodeAt(pos);
|
||||
if (!node || node.type?.name !== 'codeBlock') {
|
||||
return null;
|
||||
}
|
||||
const rawDom = instance.view.nodeDOM(pos);
|
||||
let element = rawDom instanceof HTMLElement ? rawDom : null;
|
||||
if (!element && rawDom && typeof Node !== 'undefined' && rawDom.nodeType === Node.TEXT_NODE) {
|
||||
element = rawDom.parentElement;
|
||||
}
|
||||
if (element && element.tagName.toLowerCase() !== 'pre') {
|
||||
element = element.querySelector?.('pre') || element;
|
||||
}
|
||||
return {
|
||||
pos,
|
||||
node,
|
||||
element: element || null
|
||||
};
|
||||
};
|
||||
|
||||
const syncCodeLanguageDraft = () => {
|
||||
const selected = resolveSelectionCodeBlockMeta();
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
codeBlockAnchorPos.value = selected.pos;
|
||||
const current = sanitizeCodeLanguage(selected.node?.attrs?.language);
|
||||
codeLanguageDraft.value = isSupportedHighlightLanguage(current) ? current : 'plaintext';
|
||||
};
|
||||
|
||||
const resolveActiveCodeBlockDom = () => {
|
||||
const selected = resolveSelectionCodeBlockMeta();
|
||||
if (selected) {
|
||||
codeBlockAnchorPos.value = selected.pos;
|
||||
return selected.element || null;
|
||||
}
|
||||
const anchored = resolveAnchorCodeBlockMeta();
|
||||
return anchored?.element || null;
|
||||
};
|
||||
|
||||
const updateCodeLanguageFloating = () => {
|
||||
const container = scrollContainerRef.value;
|
||||
const blockElement = resolveActiveCodeBlockDom();
|
||||
if (!container || !blockElement) {
|
||||
if (!codeLanguageInteracting.value) {
|
||||
codeLanguageFloatingVisible.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const blockRect = blockElement.getBoundingClientRect();
|
||||
const floatingWidth = 132;
|
||||
const floatingHeight = 30;
|
||||
const rawLeft = blockRect.right - containerRect.left + container.scrollLeft - floatingWidth - 8;
|
||||
const rawTop = blockRect.top - containerRect.top + container.scrollTop + 8;
|
||||
const minLeft = container.scrollLeft + 8;
|
||||
const maxLeft = container.scrollLeft + container.clientWidth - floatingWidth - 8;
|
||||
const minTop = container.scrollTop + 8;
|
||||
const maxTop = container.scrollTop + container.clientHeight - floatingHeight - 8;
|
||||
codeLanguageFloatingStyle.value = {
|
||||
left: `${Math.min(maxLeft, Math.max(minLeft, rawLeft))}px`,
|
||||
top: `${Math.min(maxTop, Math.max(minTop, rawTop))}px`
|
||||
};
|
||||
codeLanguageFloatingVisible.value = true;
|
||||
};
|
||||
|
||||
const queryCodeLanguageSuggestions = (queryString, cb) => {
|
||||
const keyword = String(queryString || '').trim().toLowerCase();
|
||||
const next = !keyword
|
||||
? BUILTIN_CODE_LANGUAGES
|
||||
: BUILTIN_CODE_LANGUAGES.filter(
|
||||
(item) => item.value.includes(keyword) || item.label.toLowerCase().includes(keyword)
|
||||
);
|
||||
cb(next);
|
||||
};
|
||||
|
||||
const applyCodeLanguage = (value) => {
|
||||
const instance = editorRef.value;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
const nextLanguage = resolveCodeBlockLanguage(value);
|
||||
if (!nextLanguage) {
|
||||
return;
|
||||
}
|
||||
const selected = resolveSelectionCodeBlockMeta() || resolveAnchorCodeBlockMeta();
|
||||
if (!selected || !Number.isFinite(selected.pos)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNode = instance.state.doc.nodeAt(selected.pos);
|
||||
if (!currentNode || currentNode.type?.name !== 'codeBlock') {
|
||||
return;
|
||||
}
|
||||
const nextAttrs = {
|
||||
...currentNode.attrs,
|
||||
language: nextLanguage
|
||||
};
|
||||
instance.view.dispatch(
|
||||
instance.state.tr.setNodeMarkup(selected.pos, currentNode.type, nextAttrs)
|
||||
);
|
||||
codeBlockAnchorPos.value = selected.pos;
|
||||
codeLanguageDraft.value = nextLanguage;
|
||||
updateCodeLanguageFloating();
|
||||
};
|
||||
|
||||
const applyCodeLanguageFromDraft = () => {
|
||||
applyCodeLanguage(codeLanguageDraft.value);
|
||||
};
|
||||
|
||||
const handleCodeLanguageSelect = (item) => {
|
||||
const value = item?.value || item?.label || codeLanguageDraft.value;
|
||||
applyCodeLanguage(value);
|
||||
};
|
||||
|
||||
const handleCodeLanguageInputBlur = () => {
|
||||
setTimeout(() => {
|
||||
if (!codeLanguageInteracting.value) {
|
||||
updateCodeLanguageFloating();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const hideCodeLanguageFloating = () => {
|
||||
codeBlockAnchorPos.value = null;
|
||||
codeLanguageFloatingVisible.value = false;
|
||||
};
|
||||
|
||||
const handleEditorBlur = () => {
|
||||
if (!codeLanguageInteracting.value) {
|
||||
hideCodeLanguageFloating();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lowlight,
|
||||
codeLanguageDraft,
|
||||
codeLanguageFloatingVisible,
|
||||
codeLanguageFloatingStyle,
|
||||
codeLanguageInteracting,
|
||||
queryCodeLanguageSuggestions,
|
||||
applyCodeLanguageFromDraft,
|
||||
handleCodeLanguageSelect,
|
||||
handleCodeLanguageInputBlur,
|
||||
syncCodeLanguageDraft,
|
||||
updateCodeLanguageFloating,
|
||||
hideCodeLanguageFloating,
|
||||
handleEditorBlur,
|
||||
resolveCodeBlockLanguage
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { COMMENT_ANCHOR_ATTR } from '@/components/document/rich-text/extensions/commentAnchorExtension';
|
||||
|
||||
const createCommentAnchorId = () => `rt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
export function useRichTextCommentBridge(options = {}) {
|
||||
const {
|
||||
editorRef,
|
||||
scrollContainerRef,
|
||||
commentEnabledRef,
|
||||
onCommentAdd,
|
||||
onCommentRemove,
|
||||
onCommentChanged
|
||||
} = options;
|
||||
|
||||
const selectionCommentVisible = ref(false);
|
||||
const selectionCommentStyle = ref({ top: '0px', left: '0px' });
|
||||
const selectionCommentHovering = ref(false);
|
||||
const selectionUpdateRaf = ref(0);
|
||||
|
||||
const isSelectionInsideEditor = () => {
|
||||
const root = editorRef.value?.view?.dom;
|
||||
const selection = window.getSelection?.();
|
||||
if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return false;
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
const common = range.commonAncestorContainer;
|
||||
const commonElement = common?.nodeType === Node.ELEMENT_NODE ? common : common?.parentElement;
|
||||
return Boolean(commonElement && root.contains(commonElement));
|
||||
};
|
||||
|
||||
const addInlineComment = () => {
|
||||
if (!commentEnabledRef.value || !editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const { from, to, empty } = editorRef.value.state.selection;
|
||||
if (empty || from === to) {
|
||||
return;
|
||||
}
|
||||
const selectedText = editorRef.value.state.doc.textBetween(from, to, ' ');
|
||||
const anchorId = createCommentAnchorId();
|
||||
const chain = editorRef.value.chain().focus();
|
||||
if (typeof chain.setCommentAnchor === 'function') {
|
||||
chain.setCommentAnchor(anchorId).run();
|
||||
} else {
|
||||
chain.setMark('commentAnchor', { anchorId }).run();
|
||||
}
|
||||
onCommentAdd?.({
|
||||
id: anchorId,
|
||||
text: selectedText || ''
|
||||
});
|
||||
onCommentChanged?.();
|
||||
selectionCommentVisible.value = false;
|
||||
};
|
||||
|
||||
const updateSelectionCommentTrigger = () => {
|
||||
if (!editorRef.value || !scrollContainerRef.value) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
if (!isSelectionInsideEditor()) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const instance = editorRef.value;
|
||||
const selection = instance.state.selection;
|
||||
const { from, to, empty } = selection;
|
||||
if (empty || from === to) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const selectedText = instance.state.doc.textBetween(from, to, ' ').trim();
|
||||
if (!selectedText) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
let fromCoords;
|
||||
let toCoords;
|
||||
try {
|
||||
fromCoords = instance.view.coordsAtPos(from);
|
||||
toCoords = instance.view.coordsAtPos(to);
|
||||
} catch (_) {
|
||||
selectionCommentVisible.value = false;
|
||||
return;
|
||||
}
|
||||
const container = scrollContainerRef.value;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const triggerWidth = commentEnabledRef.value ? 308 : 278;
|
||||
const triggerHeight = 36;
|
||||
const rawLeft = toCoords.right - containerRect.left + container.scrollLeft + 8;
|
||||
const rawTop = Math.min(fromCoords.top, toCoords.top) - containerRect.top + container.scrollTop - triggerHeight - 6;
|
||||
const minLeft = container.scrollLeft + 8;
|
||||
const maxLeft = container.scrollLeft + container.clientWidth - triggerWidth - 8;
|
||||
const minTop = container.scrollTop + 8;
|
||||
const maxTop = container.scrollTop + container.clientHeight - triggerHeight - 8;
|
||||
selectionCommentStyle.value = {
|
||||
left: `${Math.min(maxLeft, Math.max(minLeft, rawLeft))}px`,
|
||||
top: `${Math.min(maxTop, Math.max(minTop, rawTop))}px`
|
||||
};
|
||||
selectionCommentVisible.value = true;
|
||||
};
|
||||
|
||||
const requestSelectionCommentTriggerUpdate = () => {
|
||||
if (selectionUpdateRaf.value) {
|
||||
cancelAnimationFrame(selectionUpdateRaf.value);
|
||||
}
|
||||
selectionUpdateRaf.value = requestAnimationFrame(() => {
|
||||
updateSelectionCommentTrigger();
|
||||
selectionUpdateRaf.value = 0;
|
||||
});
|
||||
};
|
||||
|
||||
const getCommentAnchorElements = (ids = null) => {
|
||||
const root = editorRef.value?.view?.dom;
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
const all = Array.from(root.querySelectorAll(`span[${COMMENT_ANCHOR_ATTR}]`));
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return all;
|
||||
}
|
||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||
return all.filter((element) => idSet.has(String(element.getAttribute(COMMENT_ANCHOR_ATTR) || '').trim()));
|
||||
};
|
||||
|
||||
const getCommentIds = () => {
|
||||
const elements = getCommentAnchorElements();
|
||||
const container = scrollContainerRef.value;
|
||||
const containerRect = container?.getBoundingClientRect?.();
|
||||
const resultMap = new Map();
|
||||
|
||||
elements.forEach((element) => {
|
||||
const anchorId = String(element.getAttribute(COMMENT_ANCHOR_ATTR) || '').trim();
|
||||
if (!anchorId) {
|
||||
return;
|
||||
}
|
||||
let top = 0;
|
||||
if (container && containerRect) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
top = rect.top - containerRect.top + container.scrollTop;
|
||||
}
|
||||
if (!resultMap.has(anchorId) || top < resultMap.get(anchorId)) {
|
||||
resultMap.set(anchorId, top);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(resultMap.entries()).map(([id, top]) => ({ id, top }));
|
||||
};
|
||||
|
||||
const hlCommentIds = (ids = []) => {
|
||||
getCommentAnchorElements(ids).forEach((element) => {
|
||||
element.classList.add('comment-anchor-highlight');
|
||||
});
|
||||
};
|
||||
|
||||
const unHlCommentIds = (ids = []) => {
|
||||
getCommentAnchorElements(ids).forEach((element) => {
|
||||
element.classList.remove('comment-anchor-highlight');
|
||||
});
|
||||
};
|
||||
|
||||
const removeCommentIds = (ids = []) => {
|
||||
if (!editorRef.value || !Array.isArray(ids) || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean));
|
||||
if (idSet.size === 0) {
|
||||
return;
|
||||
}
|
||||
let transaction = editorRef.value.state.tr;
|
||||
editorRef.value.state.doc.descendants((node, pos) => {
|
||||
if (!node.isText || !Array.isArray(node.marks) || node.marks.length === 0) {
|
||||
return;
|
||||
}
|
||||
node.marks.forEach((mark) => {
|
||||
if (mark.type.name !== 'commentAnchor') {
|
||||
return;
|
||||
}
|
||||
const anchorId = String(mark.attrs?.anchorId || '').trim();
|
||||
if (!idSet.has(anchorId)) {
|
||||
return;
|
||||
}
|
||||
transaction = transaction.removeMark(pos, pos + node.nodeSize, mark);
|
||||
});
|
||||
});
|
||||
if (!transaction.docChanged) {
|
||||
return;
|
||||
}
|
||||
editorRef.value.view.dispatch(transaction);
|
||||
onCommentRemove?.(Array.from(idSet));
|
||||
onCommentChanged?.();
|
||||
};
|
||||
|
||||
const scrollToCommentId = async (id) => {
|
||||
const targetId = String(id || '').trim();
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const container = scrollContainerRef.value;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const target = getCommentAnchorElements([targetId])[0];
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const top = Math.max(targetRect.top - containerRect.top + container.scrollTop - 24, 0);
|
||||
container.scrollTo({ top, behavior: 'smooth' });
|
||||
hlCommentIds([targetId]);
|
||||
};
|
||||
|
||||
const clearSelectionRaf = () => {
|
||||
if (selectionUpdateRaf.value) {
|
||||
cancelAnimationFrame(selectionUpdateRaf.value);
|
||||
selectionUpdateRaf.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorBlur = () => {
|
||||
if (!selectionCommentHovering.value) {
|
||||
selectionCommentVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectionCommentVisible,
|
||||
selectionCommentStyle,
|
||||
selectionCommentHovering,
|
||||
addInlineComment,
|
||||
updateSelectionCommentTrigger,
|
||||
requestSelectionCommentTriggerUpdate,
|
||||
handleEditorBlur,
|
||||
clearSelectionRaf,
|
||||
getCommentIds,
|
||||
hlCommentIds,
|
||||
unHlCommentIds,
|
||||
removeCommentIds,
|
||||
scrollToCommentId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue';
|
||||
import { Editor } from '@tiptap/vue-3';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import { TextStyle } from '@tiptap/extension-text-style';
|
||||
import Color from '@tiptap/extension-color';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import { resolveRichTextComponentSystem } from '@/components/document/rich-text/components/registry';
|
||||
import { CommentAnchorExtension } from '@/components/document/rich-text/extensions/commentAnchorExtension';
|
||||
|
||||
export function useRichTextEditorRuntime(options = {}) {
|
||||
const {
|
||||
editorRef,
|
||||
bodyScrollRef,
|
||||
modelValueRef,
|
||||
valueFormatIsJsonRef,
|
||||
placeholderTextRef,
|
||||
commentEnabledRef,
|
||||
enabledComponentsRef,
|
||||
externalExtensionsRef,
|
||||
additionalExtensionsRef,
|
||||
reservedBridgeOptionsRef,
|
||||
collaborationBridgeRef,
|
||||
componentToolbarItemsRef,
|
||||
lowlight,
|
||||
applyingModelValueRef,
|
||||
lastEmittedValueRef,
|
||||
serializeModelValue,
|
||||
modelValueFromEditor,
|
||||
resolveInitialEditorContent,
|
||||
applyModelValueToEditor,
|
||||
syncLastEmittedValue,
|
||||
syncCodeLanguageDraft,
|
||||
updateCodeLanguageFloating,
|
||||
updateSelectionCommentTrigger,
|
||||
handleSelectionCommentEditorBlur,
|
||||
handleCodeLanguageEditorBlur,
|
||||
requestSelectionCommentTriggerUpdate,
|
||||
clearSelectionRaf,
|
||||
selectionCommentVisibleRef,
|
||||
onUpdateModelValue,
|
||||
onCommit,
|
||||
onReady
|
||||
} = options;
|
||||
|
||||
const buildReservedBridge = () => ({
|
||||
...reservedBridgeOptionsRef.value,
|
||||
comments: null,
|
||||
collaboration: collaborationBridgeRef?.value || null
|
||||
});
|
||||
|
||||
const handleSelectionOverlayScrollOrResize = () => {
|
||||
if (selectionCommentVisibleRef.value) {
|
||||
updateSelectionCommentTrigger();
|
||||
}
|
||||
updateCodeLanguageFloating();
|
||||
};
|
||||
|
||||
const handleKeyDown = (view, event) => {
|
||||
if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return false;
|
||||
}
|
||||
const instance = editorRef.value;
|
||||
if (instance?.isActive('listItem')) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
instance.chain().focus().liftListItem('listItem').run();
|
||||
} else {
|
||||
instance.chain().focus().sinkListItem('listItem').run();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.tr.insertText(' '));
|
||||
return true;
|
||||
};
|
||||
|
||||
const createEditor = () => {
|
||||
const componentSystem = resolveRichTextComponentSystem(enabledComponentsRef.value);
|
||||
const extraExtensions = Array.isArray(additionalExtensionsRef?.value)
|
||||
? additionalExtensionsRef.value
|
||||
: [];
|
||||
componentToolbarItemsRef.value = componentSystem.toolbarItems;
|
||||
|
||||
editorRef.value = new Editor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
blockquote: true,
|
||||
heading: { levels: [1, 2, 3] }
|
||||
}),
|
||||
Underline,
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight.configure({
|
||||
multicolor: true
|
||||
}),
|
||||
Link.configure({
|
||||
autolink: true,
|
||||
openOnClick: false,
|
||||
linkOnPaste: true
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholderTextRef.value
|
||||
}),
|
||||
...extraExtensions,
|
||||
...(commentEnabledRef.value ? [CommentAnchorExtension] : []),
|
||||
...componentSystem.extensions,
|
||||
...externalExtensionsRef.value
|
||||
],
|
||||
content: resolveInitialEditorContent(modelValueRef.value),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'yoresee-rich-text-content'
|
||||
},
|
||||
handleKeyDown
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
if (applyingModelValueRef.value) {
|
||||
return;
|
||||
}
|
||||
const nextValue = modelValueFromEditor(instance);
|
||||
const serialized = serializeModelValue(nextValue);
|
||||
if (serialized === lastEmittedValueRef.value) {
|
||||
return;
|
||||
}
|
||||
lastEmittedValueRef.value = serialized;
|
||||
onUpdateModelValue(nextValue);
|
||||
syncCodeLanguageDraft();
|
||||
updateCodeLanguageFloating();
|
||||
updateSelectionCommentTrigger();
|
||||
},
|
||||
onSelectionUpdate: () => {
|
||||
syncCodeLanguageDraft();
|
||||
updateCodeLanguageFloating();
|
||||
updateSelectionCommentTrigger();
|
||||
},
|
||||
onBlur: () => {
|
||||
handleSelectionCommentEditorBlur();
|
||||
handleCodeLanguageEditorBlur();
|
||||
onCommit();
|
||||
}
|
||||
});
|
||||
|
||||
onReady({
|
||||
editor: editorRef.value,
|
||||
reservedBridge: buildReservedBridge()
|
||||
});
|
||||
|
||||
syncCodeLanguageDraft();
|
||||
updateCodeLanguageFloating();
|
||||
};
|
||||
|
||||
const destroyEditor = () => {
|
||||
editorRef.value?.destroy();
|
||||
editorRef.value = null;
|
||||
};
|
||||
|
||||
const recreateEditor = () => {
|
||||
destroyEditor();
|
||||
createEditor();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const initialValue = valueFormatIsJsonRef.value
|
||||
? modelValueRef.value
|
||||
: String(modelValueRef.value || '');
|
||||
syncLastEmittedValue(initialValue);
|
||||
|
||||
createEditor();
|
||||
|
||||
nextTick(() => {
|
||||
bodyScrollRef.value?.addEventListener('scroll', handleSelectionOverlayScrollOrResize, {
|
||||
passive: true
|
||||
});
|
||||
bodyScrollRef.value?.addEventListener('mouseup', requestSelectionCommentTriggerUpdate);
|
||||
bodyScrollRef.value?.addEventListener('keyup', requestSelectionCommentTriggerUpdate);
|
||||
});
|
||||
document.addEventListener('selectionchange', requestSelectionCommentTriggerUpdate);
|
||||
window.addEventListener('resize', handleSelectionOverlayScrollOrResize);
|
||||
});
|
||||
|
||||
watch(modelValueRef, (nextValue) => {
|
||||
applyModelValueToEditor(nextValue);
|
||||
syncCodeLanguageDraft();
|
||||
nextTick(() => {
|
||||
updateCodeLanguageFloating();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearSelectionRaf();
|
||||
bodyScrollRef.value?.removeEventListener('mouseup', requestSelectionCommentTriggerUpdate);
|
||||
bodyScrollRef.value?.removeEventListener('keyup', requestSelectionCommentTriggerUpdate);
|
||||
bodyScrollRef.value?.removeEventListener('scroll', handleSelectionOverlayScrollOrResize);
|
||||
document.removeEventListener('selectionchange', requestSelectionCommentTriggerUpdate);
|
||||
window.removeEventListener('resize', handleSelectionOverlayScrollOrResize);
|
||||
destroyEditor();
|
||||
});
|
||||
|
||||
return {
|
||||
recreateEditor
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
const HOVER_HIDE_DELAY = 120;
|
||||
|
||||
const normalizeLinkUrl = (value = '') => {
|
||||
const input = String(value || '').trim();
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
if (/^(https?:\/\/|mailto:|tel:)/i.test(input)) {
|
||||
return input;
|
||||
}
|
||||
return `https://${input}`;
|
||||
};
|
||||
|
||||
export function useRichTextLinkOverlay(options = {}) {
|
||||
const {
|
||||
editorRef,
|
||||
scrollContainerRef,
|
||||
setLink,
|
||||
unsetLink
|
||||
} = options;
|
||||
|
||||
const linkDialogVisible = ref(false);
|
||||
const linkDialogValue = ref('');
|
||||
const linkHoverVisible = ref(false);
|
||||
const linkHoverStyle = ref({ top: '0px', left: '0px' });
|
||||
const linkHoverHref = ref('');
|
||||
const linkHoveringCard = ref(false);
|
||||
|
||||
const hoverAnchorPos = ref(null);
|
||||
const dialogSource = ref({
|
||||
type: 'selection',
|
||||
pos: null
|
||||
});
|
||||
|
||||
let mouseHost = null;
|
||||
let editorDom = null;
|
||||
let hideTimer = 0;
|
||||
|
||||
const enabled = computed(() => Boolean(editorRef.value?.view && scrollContainerRef.value));
|
||||
|
||||
const clearHideTimer = () => {
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const hideHoverCard = () => {
|
||||
linkHoverVisible.value = false;
|
||||
linkHoverHref.value = '';
|
||||
hoverAnchorPos.value = null;
|
||||
};
|
||||
|
||||
const scheduleHideHoverCard = () => {
|
||||
clearHideTimer();
|
||||
hideTimer = window.setTimeout(() => {
|
||||
hideTimer = 0;
|
||||
if (linkHoveringCard.value || linkDialogVisible.value) {
|
||||
return;
|
||||
}
|
||||
hideHoverCard();
|
||||
}, HOVER_HIDE_DELAY);
|
||||
};
|
||||
|
||||
const getEditorDom = () => editorRef.value?.view?.dom || null;
|
||||
|
||||
const resolveAnchorPos = (anchorEl) => {
|
||||
const view = editorRef.value?.view;
|
||||
const size = editorRef.value?.state?.doc?.content?.size;
|
||||
if (!view || !anchorEl || !Number.isFinite(size)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const pos = view.posAtDOM(anchorEl, 0);
|
||||
const safePos = Math.max(1, Math.min(pos + 1, size));
|
||||
return safePos;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const updateHoverCardFromAnchor = (anchorEl) => {
|
||||
const container = scrollContainerRef.value;
|
||||
if (!anchorEl || !container) {
|
||||
hideHoverCard();
|
||||
return;
|
||||
}
|
||||
const href = String(anchorEl.getAttribute('href') || '').trim();
|
||||
if (!href) {
|
||||
hideHoverCard();
|
||||
return;
|
||||
}
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
const cardWidth = 280;
|
||||
const cardHeight = 108;
|
||||
const rawLeft = rect.left - containerRect.left + container.scrollLeft;
|
||||
const rawTop = rect.bottom - containerRect.top + container.scrollTop + 8;
|
||||
const minLeft = container.scrollLeft + 8;
|
||||
const maxLeft = container.scrollLeft + container.clientWidth - cardWidth - 8;
|
||||
const minTop = container.scrollTop + 8;
|
||||
const maxTop = container.scrollTop + container.clientHeight - cardHeight - 8;
|
||||
|
||||
linkHoverStyle.value = {
|
||||
left: `${Math.max(minLeft, Math.min(rawLeft, Math.max(minLeft, maxLeft)))}px`,
|
||||
top: `${Math.max(minTop, Math.min(rawTop, Math.max(minTop, maxTop)))}px`
|
||||
};
|
||||
linkHoverHref.value = href;
|
||||
hoverAnchorPos.value = resolveAnchorPos(anchorEl);
|
||||
linkHoverVisible.value = true;
|
||||
};
|
||||
|
||||
const handleMouseMove = (event) => {
|
||||
if (linkDialogVisible.value) {
|
||||
return;
|
||||
}
|
||||
clearHideTimer();
|
||||
const root = getEditorDom();
|
||||
const target = event?.target;
|
||||
const source = target?.nodeType === Node.TEXT_NODE ? target.parentElement : target;
|
||||
if (!(source instanceof Element) || !root?.contains(source)) {
|
||||
if (!linkHoveringCard.value) {
|
||||
scheduleHideHoverCard();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const anchor = source.closest('a[href]');
|
||||
if (!anchor || !root.contains(anchor)) {
|
||||
if (!linkHoveringCard.value) {
|
||||
scheduleHideHoverCard();
|
||||
}
|
||||
return;
|
||||
}
|
||||
updateHoverCardFromAnchor(anchor);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (linkHoveringCard.value || linkDialogVisible.value) {
|
||||
return;
|
||||
}
|
||||
scheduleHideHoverCard();
|
||||
};
|
||||
|
||||
const bindEvents = () => {
|
||||
editorDom = getEditorDom();
|
||||
mouseHost = scrollContainerRef.value;
|
||||
if (!editorDom || !mouseHost) {
|
||||
return;
|
||||
}
|
||||
mouseHost.addEventListener('mousemove', handleMouseMove);
|
||||
mouseHost.addEventListener('mouseleave', handleMouseLeave);
|
||||
if (editorDom !== mouseHost) {
|
||||
editorDom.addEventListener('mousemove', handleMouseMove);
|
||||
editorDom.addEventListener('mouseleave', handleMouseLeave);
|
||||
}
|
||||
};
|
||||
|
||||
const unbindEvents = () => {
|
||||
clearHideTimer();
|
||||
if (mouseHost) {
|
||||
mouseHost.removeEventListener('mousemove', handleMouseMove);
|
||||
mouseHost.removeEventListener('mouseleave', handleMouseLeave);
|
||||
mouseHost = null;
|
||||
}
|
||||
if (editorDom) {
|
||||
editorDom.removeEventListener('mousemove', handleMouseMove);
|
||||
editorDom.removeEventListener('mouseleave', handleMouseLeave);
|
||||
editorDom = null;
|
||||
}
|
||||
hideHoverCard();
|
||||
};
|
||||
|
||||
const openSelectionLinkDialog = () => {
|
||||
const currentHref = String(editorRef.value?.getAttributes?.('link')?.href || '').trim();
|
||||
linkDialogValue.value = currentHref;
|
||||
dialogSource.value = { type: 'selection', pos: null };
|
||||
linkDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openHoverLinkDialog = () => {
|
||||
linkDialogValue.value = linkHoverHref.value;
|
||||
dialogSource.value = { type: 'hover', pos: hoverAnchorPos.value };
|
||||
linkDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const closeLinkDialog = () => {
|
||||
linkDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const applyLinkDialog = () => {
|
||||
const source = dialogSource.value;
|
||||
const normalized = normalizeLinkUrl(linkDialogValue.value);
|
||||
if (!normalized) {
|
||||
unsetLink?.({ pos: source.pos });
|
||||
linkDialogVisible.value = false;
|
||||
if (source.type === 'hover') {
|
||||
hideHoverCard();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setLink?.(normalized, { pos: source.pos });
|
||||
linkDialogVisible.value = false;
|
||||
if (source.type === 'hover') {
|
||||
linkHoverHref.value = normalized;
|
||||
}
|
||||
};
|
||||
|
||||
const removeHoverLink = () => {
|
||||
unsetLink?.({ pos: hoverAnchorPos.value });
|
||||
hideHoverCard();
|
||||
};
|
||||
|
||||
const handleLinkCardEnter = () => {
|
||||
clearHideTimer();
|
||||
linkHoveringCard.value = true;
|
||||
};
|
||||
|
||||
const handleLinkCardLeave = () => {
|
||||
linkHoveringCard.value = false;
|
||||
if (!linkDialogVisible.value) {
|
||||
scheduleHideHoverCard();
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
enabled,
|
||||
(next) => {
|
||||
unbindEvents();
|
||||
if (next) {
|
||||
bindEvents();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => editorRef.value,
|
||||
() => {
|
||||
if (!enabled.value) {
|
||||
return;
|
||||
}
|
||||
unbindEvents();
|
||||
bindEvents();
|
||||
}
|
||||
);
|
||||
|
||||
watch(linkDialogVisible, (next) => {
|
||||
if (!next && !linkHoveringCard.value) {
|
||||
scheduleHideHoverCard();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unbindEvents();
|
||||
});
|
||||
|
||||
return {
|
||||
linkDialogVisible,
|
||||
linkDialogValue,
|
||||
linkHoverVisible,
|
||||
linkHoverStyle,
|
||||
linkHoverHref,
|
||||
openSelectionLinkDialog,
|
||||
openHoverLinkDialog,
|
||||
closeLinkDialog,
|
||||
applyLinkDialog,
|
||||
removeHoverLink,
|
||||
handleLinkCardEnter,
|
||||
handleLinkCardLeave
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
export function useRichTextParagraphActions({
|
||||
editorRef,
|
||||
componentToolbarItemsRef,
|
||||
runComponentCommand,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleBlockquote,
|
||||
toggleCodeBlock,
|
||||
undo,
|
||||
redo
|
||||
}) {
|
||||
const focusForParagraphAction = (ctx, position = 'start') => {
|
||||
if (typeof ctx?.focusBlock === 'function') {
|
||||
ctx.focusBlock(position);
|
||||
return;
|
||||
}
|
||||
editorRef.value?.commands?.focus?.();
|
||||
};
|
||||
|
||||
const insertComponentByHandle = (ctx, item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
focusForParagraphAction(ctx);
|
||||
runComponentCommand(item);
|
||||
};
|
||||
|
||||
const emptyParagraphActions = computed(() => {
|
||||
const actions = [
|
||||
{
|
||||
key: 'set-paragraph',
|
||||
label: '普通文本',
|
||||
iconKey: 'paragraph',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
editorRef.value?.chain().focus().setParagraph().run();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'heading-1',
|
||||
label: '一级标题',
|
||||
iconKey: 'heading-1',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
editorRef.value?.chain().focus().setHeading({ level: 1 }).run();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'heading-2',
|
||||
label: '二级标题',
|
||||
iconKey: 'heading-2',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
editorRef.value?.chain().focus().setHeading({ level: 2 }).run();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'bullet-list',
|
||||
label: '无序列表',
|
||||
iconKey: 'bullet-list',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
toggleBulletList();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'ordered-list',
|
||||
label: '有序列表',
|
||||
iconKey: 'ordered-list',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
toggleOrderedList();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'blockquote',
|
||||
label: '引用',
|
||||
iconKey: 'blockquote',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
toggleBlockquote();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const insertActions = [
|
||||
{
|
||||
key: 'code-block',
|
||||
label: '代码块',
|
||||
iconKey: 'code-block',
|
||||
handler: (ctx) => {
|
||||
focusForParagraphAction(ctx);
|
||||
toggleCodeBlock();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
componentToolbarItemsRef.value.forEach((item) => {
|
||||
insertActions.push({
|
||||
key: `component-${item.key}`,
|
||||
label: item.label,
|
||||
iconKey: item.key,
|
||||
handler: (ctx) => {
|
||||
insertComponentByHandle(ctx, item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (insertActions.length > 0) {
|
||||
actions.push({
|
||||
key: 'insert',
|
||||
label: '插入',
|
||||
iconKey: 'insert',
|
||||
children: insertActions
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'undo',
|
||||
label: '撤销',
|
||||
handler: () => {
|
||||
undo();
|
||||
}
|
||||
});
|
||||
|
||||
actions.push({
|
||||
key: 'redo',
|
||||
label: '重做',
|
||||
handler: () => {
|
||||
redo();
|
||||
}
|
||||
});
|
||||
|
||||
return actions;
|
||||
});
|
||||
|
||||
return {
|
||||
emptyParagraphActions
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import {
|
||||
dedupeNumberList,
|
||||
resolveDomBlockFromMeta,
|
||||
resolveHandleStyle,
|
||||
resolveHoveredDomBlock,
|
||||
resolveMetaFromRange,
|
||||
resolvePositionFromMeta,
|
||||
resolveRangeByMouseCoords,
|
||||
resolveRangeFromDomBlock
|
||||
} from './paragraph-handle/paragraphHandleUtils';
|
||||
|
||||
export function useRichTextParagraphHandle({
|
||||
editorRef,
|
||||
scrollContainerRef,
|
||||
labels = {},
|
||||
resolveActions = null,
|
||||
onMutated
|
||||
}) {
|
||||
const visible = ref(false);
|
||||
const style = ref({ top: '0px', left: '0px' });
|
||||
const hoveredDomBlock = ref(null);
|
||||
const hoveredMeta = ref(null);
|
||||
const hoveringHandle = ref(false);
|
||||
|
||||
let editorDom = null;
|
||||
let mouseHost = null;
|
||||
let scrollContainer = null;
|
||||
let hideTimer = 0;
|
||||
const HIDE_DELAY_MS = 240;
|
||||
const TRANSITION_HIDE_DELAY_MS = 320;
|
||||
|
||||
const enabled = computed(() => Boolean(editorRef.value?.view && scrollContainerRef.value));
|
||||
const paragraphType = computed(() => hoveredMeta.value?.type || 'paragraph');
|
||||
const paragraphIsEmpty = computed(() => Boolean(hoveredMeta.value?.isEmpty));
|
||||
const paragraphHandleMode = computed(() => (paragraphIsEmpty.value ? 'empty' : 'normal'));
|
||||
|
||||
const buildDefaultActions = () => ([
|
||||
{
|
||||
key: 'insert-empty-paragraph',
|
||||
label: labels.insertEmptyParagraph || '插入空段落',
|
||||
iconKey: 'insert',
|
||||
children: [
|
||||
{
|
||||
key: 'add-above',
|
||||
label: labels.addAbove || '在上方',
|
||||
iconKey: 'add-above'
|
||||
},
|
||||
{
|
||||
key: 'add-below',
|
||||
label: labels.addBelow || '在下方',
|
||||
iconKey: 'add-below'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: labels.delete || 'Delete Paragraph',
|
||||
iconKey: 'delete',
|
||||
danger: true
|
||||
}
|
||||
]);
|
||||
|
||||
const paragraphActions = computed(() => {
|
||||
const defaults = buildDefaultActions();
|
||||
if (typeof resolveActions !== 'function') {
|
||||
return defaults;
|
||||
}
|
||||
const resolved = resolveActions({
|
||||
blockType: paragraphType.value,
|
||||
isEmpty: paragraphIsEmpty.value,
|
||||
defaults: defaults.slice()
|
||||
});
|
||||
if (!Array.isArray(resolved) || resolved.length === 0) {
|
||||
return defaults;
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
visible.value = false;
|
||||
hoveredDomBlock.value = null;
|
||||
hoveredMeta.value = null;
|
||||
};
|
||||
|
||||
const clearHideTimer = () => {
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReset = (delay = HIDE_DELAY_MS) => {
|
||||
clearHideTimer();
|
||||
hideTimer = window.setTimeout(() => {
|
||||
hideTimer = 0;
|
||||
if (!hoveringHandle.value) {
|
||||
reset();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const getEditorDom = () => editorRef.value?.view?.dom || null;
|
||||
const getEditorView = () => editorRef.value?.view || null;
|
||||
const getEditorState = () => getEditorView()?.state || null;
|
||||
|
||||
const updatePosition = (nextMeta = hoveredMeta.value, nextDomBlock = hoveredDomBlock.value) => {
|
||||
const container = scrollContainerRef.value;
|
||||
if (!container || !nextMeta) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
let blockRect = null;
|
||||
if (nextDomBlock?.isConnected) {
|
||||
blockRect = nextDomBlock.getBoundingClientRect();
|
||||
}
|
||||
|
||||
if (!blockRect) {
|
||||
const coords = resolvePositionFromMeta({
|
||||
view: getEditorView(),
|
||||
meta: nextMeta
|
||||
});
|
||||
if (coords) {
|
||||
blockRect = {
|
||||
top: coords.top,
|
||||
left: coords.left
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!blockRect) {
|
||||
if (!hoveringHandle.value) {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStyle = resolveHandleStyle({
|
||||
container,
|
||||
blockRect
|
||||
});
|
||||
if (!nextStyle) {
|
||||
if (!hoveringHandle.value) {
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
style.value = nextStyle;
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const updateHoveredBlock = (domBlock, event) => {
|
||||
if (!domBlock) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const view = getEditorView();
|
||||
const state = getEditorState();
|
||||
const range = resolveRangeFromDomBlock({ view, state, domBlock })
|
||||
|| resolveRangeByMouseCoords({ view, state, event });
|
||||
const meta = resolveMetaFromRange({ state, range });
|
||||
|
||||
if (!meta || meta.from >= meta.to) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedDomBlock = domBlock || resolveDomBlockFromMeta({
|
||||
view,
|
||||
editorRoot: getEditorDom(),
|
||||
meta
|
||||
});
|
||||
|
||||
hoveredDomBlock.value = resolvedDomBlock;
|
||||
hoveredMeta.value = meta;
|
||||
updatePosition(meta, resolvedDomBlock);
|
||||
};
|
||||
|
||||
const handleMouseMove = (event) => {
|
||||
clearHideTimer();
|
||||
if (event?.target instanceof Element && event.target.closest('.rich-text-paragraph-handle')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domBlock = resolveHoveredDomBlock({
|
||||
target: event?.target,
|
||||
editorDom: getEditorDom()
|
||||
});
|
||||
|
||||
if (!domBlock) {
|
||||
if (hoveringHandle.value) {
|
||||
return;
|
||||
}
|
||||
if (hoveredMeta.value) {
|
||||
// Keep a short grace period when cursor moves from tiny/empty paragraphs to the handle.
|
||||
scheduleReset(TRANSITION_HIDE_DELAY_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateHoveredBlock(domBlock, event);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (event) => {
|
||||
const relatedTarget = event?.relatedTarget;
|
||||
if (relatedTarget instanceof Element && relatedTarget.closest('.rich-text-paragraph-handle')) {
|
||||
return;
|
||||
}
|
||||
if (hoveringHandle.value) {
|
||||
return;
|
||||
}
|
||||
scheduleReset(TRANSITION_HIDE_DELAY_MS);
|
||||
};
|
||||
|
||||
const handleScrollOrResize = () => {
|
||||
if (!hoveredMeta.value) {
|
||||
return;
|
||||
}
|
||||
updatePosition(hoveredMeta.value, hoveredDomBlock.value);
|
||||
};
|
||||
|
||||
const bindEvents = () => {
|
||||
editorDom = getEditorDom();
|
||||
scrollContainer = scrollContainerRef.value;
|
||||
mouseHost = scrollContainerRef.value;
|
||||
|
||||
if (!editorDom || !scrollContainer || !mouseHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
mouseHost.addEventListener('mousemove', handleMouseMove);
|
||||
mouseHost.addEventListener('mouseleave', handleMouseLeave);
|
||||
if (editorDom !== mouseHost) {
|
||||
editorDom.addEventListener('mousemove', handleMouseMove);
|
||||
editorDom.addEventListener('mouseleave', handleMouseLeave);
|
||||
}
|
||||
scrollContainer.addEventListener('scroll', handleScrollOrResize, { passive: true });
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
};
|
||||
|
||||
const unbindEvents = () => {
|
||||
clearHideTimer();
|
||||
if (mouseHost) {
|
||||
mouseHost.removeEventListener('mousemove', handleMouseMove);
|
||||
mouseHost.removeEventListener('mouseleave', handleMouseLeave);
|
||||
mouseHost = null;
|
||||
}
|
||||
if (editorDom) {
|
||||
editorDom.removeEventListener('mousemove', handleMouseMove);
|
||||
editorDom.removeEventListener('mouseleave', handleMouseLeave);
|
||||
editorDom = null;
|
||||
}
|
||||
if (scrollContainer) {
|
||||
scrollContainer.removeEventListener('scroll', handleScrollOrResize);
|
||||
scrollContainer = null;
|
||||
}
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
reset();
|
||||
};
|
||||
|
||||
const tryInsertParagraphAt = (positions = []) => {
|
||||
const editor = editorRef.value;
|
||||
const view = editor?.view;
|
||||
const state = view?.state;
|
||||
const paragraphNodeType = state?.schema?.nodes?.paragraph;
|
||||
if (!editor || !view || !state || !paragraphNodeType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidates = dedupeNumberList(positions)
|
||||
.map((pos) => Math.max(0, Math.min(pos, state.doc.content.size)));
|
||||
|
||||
for (const pos of candidates) {
|
||||
try {
|
||||
const tr = view.state.tr.insert(pos, paragraphNodeType.create());
|
||||
view.dispatch(tr.scrollIntoView());
|
||||
editor.commands.focus(Math.max(0, Math.min(pos + 1, view.state.doc.content.size)));
|
||||
onMutated?.({ type: 'insert', position: pos, blockType: paragraphType.value });
|
||||
return true;
|
||||
} catch (_) {
|
||||
// fallback to next candidate
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const addParagraphAbove = () => {
|
||||
const meta = hoveredMeta.value;
|
||||
if (!meta) {
|
||||
return false;
|
||||
}
|
||||
const inserted = tryInsertParagraphAt([meta.from, meta.topFrom]);
|
||||
if (inserted) {
|
||||
reset();
|
||||
}
|
||||
return inserted;
|
||||
};
|
||||
|
||||
const addParagraphBelow = () => {
|
||||
const meta = hoveredMeta.value;
|
||||
if (!meta) {
|
||||
return false;
|
||||
}
|
||||
const inserted = tryInsertParagraphAt([meta.to, meta.topTo]);
|
||||
if (inserted) {
|
||||
reset();
|
||||
}
|
||||
return inserted;
|
||||
};
|
||||
|
||||
const deleteHoveredParagraph = () => {
|
||||
const editor = editorRef.value;
|
||||
const view = editor?.view;
|
||||
const meta = hoveredMeta.value;
|
||||
if (!editor || !view || !meta || meta.from >= meta.to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = view.state;
|
||||
if (state.doc.childCount <= 1) {
|
||||
editor.commands.clearContent(true);
|
||||
onMutated?.({ type: 'delete', blockType: paragraphType.value });
|
||||
reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
view.dispatch(state.tr.deleteRange(meta.from, meta.to).scrollIntoView());
|
||||
editor.commands.focus();
|
||||
onMutated?.({ type: 'delete', blockType: paragraphType.value });
|
||||
reset();
|
||||
return true;
|
||||
};
|
||||
|
||||
const focusHoveredBlock = (position = 'start') => {
|
||||
const editor = editorRef.value;
|
||||
const meta = hoveredMeta.value;
|
||||
const size = editor?.state?.doc?.content?.size;
|
||||
if (!editor || !meta || !Number.isFinite(size)) {
|
||||
return false;
|
||||
}
|
||||
const targetPos = position === 'end'
|
||||
? Math.max(meta.from + 1, meta.to - 1)
|
||||
: meta.from + 1;
|
||||
const safePos = Math.max(1, Math.min(targetPos, size));
|
||||
editor.commands.focus(safePos);
|
||||
return true;
|
||||
};
|
||||
|
||||
const runParagraphAction = (key) => {
|
||||
const findActionByKey = (actions, targetKey) => {
|
||||
if (!Array.isArray(actions) || !targetKey) {
|
||||
return null;
|
||||
}
|
||||
for (const item of actions) {
|
||||
if (item?.key === targetKey) {
|
||||
return item;
|
||||
}
|
||||
if (Array.isArray(item?.children) && item.children.length) {
|
||||
const childMatched = findActionByKey(item.children, targetKey);
|
||||
if (childMatched) {
|
||||
return childMatched;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const action = findActionByKey(paragraphActions.value, key);
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
const ctx = {
|
||||
blockType: paragraphType.value,
|
||||
isEmpty: paragraphIsEmpty.value,
|
||||
editor: editorRef.value,
|
||||
meta: hoveredMeta.value,
|
||||
focusBlock: focusHoveredBlock,
|
||||
addParagraphAbove,
|
||||
addParagraphBelow,
|
||||
deleteParagraph: deleteHoveredParagraph
|
||||
};
|
||||
if (typeof action.handler === 'function') {
|
||||
action.handler(ctx);
|
||||
return;
|
||||
}
|
||||
if (key === 'add-above') {
|
||||
addParagraphAbove();
|
||||
return;
|
||||
}
|
||||
if (key === 'add-below') {
|
||||
addParagraphBelow();
|
||||
return;
|
||||
}
|
||||
if (key === 'delete') {
|
||||
deleteHoveredParagraph();
|
||||
}
|
||||
};
|
||||
|
||||
const handleHandleEnter = () => {
|
||||
clearHideTimer();
|
||||
hoveringHandle.value = true;
|
||||
};
|
||||
|
||||
const handleHandleLeave = () => {
|
||||
hoveringHandle.value = false;
|
||||
scheduleReset(HIDE_DELAY_MS);
|
||||
};
|
||||
|
||||
watch(
|
||||
enabled,
|
||||
(next) => {
|
||||
unbindEvents();
|
||||
if (next) {
|
||||
bindEvents();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => editorRef.value,
|
||||
() => {
|
||||
if (!enabled.value) {
|
||||
return;
|
||||
}
|
||||
unbindEvents();
|
||||
bindEvents();
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearHideTimer();
|
||||
unbindEvents();
|
||||
});
|
||||
|
||||
return {
|
||||
paragraphHandleVisible: visible,
|
||||
paragraphHandleStyle: style,
|
||||
paragraphHandleMode,
|
||||
paragraphType,
|
||||
paragraphIsEmpty,
|
||||
paragraphActions,
|
||||
runParagraphAction,
|
||||
addParagraphAbove,
|
||||
addParagraphBelow,
|
||||
deleteHoveredParagraph,
|
||||
handleHandleEnter,
|
||||
handleHandleLeave
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
const normalizeColorForPicker = (value, fallback) => {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(normalized) ? normalized : fallback;
|
||||
};
|
||||
|
||||
export function useRichTextSelectionColors({ editorRef }) {
|
||||
const selectionTextColor = computed(() => {
|
||||
const value = editorRef.value?.getAttributes('textStyle')?.color;
|
||||
return normalizeColorForPicker(value, '#1f2937');
|
||||
});
|
||||
|
||||
const selectionBackgroundColor = computed(() => {
|
||||
const value = editorRef.value?.getAttributes('highlight')?.color;
|
||||
return normalizeColorForPicker(value, '');
|
||||
});
|
||||
|
||||
return {
|
||||
selectionTextColor,
|
||||
selectionBackgroundColor
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export function useRichTextToolbarActions(options = {}) {
|
||||
const {
|
||||
editorRef,
|
||||
codeLanguageDraftRef,
|
||||
resolveCodeBlockLanguage,
|
||||
hideCodeLanguageFloating,
|
||||
updateCodeLanguageFloating
|
||||
} = options;
|
||||
|
||||
const runCommand = (command) => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
command(editorRef.value.chain().focus()).run();
|
||||
};
|
||||
|
||||
const isActive = (name) => editorRef.value?.isActive(name) ?? false;
|
||||
const isHeadingActive = (level) => editorRef.value?.isActive('heading', { level }) ?? false;
|
||||
|
||||
const toggleBold = () => runCommand((chain) => chain.toggleBold());
|
||||
const toggleUnderline = () => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const chain = editorRef.value.chain().focus();
|
||||
if (typeof chain.toggleUnderline !== 'function') {
|
||||
return;
|
||||
}
|
||||
chain.toggleUnderline().run();
|
||||
};
|
||||
const toggleItalic = () => runCommand((chain) => chain.toggleItalic());
|
||||
const toggleStrike = () => runCommand((chain) => chain.toggleStrike());
|
||||
const toggleInlineCode = () => runCommand((chain) => chain.toggleCode());
|
||||
const toggleHeading = (level) => runCommand((chain) => chain.toggleHeading({ level }));
|
||||
const toggleBulletList = () => runCommand((chain) => chain.toggleBulletList());
|
||||
const toggleOrderedList = () => runCommand((chain) => chain.toggleOrderedList());
|
||||
const toggleBlockquote = () => runCommand((chain) => chain.toggleBlockquote());
|
||||
const resolveLinkChain = (pos) => {
|
||||
const chain = editorRef.value.chain();
|
||||
if (typeof pos === 'number' && Number.isFinite(pos)) {
|
||||
return chain.focus(pos).extendMarkRange('link');
|
||||
}
|
||||
return chain.focus().extendMarkRange('link');
|
||||
};
|
||||
|
||||
const setLink = (href = '', options = {}) => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const url = String(href || '').trim();
|
||||
if (!url) {
|
||||
resolveLinkChain(options.pos).unsetLink().run();
|
||||
return;
|
||||
}
|
||||
resolveLinkChain(options.pos).setLink({ href: url }).run();
|
||||
};
|
||||
const unsetLink = (options = {}) => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
resolveLinkChain(options.pos).unsetLink().run();
|
||||
};
|
||||
const setTextColor = (color = '') => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const value = String(color || '').trim();
|
||||
if (!value) {
|
||||
editorRef.value.chain().focus().unsetColor().run();
|
||||
return;
|
||||
}
|
||||
editorRef.value.chain().focus().setColor(value).run();
|
||||
};
|
||||
const setBackgroundColor = (color = '') => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const value = String(color || '').trim();
|
||||
if (!value) {
|
||||
editorRef.value.chain().focus().unsetHighlight().run();
|
||||
return;
|
||||
}
|
||||
editorRef.value.chain().focus().setHighlight({ color: value }).run();
|
||||
};
|
||||
|
||||
const toggleCodeBlock = () => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const chain = editorRef.value.chain().focus();
|
||||
if (editorRef.value.isActive('codeBlock')) {
|
||||
chain.toggleCodeBlock().run();
|
||||
hideCodeLanguageFloating?.();
|
||||
return;
|
||||
}
|
||||
const nextLanguage = resolveCodeBlockLanguage?.(codeLanguageDraftRef.value) || 'plaintext';
|
||||
codeLanguageDraftRef.value = nextLanguage;
|
||||
chain.setCodeBlock({ language: nextLanguage }).run();
|
||||
updateCodeLanguageFloating?.();
|
||||
};
|
||||
|
||||
const undo = () => runCommand((chain) => chain.undo());
|
||||
const redo = () => runCommand((chain) => chain.redo());
|
||||
|
||||
const runComponentCommand = (item) => {
|
||||
if (!editorRef.value || !item?.command) {
|
||||
return;
|
||||
}
|
||||
const chain = editorRef.value.chain().focus();
|
||||
const commandMethod = chain[item.command];
|
||||
if (typeof commandMethod !== 'function') {
|
||||
return;
|
||||
}
|
||||
commandMethod.call(chain, item.commandArgs || {});
|
||||
chain.run();
|
||||
};
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isHeadingActive,
|
||||
toggleBold,
|
||||
toggleUnderline,
|
||||
toggleItalic,
|
||||
toggleStrike,
|
||||
toggleInlineCode,
|
||||
toggleHeading,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleBlockquote,
|
||||
toggleCodeBlock,
|
||||
setLink,
|
||||
unsetLink,
|
||||
setTextColor,
|
||||
setBackgroundColor,
|
||||
undo,
|
||||
redo,
|
||||
runComponentCommand
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { createRichTextTurndown, htmlToMarkdown, markdownToHtml } from './value-bridge/markdownBridge';
|
||||
import { cloneJsonDoc, normalizeJsonDoc, serializeJsonDoc } from './value-bridge/jsonDocBridge';
|
||||
|
||||
export function useRichTextValueBridge(options = {}) {
|
||||
const { editorRef, valueFormatRef } = options;
|
||||
const applyingModelValue = ref(false);
|
||||
const lastEmittedValue = ref('');
|
||||
const isJsonValueMode = computed(() => valueFormatRef.value === 'json');
|
||||
const turndown = createRichTextTurndown();
|
||||
|
||||
const serializeModelValue = (value) => {
|
||||
if (isJsonValueMode.value) {
|
||||
return serializeJsonDoc(value);
|
||||
}
|
||||
return String(value || '');
|
||||
};
|
||||
const modelValueFromEditor = (instance) => {
|
||||
if (isJsonValueMode.value) {
|
||||
return cloneJsonDoc(instance.getJSON());
|
||||
}
|
||||
return htmlToMarkdown(instance.getHTML(), turndown);
|
||||
};
|
||||
const resolveInitialEditorContent = (modelValue) => {
|
||||
if (isJsonValueMode.value) {
|
||||
return normalizeJsonDoc(modelValue);
|
||||
}
|
||||
return markdownToHtml(String(modelValue || ''));
|
||||
};
|
||||
const applyModelValueToEditor = (modelValue) => {
|
||||
if (!editorRef.value) {
|
||||
return;
|
||||
}
|
||||
const serialized = serializeModelValue(modelValue);
|
||||
if (serialized === lastEmittedValue.value) {
|
||||
return;
|
||||
}
|
||||
applyingModelValue.value = true;
|
||||
if (isJsonValueMode.value) {
|
||||
editorRef.value.commands.setContent(normalizeJsonDoc(modelValue), false);
|
||||
} else {
|
||||
editorRef.value.commands.setContent(markdownToHtml(String(modelValue || '')), false, {
|
||||
preserveWhitespace: true
|
||||
});
|
||||
}
|
||||
lastEmittedValue.value = serialized;
|
||||
applyingModelValue.value = false;
|
||||
};
|
||||
const syncLastEmittedValue = (value) => {
|
||||
lastEmittedValue.value = serializeModelValue(value);
|
||||
};
|
||||
|
||||
return {
|
||||
applyingModelValue,
|
||||
isJsonValueMode,
|
||||
lastEmittedValue,
|
||||
serializeModelValue,
|
||||
modelValueFromEditor,
|
||||
resolveInitialEditorContent,
|
||||
applyModelValueToEditor,
|
||||
syncLastEmittedValue
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import * as Y from 'yjs';
|
||||
import { WebsocketProvider } from 'y-websocket';
|
||||
import Collaboration from '@tiptap/extension-collaboration';
|
||||
import {
|
||||
createYjsContentBridge,
|
||||
YJS_CONTENT_CARRIER,
|
||||
YJS_CONTENT_FIELD
|
||||
} from '@/composables/document/editor/collab/useYjsContentBridge';
|
||||
|
||||
import { resolveCollabUrl, getAwarenessPeerCount } from '@/utils/collabUrl';
|
||||
|
||||
const hasSeedContent = (content) => {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
if (!content || typeof content !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(content.content)) {
|
||||
return content.content.length > 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export function useRichTextYjsCollaboration(options = {}) {
|
||||
const {
|
||||
collabEnabledRef,
|
||||
collabRoomRef,
|
||||
collabUrlRef,
|
||||
collabTokenRef,
|
||||
modelValueRef,
|
||||
editorRef,
|
||||
resolveInitialEditorContent,
|
||||
onSync
|
||||
} = options;
|
||||
|
||||
const ydocRef = ref(null);
|
||||
const contentBridgeRef = ref(null);
|
||||
const providerRef = ref(null);
|
||||
const collabSyncedRef = ref(false);
|
||||
const pendingSeedRef = ref(false);
|
||||
|
||||
const emitSync = (isSynced) => {
|
||||
collabSyncedRef.value = Boolean(isSynced);
|
||||
onSync?.(Boolean(isSynced));
|
||||
};
|
||||
|
||||
const seedEditorFromModelValue = () => {
|
||||
const instance = editorRef.value;
|
||||
if (!instance) {
|
||||
return false;
|
||||
}
|
||||
const seedContent = resolveInitialEditorContent(modelValueRef.value);
|
||||
if (!hasSeedContent(seedContent)) {
|
||||
return false;
|
||||
}
|
||||
instance.commands.setContent(seedContent, false, {
|
||||
preserveWhitespace: true
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const maybeSeedAfterSync = () => {
|
||||
const bridge = contentBridgeRef.value;
|
||||
if (!collabSyncedRef.value) {
|
||||
return;
|
||||
}
|
||||
if (!bridge || !bridge.isEmpty()) {
|
||||
pendingSeedRef.value = false;
|
||||
return;
|
||||
}
|
||||
if (getAwarenessPeerCount(providerRef.value) > 1) {
|
||||
pendingSeedRef.value = false;
|
||||
return;
|
||||
}
|
||||
if (!editorRef.value) {
|
||||
pendingSeedRef.value = true;
|
||||
return;
|
||||
}
|
||||
pendingSeedRef.value = false;
|
||||
seedEditorFromModelValue();
|
||||
};
|
||||
|
||||
const teardownCollaboration = () => {
|
||||
if (providerRef.value) {
|
||||
providerRef.value.destroy();
|
||||
providerRef.value = null;
|
||||
}
|
||||
if (ydocRef.value) {
|
||||
ydocRef.value.destroy();
|
||||
ydocRef.value = null;
|
||||
}
|
||||
contentBridgeRef.value = null;
|
||||
pendingSeedRef.value = false;
|
||||
emitSync(false);
|
||||
};
|
||||
|
||||
const setupCollaboration = () => {
|
||||
teardownCollaboration();
|
||||
|
||||
if (!collabEnabledRef.value || !collabRoomRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = resolveCollabUrl(collabUrlRef.value);
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
ydocRef.value = new Y.Doc();
|
||||
contentBridgeRef.value = createYjsContentBridge({
|
||||
doc: ydocRef.value,
|
||||
carrier: YJS_CONTENT_CARRIER.XML_FRAGMENT
|
||||
});
|
||||
|
||||
providerRef.value = new WebsocketProvider(url, collabRoomRef.value, ydocRef.value, {
|
||||
params: collabTokenRef.value ? { token: collabTokenRef.value } : {}
|
||||
});
|
||||
|
||||
providerRef.value.on('sync', (isSynced) => {
|
||||
emitSync(isSynced);
|
||||
if (!isSynced) {
|
||||
return;
|
||||
}
|
||||
maybeSeedAfterSync();
|
||||
});
|
||||
};
|
||||
|
||||
const collaborationExtensionsRef = computed(() => {
|
||||
if (!collabEnabledRef.value || !ydocRef.value) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
Collaboration.configure({
|
||||
document: ydocRef.value,
|
||||
field: YJS_CONTENT_FIELD
|
||||
})
|
||||
];
|
||||
});
|
||||
|
||||
const collaborationBridgeRef = computed(() => {
|
||||
if (!ydocRef.value || !providerRef.value) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
ydoc: ydocRef.value,
|
||||
provider: providerRef.value
|
||||
};
|
||||
});
|
||||
|
||||
watch(editorRef, (instance) => {
|
||||
if (!instance || !pendingSeedRef.value) {
|
||||
return;
|
||||
}
|
||||
maybeSeedAfterSync();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setupCollaboration();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
teardownCollaboration();
|
||||
});
|
||||
|
||||
return {
|
||||
collabSyncedRef,
|
||||
collaborationExtensionsRef,
|
||||
collaborationBridgeRef,
|
||||
setupCollaboration,
|
||||
teardownCollaboration
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
decodeRichTableModelFromAttr,
|
||||
decodeRichTableModelFromMarkdownBlock,
|
||||
encodeRichTableModelForAttr,
|
||||
encodeRichTableModelToMarkdownBlock
|
||||
} from '@/components/document/rich-text/components/table/richTableModel';
|
||||
|
||||
export {
|
||||
decodeRichTableModelFromAttr,
|
||||
decodeRichTableModelFromMarkdownBlock,
|
||||
encodeRichTableModelForAttr,
|
||||
encodeRichTableModelToMarkdownBlock
|
||||
};
|
||||
|
||||
export const encodeDrawioSourceToBase64 = (value) => {
|
||||
const source = String(value || '');
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
if (typeof TextEncoder !== 'undefined') {
|
||||
const bytes = new TextEncoder().encode(source);
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
const chunk = bytes.subarray(index, index + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
return btoa(unescape(encodeURIComponent(source)));
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const decodeDrawioSourceFromBase64 = (value) => {
|
||||
const source = String(value || '').trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const binary = atob(source);
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
return decodeURIComponent(escape(binary));
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeDrawioSource = (value) => {
|
||||
const source = String(value || '').trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
if (source.startsWith('base64:')) {
|
||||
const decoded = decodeDrawioSourceFromBase64(source.slice('base64:'.length));
|
||||
return decoded || '';
|
||||
}
|
||||
return source;
|
||||
};
|
||||
|
||||
export const resolveMindmapSource = (node) => {
|
||||
if (!node || typeof node.getAttribute !== 'function') {
|
||||
return '';
|
||||
}
|
||||
const rawSource = node.getAttribute('data-source') || node.getAttribute('source') || '';
|
||||
if (rawSource) {
|
||||
try {
|
||||
return decodeURIComponent(String(rawSource));
|
||||
} catch (_) {
|
||||
return String(rawSource);
|
||||
}
|
||||
}
|
||||
if (typeof node.querySelector === 'function') {
|
||||
const textarea = node.querySelector('textarea');
|
||||
if (textarea?.value) {
|
||||
return String(textarea.value);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const resolveDrawioSource = (node) => {
|
||||
if (!node || typeof node.getAttribute !== 'function') {
|
||||
return '';
|
||||
}
|
||||
const source = node.getAttribute('data-diagram') || node.getAttribute('diagram') || '';
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(String(source));
|
||||
} catch (_) {
|
||||
return String(source);
|
||||
}
|
||||
};
|
||||
|
||||
export const extractDrawioBlocksFromHtml = (sourceHtml) => {
|
||||
const source = String(sourceHtml || '');
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks = [];
|
||||
const pattern = /<([a-z0-9-]+)\b([^>]*)>/gi;
|
||||
let matched = pattern.exec(source);
|
||||
while (matched) {
|
||||
const tagName = String(matched[1] || '').toLowerCase();
|
||||
const attrsSource = String(matched[2] || '');
|
||||
const dataTypeMatch = attrsSource.match(/\bdata-type=["']([^"']+)["']/i);
|
||||
const dataType = String(dataTypeMatch?.[1] || '').toLowerCase();
|
||||
const hasDrawioType = tagName === 'yoresee-drawio' || dataType.includes('drawio');
|
||||
if (!hasDrawioType) {
|
||||
matched = pattern.exec(source);
|
||||
continue;
|
||||
}
|
||||
const dataDiagramMatch = attrsSource.match(/\b(?:data-diagram|diagram)=["']([^"']+)["']/i);
|
||||
if (!dataDiagramMatch?.[1]) {
|
||||
matched = pattern.exec(source);
|
||||
continue;
|
||||
}
|
||||
|
||||
let decodedSource = '';
|
||||
try {
|
||||
decodedSource = decodeURIComponent(String(dataDiagramMatch[1]));
|
||||
} catch (_) {
|
||||
decodedSource = String(dataDiagramMatch[1]);
|
||||
}
|
||||
decodedSource = decodedSource.trim();
|
||||
if (!decodedSource) {
|
||||
matched = pattern.exec(source);
|
||||
continue;
|
||||
}
|
||||
|
||||
const encoded = encodeDrawioSourceToBase64(decodedSource);
|
||||
if (encoded) {
|
||||
blocks.push(`\`\`\`drawio\nbase64:${encoded}\n\`\`\``);
|
||||
}
|
||||
|
||||
matched = pattern.exec(source);
|
||||
}
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const normalizeLanguageToken = (value = '') =>
|
||||
String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_+-]/g, '');
|
||||
|
||||
export const extractLanguageFromCodeClass = (className = '') => {
|
||||
const source = String(className || '').trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
const matched = source.match(/(?:^|\s)language-([a-z0-9_+-]+)(?:\s|$)/i);
|
||||
if (!matched?.[1]) {
|
||||
return '';
|
||||
}
|
||||
return normalizeLanguageToken(matched[1]);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
const EMPTY_DOC = Object.freeze({ type: 'doc', content: [] });
|
||||
const EMPTY_DOC_JSON = '{"type":"doc","content":[]}';
|
||||
|
||||
const safeClone = (value) => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch (_) {
|
||||
return { ...EMPTY_DOC };
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeJsonDoc = (value) => {
|
||||
if (value && typeof value === 'object' && value.type === 'doc') {
|
||||
return safeClone(value);
|
||||
}
|
||||
return { ...EMPTY_DOC };
|
||||
};
|
||||
|
||||
export const serializeJsonDoc = (value) => {
|
||||
try {
|
||||
return JSON.stringify(normalizeJsonDoc(value));
|
||||
} catch (_) {
|
||||
return EMPTY_DOC_JSON;
|
||||
}
|
||||
};
|
||||
|
||||
export const cloneJsonDoc = safeClone;
|
||||
@@ -0,0 +1,186 @@
|
||||
import TurndownService from 'turndown';
|
||||
import { marked } from 'marked';
|
||||
import { COMMENT_ANCHOR_ATTR } from '@/components/document/rich-text/extensions/commentAnchorExtension';
|
||||
import {
|
||||
decodeRichTableModelFromAttr,
|
||||
decodeRichTableModelFromMarkdownBlock,
|
||||
encodeRichTableModelForAttr,
|
||||
encodeRichTableModelToMarkdownBlock,
|
||||
encodeDrawioSourceToBase64,
|
||||
extractDrawioBlocksFromHtml,
|
||||
extractLanguageFromCodeClass,
|
||||
normalizeDrawioSource,
|
||||
resolveDrawioSource,
|
||||
resolveMindmapSource
|
||||
} from './componentCodec';
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true
|
||||
});
|
||||
|
||||
export const createRichTextTurndown = () => {
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
bulletListMarker: '-',
|
||||
codeBlockStyle: 'fenced',
|
||||
emDelimiter: '*'
|
||||
});
|
||||
|
||||
turndown.addRule('fencedCodeBlockWithLanguage', {
|
||||
filter: (node) => {
|
||||
if (!node || node.nodeName !== 'PRE' || !node.firstChild) {
|
||||
return false;
|
||||
}
|
||||
return node.firstChild.nodeName === 'CODE';
|
||||
},
|
||||
replacement: (_, node) => {
|
||||
const codeNode = node.firstChild;
|
||||
const className = typeof codeNode.getAttribute === 'function' ? codeNode.getAttribute('class') || '' : '';
|
||||
const language = extractLanguageFromCodeClass(className);
|
||||
const rawText = String(codeNode.textContent || '').replace(/\n+$/, '');
|
||||
const languageSuffix = language ? language : '';
|
||||
return `\n\n\`\`\`${languageSuffix}\n${rawText}\n\`\`\`\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
turndown.addRule('yoreseeMindmap', {
|
||||
filter: (node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodeName = String(node.nodeName || '').toLowerCase();
|
||||
if (nodeName === 'yoresee-mindmap') {
|
||||
return true;
|
||||
}
|
||||
if (typeof node.getAttribute !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const dataType = String(node.getAttribute('data-type') || '').toLowerCase();
|
||||
const hasDataSource = Boolean(node.getAttribute('data-source') || node.getAttribute('source'));
|
||||
return hasDataSource && dataType.includes('mindmap');
|
||||
},
|
||||
replacement: (_, node) => {
|
||||
const source = resolveMindmapSource(node).trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
return `\n\n\`\`\`mindmap\n${source}\n\`\`\`\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
turndown.addRule('yoreseeDrawio', {
|
||||
filter: (node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodeName = String(node.nodeName || '').toLowerCase();
|
||||
if (nodeName === 'yoresee-drawio') {
|
||||
return true;
|
||||
}
|
||||
if (typeof node.getAttribute !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const dataType = String(node.getAttribute('data-type') || '').toLowerCase();
|
||||
const hasData = Boolean(node.getAttribute('data-diagram') || node.getAttribute('diagram'));
|
||||
return hasData && dataType.includes('drawio');
|
||||
},
|
||||
replacement: (_, node) => {
|
||||
const source = resolveDrawioSource(node).trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
const encoded = encodeDrawioSourceToBase64(source);
|
||||
if (!encoded) {
|
||||
return '';
|
||||
}
|
||||
return `\n\n\`\`\`drawio\nbase64:${encoded}\n\`\`\`\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
turndown.addRule('yoreseeTable', {
|
||||
filter: (node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodeName = String(node.nodeName || '').toLowerCase();
|
||||
if (nodeName === 'yoresee-table') {
|
||||
return true;
|
||||
}
|
||||
if (typeof node.getAttribute !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const dataType = String(node.getAttribute('data-type') || '').toLowerCase();
|
||||
const hasData = Boolean(node.getAttribute('data-table') || node.getAttribute('table'));
|
||||
return hasData && dataType.includes('table');
|
||||
},
|
||||
replacement: (_, node) => {
|
||||
const tableModel = decodeRichTableModelFromAttr(
|
||||
node.getAttribute('data-table') || node.getAttribute('table')
|
||||
);
|
||||
const encoded = encodeRichTableModelToMarkdownBlock(tableModel);
|
||||
if (!encoded) {
|
||||
return '';
|
||||
}
|
||||
return `\n\n\`\`\`table\n${encoded}\n\`\`\`\n\n`;
|
||||
}
|
||||
});
|
||||
|
||||
turndown.addRule('yoreseeCommentAnchor', {
|
||||
filter: (node) => node.nodeName === 'SPAN' && node.getAttribute(COMMENT_ANCHOR_ATTR),
|
||||
replacement: (content, node) => {
|
||||
const anchorId = String(node.getAttribute(COMMENT_ANCHOR_ATTR) || '').trim();
|
||||
if (!anchorId) {
|
||||
return content || '';
|
||||
}
|
||||
return `<span ${COMMENT_ANCHOR_ATTR}="${anchorId}">${content || ''}</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
return turndown;
|
||||
};
|
||||
|
||||
export const markdownToHtml = (value) => {
|
||||
const markdownSource = String(value || '');
|
||||
if (!markdownSource.trim()) {
|
||||
return '<p></p>';
|
||||
}
|
||||
|
||||
const sourceWithComponentBlocks = markdownSource
|
||||
.replace(/```drawio\s*\n([\s\S]*?)```/gi, (_, drawioSource) => {
|
||||
const normalized = normalizeDrawioSource(drawioSource);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
const encoded = encodeURIComponent(normalized);
|
||||
return `\n<yoresee-drawio data-diagram="${encoded}" data-type="drawio"></yoresee-drawio>\n`;
|
||||
})
|
||||
.replace(/```mindmap\s*\n([\s\S]*?)```/gi, (_, mindmapSource) => {
|
||||
const encoded = encodeURIComponent(String(mindmapSource || '').trim());
|
||||
return `\n<yoresee-mindmap data-source="${encoded}"></yoresee-mindmap>\n`;
|
||||
})
|
||||
.replace(/```table\s*\n([\s\S]*?)```/gi, (_, tableSource) => {
|
||||
const tableModel = decodeRichTableModelFromMarkdownBlock(tableSource);
|
||||
if (!tableModel) {
|
||||
return '';
|
||||
}
|
||||
const encoded = encodeRichTableModelForAttr(tableModel);
|
||||
return `\n<yoresee-table data-table="${encoded}" data-type="table"></yoresee-table>\n`;
|
||||
});
|
||||
|
||||
const parsed = marked.parse(sourceWithComponentBlocks, { async: false });
|
||||
return typeof parsed === 'string' ? parsed : markdownSource;
|
||||
};
|
||||
|
||||
export const htmlToMarkdown = (value, turndown) => {
|
||||
const source = String(value || '');
|
||||
if (!source.trim()) {
|
||||
return '';
|
||||
}
|
||||
const drawioFallbackBlocks = extractDrawioBlocksFromHtml(source);
|
||||
let markdown = turndown.turndown(source);
|
||||
if (drawioFallbackBlocks.length > 0 && !/```drawio[\s\S]*?```/i.test(markdown)) {
|
||||
markdown = `${markdown.trimEnd()}\n\n${drawioFallbackBlocks.join('\n\n')}`.trim();
|
||||
}
|
||||
return markdown.replace(/\n{3,}/g, '\n\n').trimEnd();
|
||||
};
|
||||
Reference in New Issue
Block a user