@olddevs/ui 4.4.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/editor/RichTextEditor.d.ts +11 -4
- package/dist/editor/RichTextEditor.d.ts.map +1 -1
- package/dist/editor/RichTextEditor.js +90 -60
- package/dist/editor/RichTextEditor.js.map +1 -1
- package/dist/editor/RichTextView.d.ts +2 -1
- package/dist/editor/RichTextView.d.ts.map +1 -1
- package/dist/editor/RichTextView.js.map +1 -1
- package/dist/editor/core/containers.d.ts +2 -6
- package/dist/editor/core/containers.d.ts.map +1 -1
- package/dist/editor/core/containers.js +2 -1
- package/dist/editor/core/containers.js.map +1 -1
- package/dist/editor/core/customTool.d.ts +8 -2
- package/dist/editor/core/customTool.d.ts.map +1 -1
- package/dist/editor/core/customTool.js.map +1 -1
- package/dist/editor/core/customToolEngine.d.ts +9 -1
- package/dist/editor/core/customToolEngine.d.ts.map +1 -1
- package/dist/editor/core/customToolEngine.js +12 -0
- package/dist/editor/core/customToolEngine.js.map +1 -1
- package/dist/editor/core/extensions.d.ts.map +1 -1
- package/dist/editor/core/extensions.js +1 -1
- package/dist/editor/core/extensions.js.map +1 -1
- package/dist/editor/core/handle.d.ts +18 -1
- package/dist/editor/core/handle.d.ts.map +1 -1
- package/dist/editor/core/handle.js +2 -1
- package/dist/editor/core/handle.js.map +1 -1
- package/dist/editor/core/handleWatchers.d.ts +30 -0
- package/dist/editor/core/handleWatchers.d.ts.map +1 -1
- package/dist/editor/core/handleWatchers.js +15 -0
- package/dist/editor/core/handleWatchers.js.map +1 -1
- package/dist/editor/core/nodeNames.d.ts +7 -0
- package/dist/editor/core/nodeNames.d.ts.map +1 -0
- package/dist/editor/core/nodeNames.js +2 -0
- package/dist/editor/core/nodeNames.js.map +1 -0
- package/dist/editor/core/schema.d.ts.map +1 -1
- package/dist/editor/core/schema.js +4 -2
- package/dist/editor/core/schema.js.map +1 -1
- package/dist/editor/core/toolCommands.d.ts +78 -0
- package/dist/editor/core/toolCommands.d.ts.map +1 -0
- package/dist/editor/core/toolCommands.js +2 -0
- package/dist/editor/core/toolCommands.js.map +1 -0
- package/dist/editor/core/toolDefinition.d.ts +139 -0
- package/dist/editor/core/toolDefinition.d.ts.map +1 -0
- package/dist/editor/core/toolDefinition.js +28 -0
- package/dist/editor/core/toolDefinition.js.map +1 -0
- package/dist/editor/core/toolDescriptor.d.ts +71 -2
- package/dist/editor/core/toolDescriptor.d.ts.map +1 -1
- package/dist/editor/core/toolDescriptor.js +6 -2
- package/dist/editor/core/toolDescriptor.js.map +1 -1
- package/dist/editor/core/toolOptions.d.ts +157 -0
- package/dist/editor/core/toolOptions.d.ts.map +1 -0
- package/dist/editor/core/toolOptions.js +21 -0
- package/dist/editor/core/toolOptions.js.map +1 -0
- package/dist/editor/core/toolState.d.ts +46 -0
- package/dist/editor/core/toolState.d.ts.map +1 -0
- package/dist/editor/core/toolState.js +2 -0
- package/dist/editor/core/toolState.js.map +1 -0
- package/dist/editor/core/tools.d.ts +3 -1
- package/dist/editor/core/tools.d.ts.map +1 -1
- package/dist/editor/core/tools.js.map +1 -1
- package/dist/editor/core/viewIsland.d.ts.map +1 -1
- package/dist/editor/core/viewIsland.js +2 -1
- package/dist/editor/core/viewIsland.js.map +1 -1
- package/dist/editor/features/builtins.d.ts +9 -4
- package/dist/editor/features/builtins.d.ts.map +1 -1
- package/dist/editor/features/builtins.js +36 -7
- package/dist/editor/features/builtins.js.map +1 -1
- package/dist/editor/features/search/SearchPanel.d.ts.map +1 -1
- package/dist/editor/features/search/SearchPanel.js +26 -12
- package/dist/editor/features/search/SearchPanel.js.map +1 -1
- package/dist/editor/features/search/revealMatch.d.ts +16 -0
- package/dist/editor/features/search/revealMatch.d.ts.map +1 -1
- package/dist/editor/features/search/revealMatch.js +22 -0
- package/dist/editor/features/search/revealMatch.js.map +1 -1
- package/dist/editor/features/search/searchPlugin.d.ts +8 -3
- package/dist/editor/features/search/searchPlugin.d.ts.map +1 -1
- package/dist/editor/features/search/searchPlugin.js +17 -6
- package/dist/editor/features/search/searchPlugin.js.map +1 -1
- package/dist/editor/features/search/searchSnapshot.d.ts +14 -0
- package/dist/editor/features/search/searchSnapshot.d.ts.map +1 -0
- package/dist/editor/features/search/searchSnapshot.js +17 -0
- package/dist/editor/features/search/searchSnapshot.js.map +1 -0
- package/dist/editor/features/search/searchTool.d.ts +3 -0
- package/dist/editor/features/search/searchTool.d.ts.map +1 -1
- package/dist/editor/features/search/searchTool.js +30 -1
- package/dist/editor/features/search/searchTool.js.map +1 -1
- package/dist/editor/features/toc/RichTextToc.d.ts +29 -1
- package/dist/editor/features/toc/RichTextToc.d.ts.map +1 -1
- package/dist/editor/features/toc/RichTextToc.js +55 -10
- package/dist/editor/features/toc/RichTextToc.js.map +1 -1
- package/dist/editor/features/toc/documentHeadings.d.ts +30 -0
- package/dist/editor/features/toc/documentHeadings.d.ts.map +1 -0
- package/dist/editor/features/toc/documentHeadings.js +20 -0
- package/dist/editor/features/toc/documentHeadings.js.map +1 -0
- package/dist/editor/features/toc/headings.d.ts +6 -2
- package/dist/editor/features/toc/headings.d.ts.map +1 -1
- package/dist/editor/features/toc/headings.js +4 -2
- package/dist/editor/features/toc/headings.js.map +1 -1
- package/dist/editor/features/toc/tocSnapshot.d.ts +10 -0
- package/dist/editor/features/toc/tocSnapshot.d.ts.map +1 -0
- package/dist/editor/features/toc/tocSnapshot.js +8 -0
- package/dist/editor/features/toc/tocSnapshot.js.map +1 -0
- package/dist/editor/features/toc/tocTool.d.ts +6 -0
- package/dist/editor/features/toc/tocTool.d.ts.map +1 -0
- package/dist/editor/features/toc/tocTool.js +21 -0
- package/dist/editor/features/toc/tocTool.js.map +1 -0
- package/dist/editor/hooks/useRichTextToolState.d.ts +43 -0
- package/dist/editor/hooks/useRichTextToolState.d.ts.map +1 -0
- package/dist/editor/hooks/useRichTextToolState.js +77 -0
- package/dist/editor/hooks/useRichTextToolState.js.map +1 -0
- package/dist/editor/index.d.ts +8 -0
- package/dist/editor/index.d.ts.map +1 -1
- package/dist/editor/index.js +3 -0
- package/dist/editor/index.js.map +1 -1
- package/docs/api/editor-extension.en.md +223 -23
- package/docs/api/editor-extension.md +224 -22
- package/docs/api/migration-notes.en.md +36 -1
- package/docs/api/migration-notes.md +35 -0
- package/docs/components/content/rich-text-toc.en.md +13 -11
- package/docs/components/content/rich-text-toc.md +12 -10
- package/docs/components/index.en.md +2 -2
- package/docs/components/index.md +1 -1
- package/docs/components/inputs/rich-text-editor.en.md +227 -11
- package/docs/components/inputs/rich-text-editor.md +228 -10
- package/docs/getting-started.en.md +7 -7
- package/docs/getting-started.md +6 -6
- package/docs/glossary.en.md +52 -48
- package/docs/glossary.md +51 -47
- package/package.json +1 -1
- package/src/editor/RichTextEditor.tsx +94 -39
- package/src/editor/RichTextView.tsx +2 -1
- package/src/editor/core/containers.ts +8 -6
- package/src/editor/core/customTool.ts +8 -2
- package/src/editor/core/customToolEngine.ts +20 -1
- package/src/editor/core/extensions.ts +2 -1
- package/src/editor/core/handle.ts +25 -1
- package/src/editor/core/handleWatchers.ts +45 -0
- package/src/editor/core/nodeNames.ts +7 -0
- package/src/editor/core/schema.ts +5 -2
- package/src/editor/core/toolCommands.ts +85 -0
- package/src/editor/core/toolDefinition.ts +208 -0
- package/src/editor/core/toolDescriptor.ts +94 -3
- package/src/editor/core/toolOptions.ts +211 -0
- package/src/editor/core/toolState.ts +56 -0
- package/src/editor/core/tools.ts +3 -1
- package/src/editor/core/viewIsland.ts +5 -4
- package/src/editor/features/builtins.ts +72 -13
- package/src/editor/features/search/SearchPanel.tsx +38 -14
- package/src/editor/features/search/revealMatch.ts +45 -0
- package/src/editor/features/search/searchPlugin.ts +40 -11
- package/src/editor/features/search/searchSnapshot.ts +29 -0
- package/src/editor/features/search/searchTool.ts +51 -1
- package/src/editor/features/toc/RichTextToc.tsx +108 -10
- package/src/editor/features/toc/documentHeadings.ts +59 -0
- package/src/editor/features/toc/headings.ts +10 -3
- package/src/editor/features/toc/tocSnapshot.ts +24 -0
- package/src/editor/features/toc/tocTool.ts +41 -0
- package/src/editor/hooks/useRichTextToolState.ts +153 -0
- package/src/editor/index.ts +19 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { Editor } from "@tiptap/core";
|
|
3
4
|
import {
|
|
4
5
|
forwardRef,
|
|
5
6
|
useCallback,
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
useState,
|
|
9
10
|
type HTMLAttributes,
|
|
10
11
|
type KeyboardEvent,
|
|
12
|
+
type MutableRefObject,
|
|
11
13
|
type RefObject,
|
|
12
14
|
} from "react";
|
|
13
15
|
import {
|
|
@@ -19,6 +21,7 @@ import { useMessages } from "../../../i18n/useMessages.js";
|
|
|
19
21
|
import { cn } from "../../../lib/cn.js";
|
|
20
22
|
import { watchHandleEditor } from "../../core/handleWatchers.js";
|
|
21
23
|
import type { RichTextEditorHandle } from "../../RichTextEditor.js";
|
|
24
|
+
import { tocDocumentHeadings } from "./documentHeadings.js";
|
|
22
25
|
import { collectHeadings, documentRoot, type TocHeading } from "./headings.js";
|
|
23
26
|
import { stickyOffset } from "./stickyOffset.js";
|
|
24
27
|
|
|
@@ -50,6 +53,13 @@ export interface RichTextTocProps extends Omit<HTMLAttributes<HTMLDivElement>, "
|
|
|
50
53
|
* По умолчанию 240px.
|
|
51
54
|
*/
|
|
52
55
|
listWidth?: number | string;
|
|
56
|
+
/**
|
|
57
|
+
* Уровни заголовков, которые попадают в Оглавление: подмножество 1–4. Заголовка
|
|
58
|
+
* другого уровня нет ни в полосе, ни в списке, а отступ пунктов считается от
|
|
59
|
+
* самого крупного из оставшихся. Читается при каждой сборке списка, поэтому
|
|
60
|
+
* смена набора уровней пересобирает его. По умолчанию все четыре.
|
|
61
|
+
*/
|
|
62
|
+
levels?: readonly (1 | 2 | 3 | 4)[];
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
/**
|
|
@@ -77,6 +87,9 @@ const itemIndent = (depth: number) => ITEM_INDENT[Math.min(depth, ITEM_INDENT.le
|
|
|
77
87
|
const OPEN_DELAY = 200;
|
|
78
88
|
const CLOSE_DELAY = 300;
|
|
79
89
|
|
|
90
|
+
/** Все уровни заголовков: Оглавление без `levels`. */
|
|
91
|
+
const ALL_LEVELS = [1, 2, 3, 4] as const;
|
|
92
|
+
|
|
80
93
|
/** Пауза после последней правки Документа, по которой список пересобирается. */
|
|
81
94
|
const REBUILD_DELAY = 150;
|
|
82
95
|
|
|
@@ -320,6 +333,26 @@ export interface TocCoreProps extends RichTextTocProps {
|
|
|
320
333
|
* уровня, а не полоса выше текста.
|
|
321
334
|
*/
|
|
322
335
|
alignToFirstHeading?: boolean;
|
|
336
|
+
/**
|
|
337
|
+
* Текущий раздел сменился (в том числе при первом показе): Редактор кладёт
|
|
338
|
+
* индекс в Состояние Инструмента `toc`. Индекс считается по списку, который
|
|
339
|
+
* видит `TocCore`; пока заголовков меньше двух, он остаётся прежним.
|
|
340
|
+
*/
|
|
341
|
+
onCurrentChange?: (index: number) => void;
|
|
342
|
+
/**
|
|
343
|
+
* Движок Редактора. Нужен команде `jumpTo` (`jumpRef`): индекс команды — это
|
|
344
|
+
* индекс заголовка в снимке Состояния `toc`, а снимок читает Документ движка
|
|
345
|
+
* на каждой транзакции. Список ядра пересобирается из DOM с задержкой
|
|
346
|
+
* (`REBUILD_DELAY`), и сразу после правки индексы двух списков расходились бы.
|
|
347
|
+
* Без движка `jumpTo` считает по списку ядра.
|
|
348
|
+
*/
|
|
349
|
+
editor?: Editor;
|
|
350
|
+
/**
|
|
351
|
+
* Куда ядро кладёт переход к заголовку по индексу снимка Состояния (команда
|
|
352
|
+
* `jumpTo` Инструмента): ведёт себя как клик по пункту. Пока ядро смонтировано,
|
|
353
|
+
* там функция, после — `null`. Нет заголовка с таким индексом — `false`.
|
|
354
|
+
*/
|
|
355
|
+
jumpRef?: MutableRefObject<((index: number) => boolean) | null>;
|
|
323
356
|
}
|
|
324
357
|
|
|
325
358
|
export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore(
|
|
@@ -327,9 +360,13 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
|
|
|
327
360
|
target,
|
|
328
361
|
offsetVar = "--topbar-h",
|
|
329
362
|
listWidth = 240,
|
|
363
|
+
levels,
|
|
330
364
|
stickyTop,
|
|
331
365
|
focusHeading,
|
|
332
366
|
alignToFirstHeading = false,
|
|
367
|
+
onCurrentChange,
|
|
368
|
+
editor,
|
|
369
|
+
jumpRef,
|
|
333
370
|
className,
|
|
334
371
|
style,
|
|
335
372
|
...rest
|
|
@@ -338,10 +375,12 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
|
|
|
338
375
|
) {
|
|
339
376
|
// Настройки хозяина читаются в обработчиках прокрутки: последние значения —
|
|
340
377
|
// через ссылку, чтобы новая функция на каждом рендере не пересоздавала слушателей.
|
|
341
|
-
const latest = useRef({ stickyTop, focusHeading, offsetVar });
|
|
378
|
+
const latest = useRef({ stickyTop, focusHeading, offsetVar, levels, onCurrentChange, editor });
|
|
342
379
|
useEffect(() => {
|
|
343
|
-
latest.current = { stickyTop, focusHeading, offsetVar };
|
|
380
|
+
latest.current = { stickyTop, focusHeading, offsetVar, levels, onCurrentChange, editor };
|
|
344
381
|
});
|
|
382
|
+
// Набор уровней сравнивается по значению: массив-литерал новый на каждом рендере.
|
|
383
|
+
const levelsKey = levels ? levels.join(",") : "";
|
|
345
384
|
const offsetNow = useCallback(() => {
|
|
346
385
|
const own = latest.current;
|
|
347
386
|
return own.stickyTop ? own.stickyTop() : stickyOffset(own.offsetVar);
|
|
@@ -351,6 +390,10 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
|
|
|
351
390
|
const [headings, setHeadings] = useState<TocHeading[]>([]);
|
|
352
391
|
const [gap, setGap] = useState(STRIP_GAP);
|
|
353
392
|
const [current, setCurrent] = useState(0);
|
|
393
|
+
// Текущий раздел — в Состояние Инструмента (`useRichTextToolState`).
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
latest.current.onCurrentChange?.(current);
|
|
396
|
+
}, [current]);
|
|
354
397
|
const [open, setOpen] = useState(false);
|
|
355
398
|
const [fits, setFits] = useState(true);
|
|
356
399
|
const stripRef = useRef<HTMLButtonElement>(null);
|
|
@@ -379,7 +422,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
|
|
|
379
422
|
return;
|
|
380
423
|
}
|
|
381
424
|
const read = () => {
|
|
382
|
-
const next = collectHeadings(element);
|
|
425
|
+
const next = collectHeadings(element, latest.current.levels);
|
|
383
426
|
setHeadings((prev) => (sameHeadings(prev, next) ? prev : next));
|
|
384
427
|
};
|
|
385
428
|
read();
|
|
@@ -397,7 +440,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
|
|
|
397
440
|
clearTimeout(timer);
|
|
398
441
|
observer.disconnect();
|
|
399
442
|
};
|
|
400
|
-
}, [element]);
|
|
443
|
+
}, [element, levelsKey]);
|
|
401
444
|
|
|
402
445
|
// Шаг полосы зависит от высоты окна и числа заголовков.
|
|
403
446
|
useEffect(() => {
|
|
@@ -426,7 +469,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
|
|
|
426
469
|
Пересчёт возвращается со следующей прокрутки после того, как переход
|
|
427
470
|
улёгся (`settled`).
|
|
428
471
|
*/
|
|
429
|
-
const pinned = useRef<{
|
|
472
|
+
const pinned = useRef<{ heading: HTMLElement; settled: boolean; timer: number } | null>(null);
|
|
430
473
|
/** (Пере)запустить ожидание: прокрутка перехода молчит `PIN_SETTLE` — переход улёгся. */
|
|
431
474
|
const settle = (lock: { settled: boolean; timer: number }) => {
|
|
432
475
|
window.clearTimeout(lock.timer);
|
|
@@ -434,12 +477,62 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
|
|
|
434
477
|
lock.settled = true;
|
|
435
478
|
}, PIN_SETTLE);
|
|
436
479
|
};
|
|
437
|
-
|
|
480
|
+
/*
|
|
481
|
+
Раздел перехода помнится узлом, а не индексом: список может пересобраться,
|
|
482
|
+
пока идёт прокрутка (правка моложе `REBUILD_DELAY`), и индекс повёл бы к
|
|
483
|
+
соседнему заголовку.
|
|
484
|
+
*/
|
|
485
|
+
const pin = (index: number, heading: HTMLElement) => {
|
|
438
486
|
if (pinned.current) window.clearTimeout(pinned.current.timer);
|
|
439
|
-
pinned.current = {
|
|
487
|
+
pinned.current = { heading, settled: false, timer: 0 };
|
|
440
488
|
settle(pinned.current);
|
|
441
489
|
setCurrent(index);
|
|
442
490
|
};
|
|
491
|
+
/*
|
|
492
|
+
Переход к пункту списка: клик и команда `jumpTo` Инструмента идут одним
|
|
493
|
+
путём. Заголовок, уже вышедший из Документа (правка моложе `REBUILD_DELAY`),
|
|
494
|
+
не пункт: список вот-вот пересоберётся.
|
|
495
|
+
*/
|
|
496
|
+
const go = (index: number): boolean => {
|
|
497
|
+
const heading = headings[index];
|
|
498
|
+
if (!heading?.element.isConnected) return false;
|
|
499
|
+
pin(index, heading.element);
|
|
500
|
+
jumpTo(heading.element, offsetNow(), latest.current.focusHeading);
|
|
501
|
+
return true;
|
|
502
|
+
};
|
|
503
|
+
/*
|
|
504
|
+
Команда `jumpTo(index)`: индекс — из снимка Состояния, а снимок читает
|
|
505
|
+
Документ движка (`tocDocumentHeadings`, тот же обход), поэтому заголовок
|
|
506
|
+
берётся оттуда: `view.nodeDOM` отдаёт его узел, ProseMirror обновляет DOM
|
|
507
|
+
синхронно с транзакцией. Список ядра при этом может отставать до
|
|
508
|
+
`REBUILD_DELAY`, поэтому читается заново, а не из состояния: от него нужен
|
|
509
|
+
только индекс пункта для подсветки.
|
|
510
|
+
*/
|
|
511
|
+
const goToSnapshotIndex = (index: number): boolean => {
|
|
512
|
+
const { editor: engine, levels: shown } = latest.current;
|
|
513
|
+
if (!engine) return go(index);
|
|
514
|
+
if (engine.isDestroyed) return false;
|
|
515
|
+
const found = tocDocumentHeadings(engine, { levels: shown ?? ALL_LEVELS })[index];
|
|
516
|
+
const node = found && engine.view.nodeDOM(found.pos);
|
|
517
|
+
if (!(node instanceof HTMLElement) || !node.isConnected || !element) return false;
|
|
518
|
+
const fresh = collectHeadings(element, shown);
|
|
519
|
+
setHeadings((prev) => (sameHeadings(prev, fresh) ? prev : fresh));
|
|
520
|
+
const listed = fresh.findIndex((heading) => heading.element === node);
|
|
521
|
+
if (listed >= 0) pin(listed, node);
|
|
522
|
+
jumpTo(node, offsetNow(), latest.current.focusHeading);
|
|
523
|
+
return true;
|
|
524
|
+
};
|
|
525
|
+
const goRef = useRef(goToSnapshotIndex);
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
goRef.current = goToSnapshotIndex;
|
|
528
|
+
});
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
if (!jumpRef) return;
|
|
531
|
+
jumpRef.current = (index) => goRef.current(index);
|
|
532
|
+
return () => {
|
|
533
|
+
jumpRef.current = null;
|
|
534
|
+
};
|
|
535
|
+
}, [jumpRef]);
|
|
443
536
|
|
|
444
537
|
// Подсветка текущего раздела. Слушатель прокрутки — на документе с захватом:
|
|
445
538
|
// так его достигает прокрутка и окна, и любого контейнера вокруг цели.
|
|
@@ -448,7 +541,13 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
|
|
|
448
541
|
let frame = 0;
|
|
449
542
|
const update = () => {
|
|
450
543
|
frame = 0;
|
|
451
|
-
|
|
544
|
+
// Список пересобрался, пока идёт прокрутка перехода: раздел перехода тот
|
|
545
|
+
// же заголовок, но под новым индексом. Замер посреди прокрутки назвал бы
|
|
546
|
+
// соседний, и он остался бы текущим, когда прокрутка уляжется.
|
|
547
|
+
const lock = pinned.current;
|
|
548
|
+
const held =
|
|
549
|
+
lock && !lock.settled ? headings.findIndex((h) => h.element === lock.heading) : -1;
|
|
550
|
+
setCurrent(held >= 0 ? held : currentIndex(headings, offsetNow()));
|
|
452
551
|
};
|
|
453
552
|
const schedule = (event?: Event) => {
|
|
454
553
|
// Считается только прокрутка, которая двигает Документ: окна или
|
|
@@ -670,8 +769,7 @@ export const TocCore = forwardRef<HTMLDivElement, TocCoreProps>(function TocCore
|
|
|
670
769
|
index === current ? "bg-bg-2 text-fg-0" : "text-fg-2 hover:text-fg-0"
|
|
671
770
|
)}
|
|
672
771
|
onClick={(event) => {
|
|
673
|
-
|
|
674
|
-
jumpTo(heading.element, offsetNow(), latest.current.focusHeading);
|
|
772
|
+
go(index);
|
|
675
773
|
/*
|
|
676
774
|
В Редакторе в правке фокус на заголовок не переходит и
|
|
677
775
|
остаётся на пункте, а фокус в списке держит его открытым:
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import type { Node as PmNode } from "@tiptap/pm/model";
|
|
3
|
+
import { WRAPPER_NODE } from "../../core/nodeNames.js";
|
|
4
|
+
import type { TocLevel, TocToolOptions } from "../../core/toolOptions.js";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Модуль без Tiptap в рантайме (только типы): его читает ядро Оглавления
|
|
8
|
+
(`TocCore`, `RichTextToc.tsx`), которому движок в бандл не нужен.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Заголовок Документа с позицией: по ней команда `jumpTo` находит DOM-узел. */
|
|
12
|
+
export interface TocDocumentHeading {
|
|
13
|
+
level: TocLevel;
|
|
14
|
+
text: string;
|
|
15
|
+
/** Позиция узла заголовка в Документе (для `editor.view.nodeDOM`). */
|
|
16
|
+
pos: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Заголовки Оглавления по Документу движка, а не по DOM: ядро Оглавления
|
|
21
|
+
* (`TocCore`) — ленивый чанк, и пока он не пришёл (или нарисован HTML-режим),
|
|
22
|
+
* узлов для обхода нет, а данные экран просит с первого кадра. Это единственный
|
|
23
|
+
* обход, по которому считаются индексы: и снимок Состояния `toc`, и команда
|
|
24
|
+
* `jumpTo` берут заголовок отсюда, поэтому индекс из снимка ведёт к тому же
|
|
25
|
+
* заголовку сразу после правки, не дожидаясь, пока ядро пересоберёт свой список
|
|
26
|
+
* из DOM (`REBUILD_DELAY`).
|
|
27
|
+
*
|
|
28
|
+
* ТЕ ЖЕ ПРАВИЛА, ЧТО У `collectHeadings` (`headings.ts`), иначе индексы разойдутся
|
|
29
|
+
* с пунктами списка: заголовок на верхнем уровне или внутри Обёрток (колонки
|
|
30
|
+
* Раскладки, свои Блоки, цитаты и списки — нет), уровень из `levels`, текст без
|
|
31
|
+
* крайних пробелов не пуст. Текст — тот же, что у `textContent` DOM: мягкий
|
|
32
|
+
* перенос в него не входит, а Текст атома — входит.
|
|
33
|
+
*
|
|
34
|
+
* Обход по верхнему уровню и Обёрткам, а не по всему Документу: стоимость — число
|
|
35
|
+
* Блоков верхнего уровня, она не растёт от вложенности и заметна не будет даже
|
|
36
|
+
* на каждой транзакции.
|
|
37
|
+
*/
|
|
38
|
+
export function tocDocumentHeadings(
|
|
39
|
+
editor: Editor,
|
|
40
|
+
{ levels }: Pick<TocToolOptions, "levels">
|
|
41
|
+
): TocDocumentHeading[] {
|
|
42
|
+
const headings: TocDocumentHeading[] = [];
|
|
43
|
+
// `start` — позиция, с которой начинается содержимое `parent`.
|
|
44
|
+
const visit = (parent: PmNode, start: number) =>
|
|
45
|
+
parent.forEach((node, offset) => {
|
|
46
|
+
const pos = start + offset;
|
|
47
|
+
if (node.type.name === WRAPPER_NODE) return visit(node, pos + 1);
|
|
48
|
+
if (node.type.name !== "heading") return;
|
|
49
|
+
const level = node.attrs.level as TocLevel;
|
|
50
|
+
const text = node
|
|
51
|
+
.textBetween(0, node.content.size, "", (leaf) =>
|
|
52
|
+
leaf.type.name === "hardBreak" ? "" : leaf.textContent
|
|
53
|
+
)
|
|
54
|
+
.trim();
|
|
55
|
+
if (text && levels.includes(level)) headings.push({ level, text, pos });
|
|
56
|
+
});
|
|
57
|
+
visit(editor.state.doc, 0);
|
|
58
|
+
return headings;
|
|
59
|
+
}
|
|
@@ -51,13 +51,20 @@ function isStructural(heading: Element, root: HTMLElement): boolean {
|
|
|
51
51
|
return true;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Заголовки Документа в порядке чтения. `only` — какие уровни (1–4) берутся:
|
|
56
|
+
* остальные не попадают в список, и глубина (`depth`) считается от самого
|
|
57
|
+
* крупного из оставшихся. Нет — все.
|
|
58
|
+
*/
|
|
59
|
+
export function collectHeadings(target: HTMLElement, only?: readonly number[]): TocHeading[] {
|
|
56
60
|
const root = documentRoot(target);
|
|
57
61
|
// Пустой заголовок (автор только начал его в Редакторе) — не пункт: у
|
|
58
62
|
// кнопки без текста не было бы имени, а у полосы — лишний штрих.
|
|
59
63
|
const found = [...root.querySelectorAll<HTMLElement>(HEADING)].filter(
|
|
60
|
-
(heading) =>
|
|
64
|
+
(heading) =>
|
|
65
|
+
isStructural(heading, root) &&
|
|
66
|
+
heading.textContent?.trim() &&
|
|
67
|
+
(!only || only.includes(Number(heading.tagName[1])))
|
|
61
68
|
);
|
|
62
69
|
const levels = found.map((heading) => Number(heading.tagName[1]));
|
|
63
70
|
const top = Math.min(...levels);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Editor } from "@tiptap/core";
|
|
4
|
+
import type { TocToolOptions } from "../../core/toolOptions.js";
|
|
5
|
+
import type { TocToolHeading, TocToolState } from "../../core/toolState.js";
|
|
6
|
+
import { tocDocumentHeadings } from "./documentHeadings.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Снимок Состояния `toc` для `useRichTextToolState`: заголовки из
|
|
10
|
+
* `tocDocumentHeadings` без позиций (они в контракт не входят) и текущий раздел.
|
|
11
|
+
* `at` — индекс текущего раздела, который сообщило ядро Оглавления.
|
|
12
|
+
*/
|
|
13
|
+
export function tocSnapshot(
|
|
14
|
+
editor: Editor,
|
|
15
|
+
options: Pick<TocToolOptions, "levels">,
|
|
16
|
+
at: number | undefined
|
|
17
|
+
): TocToolState {
|
|
18
|
+
const headings: TocToolHeading[] = tocDocumentHeadings(editor, options).map(
|
|
19
|
+
({ level, text }, index) => ({ level, text, index })
|
|
20
|
+
);
|
|
21
|
+
// Прокрутка сообщает индекс по прежнему списку: после правки он мог выйти за край.
|
|
22
|
+
const current = headings.length ? Math.min(Math.max(at ?? 0, 0), headings.length - 1) : -1;
|
|
23
|
+
return { headings, current };
|
|
24
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ToolDescriptor } from "../../core/toolDescriptor.js";
|
|
4
|
+
import type { TocToolCommands } from "../../core/toolCommands.js";
|
|
5
|
+
import type { TocLevel, TocToolOptions } from "../../core/toolOptions.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Оглавление на описании Инструмента (ADR-0068). Части для движка и UI-записей
|
|
9
|
+
* у него нет: полосу и список рисует `RichTextEditor` в левом поле рамки
|
|
10
|
+
* (`TocCore`, ленивым чанком), а описание несёт Опции — умолчания и проверки,
|
|
11
|
+
* из которых `expandTools` собирает Опции Редактора.
|
|
12
|
+
*
|
|
13
|
+
* Тяжёлых импортов нет: описание лежит в стартовом куске Редактора, а UI
|
|
14
|
+
* Оглавления — нет.
|
|
15
|
+
*/
|
|
16
|
+
const TOC_LEVELS: readonly TocLevel[] = [1, 2, 3, 4];
|
|
17
|
+
|
|
18
|
+
/** Умолчания Опций: переменная `TopBar` (ADR-0016), все четыре уровня, список в 240 пикселей. */
|
|
19
|
+
export const TOC_DEFAULTS: TocToolOptions = {
|
|
20
|
+
offsetVar: "--topbar-h",
|
|
21
|
+
levels: TOC_LEVELS,
|
|
22
|
+
listWidth: 240,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const TOC_TOOL: ToolDescriptor = {
|
|
26
|
+
name: "toc",
|
|
27
|
+
defaults: TOC_DEFAULTS,
|
|
28
|
+
checks: {
|
|
29
|
+
// Имя вставляется в `var(…)` стиля поля, поэтому — только слово с дефисами.
|
|
30
|
+
offsetVar: (value) => typeof value === "string" && /^--[\w-]+$/.test(value),
|
|
31
|
+
levels: (value) =>
|
|
32
|
+
Array.isArray(value) &&
|
|
33
|
+
value.length > 0 &&
|
|
34
|
+
value.every((level) => (TOC_LEVELS as readonly unknown[]).includes(level)),
|
|
35
|
+
listWidth: (value) =>
|
|
36
|
+
(typeof value === "number" && Number.isFinite(value) && value > 0) ||
|
|
37
|
+
(typeof value === "string" && value.trim() !== ""),
|
|
38
|
+
},
|
|
39
|
+
// Переход живёт в ленивом ядре Оглавления: мост зовёт его, пока оно смонтировано.
|
|
40
|
+
commands: ({ bridge }): TocToolCommands => ({ jumpTo: bridge.jumpToc }),
|
|
41
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Editor } from "@tiptap/react";
|
|
4
|
+
import { useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
|
|
5
|
+
import { toolSignals, watchHandleEditor } from "../core/handleWatchers.js";
|
|
6
|
+
import { toolOptions } from "../core/toolDescriptor.js";
|
|
7
|
+
import type { RichTextTools } from "../core/toolOptions.js";
|
|
8
|
+
import type { RichTextEditorHandle } from "../RichTextEditor.js";
|
|
9
|
+
import { searchSnapshot } from "../features/search/searchSnapshot.js";
|
|
10
|
+
import { tocSnapshot } from "../features/toc/tocSnapshot.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Состояние Инструмента по имени: тип из карты `RichTextTools`. Имя, которого в
|
|
14
|
+
* карте нет, — `unknown`: типизация добровольна, и Инструмент без записи в карте
|
|
15
|
+
* не ломает сборку.
|
|
16
|
+
*/
|
|
17
|
+
export type RichTextToolState<Name extends string> = Name extends keyof RichTextTools
|
|
18
|
+
? RichTextTools[Name] extends { state: infer State }
|
|
19
|
+
? State
|
|
20
|
+
: unknown
|
|
21
|
+
: unknown;
|
|
22
|
+
|
|
23
|
+
/** Снимок встроенного Инструмента; `null` — Инструмента в движке нет. */
|
|
24
|
+
const SNAPSHOTS = new Map<string, (editor: Editor, ref: object) => unknown>([
|
|
25
|
+
[
|
|
26
|
+
"search",
|
|
27
|
+
(editor, ref) => {
|
|
28
|
+
const { tools, searchOpen } = toolSignals(ref);
|
|
29
|
+
return tools?.has("search") ? searchSnapshot(editor, searchOpen === true) : null;
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
"toc",
|
|
34
|
+
(editor, ref) => {
|
|
35
|
+
const { tools, tocAt } = toolSignals(ref);
|
|
36
|
+
const options = tools && toolOptions(tools, "toc");
|
|
37
|
+
return options && tocSnapshot(editor, options, tocAt);
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Простой объект: литерал или `Object.create(null)`, не экземпляр класса. */
|
|
43
|
+
function isPlainObject(value: object): boolean {
|
|
44
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
45
|
+
return prototype === Object.prototype || prototype === null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Равенство для снимков. По значению сравниваются только простые данные:
|
|
50
|
+
* примитивы, массивы и простые объекты. Всё прочее — `Date`, `Map`, `Set`,
|
|
51
|
+
* экземпляры классов — по ссылке: у них нет собственных ключей, по которым можно
|
|
52
|
+
* было бы увидеть разницу, и «равными по значению» оказались бы любые два, то есть
|
|
53
|
+
* экран тихо не обновлялся бы. Новый экземпляр — новый снимок.
|
|
54
|
+
*/
|
|
55
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
56
|
+
if (Object.is(a, b)) return true;
|
|
57
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
58
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
59
|
+
if (!Array.isArray(a) && !(isPlainObject(a) && isPlainObject(b))) return false;
|
|
60
|
+
const left = a as Record<string, unknown>;
|
|
61
|
+
const right = b as Record<string, unknown>;
|
|
62
|
+
const keys = Object.keys(left);
|
|
63
|
+
return (
|
|
64
|
+
keys.length === Object.keys(right).length &&
|
|
65
|
+
keys.every((key) => key in right && sameValue(left[key], right[key]))
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Состояние встроенного Инструмента для своего экрана: «3 из 12» в Строке
|
|
71
|
+
* статуса из Поиска, внешняя навигация из заголовков Оглавления. Имя —
|
|
72
|
+
* Инструмент из набора `tools`, а тип снимка выводится из карты
|
|
73
|
+
* `RichTextTools`: `search` отдаёт `SearchToolState`, `toc` — `TocToolState`.
|
|
74
|
+
*
|
|
75
|
+
* ПЕРЕРИСОВКА — ТОЛЬКО ПРИ СМЕНЕ СНИМКА. Снимок пересчитывается на каждую
|
|
76
|
+
* транзакцию движка и на сигналы кита, которых в движке нет: открытие и закрытие
|
|
77
|
+
* панели Поиска, смена текущего раздела Оглавления при прокрутке. Экран
|
|
78
|
+
* перерисовывается, когда результат стал другим (простые данные — примитивы,
|
|
79
|
+
* массивы, простые объекты — сравниваются по значению, как у
|
|
80
|
+
* `useRichTextEditorState`; `Date`, `Map`, `Set` и экземпляры классов — по
|
|
81
|
+
* ссылке), поэтому набор текста, не меняющий снимок, его не трогает. Срез из `selector` сравнивается так же: `(state) => state.total`
|
|
82
|
+
* подписывает экран на одно число.
|
|
83
|
+
*
|
|
84
|
+
* `null` — до монтажа и в первом рендере экрана, на сервере, а также когда
|
|
85
|
+
* Инструмента нет в наборе `tools`: пресеты `toc` не включают, его включают
|
|
86
|
+
* именем или записью `tool("toc")`. Селектор при `null` не вызывается. Один и
|
|
87
|
+
* тот же `ref` отдаётся в `RichTextEditor`.
|
|
88
|
+
*
|
|
89
|
+
* Снимок — данные без DOM-узлов и позиций ProseMirror; изменять его не нужно.
|
|
90
|
+
* Хук в стартовый граф `RichTextEditor` не входит: его цена — только у экрана,
|
|
91
|
+
* который его импортировал.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* const ref = useRef<RichTextEditorHandle>(null);
|
|
95
|
+
* const total = useRichTextToolState(ref, "search", (state) => state.total);
|
|
96
|
+
* const headings = useRichTextToolState(ref, "toc", (state) => state.headings);
|
|
97
|
+
*/
|
|
98
|
+
export function useRichTextToolState<Name extends string>(
|
|
99
|
+
ref: RefObject<RichTextEditorHandle | null>,
|
|
100
|
+
name: Name
|
|
101
|
+
): RichTextToolState<Name> | null;
|
|
102
|
+
export function useRichTextToolState<Name extends string, Slice>(
|
|
103
|
+
ref: RefObject<RichTextEditorHandle | null>,
|
|
104
|
+
name: Name,
|
|
105
|
+
selector: (state: RichTextToolState<Name>) => Slice
|
|
106
|
+
): Slice | null;
|
|
107
|
+
export function useRichTextToolState(
|
|
108
|
+
ref: RefObject<RichTextEditorHandle | null>,
|
|
109
|
+
name: string,
|
|
110
|
+
selector?: (state: never) => unknown
|
|
111
|
+
): unknown {
|
|
112
|
+
const last = useRef<{ value: unknown } | null>(null);
|
|
113
|
+
|
|
114
|
+
const subscribe = useCallback(
|
|
115
|
+
(listener: () => void) => {
|
|
116
|
+
// Транзакции движка — на текущем движке; он меняется — пересаживаем слушателя.
|
|
117
|
+
let followed: Editor | null = null;
|
|
118
|
+
const follow = () => {
|
|
119
|
+
followed?.off("transaction", listener);
|
|
120
|
+
const editor = ref.current?.editor ?? null;
|
|
121
|
+
followed = editor && !editor.isDestroyed ? editor : null;
|
|
122
|
+
followed?.on("transaction", listener);
|
|
123
|
+
};
|
|
124
|
+
follow();
|
|
125
|
+
const stop = watchHandleEditor(ref, () => {
|
|
126
|
+
follow();
|
|
127
|
+
listener();
|
|
128
|
+
});
|
|
129
|
+
return () => {
|
|
130
|
+
stop();
|
|
131
|
+
followed?.off("transaction", listener);
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
[ref]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const read = (): unknown => {
|
|
138
|
+
const editor = ref.current?.editor;
|
|
139
|
+
// Своё Состояние — функция описания из набора этого Редактора; встроенное — таблица.
|
|
140
|
+
const own = toolSignals(ref).tools?.descriptors.find((entry) => entry.name === name)?.state;
|
|
141
|
+
const snapshot =
|
|
142
|
+
editor && !editor.isDestroyed
|
|
143
|
+
? ((own ? own(editor) : SNAPSHOTS.get(name)?.(editor, ref)) ?? null)
|
|
144
|
+
: null;
|
|
145
|
+
const value = snapshot === null || !selector ? snapshot : selector(snapshot as never);
|
|
146
|
+
// Прежняя ссылка, пока значение то же: иначе React видит новый снимок на каждый рендер.
|
|
147
|
+
if (last.current && sameValue(last.current.value, value)) return last.current.value;
|
|
148
|
+
last.current = { value };
|
|
149
|
+
return value;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return useSyncExternalStore(subscribe, read, () => null);
|
|
153
|
+
}
|
package/src/editor/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ export { RichTextEditor } from "./RichTextEditor.js";
|
|
|
19
19
|
export type { RichTextEditorHandle, RichTextEditorProps } from "./RichTextEditor.js";
|
|
20
20
|
export type { RichTextDocument } from "./core/jsonDocument.js";
|
|
21
21
|
export { useRichTextEditorState } from "./hooks/useRichTextEditorState.js";
|
|
22
|
+
export { useRichTextToolState } from "./hooks/useRichTextToolState.js";
|
|
23
|
+
export type { RichTextToolState } from "./hooks/useRichTextToolState.js";
|
|
22
24
|
export { RichTextView } from "./RichTextView.js";
|
|
23
25
|
export type { RichTextViewProps } from "./RichTextView.js";
|
|
24
26
|
export { RichTextToc } from "./features/toc/RichTextToc.js";
|
|
@@ -36,6 +38,23 @@ export type {
|
|
|
36
38
|
} from "./core/schema.js";
|
|
37
39
|
export { RICH_TEXT_PRESETS } from "./core/tools.js";
|
|
38
40
|
export type { RichTextEditorPreset } from "./core/tools.js";
|
|
41
|
+
export { tool } from "./core/toolOptions.js";
|
|
42
|
+
export { defineTool } from "./core/toolDefinition.js";
|
|
43
|
+
export type {
|
|
44
|
+
CustomToolBody,
|
|
45
|
+
ToolDefinition,
|
|
46
|
+
ToolDefinitionContext,
|
|
47
|
+
ToolDefinitionInput,
|
|
48
|
+
} from "./core/toolDefinition.js";
|
|
49
|
+
export type {
|
|
50
|
+
ConfiguredTool,
|
|
51
|
+
RichTextTools,
|
|
52
|
+
SearchToolOptions,
|
|
53
|
+
TocLevel,
|
|
54
|
+
TocToolOptions,
|
|
55
|
+
} from "./core/toolOptions.js";
|
|
56
|
+
export type { SearchToolCommands, TocToolCommands } from "./core/toolCommands.js";
|
|
57
|
+
export type { SearchToolState, TocToolHeading, TocToolState } from "./core/toolState.js";
|
|
39
58
|
export { markRange } from "./core/customTool.js";
|
|
40
59
|
export type {
|
|
41
60
|
TextCardRange,
|