feat(documents): render markdown as rich text in editor
This commit is contained in:
@@ -13,6 +13,7 @@ const props = defineProps<{
|
|||||||
room: string;
|
room: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
plain?: boolean;
|
plain?: boolean;
|
||||||
|
markdown?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -39,11 +40,158 @@ function escapeHtml(value: string) {
|
|||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseInlineMarkdown(value: string) {
|
||||||
|
let output = escapeHtml(value);
|
||||||
|
|
||||||
|
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, href: string) => {
|
||||||
|
const rawHref = String(href ?? "").trim();
|
||||||
|
const safeHref = /^(https?:\/\/|mailto:|tel:|\/)/i.test(rawHref) ? rawHref : "";
|
||||||
|
if (!safeHref) return label;
|
||||||
|
return `<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
output = output.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||||
|
output = output.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||||
|
output = output.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
||||||
|
output = output.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1<em>$2</em>");
|
||||||
|
output = output.replace(/(^|[^_])_([^_]+)_(?!_)/g, "$1<em>$2</em>");
|
||||||
|
output = output.replace(/~~([^~]+)~~/g, "<s>$1</s>");
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownToHtml(value: string) {
|
||||||
|
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const paragraphLines: string[] = [];
|
||||||
|
const quoteLines: string[] = [];
|
||||||
|
const listItems: string[] = [];
|
||||||
|
let listType: "ul" | "ol" | null = null;
|
||||||
|
let inCodeBlock = false;
|
||||||
|
let codeLines: string[] = [];
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
if (!paragraphLines.length) return;
|
||||||
|
chunks.push(`<p>${paragraphLines.map((line) => parseInlineMarkdown(line)).join("<br />")}</p>`);
|
||||||
|
paragraphLines.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushQuote = () => {
|
||||||
|
if (!quoteLines.length) return;
|
||||||
|
chunks.push(`<blockquote><p>${quoteLines.map((line) => parseInlineMarkdown(line)).join("<br />")}</p></blockquote>`);
|
||||||
|
quoteLines.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushList = () => {
|
||||||
|
if (!listType || !listItems.length) {
|
||||||
|
listType = null;
|
||||||
|
listItems.length = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = listItems.map((item) => `<li>${parseInlineMarkdown(item)}</li>`).join("");
|
||||||
|
chunks.push(`<${listType}>${items}</${listType}>`);
|
||||||
|
listType = null;
|
||||||
|
listItems.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushCode = () => {
|
||||||
|
if (!inCodeBlock) return;
|
||||||
|
chunks.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||||
|
inCodeBlock = false;
|
||||||
|
codeLines = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const sourceLine of lines) {
|
||||||
|
const trimmed = sourceLine.trim();
|
||||||
|
|
||||||
|
if (inCodeBlock) {
|
||||||
|
if (trimmed.startsWith("```")) {
|
||||||
|
flushCode();
|
||||||
|
} else {
|
||||||
|
codeLines.push(sourceLine);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith("```")) {
|
||||||
|
flushParagraph();
|
||||||
|
flushQuote();
|
||||||
|
flushList();
|
||||||
|
inCodeBlock = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
flushParagraph();
|
||||||
|
flushQuote();
|
||||||
|
flushList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||||
|
if (headingMatch) {
|
||||||
|
flushParagraph();
|
||||||
|
flushQuote();
|
||||||
|
flushList();
|
||||||
|
const headingHashes = headingMatch[1] ?? "";
|
||||||
|
const headingText = headingMatch[2] ?? "";
|
||||||
|
const level = Math.min(6, Math.max(1, headingHashes.length || 1));
|
||||||
|
chunks.push(`<h${level}>${parseInlineMarkdown(headingText)}</h${level}>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
||||||
|
flushParagraph();
|
||||||
|
flushQuote();
|
||||||
|
flushList();
|
||||||
|
chunks.push("<hr />");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteMatch = sourceLine.match(/^\s*>\s?(.*)$/);
|
||||||
|
if (quoteMatch) {
|
||||||
|
flushParagraph();
|
||||||
|
flushList();
|
||||||
|
quoteLines.push(quoteMatch[1] ?? "");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushQuote();
|
||||||
|
|
||||||
|
const unorderedMatch = sourceLine.match(/^\s*[-*+]\s+(.+)$/);
|
||||||
|
const orderedMatch = sourceLine.match(/^\s*\d+\.\s+(.+)$/);
|
||||||
|
if (unorderedMatch || orderedMatch) {
|
||||||
|
flushParagraph();
|
||||||
|
const nextType: "ul" | "ol" = orderedMatch ? "ol" : "ul";
|
||||||
|
if (listType && listType !== nextType) {
|
||||||
|
flushList();
|
||||||
|
}
|
||||||
|
listType = nextType;
|
||||||
|
listItems.push((orderedMatch?.[1] ?? unorderedMatch?.[1] ?? "").trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushList();
|
||||||
|
paragraphLines.push(sourceLine.trimEnd());
|
||||||
|
}
|
||||||
|
|
||||||
|
flushParagraph();
|
||||||
|
flushQuote();
|
||||||
|
flushList();
|
||||||
|
flushCode();
|
||||||
|
|
||||||
|
if (!chunks.length) return "<p></p>";
|
||||||
|
return chunks.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeInitialContent(value: string) {
|
function normalizeInitialContent(value: string) {
|
||||||
const input = value.trim();
|
const input = value.trim();
|
||||||
if (!input) return "<p></p>";
|
if (!input) return "<p></p>";
|
||||||
if (input.includes("<") && input.includes(">")) return value;
|
if (input.includes("<") && input.includes(">")) return value;
|
||||||
|
|
||||||
|
if (props.markdown) {
|
||||||
|
return markdownToHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
const blocks = value
|
const blocks = value
|
||||||
.replaceAll("\r\n", "\n")
|
.replaceAll("\r\n", "\n")
|
||||||
.split(/\n\n+/)
|
.split(/\n\n+/)
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ onBeforeUnmount(() => {
|
|||||||
<ContactCollaborativeEditor
|
<ContactCollaborativeEditor
|
||||||
:key="`doc-editor-${props.selectedDocument.id}`"
|
:key="`doc-editor-${props.selectedDocument.id}`"
|
||||||
:model-value="props.selectedDocument.body"
|
:model-value="props.selectedDocument.body"
|
||||||
|
:markdown="true"
|
||||||
:room="`crm-doc-${props.selectedDocument.id}`"
|
:room="`crm-doc-${props.selectedDocument.id}`"
|
||||||
placeholder="Describe policy, steps, rules, and exceptions..."
|
placeholder="Describe policy, steps, rules, and exceptions..."
|
||||||
@update:model-value="emit('update-selected-document-body', $event)"
|
@update:model-value="emit('update-selected-document-body', $event)"
|
||||||
|
|||||||
Reference in New Issue
Block a user