feat(documents): use toast-ui markdown rich editor
This commit is contained in:
@@ -13,7 +13,6 @@ const props = defineProps<{
|
|||||||
room: string;
|
room: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
plain?: boolean;
|
plain?: boolean;
|
||||||
markdown?: boolean;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -40,158 +39,11 @@ 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+/)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import ContactCollaborativeEditor from "~~/app/components/ContactCollaborativeEditor.client.vue";
|
import MarkdownRichEditor from "~~/app/components/workspace/documents/MarkdownRichEditor.client.vue";
|
||||||
|
|
||||||
type DocumentSortOption = {
|
type DocumentSortOption = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -179,11 +179,9 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
|
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||||
<ContactCollaborativeEditor
|
<MarkdownRichEditor
|
||||||
: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}`"
|
|
||||||
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)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
type ToastUiEditor = {
|
||||||
|
destroy: () => void;
|
||||||
|
getMarkdown: () => string;
|
||||||
|
getHTML: () => string;
|
||||||
|
setMarkdown: (markdown: string, cursorToEnd?: boolean) => void;
|
||||||
|
setHTML: (html: string, cursorToEnd?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "update:modelValue", value: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const mountEl = ref<HTMLDivElement | null>(null);
|
||||||
|
const editor = ref<ToastUiEditor | null>(null);
|
||||||
|
const isSyncing = ref(false);
|
||||||
|
|
||||||
|
function looksLikeHtml(value: string) {
|
||||||
|
return /<([a-z][\w-]*)\b[^>]*>/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!mountEl.value) return;
|
||||||
|
const [{ default: Editor }] = await Promise.all([
|
||||||
|
import("@toast-ui/editor"),
|
||||||
|
import("@toast-ui/editor/dist/toastui-editor.css"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const initialValue = String(props.modelValue ?? "");
|
||||||
|
const instance = new Editor({
|
||||||
|
el: mountEl.value,
|
||||||
|
initialEditType: "markdown",
|
||||||
|
previewStyle: "vertical",
|
||||||
|
initialValue: looksLikeHtml(initialValue) ? "" : initialValue,
|
||||||
|
placeholder: props.placeholder ?? "Write with Markdown...",
|
||||||
|
height: "520px",
|
||||||
|
usageStatistics: false,
|
||||||
|
events: {
|
||||||
|
change: () => {
|
||||||
|
if (isSyncing.value) return;
|
||||||
|
emit("update:modelValue", instance.getMarkdown());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (looksLikeHtml(initialValue)) {
|
||||||
|
isSyncing.value = true;
|
||||||
|
instance.setHTML(initialValue, false);
|
||||||
|
emit("update:modelValue", instance.getMarkdown());
|
||||||
|
isSyncing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.value = instance as unknown as ToastUiEditor;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(incoming) => {
|
||||||
|
const instance = editor.value;
|
||||||
|
if (!instance || isSyncing.value) return;
|
||||||
|
const next = String(incoming ?? "");
|
||||||
|
const currentMarkdown = instance.getMarkdown();
|
||||||
|
const currentHtml = instance.getHTML();
|
||||||
|
if (next === currentMarkdown || next === currentHtml) return;
|
||||||
|
|
||||||
|
isSyncing.value = true;
|
||||||
|
if (looksLikeHtml(next)) {
|
||||||
|
instance.setHTML(next, false);
|
||||||
|
emit("update:modelValue", instance.getMarkdown());
|
||||||
|
} else {
|
||||||
|
instance.setMarkdown(next, false);
|
||||||
|
}
|
||||||
|
isSyncing.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
editor.value?.destroy();
|
||||||
|
editor.value = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="markdown-rich-editor min-h-[420px]">
|
||||||
|
<div ref="mountEl" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.markdown-rich-editor :deep(.toastui-editor-defaultUI) {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-rich-editor :deep(.toastui-editor-mode-switch) {
|
||||||
|
border-top: 1px solid color-mix(in oklab, var(--color-base-content) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-rich-editor :deep(.toastui-editor-main-container) {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1345
frontend/package-lock.json
generated
1345
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@
|
|||||||
"@tiptap/extension-placeholder": "^2.27.2",
|
"@tiptap/extension-placeholder": "^2.27.2",
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
"@tiptap/vue-3": "^2.27.2",
|
"@tiptap/vue-3": "^2.27.2",
|
||||||
|
"@toast-ui/editor": "^3.2.2",
|
||||||
"@vue/apollo-composable": "^4.2.2",
|
"@vue/apollo-composable": "^4.2.2",
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"ai": "^6.0.91",
|
"ai": "^6.0.91",
|
||||||
|
|||||||
Reference in New Issue
Block a user