noteJS-react/src/utils/markdown.ts

253 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { marked } from "marked";
// Расширение для спойлеров
const spoilerExtension = {
name: "spoiler",
level: "inline" as const,
start(src: string) {
return src.match(/\|\|/)?.index;
},
tokenizer(src: string) {
const rule = /^\|\|(.*?)\|\|/;
const match = rule.exec(src);
if (match) {
return {
type: "spoiler",
raw: match[0],
text: match[1].trim(),
};
}
},
renderer(token: any) {
return `<span class="spoiler" title="Нажмите, чтобы показать">${token.text}</span>`;
},
};
// Функция для рендеринга вложенных токенов
function renderTokens(tokens: any[], renderer: any): string {
return tokens
.map((token) => {
// Используем кастомный renderer если он есть
if (renderer[token.type]) {
return renderer[token.type](token);
}
// Fallback для встроенных типов токенов
if (token.type === "text") {
return token.text || "";
}
if (token.type === "strong") {
return `<strong>${renderTokens(token.tokens || [], renderer)}</strong>`;
}
if (token.type === "em") {
return `<em>${renderTokens(token.tokens || [], renderer)}</em>`;
}
if (token.type === "codespan") {
return `<code>${token.text || ""}</code>`;
}
if (token.type === "del") {
return `<del>${renderTokens(token.tokens || [], renderer)}</del>`;
}
if (token.type === "link") {
// Для ссылок используем кастомный renderer если доступен
if (renderer.link) {
return renderer.link(token);
}
// Fallback для встроенных ссылок
const href = token.href || "";
const title = token.title ? ` title="${token.title}"` : "";
const text =
token.tokens && token.tokens.length > 0
? renderTokens(token.tokens, renderer)
: token.text || "";
return `<a href="${href}"${title}>${text}</a>`;
}
if (token.type === "spoiler") {
// Для спойлеров используем кастомный renderer если доступен
if (renderer.spoiler) {
return renderer.spoiler(token);
}
return `<span class="spoiler" title="Нажмите, чтобы показать">${
token.text || ""
}</span>`;
}
return token.text || "";
})
.join("");
}
// Кастомный renderer для внешних ссылок и чекбоксов
const renderer: any = {
link(token: any) {
const href = token.href;
const title = token.title;
// Правильно обрабатываем вложенные токены для форматирования внутри ссылок
let text = "";
if (token.tokens && token.tokens.length > 0) {
text = renderTokens(token.tokens, this);
} else if (token.text) {
text = token.text;
}
try {
const url = new URL(href, window.location.href);
const isExternal = url.origin !== window.location.origin;
if (isExternal) {
return `<a href="${href}" title="${
title || ""
}" target="_blank" rel="noopener noreferrer" class="external-link">${text}</a>`;
}
} catch {}
return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
},
// Кастомный renderer для элементов списка с чекбоксами
listitem(token: any) {
const task = token.task;
const checked = token.checked;
// Правильно обрабатываем вложенные токены для форматирования
let content = "";
if (token.tokens && token.tokens.length > 0) {
// Рендерим вложенные токены используя наш renderer
content = renderTokens(token.tokens, this);
} else if (token.text) {
// Если токенов нет, используем текст (для обратной совместимости)
content = token.text;
}
if (task) {
const checkbox = `<input type="checkbox" ${checked ? "checked" : ""} />`;
return `<li class="task-list-item">${checkbox} ${content}</li>\n`;
}
return `<li>${content}</li>\n`;
},
};
// Настройка marked
marked.use({
extensions: [spoilerExtension],
gfm: true,
breaks: true,
renderer,
});
export const parseMarkdown = (text: string): string => {
return marked.parse(text) as string;
};
// Функция для извлечения тегов из текста
export const extractTags = (content: string): string[] => {
const tagRegex = /#([а-яё\w]+)/gi;
const tags: string[] = [];
let match;
while ((match = tagRegex.exec(content)) !== null) {
const matchIndex = match.index;
const beforeContext = content.substring(
Math.max(0, matchIndex - 100),
matchIndex
);
const afterContext = content.substring(
matchIndex + match[0].length,
Math.min(content.length, matchIndex + match[0].length + 100)
);
const lastOpenTag = beforeContext.lastIndexOf("<");
const lastCloseTag = beforeContext.lastIndexOf(">");
if (lastOpenTag > lastCloseTag) {
continue;
}
const lastQuote = Math.max(
beforeContext.lastIndexOf('"'),
beforeContext.lastIndexOf("'")
);
const lastEquals = beforeContext.lastIndexOf("=");
if (lastEquals > -1 && lastQuote > lastEquals) {
const nextQuote = Math.min(
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
);
if (nextQuote !== Infinity) {
continue;
}
}
const tag = match[1];
// Проверяем, есть ли уже тег с таким же именем (регистронезависимо)
if (!tags.some((t) => t.toLowerCase() === tag.toLowerCase())) {
tags.push(tag);
}
}
return tags;
};
// Функция для преобразования тегов в кликабельные элементы
export const makeTagsClickable = (content: string): string => {
const tagRegex = /#([а-яё\w]+)/gi;
const matches: Array<{ fullMatch: string; tag: string; index: number }> = [];
let match;
while ((match = tagRegex.exec(content)) !== null) {
matches.push({
fullMatch: match[0],
tag: match[1],
index: match.index,
});
}
let result = content;
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const beforeTag = result.substring(0, match.index);
const afterTag = result.substring(match.index + match.fullMatch.length);
const lastOpenTag = beforeTag.lastIndexOf("<");
const lastCloseTag = beforeTag.lastIndexOf(">");
if (lastOpenTag > lastCloseTag) {
continue;
}
const beforeContext = beforeTag.substring(Math.max(0, match.index - 100));
const lastQuote = Math.max(
beforeContext.lastIndexOf('"'),
beforeContext.lastIndexOf("'")
);
const lastEquals = beforeContext.lastIndexOf("=");
if (lastEquals > -1 && lastQuote > lastEquals) {
const afterContext = afterTag.substring(
0,
Math.min(100, afterTag.length)
);
const nextQuote = Math.min(
afterContext.indexOf('"') !== -1 ? afterContext.indexOf('"') : Infinity,
afterContext.indexOf("'") !== -1 ? afterContext.indexOf("'") : Infinity
);
if (nextQuote !== Infinity) {
continue;
}
}
const replacement = `<span class="tag-in-note" data-tag="${match.tag}">${match.fullMatch}</span>`;
result = beforeTag + replacement + afterTag;
}
return result;
};
// Функция для подсветки найденного текста
export const highlightSearchText = (content: string, query: string): string => {
if (!query.trim()) return content;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
return content.replace(regex, '<mark class="search-highlight">$1</mark>');
};