diff --git a/frontend/src/Editor.tsx b/frontend/src/Editor.tsx index 08ed83d..afd6baf 100644 --- a/frontend/src/Editor.tsx +++ b/frontend/src/Editor.tsx @@ -40,6 +40,27 @@ const AuthImage = Image.extend({ const isInternal = (href: string) => /^\/p\/[A-Za-z0-9_-]+$/.test(href); +/** Адрес, который можно повесить на выделение: http(s), mailto или внутренняя ссылка /p/. */ +const isLinkTarget = (s: string) => /^(https?:\/\/[^\s]+|mailto:[^\s]+|\/p\/[A-Za-z0-9_-]+)$/i.test(s); + +/** Похоже ли на Markdown: ссылки, картинки, заголовки, списки, цитаты, код, таблицы, жирный/курсив. */ +const looksLikeMarkdown = (s: string) => + /!?\[[^\]\n]+\]\([^)\s]+\)|\*\*[^*\n]+\*\*|__[^_\n]+__|~~[^~\n]+~~|`[^`\n]+`/.test(s) || + /^(#{1,6}\s|\s*[-*+]\s|\s*\d+[.)]\s|>\s?|```|\|.*\|\s*$|\s*[-*_]{3,}\s*$)/m.test(s); + +/** + * Вставка Markdown тем же парсером, которым загружается страница. + * Один абзац (например, одна ссылка [Назва](/p/) или фраза с ней) вставляется в строку, без нового абзаца. + */ +function pasteMarkdown(ed: Editor, md: string): boolean { + const doc = ed.markdown?.parse(md); + const blocks = doc?.content ?? []; + if (!blocks.length) return false; + const content = blocks.length === 1 && blocks[0].type === "paragraph" ? blocks[0].content ?? [] : blocks; + if (!content.length) return false; + return ed.chain().focus().insertContent(content).run(); +} + export function WikiEditor(props: { markdown: string; pageId: string; nodes: Node[]; onChange: (getMarkdown: () => string) => void; @@ -88,11 +109,24 @@ export function WikiEditor(props: { else if (/^(https?:|mailto:)/i.test(href)) window.open(href, "_blank", "noopener,noreferrer"); return true; }, - handlePaste: (_view, event) => { - const files = [...(event.clipboardData?.files ?? [])].filter((f) => f.type.startsWith("image/")); - if (!files.length || !editor) return false; - void upload(editor, files); - return true; + handlePaste: (view, event): boolean => { + const cd = event.clipboardData; + if (!cd || !editor) return false; + const files = [...cd.files].filter((f) => f.type.startsWith("image/")); + if (files.length) { void upload(editor, files); return true; } + const plain = cd.getData("text/plain"); + const target = plain.trim(); + // Выделили текст и вставили адрес — выделение становится ссылкой. + if (!view.state.selection.empty && isLinkTarget(target) && !editor.isActive("codeBlock")) { + // Курсор — в конец ссылки: следующий ввод не заменит только что оформленный текст. + const end = view.state.selection.to; + editor.chain().focus().setLink({ href: target }).setTextSelection(end).run(); + return true; + } + // HTML (из браузера, из другой страницы вики) — как по умолчанию в TipTap. + if (cd.types.includes("text/html")) return false; + if (!plain || editor.isActive("codeBlock") || editor.isActive("code") || !looksLikeMarkdown(plain)) return false; + return pasteMarkdown(editor, plain); }, handleDrop: (view, event) => { const files = [...(event.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith("image/"));