387 lines
10 KiB
Vue
387 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
import { EditorContent, useEditor } from "@tiptap/vue-3";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import Collaboration from "@tiptap/extension-collaboration";
|
|
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
|
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
import * as Y from "yjs";
|
|
import { WebrtcProvider } from "y-webrtc";
|
|
|
|
const props = defineProps<{
|
|
modelValue: string;
|
|
room: string;
|
|
placeholder?: string;
|
|
plain?: boolean;
|
|
markdown?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(event: "update:modelValue", value: string): void;
|
|
}>();
|
|
|
|
const ydoc = new Y.Doc();
|
|
const provider = new WebrtcProvider(props.room, ydoc);
|
|
const isBootstrapped = ref(false);
|
|
const awarenessVersion = ref(0);
|
|
|
|
const userPalette = ["#2563eb", "#0ea5e9", "#14b8a6", "#16a34a", "#eab308", "#f97316", "#ef4444"];
|
|
const currentUser = {
|
|
name: `You ${Math.floor(Math.random() * 900 + 100)}`,
|
|
color: userPalette[Math.floor(Math.random() * userPalette.length)],
|
|
};
|
|
|
|
function escapeHtml(value: string) {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.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) {
|
|
const input = value.trim();
|
|
if (!input) return "<p></p>";
|
|
if (input.includes("<") && input.includes(">")) return value;
|
|
|
|
if (props.markdown) {
|
|
return markdownToHtml(value);
|
|
}
|
|
|
|
const blocks = value
|
|
.replaceAll("\r\n", "\n")
|
|
.split(/\n\n+/)
|
|
.map((block) => `<p>${escapeHtml(block).replaceAll("\n", "<br />")}</p>`);
|
|
|
|
return blocks.join("");
|
|
}
|
|
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit.configure({
|
|
history: false,
|
|
}),
|
|
Placeholder.configure({
|
|
placeholder: props.placeholder ?? "Type here...",
|
|
includeChildren: true,
|
|
}),
|
|
Collaboration.configure({
|
|
document: ydoc,
|
|
field: "contact",
|
|
}),
|
|
CollaborationCursor.configure({
|
|
provider,
|
|
user: currentUser,
|
|
}),
|
|
],
|
|
autofocus: true,
|
|
editorProps: {
|
|
attributes: {
|
|
class: "contact-editor-content",
|
|
spellcheck: "true",
|
|
},
|
|
},
|
|
onCreate: ({ editor: instance }) => {
|
|
if (instance.isEmpty) {
|
|
instance.commands.setContent(normalizeInitialContent(props.modelValue), false);
|
|
}
|
|
isBootstrapped.value = true;
|
|
},
|
|
onUpdate: ({ editor: instance }) => {
|
|
emit("update:modelValue", instance.getHTML());
|
|
},
|
|
});
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(incoming) => {
|
|
const instance = editor.value;
|
|
if (!instance || !isBootstrapped.value) return;
|
|
|
|
const current = instance.getHTML();
|
|
if (incoming === current || !incoming.trim()) return;
|
|
|
|
if (instance.isEmpty) {
|
|
instance.commands.setContent(normalizeInitialContent(incoming), false);
|
|
}
|
|
},
|
|
);
|
|
|
|
const peerCount = computed(() => {
|
|
awarenessVersion.value;
|
|
const states = Array.from(provider.awareness.getStates().values());
|
|
return states.length;
|
|
});
|
|
|
|
const onAwarenessChange = () => {
|
|
awarenessVersion.value += 1;
|
|
};
|
|
|
|
provider.awareness.on("change", onAwarenessChange);
|
|
|
|
function runCommand(action: () => void) {
|
|
const instance = editor.value;
|
|
if (!instance) return;
|
|
action();
|
|
instance.commands.focus();
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
provider.awareness.off("change", onAwarenessChange);
|
|
editor.value?.destroy();
|
|
provider.destroy();
|
|
ydoc.destroy();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div :class="props.plain ? 'space-y-2' : 'space-y-3'">
|
|
<div :class="props.plain ? 'flex flex-wrap items-center justify-between gap-2 bg-transparent p-0' : 'flex flex-wrap items-center justify-between gap-2 rounded-xl border border-base-300 bg-base-100 p-2'">
|
|
<div class="flex flex-wrap items-center gap-1">
|
|
<button
|
|
class="btn btn-xs"
|
|
:class="editor?.isActive('bold') ? 'btn-primary' : 'btn-ghost'"
|
|
@click="runCommand(() => editor?.chain().focus().toggleBold().run())"
|
|
>
|
|
B
|
|
</button>
|
|
<button
|
|
class="btn btn-xs"
|
|
:class="editor?.isActive('italic') ? 'btn-primary' : 'btn-ghost'"
|
|
@click="runCommand(() => editor?.chain().focus().toggleItalic().run())"
|
|
>
|
|
I
|
|
</button>
|
|
<button
|
|
class="btn btn-xs"
|
|
:class="editor?.isActive('bulletList') ? 'btn-primary' : 'btn-ghost'"
|
|
@click="runCommand(() => editor?.chain().focus().toggleBulletList().run())"
|
|
>
|
|
List
|
|
</button>
|
|
<button
|
|
class="btn btn-xs"
|
|
:class="editor?.isActive('heading', { level: 2 }) ? 'btn-primary' : 'btn-ghost'"
|
|
@click="runCommand(() => editor?.chain().focus().toggleHeading({ level: 2 }).run())"
|
|
>
|
|
H2
|
|
</button>
|
|
<button
|
|
class="btn btn-xs"
|
|
:class="editor?.isActive('blockquote') ? 'btn-primary' : 'btn-ghost'"
|
|
@click="runCommand(() => editor?.chain().focus().toggleBlockquote().run())"
|
|
>
|
|
Quote
|
|
</button>
|
|
</div>
|
|
|
|
<p class="px-1 text-xs text-base-content/60">Live: {{ peerCount }}</p>
|
|
</div>
|
|
|
|
<div :class="props.plain ? 'bg-transparent p-0' : 'rounded-xl border border-base-300 bg-base-100 p-2'">
|
|
<EditorContent :editor="editor" class="contact-editor min-h-[420px]" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.contact-editor :deep(.ProseMirror) {
|
|
min-height: 390px;
|
|
padding: 0.75rem;
|
|
outline: none;
|
|
line-height: 1.65;
|
|
color: rgba(17, 24, 39, 0.95);
|
|
}
|
|
|
|
.contact-editor :deep(.ProseMirror p) {
|
|
margin: 0.45rem 0;
|
|
}
|
|
|
|
.contact-editor :deep(.ProseMirror h1),
|
|
.contact-editor :deep(.ProseMirror h2),
|
|
.contact-editor :deep(.ProseMirror h3) {
|
|
margin: 0.75rem 0 0.45rem;
|
|
font-weight: 700;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.contact-editor :deep(.ProseMirror ul),
|
|
.contact-editor :deep(.ProseMirror ol) {
|
|
margin: 0.45rem 0;
|
|
padding-left: 1.25rem;
|
|
}
|
|
|
|
.contact-editor :deep(.ProseMirror blockquote) {
|
|
margin: 0.6rem 0;
|
|
border-left: 3px solid rgba(30, 107, 255, 0.5);
|
|
padding-left: 0.75rem;
|
|
color: rgba(55, 65, 81, 0.95);
|
|
}
|
|
|
|
.contact-editor :deep(.ProseMirror .collaboration-cursor__caret) {
|
|
margin-left: -1px;
|
|
margin-right: -1px;
|
|
border-left: 1px solid currentColor;
|
|
border-right: 1px solid currentColor;
|
|
pointer-events: none;
|
|
position: relative;
|
|
}
|
|
|
|
.contact-editor :deep(.ProseMirror .collaboration-cursor__label) {
|
|
position: absolute;
|
|
top: -1.35em;
|
|
left: -1px;
|
|
border-radius: 4px;
|
|
padding: 0.1rem 0.4rem;
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
line-height: 1.2;
|
|
}
|
|
</style>
|