Files
crm-wiki/frontend/src/Editor.tsx

243 lines
13 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 { useEffect, useState } from "react";
import { EditorContent, useEditor, useEditorState, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import { TableKit } from "@tiptap/extension-table";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { Placeholder } from "@tiptap/extensions";
import { Markdown } from "@tiptap/markdown";
import { Api, imageUrl, type Node } from "./api";
import { Icon } from "./Icon";
import { PagePickDialog, PromptDialog } from "./Dialogs";
import { navigate } from "./router";
// Картинка: src в Markdown остаётся /api/images/<id>, а показываем blob-URL, загруженный с токеном.
const AuthImage = Image.extend({
addNodeView() {
return ({ node }) => {
const img = document.createElement("img");
let src = "";
const load = (s: string, alt: string) => {
img.alt = alt;
if (s === src) return;
src = s;
img.classList.add("op-40");
imageUrl(s).then((u) => { if (src === s) { img.src = u; img.classList.remove("op-40"); } })
.catch(() => { img.alt = "зображення недоступне"; });
};
load(node.attrs.src, node.attrs.alt ?? "");
return {
dom: img,
update: (n) => {
if (n.type.name !== "image") return false;
load(n.attrs.src, n.attrs.alt ?? "");
return true;
},
};
};
},
});
const isInternal = (href: string) => /^\/p\/[A-Za-z0-9_-]+$/.test(href);
/** Адрес, который можно повесить на выделение: http(s), mailto или внутренняя ссылка /p/<id>. */
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/<id>) или фраза с ней) вставляется в строку, без нового абзаца.
*/
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[];
/** false — режим просмотра: тот же рендер, без панели и курсора; ссылки кликабельны. */
editable: boolean;
onChange: (getMarkdown: () => string) => void;
}) {
const [dialog, setDialog] = useState<null | "link" | "page">(null);
const [upErr, setUpErr] = useState("");
const upload = async (ed: Editor, files: File[], pos?: number) => {
setUpErr("");
for (const f of files) {
try {
const r = await Api.upload(f, props.pageId);
const content = { type: "image", attrs: { src: r.url, alt: f.name.replace(/\.[^.]+$/, "") } };
if (pos != null) ed.chain().focus().insertContentAt(pos, content).run();
else ed.chain().focus().insertContent(content).run();
} catch (e) {
setUpErr(`Не вдалося завантажити «${f.name}»: ${(e as Error).message}`);
}
}
};
const editor = useEditor({
editable: props.editable,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
}),
AuthImage,
TableKit.configure({ table: { resizable: false } }),
TaskList,
TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: "Почніть писати… Markdown працює: # заголовок, - список, **жирний**" }),
Markdown.configure({ markedOptions: { gfm: true } }),
],
content: props.markdown,
contentType: "markdown",
onUpdate: ({ editor }) => props.onChange(() => editor.getMarkdown()),
editorProps: {
attributes: { class: "wk-prose", spellcheck: "true" },
handleClick: (_view, _pos, event) => {
const a = (event.target as HTMLElement).closest("a");
const href = a?.getAttribute("href");
if (!href) return false;
event.preventDefault();
if (isInternal(href)) void navigate(href);
else if (/^(https?:|mailto:)/i.test(href)) window.open(href, "_blank", "noopener,noreferrer");
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/"));
if (!files.length || !editor) return false;
event.preventDefault();
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;
void upload(editor, files, pos);
return true;
},
},
});
const s = useEditorState({
editor,
selector: ({ editor: e }) => ({
h1: e.isActive("heading", { level: 1 }), h2: e.isActive("heading", { level: 2 }), h3: e.isActive("heading", { level: 3 }),
bold: e.isActive("bold"), italic: e.isActive("italic"), strike: e.isActive("strike"), code: e.isActive("code"),
ul: e.isActive("bulletList"), ol: e.isActive("orderedList"), task: e.isActive("taskList"),
quote: e.isActive("blockquote"), codeBlock: e.isActive("codeBlock"), link: e.isActive("link"),
table: e.isActive("table"), undo: e.can().undo(), redo: e.can().redo(),
}),
});
// Переключение просмотр ↔ редактирование без пересоздания редактора (несохранённое не теряется).
useEffect(() => {
if (!editor || editor.isEditable === props.editable) return;
editor.setEditable(props.editable, false);
// Курсор — в конец текста сразу (синхронно), чтобы фокус не остался на кнопке «Редагувати».
if (props.editable) {
editor.commands.setTextSelection(editor.state.doc.content.size);
editor.view.focus();
}
}, [editor, props.editable]);
if (!editor) return null;
const c = () => editor.chain().focus();
const B = (p: { on?: boolean; title: string; icon?: string; text?: string; run: () => void; disabled?: boolean }) => (
<button type="button" className={"wk-tb" + (p.on ? " on" : "")} title={p.title} aria-label={p.title} aria-pressed={p.on}
disabled={p.disabled} onMouseDown={(e) => e.preventDefault()} onClick={p.run}>
{p.icon ? <Icon name={p.icon} /> : <b>{p.text}</b>}
</button>
);
const pickImage = () => {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = "image/png,image/jpeg,image/gif,image/webp";
inp.multiple = true;
inp.onchange = () => void upload(editor, [...(inp.files ?? [])]);
inp.click();
};
return (
<div className={"wk-editor" + (props.editable ? "" : " wk-view")}>
{props.editable && <div className="wk-toolbar" role="toolbar" aria-label="Форматування">
<B title="Заголовок 1" text="H1" on={s?.h1} run={() => c().toggleHeading({ level: 1 }).run()} />
<B title="Заголовок 2" text="H2" on={s?.h2} run={() => c().toggleHeading({ level: 2 }).run()} />
<B title="Заголовок 3" text="H3" on={s?.h3} run={() => c().toggleHeading({ level: 3 }).run()} />
<span className="wk-sep" />
<B title="Жирний (Ctrl+B)" icon="bold" on={s?.bold} run={() => c().toggleBold().run()} />
<B title="Курсив (Ctrl+I)" icon="italic" on={s?.italic} run={() => c().toggleItalic().run()} />
<B title="Закреслений" icon="strike" on={s?.strike} run={() => c().toggleStrike().run()} />
<B title="Код" icon="code" on={s?.code} run={() => c().toggleCode().run()} />
<span className="wk-sep" />
<B title="Маркований список" icon="ul" on={s?.ul} run={() => c().toggleBulletList().run()} />
<B title="Нумерований список" icon="ol" on={s?.ol} run={() => c().toggleOrderedList().run()} />
<B title="Список завдань" icon="task" on={s?.task} run={() => c().toggleTaskList().run()} />
<B title="Цитата" icon="quote" on={s?.quote} run={() => c().toggleBlockquote().run()} />
<B title="Блок коду" text="{ }" on={s?.codeBlock} run={() => c().toggleCodeBlock().run()} />
<B title="Роздільник" icon="hr" run={() => c().setHorizontalRule().run()} />
<span className="wk-sep" />
<B title={s?.link ? "Прибрати посилання" : "Посилання"} icon="link" on={s?.link}
run={() => (s?.link ? c().unsetLink().run() : setDialog("link"))} />
<B title="Посилання на сторінку вікі" icon="pagelink" run={() => setDialog("page")} />
<B title="Зображення (можна вставити з буфера або перетягнути)" icon="image" run={pickImage} />
<B title="Таблиця" icon="table" on={s?.table} run={() => c().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} disabled={s?.table} />
{s?.table && <>
<B title="Додати рядок" icon="rowadd" run={() => c().addRowAfter().run()} />
<B title="Додати стовпець" icon="coladd" run={() => c().addColumnAfter().run()} />
<B title="Видалити рядок" icon="rowdel" run={() => c().deleteRow().run()} />
<B title="Видалити стовпець" icon="coldel" run={() => c().deleteColumn().run()} />
<B title="Видалити таблицю" icon="trash" run={() => c().deleteTable().run()} />
</>}
<span className="wk-sep" />
<B title="Скасувати (Ctrl+Z)" icon="undo" disabled={!s?.undo} run={() => c().undo().run()} />
<B title="Повторити (Ctrl+Shift+Z)" icon="redo" disabled={!s?.redo} run={() => c().redo().run()} />
</div>}
{upErr && <div className="err">{upErr}</div>}
<EditorContent editor={editor} />
{dialog === "link" && (
<PromptDialog title="Посилання" label="Адреса (https://…)" initial="https://" ok="Вставити"
onClose={() => setDialog(null)}
onOk={(url) => {
let href = url.trim();
if (!/^(https?:|mailto:|\/p\/)/i.test(href)) href = "https://" + href.replace(/^\/+/, "");
if (editor.state.selection.empty) c().insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] }).run();
else c().extendMarkRange("link").setLink({ href }).run();
}} />
)}
{dialog === "page" && (
<PagePickDialog nodes={props.nodes} exclude={props.pageId} onClose={() => setDialog(null)}
onPick={(n) => {
const href = `/p/${n.id}`;
if (editor.state.selection.empty) c().insertContent({ type: "text", text: n.title, marks: [{ type: "link", attrs: { href } }] }).insertContent(" ").run();
else c().extendMarkRange("link").setLink({ href }).run();
}} />
)}
</div>
);
}