Add v2 rewrite: monorepo with desktop and web apps, shared packages, docs, and wireframes
This commit is contained in:
336
apps/web/src/lib/markdown.ts
Normal file
336
apps/web/src/lib/markdown.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import type { MemoAnnotation } from "@mosaiciq/contracts/rpc";
|
||||
import { cx } from "./cn";
|
||||
|
||||
export function markdownToEditableHtml(content: string, annotations: MemoAnnotation[] = []) {
|
||||
if (!content) return "";
|
||||
|
||||
const lines = content.split(/\r?\n/);
|
||||
const blocks: string[] = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
blocks.push("<p><br></p>");
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedItems: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const match = lines[index].match(/^\s*[-*]\s+(.+)$/);
|
||||
if (!match) break;
|
||||
unorderedItems.push(match[1]);
|
||||
index += 1;
|
||||
}
|
||||
if (unorderedItems.length) {
|
||||
index -= 1;
|
||||
blocks.push(`<ul>${unorderedItems.map((item) => `<li>${renderEditableInlineMarkdown(item, annotations)}</li>`).join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedItems: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const match = lines[index].match(/^\s*\d+\.\s+(.+)$/);
|
||||
if (!match) break;
|
||||
orderedItems.push(match[1]);
|
||||
index += 1;
|
||||
}
|
||||
if (orderedItems.length) {
|
||||
index -= 1;
|
||||
blocks.push(`<ol>${orderedItems.map((item) => `<li>${renderEditableInlineMarkdown(item, annotations)}</li>`).join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
if (heading) {
|
||||
const level = Math.min(heading[1].length + 3, 6);
|
||||
blocks.push(`<h${level}>${renderEditableInlineMarkdown(heading[2], annotations)}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = line.match(/^>\s+(.+)$/);
|
||||
if (quote) {
|
||||
blocks.push(`<blockquote>${renderEditableInlineMarkdown(quote[1], annotations)}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
blocks.push(`<p>${renderEditableInlineMarkdown(line, annotations)}</p>`);
|
||||
}
|
||||
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function renderEditableInlineMarkdown(value: string, annotations: MemoAnnotation[] = []) {
|
||||
const pattern = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*)/g;
|
||||
let cursor = 0;
|
||||
let html = "";
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(value)) !== null) {
|
||||
if (match.index > cursor) html += renderAnnotatedPlainText(value.slice(cursor, match.index), annotations);
|
||||
|
||||
const token = match[0];
|
||||
if (token.startsWith("**")) {
|
||||
html += `<strong>${escapeHtml(token.slice(2, -2))}</strong>`;
|
||||
} else if (token.startsWith("*")) {
|
||||
html += `<em>${escapeHtml(token.slice(1, -1))}</em>`;
|
||||
} else if (token.startsWith("`")) {
|
||||
html += `<code>${escapeHtml(token.slice(1, -1))}</code>`;
|
||||
} else {
|
||||
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
html += link ? `<a href="${escapeAttribute(link[2])}" rel="noreferrer">${escapeHtml(link[1])}</a>` : escapeHtml(token);
|
||||
}
|
||||
|
||||
cursor = match.index + token.length;
|
||||
}
|
||||
|
||||
if (cursor < value.length) html += renderAnnotatedPlainText(value.slice(cursor), annotations);
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderAnnotatedPlainText(value: string, annotations: MemoAnnotation[]) {
|
||||
const candidates = annotations.filter((annotation) => annotation.selectedText);
|
||||
let cursor = 0;
|
||||
let html = "";
|
||||
|
||||
while (cursor < value.length) {
|
||||
const match = findNextAnnotationMatch(value, candidates, cursor);
|
||||
if (!match) {
|
||||
html += escapeHtml(value.slice(cursor));
|
||||
break;
|
||||
}
|
||||
|
||||
if (match.index > cursor) html += escapeHtml(value.slice(cursor, match.index));
|
||||
|
||||
const matchingAnnotations = candidates.filter((annotation) => annotation.selectedText === match.text);
|
||||
html += `<mark class="${annotationClassName(matchingAnnotations)}" data-annotation-id="${escapeAttribute(matchingAnnotations[0].id)}">${escapeHtml(match.text)}</mark>`;
|
||||
cursor = match.index + match.text.length;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function findNextAnnotationMatch(value: string, annotations: MemoAnnotation[], offset: number) {
|
||||
let bestMatch: { index: number; text: string } | null = null;
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const index = value.indexOf(annotation.selectedText, offset);
|
||||
if (index === -1) continue;
|
||||
if (
|
||||
!bestMatch ||
|
||||
index < bestMatch.index ||
|
||||
(index === bestMatch.index && annotation.selectedText.length > bestMatch.text.length)
|
||||
) {
|
||||
bestMatch = { index, text: annotation.selectedText };
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function annotationClassName(annotations: MemoAnnotation[]) {
|
||||
const kinds = new Set(annotations.map((annotation) => annotation.kind));
|
||||
return cx(
|
||||
"px-0.5",
|
||||
kinds.has("highlight") && "bg-[oklch(92%_0.08_95)]",
|
||||
kinds.has("comment") && "bg-[oklch(92%_0.06_80)] underline decoration-[var(--accent)] decoration-2 underline-offset-2",
|
||||
kinds.has("strike") && "line-through decoration-[var(--red)] decoration-2"
|
||||
);
|
||||
}
|
||||
|
||||
export function getAnnotationSignature(annotations: MemoAnnotation[]) {
|
||||
return annotations
|
||||
.map((annotation) => `${annotation.id}:${annotation.kind}:${annotation.status}:${annotation.selectedText}`)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
export function getSelectionWithin(root: HTMLElement | null) {
|
||||
if (!root) return "";
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return "";
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!root.contains(range.commonAncestorContainer)) return "";
|
||||
return selection.toString().trim();
|
||||
}
|
||||
|
||||
export function editableHtmlToMarkdown(root: HTMLElement) {
|
||||
const blocks = Array.from(root.childNodes).flatMap((node) => serializeBlockNode(node));
|
||||
return joinMarkdownBlocks(blocks);
|
||||
}
|
||||
|
||||
function joinMarkdownBlocks(blocks: string[]) {
|
||||
if (blocks.every((block) => !block.trim())) return "";
|
||||
|
||||
return blocks.reduce((markdown, block, index) => {
|
||||
if (index === 0) return block;
|
||||
const previous = markdown.split("\n").at(-1) ?? "";
|
||||
const separator = !previous.trim() || !block.trim() || shouldKeepAdjacent(previous, block) ? "\n" : "\n";
|
||||
return `${markdown}${separator}${block}`;
|
||||
}, "");
|
||||
}
|
||||
|
||||
function shouldKeepAdjacent(previous: string, next: string) {
|
||||
return (
|
||||
(/^[-*]\s+\S/.test(previous) && /^[-*]\s+\S/.test(next)) ||
|
||||
(/^\d+\.\s+\S/.test(previous) && /^\d+\.\s+\S/.test(next)) ||
|
||||
(/^>\s+\S/.test(previous) && /^>\s+\S/.test(next))
|
||||
);
|
||||
}
|
||||
|
||||
function serializeBlockNode(node: ChildNode): string[] {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent?.trim();
|
||||
return text ? [text] : [];
|
||||
}
|
||||
|
||||
if (!(node instanceof HTMLElement)) return [];
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
|
||||
if (tagName === "ul") {
|
||||
return Array.from(node.children)
|
||||
.filter((child) => child.tagName.toLowerCase() === "li")
|
||||
.map((child) => `- ${serializeInlineNode(child).trim()}`)
|
||||
.filter((line) => line.length > 2);
|
||||
}
|
||||
|
||||
if (tagName === "ol") {
|
||||
return Array.from(node.children)
|
||||
.filter((child) => child.tagName.toLowerCase() === "li")
|
||||
.map((child, index) => `${index + 1}. ${serializeInlineNode(child).trim()}`)
|
||||
.filter((line) => /\d+\.\s+\S/.test(line));
|
||||
}
|
||||
|
||||
if (/^h[1-6]$/.test(tagName)) {
|
||||
const level = Math.min(Math.max(Number(tagName.slice(1)) - 3, 1), 3);
|
||||
const text = serializeInlineNode(node).trim();
|
||||
return text ? [`${"#".repeat(level)} ${text}`] : [];
|
||||
}
|
||||
|
||||
if (tagName === "blockquote") {
|
||||
const text = serializeInlineNode(node).trim();
|
||||
return text ? text.split(/\r?\n/).map((line) => `> ${line}`) : [];
|
||||
}
|
||||
|
||||
if (tagName === "div" || tagName === "p") {
|
||||
const text = serializeInlineNode(node).trim();
|
||||
return text ? [text] : [""];
|
||||
}
|
||||
|
||||
const text = serializeInlineNode(node).trim();
|
||||
return text ? [text] : [];
|
||||
}
|
||||
|
||||
function serializeInlineNode(node: ChildNode): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
|
||||
if (!(node instanceof HTMLElement)) return "";
|
||||
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
if (tagName === "br") return "\n";
|
||||
|
||||
const text = Array.from(node.childNodes).map((child) => serializeInlineNode(child)).join("");
|
||||
if (!text) return "";
|
||||
|
||||
if (tagName === "strong" || tagName === "b") return `**${text}**`;
|
||||
if (tagName === "em" || tagName === "i") return `*${text}*`;
|
||||
if (tagName === "code") return `\`${text.replace(/`/g, "")}\``;
|
||||
if (tagName === "a") {
|
||||
const href = node.getAttribute("href");
|
||||
return href ? `[${text}](${href})` : text;
|
||||
}
|
||||
if (tagName === "li") return text.replace(/\n+/g, " ").trim();
|
||||
if (tagName === "div" || tagName === "p") return text;
|
||||
return node.textContent ?? "";
|
||||
}
|
||||
|
||||
export function normalizeEditableMarkdown(root: HTMLElement): string {
|
||||
const caretOffset = getCaretTextOffset(root);
|
||||
const markdown = editableHtmlToMarkdown(root);
|
||||
const html = markdownToEditableHtml(markdown);
|
||||
|
||||
if (root.innerHTML !== html) {
|
||||
root.innerHTML = html;
|
||||
if (caretOffset !== null) restoreCaretTextOffset(root, caretOffset);
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
export function getCaretTextOffset(root: HTMLElement): number | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!root.contains(range.startContainer)) return null;
|
||||
|
||||
const prefixRange = document.createRange();
|
||||
prefixRange.selectNodeContents(root);
|
||||
prefixRange.setEnd(range.startContainer, range.startOffset);
|
||||
return prefixRange.toString().length;
|
||||
}
|
||||
|
||||
export function restoreCaretTextOffset(root: HTMLElement, offset: number): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let remaining = offset;
|
||||
let node = walker.nextNode();
|
||||
let lastTextNode: Text | null = null;
|
||||
|
||||
while (node) {
|
||||
const textNode = node as Text;
|
||||
lastTextNode = textNode;
|
||||
const length = textNode.textContent?.length ?? 0;
|
||||
|
||||
if (remaining <= length) {
|
||||
const range = document.createRange();
|
||||
range.setStart(textNode, remaining);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return;
|
||||
}
|
||||
|
||||
remaining -= length;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
if (lastTextNode) {
|
||||
range.setStart(lastTextNode, lastTextNode.textContent?.length ?? 0);
|
||||
} else {
|
||||
range.selectNodeContents(root);
|
||||
}
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
export function insertPlainText(text: string) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
selection.deleteFromDocument();
|
||||
const range = selection.getRangeAt(0);
|
||||
const textNode = document.createTextNode(text);
|
||||
range.insertNode(textNode);
|
||||
range.setStartAfter(textNode);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
export function insertParagraphBreak() {
|
||||
document.execCommand("insertParagraph");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string) {
|
||||
return escapeHtml(value).replace(/'/g, "'");
|
||||
}
|
||||
Reference in New Issue
Block a user