Режимы просмотра и редактирования страницы; сохранение перед уходом со страницы

This commit is contained in:
crm-wiki agent
2026-10-13 14:36:30 +00:00
parent 4ebbbe4533
commit 1104838d12
5 changed files with 95 additions and 22 deletions

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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)}