@inkbrick/react 0.0.0-stage → 0.1.0-alpha.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/dist/FormulaPalette-WFRPRHS5.js +320 -0
- package/dist/FormulaPalette-WFRPRHS5.js.map +1 -0
- package/dist/chunk-TPUX4C45.js +2309 -0
- package/dist/chunk-TPUX4C45.js.map +1 -0
- package/dist/index.d.ts +1431 -0
- package/dist/index.js +4880 -0
- package/dist/index.js.map +1 -0
- package/dist/mathCss-FLPE6QBM.js +361 -0
- package/dist/mathCss-FLPE6QBM.js.map +1 -0
- package/package.json +46 -3
- package/src/block-handle/BlockHandleController.ts +93 -0
- package/src/block-handle/BlockHandleHost.tsx +108 -0
- package/src/block-handle/DefaultBlockHandle.tsx +71 -0
- package/src/block-handle/DefaultBlockMenu.tsx +114 -0
- package/src/block-handle/DropIndicatorHost.tsx +58 -0
- package/src/block-handle/actions.tsx +146 -0
- package/src/block-handle/blockDrag.ts +215 -0
- package/src/block-handle/geometry.ts +64 -0
- package/src/block-handle/gripGesture.ts +108 -0
- package/src/block-handle/types.ts +65 -0
- package/src/block-handle/useBlockDrag.ts +21 -0
- package/src/block-handle/useBlockHandles.ts +125 -0
- package/src/blocks/BlockChildren.tsx +17 -0
- package/src/blocks/BlockProps.ts +5 -0
- package/src/blocks/CalloutBlock.tsx +61 -0
- package/src/blocks/CodeBlock.tsx +96 -0
- package/src/blocks/DividerBlock.tsx +15 -0
- package/src/blocks/Editable.tsx +84 -0
- package/src/blocks/FormulaBlock.tsx +173 -0
- package/src/blocks/HeadingBlock.tsx +22 -0
- package/src/blocks/ImageBlock.tsx +362 -0
- package/src/blocks/ListBlock.tsx +86 -0
- package/src/blocks/NoteBlock.tsx +10 -0
- package/src/blocks/PageBlock.tsx +10 -0
- package/src/blocks/ParagraphBlock.tsx +20 -0
- package/src/blocks/QuoteBlock.tsx +18 -0
- package/src/blocks/UnknownBlockView.tsx +27 -0
- package/src/blocks/defineBlock.ts +49 -0
- package/src/clipboard/html/blocks.ts +379 -0
- package/src/clipboard/html/inline.ts +92 -0
- package/src/clipboard/html/style.ts +92 -0
- package/src/clipboard/html/tags.ts +77 -0
- package/src/clipboard/htmlToFragment.ts +41 -0
- package/src/clipboard/useClipboard.ts +296 -0
- package/src/code/highlight.ts +56 -0
- package/src/code/languages.ts +236 -0
- package/src/code/scan/css.ts +164 -0
- package/src/code/scan/generic.ts +328 -0
- package/src/code/scan/html.ts +129 -0
- package/src/code/scan/markdown.ts +58 -0
- package/src/code/scan/shared.ts +81 -0
- package/src/code/scan/yaml.ts +34 -0
- package/src/code/types.ts +34 -0
- package/src/context/EditorContext.ts +9 -0
- package/src/context/EditorProvider.tsx +172 -0
- package/src/controllers/adapters.tsx +63 -0
- package/src/editing/BlockSelectionController.ts +27 -0
- package/src/editing/CaretController.ts +27 -0
- package/src/editing/EditorView.ts +108 -0
- package/src/editing/ViewportController.ts +63 -0
- package/src/editing/beforeInput.ts +276 -0
- package/src/editing/deleteAtBoundary.ts +16 -0
- package/src/editing/inputActions.ts +109 -0
- package/src/editing/renderCode.ts +69 -0
- package/src/editing/renderInline.ts +144 -0
- package/src/editing/selection.ts +262 -0
- package/src/editing/useEditorInput.ts +223 -0
- package/src/editing/useSelectionState.ts +46 -0
- package/src/formula/FormulaController.ts +51 -0
- package/src/formula/FormulaPalette.tsx +79 -0
- package/src/formula/FormulaPopover.tsx +181 -0
- package/src/formula/LazyFormulaPalette.tsx +42 -0
- package/src/formula/actions.ts +138 -0
- package/src/formula/snippet.ts +70 -0
- package/src/formula/symbols.ts +264 -0
- package/src/hooks/useBlock.ts +21 -0
- package/src/hooks/useBlockController.ts +30 -0
- package/src/hooks/useChildren.ts +16 -0
- package/src/hooks/useDocument.ts +16 -0
- package/src/hooks/useEditor.ts +13 -0
- package/src/hooks/useEditorView.ts +13 -0
- package/src/hooks/useHistory.ts +46 -0
- package/src/hooks/useReadOnly.ts +21 -0
- package/src/hooks/useT.ts +10 -0
- package/src/i18n/en.ts +182 -0
- package/src/i18n/ru.ts +174 -0
- package/src/i18n/translate.ts +77 -0
- package/src/icons/ICONS_LICENSE.md +50 -0
- package/src/icons/Icon.tsx +41 -0
- package/src/icons/lucide.ts +234 -0
- package/src/images/ImageError.ts +39 -0
- package/src/images/UploadTracker.ts +49 -0
- package/src/images/insertImages.ts +142 -0
- package/src/images/types.ts +22 -0
- package/src/images/upload.ts +62 -0
- package/src/index.ts +102 -0
- package/src/keymap/defaultKeymap.ts +189 -0
- package/src/keymap/keymap.ts +42 -0
- package/src/keymap/shortcut.ts +105 -0
- package/src/keymap/types.ts +40 -0
- package/src/math/FormulaView.tsx +62 -0
- package/src/math/mathCss.ts +4 -0
- package/src/math/renderer.ts +198 -0
- package/src/registry/defaultBlockComponents.ts +26 -0
- package/src/registry/resolveBlockComponent.ts +14 -0
- package/src/renderer/BlockRenderer.tsx +63 -0
- package/src/renderer/DocumentRenderer.tsx +33 -0
- package/src/renderer/DocumentView.tsx +94 -0
- package/src/renderer/virtual/VirtualChildList.tsx +355 -0
- package/src/renderer/virtual/estimate.ts +62 -0
- package/src/renderer/virtual/virtualRenderer.tsx +35 -0
- package/src/slash/DefaultSlashMenu.tsx +119 -0
- package/src/slash/SlashMenuController.ts +66 -0
- package/src/slash/SlashMenuHost.tsx +48 -0
- package/src/slash/applySlashItem.ts +57 -0
- package/src/slash/defaultSlashItems.tsx +186 -0
- package/src/slash/filter.ts +29 -0
- package/src/slash/items.ts +30 -0
- package/src/slash/syncSlashMenu.ts +67 -0
- package/src/slash/types.ts +21 -0
- package/src/toolbar/DefaultToolbar.tsx +281 -0
- package/src/toolbar/ToolbarController.ts +100 -0
- package/src/toolbar/ToolbarHost.tsx +81 -0
- package/src/toolbar/formatting.ts +75 -0
- package/src/toolbar/rich.ts +58 -0
- package/src/toolbar/syncToolbar.ts +39 -0
- package/src/toolbar/types.ts +21 -0
- package/src/ui/EditorUIContext.ts +26 -0
- package/src/ui/presentation.ts +24 -0
- package/src/ui/types.ts +70 -0
- package/README.md +0 -3
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useLayoutEffect, useMemo, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { Editor } from '@inkbrick/engine';
|
|
4
|
+
|
|
5
|
+
import { EditorView } from '../editing/EditorView';
|
|
6
|
+
import { highlightCode } from '../code/highlight';
|
|
7
|
+
import type { CodeHighlighter } from '../code/types';
|
|
8
|
+
import { createTranslator, resolveMessages } from '../i18n/translate';
|
|
9
|
+
import { temmlRenderer, type FormulaRenderer } from '../math/renderer';
|
|
10
|
+
import type { PartialMessages } from '../i18n/en';
|
|
11
|
+
import { resolveKeymap } from '../keymap/keymap';
|
|
12
|
+
import type { KeyBinding } from '../keymap/types';
|
|
13
|
+
import { resolveSlashItems } from '../slash/defaultSlashItems';
|
|
14
|
+
import type { ImageOptions } from '../images/types';
|
|
15
|
+
import type { SlashMenuItem } from '../slash/types';
|
|
16
|
+
import { EditorUIContext } from '../ui/EditorUIContext';
|
|
17
|
+
import type { EditorPresentation } from '../ui/presentation';
|
|
18
|
+
import type { BlockComponentRegistry, EditorSlots } from '../ui/types';
|
|
19
|
+
import { EditorContext, EditorViewContext } from './EditorContext';
|
|
20
|
+
import { blockComponentsOf, type ReactBlockDefinition } from '../blocks/defineBlock';
|
|
21
|
+
import { controllerKeymap, controllerSlashItems, mergeById } from '../controllers/adapters';
|
|
22
|
+
|
|
23
|
+
export interface EditorProviderProps {
|
|
24
|
+
readonly editor: Editor;
|
|
25
|
+
readonly children: ReactNode;
|
|
26
|
+
/**
|
|
27
|
+
* Визуальный слой одним объектом (дизайн-система): представления блоков, оболочка, slash-меню, подсветка кода
|
|
28
|
+
* и формулы — см. `definePresentation`. Отдельные props ниже уточняют его. Держите объект стабильным.
|
|
29
|
+
*/
|
|
30
|
+
readonly presentation?: EditorPresentation;
|
|
31
|
+
/**
|
|
32
|
+
* Пользовательские блоки из `defineBlock`: их компоненты добавляются к `components` (prop `components`
|
|
33
|
+
* с тем же flavour важнее). Схемы и контроллеры этих блоков передайте и в `new Editor({ blocks })`:
|
|
34
|
+
* пункты slash-меню, меню блока и клавиши берутся из контроллеров редактора. Держите массив стабильным.
|
|
35
|
+
*/
|
|
36
|
+
readonly blocks?: readonly ReactBlockDefinition<any>[];
|
|
37
|
+
/**
|
|
38
|
+
* Свои React-представления блоков: `{ 'block:paragraph': MyParagraph }`.
|
|
39
|
+
* Не указанные flavour используют стандартные компоненты Inkbrick.
|
|
40
|
+
* Чтобы не перерисовывать документ без необходимости, держите объект стабильным
|
|
41
|
+
* (вынесите из компонента или оберните в useMemo).
|
|
42
|
+
*/
|
|
43
|
+
readonly components?: BlockComponentRegistry;
|
|
44
|
+
readonly slots?: EditorSlots;
|
|
45
|
+
/**
|
|
46
|
+
* Пункты slash-меню. Дополняют стандартные (одинаковый `id` заменяет стандартный пункт);
|
|
47
|
+
* `false` выключает меню. Держите массив стабильным (вынесите из компонента или useMemo).
|
|
48
|
+
*/
|
|
49
|
+
readonly slashItems?: readonly SlashMenuItem[] | false;
|
|
50
|
+
/**
|
|
51
|
+
* Подсветка синтаксиса в блоках кода. По умолчанию — встроенный `highlightCode` (без зависимостей);
|
|
52
|
+
* свой подсветчик (Shiki, Prism, highlight.js) — функция `(code, language) => токены`; `false` выключает подсветку.
|
|
53
|
+
* Держите функцию стабильной: смена функции перерисовывает все блоки кода.
|
|
54
|
+
*/
|
|
55
|
+
readonly highlighter?: CodeHighlighter | false;
|
|
56
|
+
/**
|
|
57
|
+
* Отрисовка формул (блоки и встроенные): по умолчанию Temml → MathML, подгружается при первой формуле.
|
|
58
|
+
* Свой подсчётчик (KaTeX, MathJax) — функция `(latex, target, { display }) => void | Promise<void>`;
|
|
59
|
+
* `false` — показывать только исходный код. Держите функцию стабильной: её смена перерисовывает все формулы.
|
|
60
|
+
*/
|
|
61
|
+
readonly formulaRenderer?: FormulaRenderer | false;
|
|
62
|
+
/**
|
|
63
|
+
* Язык интерфейса: код подключённого словаря (`'en'`, `'ru'` или свой, см. `registerLocale` и `locales`).
|
|
64
|
+
* `'pt-BR'` без своего словаря возьмёт `'pt'`; неизвестный код — английский. По умолчанию `'en'`.
|
|
65
|
+
*/
|
|
66
|
+
readonly locale?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Словари, подключаемые только к этому редактору: `{ de: { 'menu.delete': 'Löschen', … } }`.
|
|
69
|
+
* Словарь может быть неполным — недостающее берётся из английского. Держите объект стабильным.
|
|
70
|
+
*/
|
|
71
|
+
readonly locales?: Readonly<Record<string, PartialMessages>>;
|
|
72
|
+
/** Точечные замены строк поверх выбранного языка: `{ 'menu.delete': 'Убрать' }`. Держите объект стабильным. */
|
|
73
|
+
readonly messages?: PartialMessages;
|
|
74
|
+
/**
|
|
75
|
+
* Свои привязки клавиш. Привязка с `id` стандартной (`'undo'`, `'format.bold'`, `'indent'`, …) заменяет её,
|
|
76
|
+
* новая проверяется раньше стандартных; `unbind('id')` отключает стандартную. Держите массив стабильным.
|
|
77
|
+
*/
|
|
78
|
+
readonly keymap?: readonly KeyBinding[];
|
|
79
|
+
/**
|
|
80
|
+
* Режим только чтения: ввод, команды, undo, toolbar, ручки блоков и slash-меню отключены,
|
|
81
|
+
* выделение и копирование работают. Применяется к редактору (`editor.setReadOnly`), поэтому
|
|
82
|
+
* защищает документ и от вызовов из кода.
|
|
83
|
+
*/
|
|
84
|
+
readonly readOnly?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Изображения: `upload` — куда сохранять файлы (иначе картинки встраиваются в документ как data:-адреса
|
|
87
|
+
* до `maxInlineBytes`), `acceptedTypes`, `onError`. Держите объект стабильным.
|
|
88
|
+
*/
|
|
89
|
+
readonly images?: ImageOptions;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function EditorProvider({
|
|
93
|
+
editor,
|
|
94
|
+
children,
|
|
95
|
+
presentation,
|
|
96
|
+
blocks,
|
|
97
|
+
components,
|
|
98
|
+
slots,
|
|
99
|
+
slashItems,
|
|
100
|
+
keymap,
|
|
101
|
+
highlighter,
|
|
102
|
+
formulaRenderer,
|
|
103
|
+
locale,
|
|
104
|
+
locales,
|
|
105
|
+
messages,
|
|
106
|
+
readOnly,
|
|
107
|
+
images,
|
|
108
|
+
}: EditorProviderProps) {
|
|
109
|
+
const view = useMemo(() => new EditorView(), [editor]);
|
|
110
|
+
|
|
111
|
+
useLayoutEffect(() => {
|
|
112
|
+
if (readOnly === undefined) return;
|
|
113
|
+
|
|
114
|
+
const previous = editor.readOnly;
|
|
115
|
+
|
|
116
|
+
editor.setReadOnly(readOnly);
|
|
117
|
+
|
|
118
|
+
return () => editor.setReadOnly(previous);
|
|
119
|
+
}, [editor, readOnly]);
|
|
120
|
+
|
|
121
|
+
const t = useMemo(
|
|
122
|
+
() => createTranslator(resolveMessages({ locale, locales, messages })),
|
|
123
|
+
[locale, locales, messages],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const ui = useMemo(() => {
|
|
127
|
+
const visual = presentation ?? {};
|
|
128
|
+
const code = highlighter ?? visual.highlighter;
|
|
129
|
+
const math = formulaRenderer ?? visual.formulaRenderer;
|
|
130
|
+
const customSlash = slashItems ?? visual.slashItems;
|
|
131
|
+
const customKeys = keymap ?? [];
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
components: { ...blockComponentsOf(blocks), ...visual.components, ...components },
|
|
135
|
+
slots: { ...visual.slots, ...slots },
|
|
136
|
+
// Пункты контроллеров идут после стандартных; пункт приложения с тем же id важнее.
|
|
137
|
+
slashItems:
|
|
138
|
+
customSlash === false
|
|
139
|
+
? []
|
|
140
|
+
: resolveSlashItems(mergeById(controllerSlashItems(editor), customSlash ?? []), t),
|
|
141
|
+
highlighter: code === false ? null : (code ?? highlightCode),
|
|
142
|
+
formulaRenderer: math === false ? null : (math ?? temmlRenderer),
|
|
143
|
+
locale: locale ?? 'en',
|
|
144
|
+
t,
|
|
145
|
+
keymap: resolveKeymap(mergeById(controllerKeymap(editor), customKeys, 'preferred-first')),
|
|
146
|
+
images: images ?? {},
|
|
147
|
+
readOnly,
|
|
148
|
+
};
|
|
149
|
+
}, [
|
|
150
|
+
editor,
|
|
151
|
+
presentation,
|
|
152
|
+
blocks,
|
|
153
|
+
components,
|
|
154
|
+
slots,
|
|
155
|
+
slashItems,
|
|
156
|
+
keymap,
|
|
157
|
+
highlighter,
|
|
158
|
+
formulaRenderer,
|
|
159
|
+
locale,
|
|
160
|
+
t,
|
|
161
|
+
readOnly,
|
|
162
|
+
images,
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<EditorContext.Provider value={editor}>
|
|
167
|
+
<EditorViewContext.Provider value={view}>
|
|
168
|
+
<EditorUIContext.Provider value={ui}>{children}</EditorUIContext.Provider>
|
|
169
|
+
</EditorViewContext.Provider>
|
|
170
|
+
</EditorContext.Provider>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { Editor } from '@inkbrick/engine';
|
|
4
|
+
|
|
5
|
+
import { Icon } from '../icons/Icon';
|
|
6
|
+
import { ICON_SHAPES, type IconName } from '../icons/lucide';
|
|
7
|
+
import type { KeyBinding, KeymapContext } from '../keymap/types';
|
|
8
|
+
import type { SlashMenuItem } from '../slash/types';
|
|
9
|
+
|
|
10
|
+
export function iconByName(name: string | undefined): ReactNode {
|
|
11
|
+
return name && Object.hasOwn(ICON_SHAPES, name) ? <Icon name={name as IconName} /> : undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function controllerSlashItems(editor: Editor): SlashMenuItem[] {
|
|
15
|
+
return editor.controllers.entries().flatMap(([flavour, controller]) =>
|
|
16
|
+
(controller.slashItems ?? []).map<SlashMenuItem>((item) => ({
|
|
17
|
+
id: item.id,
|
|
18
|
+
title: item.title,
|
|
19
|
+
description: item.description,
|
|
20
|
+
keywords: item.keywords,
|
|
21
|
+
icon: iconByName(item.icon),
|
|
22
|
+
flavour,
|
|
23
|
+
props: item.props,
|
|
24
|
+
})),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function controlledBlockAt(context: KeymapContext, flavour: string) {
|
|
29
|
+
const selection = context.selection();
|
|
30
|
+
const block = selection && context.editor.store.getBlock(selection.from.blockId);
|
|
31
|
+
|
|
32
|
+
return block?.flavour === flavour ? block : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function controllerKeymap(editor: Editor): KeyBinding[] {
|
|
36
|
+
return editor.controllers.entries().flatMap(([flavour, controller]) =>
|
|
37
|
+
(controller.keymap ?? []).map<KeyBinding>((binding) => ({
|
|
38
|
+
id: binding.id,
|
|
39
|
+
keys: binding.keys,
|
|
40
|
+
when: (context) => controlledBlockAt(context, flavour) !== undefined,
|
|
41
|
+
run: (context) => {
|
|
42
|
+
const block = controlledBlockAt(context, flavour);
|
|
43
|
+
|
|
44
|
+
return block ? binding.run(context.editor.controllers.contextOf(block)) : false;
|
|
45
|
+
},
|
|
46
|
+
})),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Объединяет пункты по id: пункт из `preferred` заменяет одноимённый из `base`.
|
|
52
|
+
* Порядок: сначала `base`, затем `preferred` (или наоборот — `'preferred-first'`, для клавиш: проверяются раньше).
|
|
53
|
+
*/
|
|
54
|
+
export function mergeById<T extends { readonly id: string }>(
|
|
55
|
+
base: readonly T[],
|
|
56
|
+
preferred: readonly T[],
|
|
57
|
+
order: 'base-first' | 'preferred-first' = 'base-first',
|
|
58
|
+
): T[] {
|
|
59
|
+
const ids = new Set(preferred.map((item) => item.id));
|
|
60
|
+
const rest = base.filter((item) => !ids.has(item.id));
|
|
61
|
+
|
|
62
|
+
return order === 'base-first' ? [...rest, ...preferred] : [...preferred, ...rest];
|
|
63
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Какие блоки сейчас затронуты непустым выделением. Подписка идёт на уровне отдельного блока:
|
|
3
|
+
* при смене выделения перерисовываются только те блоки, у которых флаг `selected` изменился.
|
|
4
|
+
*/
|
|
5
|
+
export class BlockSelectionController {
|
|
6
|
+
private selected: ReadonlySet<string> = new Set();
|
|
7
|
+
|
|
8
|
+
private readonly listeners = new Set<() => void>();
|
|
9
|
+
|
|
10
|
+
isSelected = (blockId: string): boolean => this.selected.has(blockId);
|
|
11
|
+
|
|
12
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
13
|
+
this.listeners.add(listener);
|
|
14
|
+
|
|
15
|
+
return () => {
|
|
16
|
+
this.listeners.delete(listener);
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
set(ids: readonly string[]): void {
|
|
21
|
+
if (ids.length === this.selected.size && ids.every((id) => this.selected.has(id))) return;
|
|
22
|
+
|
|
23
|
+
this.selected = new Set(ids);
|
|
24
|
+
|
|
25
|
+
for (const listener of [...this.listeners]) listener();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* В каком блоке сейчас стоит каретка (выделение свёрнуто). Подписка идёт на уровне блока: при переносе каретки
|
|
3
|
+
* перерисовываются только два блока — тот, который она покинула, и тот, куда попала.
|
|
4
|
+
*/
|
|
5
|
+
export class CaretController {
|
|
6
|
+
private blockId: string | null = null;
|
|
7
|
+
|
|
8
|
+
private readonly listeners = new Set<() => void>();
|
|
9
|
+
|
|
10
|
+
has = (blockId: string): boolean => this.blockId === blockId;
|
|
11
|
+
|
|
12
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
13
|
+
this.listeners.add(listener);
|
|
14
|
+
|
|
15
|
+
return () => {
|
|
16
|
+
this.listeners.delete(listener);
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
set(blockId: string | null): void {
|
|
21
|
+
if (this.blockId === blockId) return;
|
|
22
|
+
|
|
23
|
+
this.blockId = blockId;
|
|
24
|
+
|
|
25
|
+
for (const listener of [...this.listeners]) listener();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { BlockId } from '@inkbrick/document';
|
|
2
|
+
|
|
3
|
+
import { BlockHandleController } from '../block-handle/BlockHandleController';
|
|
4
|
+
import { UploadTracker } from '../images/UploadTracker';
|
|
5
|
+
import { BlockSelectionController } from './BlockSelectionController';
|
|
6
|
+
import { FormulaController } from '../formula/FormulaController';
|
|
7
|
+
import { CaretController } from './CaretController';
|
|
8
|
+
import { SlashMenuController } from '../slash/SlashMenuController';
|
|
9
|
+
import { ToolbarController } from '../toolbar/ToolbarController';
|
|
10
|
+
import { ViewportController } from './ViewportController';
|
|
11
|
+
import { setDomSelection, type DocPoint } from './selection';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Связь модели с DOM: корневой редактируемый элемент и элементы текстовых блоков.
|
|
15
|
+
* Нужна, чтобы после изменения модели вернуть курсор/выделение на место.
|
|
16
|
+
*/
|
|
17
|
+
export class EditorView {
|
|
18
|
+
private readonly elements = new Map<BlockId, HTMLElement>();
|
|
19
|
+
|
|
20
|
+
private root: HTMLElement | null = null;
|
|
21
|
+
|
|
22
|
+
readonly slash = new SlashMenuController();
|
|
23
|
+
|
|
24
|
+
readonly uploads = new UploadTracker();
|
|
25
|
+
|
|
26
|
+
readonly selection = new BlockSelectionController();
|
|
27
|
+
|
|
28
|
+
readonly caret = new CaretController();
|
|
29
|
+
|
|
30
|
+
readonly blocks = new BlockHandleController();
|
|
31
|
+
|
|
32
|
+
readonly formula = new FormulaController();
|
|
33
|
+
|
|
34
|
+
readonly toolbar = new ToolbarController();
|
|
35
|
+
|
|
36
|
+
readonly viewport = new ViewportController();
|
|
37
|
+
|
|
38
|
+
pointerDown = false;
|
|
39
|
+
|
|
40
|
+
private pending: { from: DocPoint; to: DocPoint } | null = null;
|
|
41
|
+
|
|
42
|
+
setRoot(element: HTMLElement | null): () => void {
|
|
43
|
+
this.root = element;
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
if (this.root === element) this.root = null;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getRoot(): HTMLElement | null {
|
|
51
|
+
return this.root;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
register(id: BlockId, element: HTMLElement): () => void {
|
|
55
|
+
this.elements.set(id, element);
|
|
56
|
+
|
|
57
|
+
const pending = this.pending;
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
pending &&
|
|
61
|
+
this.elements.has(pending.from.blockId) &&
|
|
62
|
+
this.elements.has(pending.to.blockId)
|
|
63
|
+
) {
|
|
64
|
+
// Регистрация идёт раньше, чем блок заполнит текст (тоже в layout-эффекте): ставим после фиксации.
|
|
65
|
+
queueMicrotask(() => {
|
|
66
|
+
if (this.pending === pending) this.placeSelection(pending.from, pending.to);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
// Не удаляем, если за это время зарегистрировали другой элемент для того же id.
|
|
72
|
+
if (this.elements.get(id) === element) {
|
|
73
|
+
this.elements.delete(id);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getElement(id: BlockId): HTMLElement | undefined {
|
|
79
|
+
return this.elements.get(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
placeCaret(id: BlockId, offset: number): void {
|
|
83
|
+
this.placeSelection({ blockId: id, offset }, { blockId: id, offset });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
placeSelection(from: DocPoint, to: DocPoint): void {
|
|
87
|
+
const start = this.elements.get(from.blockId);
|
|
88
|
+
const end = this.elements.get(to.blockId);
|
|
89
|
+
|
|
90
|
+
if (!start || !end) {
|
|
91
|
+
// Блок за пределами окна виртуального рендерера: просим отрисовать его и ставим выделение,
|
|
92
|
+
// когда он зарегистрируется (прокрутка к нему — ниже, через scrollIntoView).
|
|
93
|
+
this.pending = this.viewport.reveal([from.blockId, to.blockId]) ? { from, to } : null;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.pending = null;
|
|
98
|
+
|
|
99
|
+
// Фокус принадлежит корню: отдельные блоки не являются редактируемыми элементами.
|
|
100
|
+
if (this.root && globalThis.document.activeElement !== this.root) {
|
|
101
|
+
this.root.focus({ preventScroll: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setDomSelection(start, from.offset, end, to.offset);
|
|
105
|
+
|
|
106
|
+
end.scrollIntoView?.({ block: 'nearest' });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Какие блоки обязаны оставаться в DOM при виртуальной отрисовке: края выделения (там стоит каретка или
|
|
3
|
+
* начинается/кончается выделение) и блоки, которые редактор попросил показать (`reveal`), например перед
|
|
4
|
+
* установкой каретки в блок за пределами окна. Обычный рендерер это состояние не читает.
|
|
5
|
+
*/
|
|
6
|
+
export class ViewportController {
|
|
7
|
+
private edges: readonly string[] = [];
|
|
8
|
+
|
|
9
|
+
private revealed: readonly string[] = [];
|
|
10
|
+
|
|
11
|
+
private lists = 0;
|
|
12
|
+
|
|
13
|
+
private version = 0;
|
|
14
|
+
|
|
15
|
+
private readonly listeners = new Set<() => void>();
|
|
16
|
+
|
|
17
|
+
getVersion = (): number => this.version;
|
|
18
|
+
|
|
19
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
20
|
+
this.listeners.add(listener);
|
|
21
|
+
|
|
22
|
+
return () => {
|
|
23
|
+
this.listeners.delete(listener);
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
getPinned(): readonly string[] {
|
|
28
|
+
return this.revealed.length === 0 ? this.edges : [...this.edges, ...this.revealed];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get virtual(): boolean {
|
|
32
|
+
return this.lists > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
attach(): () => void {
|
|
36
|
+
this.lists += 1;
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
this.lists -= 1;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setEdges(ids: readonly string[]): void {
|
|
44
|
+
if (ids.length === this.edges.length && ids.every((id, i) => this.edges[i] === id)) return;
|
|
45
|
+
|
|
46
|
+
this.edges = ids;
|
|
47
|
+
this.notify();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
reveal(ids: readonly string[]): boolean {
|
|
51
|
+
if (!this.virtual) return false;
|
|
52
|
+
|
|
53
|
+
this.revealed = ids;
|
|
54
|
+
this.notify();
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private notify(): void {
|
|
59
|
+
this.version += 1;
|
|
60
|
+
|
|
61
|
+
for (const listener of [...this.listeners]) listener();
|
|
62
|
+
}
|
|
63
|
+
}
|