diff --git a/frontend/app/components/ContactCollaborativeEditor.client.vue b/frontend/app/components/ContactCollaborativeEditor.client.vue index af1ad41..c53eddb 100644 --- a/frontend/app/components/ContactCollaborativeEditor.client.vue +++ b/frontend/app/components/ContactCollaborativeEditor.client.vue @@ -13,6 +13,7 @@ const props = defineProps<{ room: string; placeholder?: string; plain?: boolean; + markdown?: boolean; }>(); const emit = defineEmits<{ @@ -39,11 +40,158 @@ function escapeHtml(value: string) { .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 `${label}`; + }); + + output = output.replace(/`([^`]+)`/g, "$1"); + output = output.replace(/\*\*([^*]+)\*\*/g, "$1"); + output = output.replace(/__([^_]+)__/g, "$1"); + output = output.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, "$1$2"); + output = output.replace(/(^|[^_])_([^_]+)_(?!_)/g, "$1$2"); + output = output.replace(/~~([^~]+)~~/g, "$1"); + + 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(`

${paragraphLines.map((line) => parseInlineMarkdown(line)).join("
")}

`); + paragraphLines.length = 0; + }; + + const flushQuote = () => { + if (!quoteLines.length) return; + chunks.push(`

${quoteLines.map((line) => parseInlineMarkdown(line)).join("
")}

`); + quoteLines.length = 0; + }; + + const flushList = () => { + if (!listType || !listItems.length) { + listType = null; + listItems.length = 0; + return; + } + const items = listItems.map((item) => `
  • ${parseInlineMarkdown(item)}
  • `).join(""); + chunks.push(`<${listType}>${items}`); + listType = null; + listItems.length = 0; + }; + + const flushCode = () => { + if (!inCodeBlock) return; + chunks.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + 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(`${parseInlineMarkdown(headingText)}`); + continue; + } + + if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { + flushParagraph(); + flushQuote(); + flushList(); + chunks.push("
    "); + 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 "

    "; + return chunks.join(""); +} + function normalizeInitialContent(value: string) { const input = value.trim(); if (!input) return "

    "; if (input.includes("<") && input.includes(">")) return value; + if (props.markdown) { + return markdownToHtml(value); + } + const blocks = value .replaceAll("\r\n", "\n") .split(/\n\n+/) diff --git a/frontend/app/components/workspace/documents/CrmDocumentsPanel.vue b/frontend/app/components/workspace/documents/CrmDocumentsPanel.vue index 2575531..ac298af 100644 --- a/frontend/app/components/workspace/documents/CrmDocumentsPanel.vue +++ b/frontend/app/components/workspace/documents/CrmDocumentsPanel.vue @@ -182,6 +182,7 @@ onBeforeUnmount(() => {