[feature][editor] Рекурсивная нода subpages: дерево всех страниц-детей текущей страницы #150

Closed
opened 2026-06-24 05:13:59 +03:00 by Ghost · 0 comments

Кратко

Нужна нода редактора, которую можно вставить на страницу, и она рекурсивно показывает всё дерево потомков текущей страницы (а не один уровень).

Решения по дизайну (согласованы):

  • расширяем существующую ноду subpages (а не плодим новую), добавляя режим «дерево»;
  • дерево рендерится полностью раскрытым (без сворачивания узлов);
  • глубина не ограничена — показываем всё поддерево целиком.

Текущее поведение

Нода subpages уже существует и показывает только один уровень прямых детей:

  • Схема: packages/editor-ext/src/lib/subpages/subpages.tsatom/block/draggable/isolating, без атрибутов (SubpagesAttributes {}), команда insertSubpages, ReactNodeViewRenderer(this.options.view).
  • View: apps/client/src/features/editor/components/subpages/subpages-view.tsx — берёт editor.storage.pageId, тянет прямых детей через useGetSidebarPagesQuery({ pageId }) (эндпоинт /pages/sidebar-pages, плоский список) и рисует плоский <Stack> ссылок. Для шаринга — useSharedPageSubpages(pageId) (один уровень из sharedTreeDataAtom).
  • Регистрация: apps/client/src/features/editor/extensions/extensions.ts:389 (Subpages.configure({ view: SubpagesView })), слэш-меню apps/client/src/features/editor/components/slash-menu/menu-items.ts:512, тулбар apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx:91, bubble-меню удаления apps/client/src/features/editor/components/subpages/subpages-menu.tsx (подключено в apps/client/src/features/editor/page-editor.tsx:507).
  • Сервер: нода объявлена в общем списке расширений apps/server/src/collaboration/collaboration.util.ts:37,115 — нужно, чтобы ProseMirror-схема на сервере не вырезала ноду.

Ключевая находка: бэкенд уже умеет отдавать всё поддерево

  • Эндпоинт POST /pages/treeapps/server/src/core/page/page.controller.ts:582pageService.getSidebarPagesTree(spaceId, userId, spaceCanEdit, pageId). При переданном pageId он зовёт getPageAndDescendants(pageId)рекурсивный SQL CTE (apps/server/src/database/repos/page/page.repo.ts:629), возвращающий плоский список «страница + все потомки» с учётом прав (рестриктнутые поддеревья отсекаются) и полями hasChildren/canEdit (apps/server/src/core/page/services/sidebar-pages-tree.util.ts).
  • Фронтовая обёртка уже есть: getSpaceTree({ spaceId, pageId })apps/client/src/features/page/services/page-service.ts:95 (POST /pages/tree, возвращает IPage[]). Сейчас используется только для «expand all» в сайдбаре; react-query-хука для неё пока нет.

Вывод: рекурсивный режим строится поверх готовой инфраструктуры — изменений бэкенда не требуется. Один запрос /pages/tree с pageId отдаёт всё поддерево без N+1; вложенное дерево из плоского списка собираем на клиенте по parentPageId.

Объём изменений

1. Схема ноды — добавить атрибут recursive

packages/editor-ext/src/lib/subpages/subpages.ts. Добавить addAttributes и прокинуть атрибут в parseHTML/renderHTML, чтобы он персистился в документе и переживал HTML-раунд-трип:

export interface SubpagesAttributes {
  recursive: boolean; // false => flat list (as before), true => child tree
}

addAttributes() {
  return {
    recursive: {
      default: false, // existing inserted nodes stay flat -> backward compatible
      parseHTML: (el) => el.getAttribute("data-recursive") === "true",
      renderHTML: (attrs) =>
        attrs.recursive ? { "data-recursive": "true" } : {},
    },
  };
},

Команда insertSubpages уже принимает attributes — вызов insertSubpages({ recursive: true }) работает без изменений сигнатуры. Нода импортируется из общего пакета @docmost/editor-ext и на клиенте, и на сервере — одно объявление атрибута покрывает обе стороны.

2. Хук данных — react-query для поддерева

В apps/client/src/features/page/queries/page-query.ts добавить хук поверх getSpaceTree:

// Returns the full descendant subtree (flat IPage[]) of a page in one request.
export function useGetPageTreeQuery(pageId: string) {
  return useQuery({
    queryKey: ["page-tree", pageId],
    queryFn: () => getSpaceTree({ pageId } as any), // endpoint derives spaceId from pageId
    enabled: !!pageId,
    staleTime: 30 * 1000,
  });
}

Тип getSpaceTree сейчас требует spaceId (apps/client/src/features/page/services/page-service.ts:95), хотя эндпоинт принимает либо spaceId, либо pageId. Нужно ослабить тип до { spaceId?: string; pageId?: string } (минимальная правка).

3. View — ветвление flat/recursive + рекурсивный рендер

apps/client/src/features/editor/components/subpages/subpages-view.tsx:

  • recursive === false → текущий код без изменений.
  • recursive === trueuseGetPageTreeQuery(currentPageId), из плоского IPage[] собрать вложенное дерево по parentPageId, найти детей текущей страницы и рендерить рекурсивным под-компонентом. Сама корневая страница (первый элемент ответа) в вывод не попадает — показываем только её потомков.
const recursive = props.node.attrs.recursive === true;

// Build id -> node map with children; attach by parentPageId. Guard against
// cycles with a visited set (defensive — hierarchy should be acyclic).
function buildSubtree(pages, rootId) {
  const byId = new Map(pages.map((p) => [p.id, { ...p, children: [] }]));
  for (const p of byId.values()) {
    if (p.parentPageId && byId.has(p.parentPageId) && p.id !== rootId) {
      byId.get(p.parentPageId).children.push(p);
    }
  }
  const root = byId.get(rootId);
  return root ? sortPositionKeys(root.children) : [];
}

function TreeNode({ node, depth }) {
  return (
    <>
      <Anchor style={{ paddingLeft: depth * 16 }}>{/* icon + title */}</Anchor>
      {sortPositionKeys(node.children ?? []).map((child) => (
        <TreeNode key={child.id} node={child} depth={depth + 1} />
      ))}
    </>
  );
}

Сортировка на каждом уровне — через существующий sortPositionKeys (apps/client/src/features/page/tree/utils/utils.ts:4). Разметка ссылки/иконки и состояния isLoading/error/«No subpages» переиспользуются из текущего view.

4. Шаринг / публичные страницы

apps/client/src/features/share/hooks/use-shared-page-subpages.ts сейчас возвращает только прямых детей из sharedTreeDataAtom. Для рекурсива добавить вариант, возвращающий узел целиком с его childrensharedTreeDataAtom дерево уже вложенное), и в recursive-ветке рендерить его тем же TreeNode. В публичном контексте поддерево берётся из уже загруженного shared-дерева, без обращения к /pages/tree.

5. Переключатель режима

  • Bubble-меню (subpages-menu.tsx): кнопка-тоггл «плоский список ⇄ дерево»: editor.commands.updateAttributes("subpages", { recursive: !current }).
  • Слэш-меню (menu-items.ts:511): оставить текущий пункт (вставляет плоский, recursive:false) и добавить второй для обнаружимости — «Page tree (child pages, recursive)», команда insertSubpages({ recursive: true }). Аналогично можно добавить пункт в тулбар.

Что НЕ требуется

  • Бэкенд — без изменений (/pages/tree уже всё умеет).
  • Список расширений коллаборации — без изменений (Subpages уже там; добавление атрибута схему не ломает).

Краевые случаи и риски

  1. Обратная совместимость. recursive по умолчанию false → все ранее вставленные ноды остаются плоскими. Поведение не меняется без явного включения.
  2. Производительность / большие деревья. «Без ограничения + полностью раскрыто» → страница с огромным поддеревом отрендерит сотни ссылок. Запрос дешёвый (один CTE, без N+1), узкое место — DOM. Стоит заложить мягкую защиту: при > N узлов показывать счётчик/пометку, не ограничивая данные.
  3. Циклы в иерархии. Дерево ацикличное по модели (parentPageId), но сборку на клиенте делаем с защитой (visited / p.id !== rootId).
  4. Права доступа. /pages/tree уже отсекает рестриктнутые поддеревья на сервере — пользователь не увидит недоступное (даже строже плоского /pages/sidebar-pages).
  5. Экспорт в Markdown/PDF/статический HTML. Текущая subpages-нода динамическая и в экспорт не сериализуется (серверного сериализатора нет). Рекурсивный режим унаследует это: в экспортированном файле дерева не будет. Если экспорт дерева нужен — отдельная задача.
  6. Актуальность данных. Дерево тянется react-query; при создании/перемещении/переименовании дочерних страниц нужна инвалидация ключа ["page-tree", pageId] в соответствующих мутациях, иначе нода покажет устаревший список до рефетча.
  7. Контекст pageId. View опирается на editor.storage.pageId (apps/client/src/features/editor/page-editor.tsx:341) — нода привязана к текущей странице, что и требуется.

Оценка объёма

~6 файлов на клиенте + 1 атрибут в общем пакете editor-ext; бэкенд без изменений.

## Кратко Нужна нода редактора, которую можно вставить на страницу, и она **рекурсивно** показывает всё дерево потомков текущей страницы (а не один уровень). Решения по дизайну (согласованы): - **расширяем существующую ноду `subpages`** (а не плодим новую), добавляя режим «дерево»; - дерево рендерится **полностью раскрытым** (без сворачивания узлов); - **глубина не ограничена** — показываем всё поддерево целиком. ## Текущее поведение Нода `subpages` уже существует и показывает **только один уровень** прямых детей: - Схема: `packages/editor-ext/src/lib/subpages/subpages.ts` — `atom`/`block`/`draggable`/`isolating`, без атрибутов (`SubpagesAttributes {}`), команда `insertSubpages`, `ReactNodeViewRenderer(this.options.view)`. - View: `apps/client/src/features/editor/components/subpages/subpages-view.tsx` — берёт `editor.storage.pageId`, тянет прямых детей через `useGetSidebarPagesQuery({ pageId })` (эндпоинт `/pages/sidebar-pages`, плоский список) и рисует плоский `<Stack>` ссылок. Для шаринга — `useSharedPageSubpages(pageId)` (один уровень из `sharedTreeDataAtom`). - Регистрация: `apps/client/src/features/editor/extensions/extensions.ts:389` (`Subpages.configure({ view: SubpagesView })`), слэш-меню `apps/client/src/features/editor/components/slash-menu/menu-items.ts:512`, тулбар `apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx:91`, bubble-меню удаления `apps/client/src/features/editor/components/subpages/subpages-menu.tsx` (подключено в `apps/client/src/features/editor/page-editor.tsx:507`). - Сервер: нода объявлена в общем списке расширений `apps/server/src/collaboration/collaboration.util.ts:37,115` — нужно, чтобы ProseMirror-схема на сервере не вырезала ноду. ## Ключевая находка: бэкенд уже умеет отдавать всё поддерево - Эндпоинт `POST /pages/tree` → `apps/server/src/core/page/page.controller.ts:582` → `pageService.getSidebarPagesTree(spaceId, userId, spaceCanEdit, pageId)`. При переданном `pageId` он зовёт `getPageAndDescendants(pageId)` — **рекурсивный SQL CTE** (`apps/server/src/database/repos/page/page.repo.ts:629`), возвращающий плоский список «страница + все потомки» с учётом прав (рестриктнутые поддеревья отсекаются) и полями `hasChildren`/`canEdit` (`apps/server/src/core/page/services/sidebar-pages-tree.util.ts`). - Фронтовая обёртка уже есть: `getSpaceTree({ spaceId, pageId })` → `apps/client/src/features/page/services/page-service.ts:95` (`POST /pages/tree`, возвращает `IPage[]`). Сейчас используется только для «expand all» в сайдбаре; **react-query-хука для неё пока нет**. **Вывод:** рекурсивный режим строится поверх готовой инфраструктуры — **изменений бэкенда не требуется**. Один запрос `/pages/tree` с `pageId` отдаёт всё поддерево без N+1; вложенное дерево из плоского списка собираем на клиенте по `parentPageId`. ## Объём изменений ### 1. Схема ноды — добавить атрибут `recursive` `packages/editor-ext/src/lib/subpages/subpages.ts`. Добавить `addAttributes` и прокинуть атрибут в `parseHTML`/`renderHTML`, чтобы он персистился в документе и переживал HTML-раунд-трип: ```ts export interface SubpagesAttributes { recursive: boolean; // false => flat list (as before), true => child tree } addAttributes() { return { recursive: { default: false, // existing inserted nodes stay flat -> backward compatible parseHTML: (el) => el.getAttribute("data-recursive") === "true", renderHTML: (attrs) => attrs.recursive ? { "data-recursive": "true" } : {}, }, }; }, ``` Команда `insertSubpages` уже принимает `attributes` — вызов `insertSubpages({ recursive: true })` работает без изменений сигнатуры. Нода импортируется из общего пакета `@docmost/editor-ext` и на клиенте, и на сервере — одно объявление атрибута покрывает обе стороны. ### 2. Хук данных — react-query для поддерева В `apps/client/src/features/page/queries/page-query.ts` добавить хук поверх `getSpaceTree`: ```ts // Returns the full descendant subtree (flat IPage[]) of a page in one request. export function useGetPageTreeQuery(pageId: string) { return useQuery({ queryKey: ["page-tree", pageId], queryFn: () => getSpaceTree({ pageId } as any), // endpoint derives spaceId from pageId enabled: !!pageId, staleTime: 30 * 1000, }); } ``` Тип `getSpaceTree` сейчас требует `spaceId` (`apps/client/src/features/page/services/page-service.ts:95`), хотя эндпоинт принимает либо `spaceId`, либо `pageId`. Нужно ослабить тип до `{ spaceId?: string; pageId?: string }` (минимальная правка). ### 3. View — ветвление flat/recursive + рекурсивный рендер `apps/client/src/features/editor/components/subpages/subpages-view.tsx`: - `recursive === false` → текущий код без изменений. - `recursive === true` → `useGetPageTreeQuery(currentPageId)`, из плоского `IPage[]` собрать вложенное дерево по `parentPageId`, найти детей текущей страницы и рендерить рекурсивным под-компонентом. Сама корневая страница (первый элемент ответа) в вывод не попадает — показываем только её потомков. ```tsx const recursive = props.node.attrs.recursive === true; // Build id -> node map with children; attach by parentPageId. Guard against // cycles with a visited set (defensive — hierarchy should be acyclic). function buildSubtree(pages, rootId) { const byId = new Map(pages.map((p) => [p.id, { ...p, children: [] }])); for (const p of byId.values()) { if (p.parentPageId && byId.has(p.parentPageId) && p.id !== rootId) { byId.get(p.parentPageId).children.push(p); } } const root = byId.get(rootId); return root ? sortPositionKeys(root.children) : []; } function TreeNode({ node, depth }) { return ( <> <Anchor style={{ paddingLeft: depth * 16 }}>{/* icon + title */}</Anchor> {sortPositionKeys(node.children ?? []).map((child) => ( <TreeNode key={child.id} node={child} depth={depth + 1} /> ))} </> ); } ``` Сортировка на каждом уровне — через существующий `sortPositionKeys` (`apps/client/src/features/page/tree/utils/utils.ts:4`). Разметка ссылки/иконки и состояния `isLoading`/`error`/«No subpages» переиспользуются из текущего view. ### 4. Шаринг / публичные страницы `apps/client/src/features/share/hooks/use-shared-page-subpages.ts` сейчас возвращает только прямых детей из `sharedTreeDataAtom`. Для рекурсива добавить вариант, возвращающий узел целиком с его `children` (в `sharedTreeDataAtom` дерево уже вложенное), и в recursive-ветке рендерить его тем же `TreeNode`. В публичном контексте поддерево берётся из уже загруженного shared-дерева, без обращения к `/pages/tree`. ### 5. Переключатель режима - **Bubble-меню** (`subpages-menu.tsx`): кнопка-тоггл «плоский список ⇄ дерево»: `editor.commands.updateAttributes("subpages", { recursive: !current })`. - **Слэш-меню** (`menu-items.ts:511`): оставить текущий пункт (вставляет плоский, `recursive:false`) и добавить второй для обнаружимости — «Page tree (child pages, recursive)», команда `insertSubpages({ recursive: true })`. Аналогично можно добавить пункт в тулбар. ### Что НЕ требуется - Бэкенд — без изменений (`/pages/tree` уже всё умеет). - Список расширений коллаборации — без изменений (`Subpages` уже там; добавление атрибута схему не ломает). ## Краевые случаи и риски 1. **Обратная совместимость.** `recursive` по умолчанию `false` → все ранее вставленные ноды остаются плоскими. Поведение не меняется без явного включения. 2. **Производительность / большие деревья.** «Без ограничения + полностью раскрыто» → страница с огромным поддеревом отрендерит сотни ссылок. Запрос дешёвый (один CTE, без N+1), узкое место — DOM. Стоит заложить мягкую защиту: при `> N` узлов показывать счётчик/пометку, не ограничивая данные. 3. **Циклы в иерархии.** Дерево ацикличное по модели (`parentPageId`), но сборку на клиенте делаем с защитой (`visited` / `p.id !== rootId`). 4. **Права доступа.** `/pages/tree` уже отсекает рестриктнутые поддеревья на сервере — пользователь не увидит недоступное (даже строже плоского `/pages/sidebar-pages`). 5. **Экспорт в Markdown/PDF/статический HTML.** Текущая `subpages`-нода динамическая и в экспорт не сериализуется (серверного сериализатора нет). Рекурсивный режим унаследует это: в экспортированном файле дерева не будет. Если экспорт дерева нужен — отдельная задача. 6. **Актуальность данных.** Дерево тянется react-query; при создании/перемещении/переименовании дочерних страниц нужна инвалидация ключа `["page-tree", pageId]` в соответствующих мутациях, иначе нода покажет устаревший список до рефетча. 7. **Контекст `pageId`.** View опирается на `editor.storage.pageId` (`apps/client/src/features/editor/page-editor.tsx:341`) — нода привязана к текущей странице, что и требуется. ## Оценка объёма ~6 файлов на клиенте + 1 атрибут в общем пакете `editor-ext`; **бэкенд без изменений**.
Ghost added the feature label 2026-06-24 05:13:59 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#150