blob: eb4ad16e1573ebaf19bf1d37b9ff92a2157f3102 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState, useRef } from 'react';
import { minimalSetup } from 'codemirror';
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, placeholder } from '@codemirror/view';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { languages } from '@codemirror/language-data';
import copy from 'copy-to-clipboard';
import Tooltip from 'bootstrap/js/dist/tooltip';
import { Editor } from '../types';
import { isDarkTheme } from '@/utils/common';
import { createCodeMirrorAdapter } from './codemirror/adapter';
const editableCompartment = new Compartment();
interface htmlRenderConfig {
copyText: string;
copySuccessText: string;
}
export function htmlRender(el: HTMLElement | null, config?: htmlRenderConfig) {
if (!el) return;
const { copyText = '', copySuccessText = '' } = config || {
copyText: 'Copy to clipboard',
copySuccessText: 'Copied!',
};
// Replace all br tags with newlines
// Fixed an issue where the BR tag in the editor block formula HTML caused rendering errors.
el.querySelectorAll('p').forEach((p) => {
if (p.innerHTML.startsWith('$$') && p.innerHTML.endsWith('$$')) {
const str = p.innerHTML.replace(/<br>/g, '\n');
p.innerHTML = str;
}
});
// change table style
el.querySelectorAll('table').forEach((table) => {
if (
(table.parentNode as HTMLDivElement)?.classList.contains(
'table-responsive',
)
) {
return;
}
table.classList.add('table', 'table-bordered');
const div = document.createElement('div');
div.className = 'table-responsive';
table.parentNode?.replaceChild(div, table);
div.appendChild(table);
});
// add rel nofollow for link not includes domain
el.querySelectorAll('a').forEach((a) => {
const base = window.location.origin;
const targetUrl = new URL(a.href, base);
if (targetUrl.origin !== base) {
a.rel = 'nofollow';
}
});
// Add copy button to all pre tags
el.querySelectorAll('pre').forEach((pre) => {
// Create copy button
const codeWrap = document.createElement('div');
codeWrap.className = 'position-relative a-code-wrap';
const codeTool = document.createElement('div');
codeTool.className = 'a-code-tool';
const uniqueId = `a-copy-code-${Date.now().toString().substring(5)}-${Math.floor(Math.random() * 10)}${Math.floor(Math.random() * 10)}${Math.floor(Math.random() * 10)}`;
const str = `
<a role="button" class="link-secondary a-copy-code" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="${copyText}" id="${uniqueId}">
<i class="br bi-copy"></i>
</a>
`;
codeTool.innerHTML = str;
pre.style.position = 'relative';
codeWrap.appendChild(codeTool);
pre.parentNode?.replaceChild(codeWrap, pre);
codeWrap.appendChild(pre);
const tooltipTriggerList = el.querySelectorAll('.a-copy-code');
Array.from(tooltipTriggerList)?.map(
(tooltipTriggerEl) => new Tooltip(tooltipTriggerEl),
);
// Copy pre content on button click
const copyBtn = codeTool.querySelector('.a-copy-code');
copyBtn?.addEventListener('click', () => {
const textToCopy = pre.textContent || '';
copy(textToCopy);
// Change tooltip text on copy success
const tooltipInstance = Tooltip.getOrCreateInstance(`#${uniqueId}`);
tooltipInstance?.setContent({ '.tooltip-inner': copySuccessText });
const myTooltipEl = document.querySelector(`#${uniqueId}`);
myTooltipEl?.addEventListener('hidden.bs.tooltip', () => {
tooltipInstance.setContent({ '.tooltip-inner': copyText });
});
});
});
}
export const useEditor = ({
editorRef,
placeholder: placeholderText,
autoFocus,
initialValue,
onChange,
onFocus,
onBlur,
}) => {
const [editor, setEditor] = useState<Editor | null>(null);
const isInternalUpdateRef = useRef<boolean>(false);
const init = async () => {
const isDark = isDarkTheme();
const theme = EditorView.theme({
'&': {
height: '100%',
padding: '.375rem .75rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-content': {
width: '100%',
},
'.cm-line': {
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
},
'.ͼ7, .ͼ6': {
textDecoration: 'none',
},
'.cm-cursor': {
'border-left-color': isDark ? 'white' : 'black',
},
});
const startState = EditorState.create({
doc: initialValue || '',
extensions: [
minimalSetup,
markdown({
codeLanguages: languages,
base: markdownLanguage,
}),
theme,
placeholder(placeholderText),
EditorView.lineWrapping,
editableCompartment.of(EditorView.editable.of(true)),
EditorView.domEventHandlers({
paste(event) {
const clipboard = event.clipboardData as DataTransfer;
const htmlStr = clipboard.getData('text/html');
const imgRegex =
/<img([\s\S]*?) src\s*=\s*(['"])([\s\S]*?)\2([^>]*)>/;
return Boolean(htmlStr.match(imgRegex));
},
}),
],
});
const view = new EditorView({
parent: editorRef.current,
state: startState,
});
const cm = createCodeMirrorAdapter(view as Editor);
cm.setReadOnly = (readOnly: boolean) => {
cm.dispatch({
effects: editableCompartment.reconfigure(
EditorView.editable.of(!readOnly),
),
});
};
if (autoFocus) {
setTimeout(() => {
cm.focus();
}, 10);
}
const originalSetValue = cm.setValue;
cm.setValue = (newValue: string) => {
isInternalUpdateRef.current = true;
originalSetValue.call(cm, newValue);
setTimeout(() => {
isInternalUpdateRef.current = false;
}, 0);
};
cm.on('change', () => {
if (!isInternalUpdateRef.current && onChange) {
const newValue = cm.getValue();
onChange(newValue);
}
});
cm.on('focus', () => {
onFocus?.();
});
cm.on('blur', () => {
onBlur?.();
});
setEditor(cm);
return cm;
};
useEffect(() => {
if (!editorRef.current) {
return;
}
if (editorRef.current.children.length > 0 || editor) {
return;
}
init();
}, [editor]);
return editor;
};