243 lines
13 KiB
TypeScript
243 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|