Режимы просмотра и редактирования страницы; сохранение перед уходом со страницы
This commit is contained in:
@@ -4,8 +4,8 @@ import { retryReady, useAuth } from "./crm";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
import { PageView } from "./PageView";
|
||||
import { hrefOf, navigate, useRoute } from "./router";
|
||||
import { markOpenInEdit, PageView } from "./PageView";
|
||||
import { hrefOf, navigate, setLeaveGuard, useRoute } from "./router";
|
||||
import { childrenMap, Tree } from "./Tree";
|
||||
|
||||
type Dlg =
|
||||
@@ -92,6 +92,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
const [q, setQ] = useState("");
|
||||
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||
const [sideOpen, setSideOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const reloadTree = useCallback(async () => {
|
||||
try { setNodes((await Api.tree()).nodes); setTreeErr(""); }
|
||||
@@ -107,6 +108,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
const currentId = route.kind === "root" ? null : route.id;
|
||||
useEffect(() => {
|
||||
setSideOpen(false);
|
||||
setEditing(false);
|
||||
if (!currentId) { setPage(null); setPageErr(""); return; }
|
||||
let live = true;
|
||||
setPageErr("");
|
||||
@@ -131,6 +133,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
|
||||
const create = async (what: "page" | "folder", title: string, parentId: string | null) => {
|
||||
const p = what === "page" ? await Api.createPage(title, parentId) : await Api.createFolder(title, parentId);
|
||||
if (p.kind === "page") markOpenInEdit(p.id); // новая страница — сразу в редакторе
|
||||
await reloadTree();
|
||||
void navigate(hrefOf(p.kind, p.id));
|
||||
};
|
||||
@@ -159,6 +162,9 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
|
||||
const actions = (n: Node) => (
|
||||
<div className="row wrap">
|
||||
{n.kind === "page" && !editing && (
|
||||
<button className="btn sm ghost" onClick={() => setDlg({ kind: "rename", node: n })}><Icon name="edit" />Перейменувати</button>
|
||||
)}
|
||||
{n.kind === "folder" && <>
|
||||
<button className="btn sm gold" onClick={() => setDlg({ kind: "new", what: "page", parentId: n.id })}><Icon name="plus" />Сторінка</button>
|
||||
<button className="btn sm" onClick={() => setDlg({ kind: "new", what: "folder", parentId: n.id })}><Icon name="folder" />Папка</button>
|
||||
@@ -198,7 +204,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
</>;
|
||||
} else {
|
||||
content = <>
|
||||
<PageView key={page.id} page={page} nodes={nodes ?? []}
|
||||
<PageView key={`${page.id}:${page.version}`} page={page} nodes={nodes ?? []} onModeChange={setEditing}
|
||||
onSaved={(p, titleChanged) => {
|
||||
setNodes((list) => list?.map((x) => (x.id === p.id ? { ...x, title: p.title, version: p.version, updatedAt: p.updatedAt, updatedByName: p.updatedByName } : x)) ?? list);
|
||||
if (titleChanged) void reloadTree();
|
||||
@@ -292,8 +298,10 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) {
|
||||
await Api.remove(dlg.node.id);
|
||||
const parent = dlg.node.parentId;
|
||||
await reloadTree();
|
||||
if (currentId === dlg.node.id || (currentId && isInside(currentId, dlg.node.id, byId)))
|
||||
if (currentId === dlg.node.id || (currentId && isInside(currentId, dlg.node.id, byId))) {
|
||||
setLeaveGuard(null); // страницы больше нет — сохранять нечего
|
||||
void navigate(parent ? hrefOf("folder", parent) : "/", true);
|
||||
}
|
||||
}} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
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";
|
||||
@@ -63,6 +63,8 @@ function pasteMarkdown(ed: Editor, md: string): boolean {
|
||||
|
||||
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);
|
||||
@@ -83,6 +85,7 @@ export function WikiEditor(props: {
|
||||
};
|
||||
|
||||
const editor = useEditor({
|
||||
editable: props.editable,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
@@ -150,6 +153,17 @@ export function WikiEditor(props: {
|
||||
}),
|
||||
});
|
||||
|
||||
// Переключение просмотр ↔ редактирование без пересоздания редактора (несохранённое не теряется).
|
||||
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 }) => (
|
||||
@@ -168,8 +182,8 @@ export function WikiEditor(props: {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="wk-editor">
|
||||
<div className="wk-toolbar" role="toolbar" aria-label="Форматування">
|
||||
<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()} />
|
||||
@@ -201,7 +215,7 @@ export function WikiEditor(props: {
|
||||
<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>
|
||||
</div>}
|
||||
{upErr && <div className="err">{upErr}</div>}
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Api, ApiError, type Node, type Page } from "./api";
|
||||
import { WikiEditor } from "./Editor";
|
||||
import { Icon } from "./Icon";
|
||||
import { setLeaveGuard } from "./router";
|
||||
|
||||
type SaveState = "saved" | "dirty" | "saving" | "error" | "conflict";
|
||||
|
||||
const fmt = (iso: string) => new Date(iso).toLocaleString("uk-UA", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
// Только что созданные страницы открываются сразу в редакторе.
|
||||
const openInEdit = new Set<string>();
|
||||
export const markOpenInEdit = (id: string) => openInEdit.add(id);
|
||||
|
||||
/**
|
||||
* Страница: заголовок + редактор, автосохранение через 1 с после правки.
|
||||
* Страница: по умолчанию просмотр (тот же TipTap, но только для чтения), по «Редагувати» — редактор.
|
||||
* В редакторе автосохранение через 1 с после правки.
|
||||
* Сохраняем с версией: если страницу уже изменил кто-то другой, сервер отвечает 409 и мы показываем выбор.
|
||||
*/
|
||||
export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page, titleChanged: boolean) => void }) {
|
||||
export function PageView(props: {
|
||||
page: Page; nodes: Node[]; onSaved: (p: Page, titleChanged: boolean) => void; onModeChange?: (editing: boolean) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(() => openInEdit.delete(props.page.id));
|
||||
const [leaveMsg, setLeaveMsg] = useState("");
|
||||
const [title, setTitle] = useState(props.page.title);
|
||||
const [state, setState] = useState<SaveState>("saved");
|
||||
const [err, setErr] = useState("");
|
||||
@@ -28,10 +38,11 @@ export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page,
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
const inflight = useRef<Promise<void> | null>(null);
|
||||
|
||||
const save = useCallback(async (): Promise<void> => {
|
||||
/** Сохранить несохранённое. true — на сервере всё актуально. */
|
||||
const save = useCallback(async (): Promise<boolean> => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (inflight.current) { await inflight.current; }
|
||||
if (!dirty.current) return;
|
||||
if (!dirty.current) return true;
|
||||
const t = titleRef.current.trim() || "Без назви";
|
||||
dirty.current = false;
|
||||
setState("saving");
|
||||
@@ -59,6 +70,7 @@ export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page,
|
||||
inflight.current = run;
|
||||
await run;
|
||||
inflight.current = null;
|
||||
return !dirty.current;
|
||||
}, [props.page.id]);
|
||||
|
||||
const schedule = useCallback(() => {
|
||||
@@ -73,7 +85,13 @@ export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page,
|
||||
|
||||
// Перед переходом на другую страницу — дописать изменения. При закрытии вкладки — предупредить.
|
||||
useEffect(() => {
|
||||
setLeaveGuard(async () => { if (dirty.current && !conflictRef.current) await save(); });
|
||||
// Уходим со страницы (в т. ч. по ссылке /p/<id>): сначала сохраняем; не вышло — остаёмся и объясняем почему.
|
||||
setLeaveGuard(async () => {
|
||||
if (!dirty.current) return true;
|
||||
const ok = !conflictRef.current && (await save());
|
||||
setLeaveMsg(ok ? "" : "Перехід скасовано: зміни не збережено. Збережіть їх або вирішіть конфлікт версій.");
|
||||
return ok;
|
||||
});
|
||||
const before = (e: BeforeUnloadEvent) => { if (dirty.current) { e.preventDefault(); } };
|
||||
window.addEventListener("beforeunload", before);
|
||||
return () => { setLeaveGuard(null); window.removeEventListener("beforeunload", before); window.clearTimeout(timer.current); };
|
||||
@@ -93,6 +111,14 @@ export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page,
|
||||
setState("saved");
|
||||
setEditorKey((k) => k + 1);
|
||||
};
|
||||
useEffect(() => { props.onModeChange?.(editing); }, [editing]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** «Готово»: дописать изменения и вернуться к просмотру. */
|
||||
const finish = async () => {
|
||||
if (conflictRef.current) return;
|
||||
if (await save()) { setEditing(false); setLeaveMsg(""); }
|
||||
};
|
||||
|
||||
const keepMine = () => {
|
||||
if (!conflict) return;
|
||||
version.current = conflict.version;
|
||||
@@ -112,14 +138,25 @@ export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page,
|
||||
return (
|
||||
<div>
|
||||
<div className="wk-titlebar">
|
||||
<input className="wk-title" value={title} maxLength={200} placeholder="Без назви" aria-label="Назва сторінки"
|
||||
onChange={(e) => { setTitle(e.target.value); titleRef.current = e.target.value; schedule(); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (document.querySelector(".wk-prose") as HTMLElement | null)?.focus(); } }} />
|
||||
{editing ? (
|
||||
<input className="wk-title" value={title} maxLength={200} placeholder="Без назви" aria-label="Назва сторінки"
|
||||
onChange={(e) => { setTitle(e.target.value); titleRef.current = e.target.value; schedule(); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (document.querySelector(".wk-prose") as HTMLElement | null)?.focus(); } }} />
|
||||
) : (
|
||||
<h1 className="wk-title-view">{title}</h1>
|
||||
)}
|
||||
{editing ? (
|
||||
<button key="done" className="btn sm gold" onClick={() => void finish()} disabled={state === "saving" || !!conflict}
|
||||
title="Зберегти зміни й повернутися до перегляду"><Icon name="check" />Готово</button>
|
||||
) : (
|
||||
<button key="edit" className="btn sm gold" onClick={() => setEditing(true)}><Icon name="edit" />Редагувати</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="row wrap small mute mt-6">
|
||||
{status}
|
||||
{editing && status}
|
||||
<span>змінено {fmt(meta.updatedAt)}{meta.by ? ` · ${meta.by}` : ""}</span>
|
||||
</div>
|
||||
{leaveMsg && <div className="err">{leaveMsg}</div>}
|
||||
{state === "error" && err && <div className="err">{err}</div>}
|
||||
{conflict && (
|
||||
<div className="blockbar">
|
||||
@@ -133,7 +170,10 @@ export function PageView(props: { page: Page; nodes: Node[]; onSaved: (p: Page,
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<WikiEditor key={editorKey} markdown={markdown} pageId={props.page.id} nodes={props.nodes}
|
||||
{!editing && !getMd.current().trim() && (
|
||||
<div className="empty">Сторінка порожня. Натисніть «Редагувати», щоб додати текст.</div>
|
||||
)}
|
||||
<WikiEditor key={editorKey} markdown={markdown} pageId={props.page.id} nodes={props.nodes} editable={editing}
|
||||
onChange={(g) => { getMd.current = g; schedule(); }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,13 +9,17 @@ const subs = new Set<() => void>();
|
||||
let path = location.pathname;
|
||||
window.addEventListener("popstate", () => { path = location.pathname; subs.forEach((f) => f()); });
|
||||
|
||||
let guard: (() => Promise<void>) | null = null;
|
||||
/** Перед уходом со страницы (например, дождаться сохранения). */
|
||||
export function setLeaveGuard(g: (() => Promise<void>) | null) { guard = g; }
|
||||
let guard: (() => Promise<boolean>) | null = null;
|
||||
/** Перед уходом со страницы: дописать изменения. false — уйти нельзя (не сохранилось, причина видна на странице). */
|
||||
export function setLeaveGuard(g: (() => Promise<boolean>) | null) { guard = g; }
|
||||
|
||||
export async function navigate(to: string, replace = false) {
|
||||
if (to === path) return;
|
||||
if (guard) { try { await guard(); } catch { /* ошибка сохранения видна на самой странице */ } }
|
||||
if (guard) {
|
||||
let ok = false;
|
||||
try { ok = await guard(); } catch { ok = false; }
|
||||
if (!ok) return;
|
||||
}
|
||||
if (replace) history.replaceState(null, "", to);
|
||||
else history.pushState(null, "", to);
|
||||
path = to;
|
||||
|
||||
@@ -102,3 +102,10 @@
|
||||
/* строки-ссылки (.mrow как <a>) — без подчёркивания и золотого цвета ссылок */
|
||||
a.mrow,a.wk-row{color:inherit;text-decoration:none}
|
||||
a.mrow:hover .t{color:var(--gold-text)}
|
||||
|
||||
/* ---------------------------------------------------------------- просмотр / редактирование */
|
||||
.wk-titlebar{display:flex;align-items:flex-start;gap:var(--sp-12)}
|
||||
.wk-titlebar .wk-title,.wk-titlebar .wk-title-view{flex:1;min-width:0}
|
||||
.wk-titlebar .btn{flex:none;margin-top:var(--sp-4)}
|
||||
.wk-title-view{padding:var(--sp-4) 0;border-bottom:1px solid transparent;overflow-wrap:anywhere}
|
||||
.wk-view .wk-prose{min-height:0;padding-top:var(--sp-10)}
|
||||
|
||||
Reference in New Issue
Block a user