@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.
Files changed (131) hide show
  1. package/dist/FormulaPalette-WFRPRHS5.js +320 -0
  2. package/dist/FormulaPalette-WFRPRHS5.js.map +1 -0
  3. package/dist/chunk-TPUX4C45.js +2309 -0
  4. package/dist/chunk-TPUX4C45.js.map +1 -0
  5. package/dist/index.d.ts +1431 -0
  6. package/dist/index.js +4880 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/mathCss-FLPE6QBM.js +361 -0
  9. package/dist/mathCss-FLPE6QBM.js.map +1 -0
  10. package/package.json +46 -3
  11. package/src/block-handle/BlockHandleController.ts +93 -0
  12. package/src/block-handle/BlockHandleHost.tsx +108 -0
  13. package/src/block-handle/DefaultBlockHandle.tsx +71 -0
  14. package/src/block-handle/DefaultBlockMenu.tsx +114 -0
  15. package/src/block-handle/DropIndicatorHost.tsx +58 -0
  16. package/src/block-handle/actions.tsx +146 -0
  17. package/src/block-handle/blockDrag.ts +215 -0
  18. package/src/block-handle/geometry.ts +64 -0
  19. package/src/block-handle/gripGesture.ts +108 -0
  20. package/src/block-handle/types.ts +65 -0
  21. package/src/block-handle/useBlockDrag.ts +21 -0
  22. package/src/block-handle/useBlockHandles.ts +125 -0
  23. package/src/blocks/BlockChildren.tsx +17 -0
  24. package/src/blocks/BlockProps.ts +5 -0
  25. package/src/blocks/CalloutBlock.tsx +61 -0
  26. package/src/blocks/CodeBlock.tsx +96 -0
  27. package/src/blocks/DividerBlock.tsx +15 -0
  28. package/src/blocks/Editable.tsx +84 -0
  29. package/src/blocks/FormulaBlock.tsx +173 -0
  30. package/src/blocks/HeadingBlock.tsx +22 -0
  31. package/src/blocks/ImageBlock.tsx +362 -0
  32. package/src/blocks/ListBlock.tsx +86 -0
  33. package/src/blocks/NoteBlock.tsx +10 -0
  34. package/src/blocks/PageBlock.tsx +10 -0
  35. package/src/blocks/ParagraphBlock.tsx +20 -0
  36. package/src/blocks/QuoteBlock.tsx +18 -0
  37. package/src/blocks/UnknownBlockView.tsx +27 -0
  38. package/src/blocks/defineBlock.ts +49 -0
  39. package/src/clipboard/html/blocks.ts +379 -0
  40. package/src/clipboard/html/inline.ts +92 -0
  41. package/src/clipboard/html/style.ts +92 -0
  42. package/src/clipboard/html/tags.ts +77 -0
  43. package/src/clipboard/htmlToFragment.ts +41 -0
  44. package/src/clipboard/useClipboard.ts +296 -0
  45. package/src/code/highlight.ts +56 -0
  46. package/src/code/languages.ts +236 -0
  47. package/src/code/scan/css.ts +164 -0
  48. package/src/code/scan/generic.ts +328 -0
  49. package/src/code/scan/html.ts +129 -0
  50. package/src/code/scan/markdown.ts +58 -0
  51. package/src/code/scan/shared.ts +81 -0
  52. package/src/code/scan/yaml.ts +34 -0
  53. package/src/code/types.ts +34 -0
  54. package/src/context/EditorContext.ts +9 -0
  55. package/src/context/EditorProvider.tsx +172 -0
  56. package/src/controllers/adapters.tsx +63 -0
  57. package/src/editing/BlockSelectionController.ts +27 -0
  58. package/src/editing/CaretController.ts +27 -0
  59. package/src/editing/EditorView.ts +108 -0
  60. package/src/editing/ViewportController.ts +63 -0
  61. package/src/editing/beforeInput.ts +276 -0
  62. package/src/editing/deleteAtBoundary.ts +16 -0
  63. package/src/editing/inputActions.ts +109 -0
  64. package/src/editing/renderCode.ts +69 -0
  65. package/src/editing/renderInline.ts +144 -0
  66. package/src/editing/selection.ts +262 -0
  67. package/src/editing/useEditorInput.ts +223 -0
  68. package/src/editing/useSelectionState.ts +46 -0
  69. package/src/formula/FormulaController.ts +51 -0
  70. package/src/formula/FormulaPalette.tsx +79 -0
  71. package/src/formula/FormulaPopover.tsx +181 -0
  72. package/src/formula/LazyFormulaPalette.tsx +42 -0
  73. package/src/formula/actions.ts +138 -0
  74. package/src/formula/snippet.ts +70 -0
  75. package/src/formula/symbols.ts +264 -0
  76. package/src/hooks/useBlock.ts +21 -0
  77. package/src/hooks/useBlockController.ts +30 -0
  78. package/src/hooks/useChildren.ts +16 -0
  79. package/src/hooks/useDocument.ts +16 -0
  80. package/src/hooks/useEditor.ts +13 -0
  81. package/src/hooks/useEditorView.ts +13 -0
  82. package/src/hooks/useHistory.ts +46 -0
  83. package/src/hooks/useReadOnly.ts +21 -0
  84. package/src/hooks/useT.ts +10 -0
  85. package/src/i18n/en.ts +182 -0
  86. package/src/i18n/ru.ts +174 -0
  87. package/src/i18n/translate.ts +77 -0
  88. package/src/icons/ICONS_LICENSE.md +50 -0
  89. package/src/icons/Icon.tsx +41 -0
  90. package/src/icons/lucide.ts +234 -0
  91. package/src/images/ImageError.ts +39 -0
  92. package/src/images/UploadTracker.ts +49 -0
  93. package/src/images/insertImages.ts +142 -0
  94. package/src/images/types.ts +22 -0
  95. package/src/images/upload.ts +62 -0
  96. package/src/index.ts +102 -0
  97. package/src/keymap/defaultKeymap.ts +189 -0
  98. package/src/keymap/keymap.ts +42 -0
  99. package/src/keymap/shortcut.ts +105 -0
  100. package/src/keymap/types.ts +40 -0
  101. package/src/math/FormulaView.tsx +62 -0
  102. package/src/math/mathCss.ts +4 -0
  103. package/src/math/renderer.ts +198 -0
  104. package/src/registry/defaultBlockComponents.ts +26 -0
  105. package/src/registry/resolveBlockComponent.ts +14 -0
  106. package/src/renderer/BlockRenderer.tsx +63 -0
  107. package/src/renderer/DocumentRenderer.tsx +33 -0
  108. package/src/renderer/DocumentView.tsx +94 -0
  109. package/src/renderer/virtual/VirtualChildList.tsx +355 -0
  110. package/src/renderer/virtual/estimate.ts +62 -0
  111. package/src/renderer/virtual/virtualRenderer.tsx +35 -0
  112. package/src/slash/DefaultSlashMenu.tsx +119 -0
  113. package/src/slash/SlashMenuController.ts +66 -0
  114. package/src/slash/SlashMenuHost.tsx +48 -0
  115. package/src/slash/applySlashItem.ts +57 -0
  116. package/src/slash/defaultSlashItems.tsx +186 -0
  117. package/src/slash/filter.ts +29 -0
  118. package/src/slash/items.ts +30 -0
  119. package/src/slash/syncSlashMenu.ts +67 -0
  120. package/src/slash/types.ts +21 -0
  121. package/src/toolbar/DefaultToolbar.tsx +281 -0
  122. package/src/toolbar/ToolbarController.ts +100 -0
  123. package/src/toolbar/ToolbarHost.tsx +81 -0
  124. package/src/toolbar/formatting.ts +75 -0
  125. package/src/toolbar/rich.ts +58 -0
  126. package/src/toolbar/syncToolbar.ts +39 -0
  127. package/src/toolbar/types.ts +21 -0
  128. package/src/ui/EditorUIContext.ts +26 -0
  129. package/src/ui/presentation.ts +24 -0
  130. package/src/ui/types.ts +70 -0
  131. package/README.md +0 -3
@@ -0,0 +1,355 @@
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useMemo,
5
+ useReducer,
6
+ useRef,
7
+ useState,
8
+ useSyncExternalStore,
9
+ type ReactNode,
10
+ } from 'react';
11
+
12
+ import type { BlockId } from '@inkbrick/document';
13
+ import type { Editor } from '@inkbrick/engine';
14
+
15
+ import { useEditor } from '../../hooks/useEditor';
16
+ import { useEditorView } from '../../hooks/useEditorView';
17
+ import { BlockRenderer } from '../BlockRenderer';
18
+ import type { ChildListProps } from '../DocumentRenderer';
19
+ import { estimateSubtree, type BlockHeightEstimate } from './estimate';
20
+
21
+ export interface ResolvedVirtualOptions {
22
+ readonly threshold: number;
23
+ readonly overscan: number;
24
+ readonly maxRendered: number;
25
+ readonly estimateHeight: BlockHeightEstimate;
26
+ }
27
+
28
+ interface Range {
29
+ readonly top: number;
30
+ readonly bottom: number;
31
+ }
32
+
33
+ const SPACER = 'data-inkbrick-spacer';
34
+
35
+ function upperBound(offsets: Float64Array, value: number): number {
36
+ let lo = 0;
37
+ let hi = offsets.length;
38
+
39
+ while (lo < hi) {
40
+ const mid = (lo + hi) >>> 1;
41
+
42
+ if (offsets[mid] <= value) lo = mid + 1;
43
+ else hi = mid;
44
+ }
45
+
46
+ return lo;
47
+ }
48
+
49
+ function scrollParents(element: HTMLElement): HTMLElement[] {
50
+ const result: HTMLElement[] = [];
51
+
52
+ for (
53
+ let node = element.parentElement;
54
+ node && node !== document.body && node !== document.documentElement;
55
+ node = node.parentElement
56
+ ) {
57
+ if (/(auto|scroll|hidden|clip)/.test(getComputedStyle(node).overflowY)) result.push(node);
58
+ }
59
+
60
+ return result;
61
+ }
62
+
63
+ function visibleRange(marker: HTMLElement, parents: readonly HTMLElement[]): Range {
64
+ let top = 0;
65
+ let bottom = window.innerHeight;
66
+
67
+ for (const parent of parents) {
68
+ const rect = parent.getBoundingClientRect();
69
+
70
+ top = Math.max(top, rect.top);
71
+ bottom = Math.min(bottom, rect.bottom);
72
+ }
73
+
74
+ const origin = marker.getBoundingClientRect().top;
75
+
76
+ return { top: top - origin, bottom: Math.max(top, bottom) - origin };
77
+ }
78
+
79
+ function indexOfItem(
80
+ editor: Editor,
81
+ parentId: BlockId,
82
+ ids: readonly BlockId[],
83
+ id: BlockId,
84
+ ): number {
85
+ let block = editor.store.getBlock(id);
86
+
87
+ while (block && block.parentId !== parentId) {
88
+ block = block.parentId ? editor.store.getBlock(block.parentId) : undefined;
89
+ }
90
+
91
+ return block ? ids.indexOf(block.id) : -1;
92
+ }
93
+
94
+ function Spacer({ height }: { readonly height: number }) {
95
+ return (
96
+ <div
97
+ className="inkbrick-spacer"
98
+ data-inkbrick-spacer=""
99
+ aria-hidden="true"
100
+ contentEditable={false}
101
+ style={{ height }}
102
+ />
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Дети контейнера с окном отрисовки: в DOM — блоки около видимой области, первый и последний блок (для Ctrl+A),
108
+ * края выделения, перетаскиваемый блок и запрошенные редактором (`view.viewport`); остальные заменены
109
+ * распорками. Высота отрисованного блока измеряется (вместе с отступом до следующего), неотрисованного — оценивается.
110
+ * Короткие списки (меньше `threshold`) рисуются целиком теми же элементами, поэтому переход порога ничего не
111
+ * пересоздаёт.
112
+ */
113
+ export function VirtualChildList({
114
+ parentId,
115
+ ids,
116
+ options,
117
+ }: ChildListProps & { readonly options: ResolvedVirtualOptions }) {
118
+ const editor = useEditor();
119
+ const view = useEditorView();
120
+ const windowed = ids.length >= options.threshold;
121
+ const marker = useRef<HTMLDivElement>(null);
122
+ const parents = useRef<HTMLElement[]>([]);
123
+ // Измеренные высоты блоков и оценки неотрисованных (оценка не обновляется, пока блок не отрисуют и не измерят).
124
+ const [heights] = useState(() => new Map<BlockId, number>());
125
+ const [estimates] = useState(() => new Map<BlockId, number>());
126
+ const [measured, remeasure] = useReducer((n: number) => n + 1, 0);
127
+ const [range, setRange] = useState<Range>(() => ({
128
+ top: 0,
129
+ bottom: typeof window === 'undefined' ? 1000 : window.innerHeight,
130
+ }));
131
+
132
+ const pins = useSyncExternalStore(
133
+ view.viewport.subscribe,
134
+ view.viewport.getVersion,
135
+ view.viewport.getVersion,
136
+ );
137
+ const dragId = useSyncExternalStore(
138
+ view.blocks.subscribe,
139
+ () => view.blocks.getState().drag?.blockId ?? null,
140
+ () => null,
141
+ );
142
+ const dropId = useSyncExternalStore(
143
+ view.blocks.subscribe,
144
+ () => view.blocks.getState().drag?.targetId ?? null,
145
+ () => null,
146
+ );
147
+
148
+ useEffect(() => (windowed ? view.viewport.attach() : undefined), [view, windowed]);
149
+
150
+ const offsets = useMemo(() => {
151
+ if (!windowed) return null;
152
+
153
+ const result = new Float64Array(ids.length + 1);
154
+ const getBlock = (id: BlockId) => editor.store.getBlock(id);
155
+
156
+ for (let i = 0; i < ids.length; i++) {
157
+ const id = ids[i];
158
+ let height = heights.get(id) ?? estimates.get(id);
159
+
160
+ if (height === undefined) {
161
+ height = estimateSubtree(getBlock, id, options.estimateHeight);
162
+ estimates.set(id, height);
163
+ }
164
+
165
+ result[i + 1] = result[i] + height;
166
+ }
167
+
168
+ return result;
169
+ // measured — версия изменяемой карты heights
170
+ }, [ids, windowed, measured, heights, estimates, editor, options.estimateHeight]);
171
+
172
+ const rendered = useMemo(() => {
173
+ if (!offsets) return null;
174
+
175
+ const last = ids.length - 1;
176
+ const from = Math.max(0, upperBound(offsets, range.top - options.overscan) - 1);
177
+ const to = Math.min(
178
+ last,
179
+ upperBound(offsets, range.bottom + options.overscan) - 1,
180
+ from + options.maxRendered - 1,
181
+ );
182
+ const pinned = new Set<number>([0, last]);
183
+
184
+ for (const id of [...view.viewport.getPinned(), dragId, dropId]) {
185
+ if (id) pinned.add(indexOfItem(editor, parentId, ids, id));
186
+ }
187
+
188
+ pinned.delete(-1);
189
+
190
+ const result: number[] = [];
191
+
192
+ for (const i of [...pinned].sort((a, b) => a - b)) if (i < from) result.push(i);
193
+ for (let i = from; i <= to; i++) result.push(i);
194
+ for (const i of [...pinned].sort((a, b) => a - b)) if (i > to) result.push(i);
195
+
196
+ return result;
197
+ // pins — версия view.viewport: getPinned() меняется вместе с ней
198
+ }, [offsets, ids, range, options, view, editor, parentId, dragId, dropId, pins]);
199
+
200
+ const observer = useRef<ResizeObserver | null>(null);
201
+ const observed = useRef(new Map<Element, number>());
202
+
203
+ useLayoutEffect(() => {
204
+ if (!windowed || !rendered || !marker.current) return;
205
+
206
+ const seen = new Set<Element>();
207
+ const expected = new Set(rendered.map((i) => ids[i]));
208
+ let changed = false;
209
+ let previous: { id: BlockId; top: number; height: number } | null = null;
210
+ let remaining = rendered.length;
211
+
212
+ const settle = (height: number) => {
213
+ if (!previous) return;
214
+ // Элемент без раскладки (скрыт, jsdom) не измеряем: остаётся оценка.
215
+ if (height > 0 && Math.abs((heights.get(previous.id) ?? -1) - height) > 0.5) {
216
+ heights.set(previous.id, height);
217
+ changed = true;
218
+ }
219
+ previous = null;
220
+ };
221
+
222
+ for (
223
+ let element = marker.current.nextElementSibling;
224
+ element && remaining > 0;
225
+ element = element.nextElementSibling
226
+ ) {
227
+ const rect = element.getBoundingClientRect();
228
+
229
+ if (previous) settle(rect.top - previous.top);
230
+ if (element.hasAttribute(SPACER)) continue;
231
+
232
+ remaining -= 1;
233
+
234
+ const id = element.getAttribute('data-block-id');
235
+
236
+ if (id && expected.has(id)) {
237
+ previous = { id, top: rect.top, height: rect.height };
238
+ seen.add(element);
239
+ }
240
+ }
241
+
242
+ if (previous) settle((previous as { height: number }).height);
243
+
244
+ if (typeof ResizeObserver !== 'undefined') {
245
+ const sizes = observed.current;
246
+
247
+ observer.current ??= new ResizeObserver((entries) => {
248
+ let resized = false;
249
+
250
+ for (const entry of entries) {
251
+ const size = entry.contentRect.height;
252
+ const known = sizes.get(entry.target);
253
+
254
+ // Первое уведомление приходит сразу после observe — это не изменение.
255
+ if (known !== undefined && Math.abs(known - size) > 0.5) resized = true;
256
+ sizes.set(entry.target, size);
257
+ }
258
+
259
+ if (resized) remeasure();
260
+ });
261
+
262
+ for (const element of [...sizes.keys()]) {
263
+ if (!seen.has(element)) {
264
+ observer.current.unobserve(element);
265
+ sizes.delete(element);
266
+ }
267
+ }
268
+
269
+ for (const element of seen) {
270
+ if (!sizes.has(element)) observer.current.observe(element);
271
+ }
272
+ }
273
+
274
+ const next = visibleRange(marker.current, parents.current);
275
+ const step = options.overscan / 4;
276
+
277
+ setRange((current) =>
278
+ Math.abs(current.top - next.top) < step && Math.abs(current.bottom - next.bottom) < step
279
+ ? current
280
+ : next,
281
+ );
282
+
283
+ if (changed) remeasure();
284
+ });
285
+
286
+ useEffect(() => () => observer.current?.disconnect(), []);
287
+
288
+ useEffect(() => {
289
+ if (!windowed || !marker.current) return;
290
+
291
+ parents.current = scrollParents(marker.current);
292
+
293
+ let frame = 0;
294
+ const step = options.overscan / 4;
295
+
296
+ const onScroll = () => {
297
+ if (frame) return;
298
+
299
+ frame = requestAnimationFrame(() => {
300
+ frame = 0;
301
+ if (!marker.current) return;
302
+
303
+ const next = visibleRange(marker.current, parents.current);
304
+
305
+ setRange((current) =>
306
+ Math.abs(current.top - next.top) < step && Math.abs(current.bottom - next.bottom) < step
307
+ ? current
308
+ : next,
309
+ );
310
+ });
311
+ };
312
+
313
+ window.addEventListener('scroll', onScroll, { capture: true, passive: true });
314
+ window.addEventListener('resize', onScroll);
315
+
316
+ return () => {
317
+ cancelAnimationFrame(frame);
318
+ window.removeEventListener('scroll', onScroll, { capture: true });
319
+ window.removeEventListener('resize', onScroll);
320
+ };
321
+ }, [windowed, options.overscan]);
322
+
323
+ if (!rendered || !offsets) {
324
+ return (
325
+ <>
326
+ {null}
327
+ {ids.map((id) => (
328
+ <BlockRenderer key={id} id={id} />
329
+ ))}
330
+ </>
331
+ );
332
+ }
333
+
334
+ const items: ReactNode[] = [];
335
+ let next = 0;
336
+
337
+ for (const i of rendered) {
338
+ if (i > next) items.push(<Spacer key={`gap:${next}`} height={offsets[i] - offsets[next]} />);
339
+ items.push(<BlockRenderer key={ids[i]} id={ids[i]} />);
340
+ next = i + 1;
341
+ }
342
+
343
+ return (
344
+ <>
345
+ <div
346
+ ref={marker}
347
+ className="inkbrick-spacer"
348
+ data-inkbrick-spacer="start"
349
+ aria-hidden="true"
350
+ contentEditable={false}
351
+ />
352
+ {items}
353
+ </>
354
+ );
355
+ }
@@ -0,0 +1,62 @@
1
+ import type { BaseBlock, BlockId } from '@inkbrick/document';
2
+
3
+ export type BlockHeightEstimate = (block: BaseBlock) => number;
4
+
5
+ const CHARS_PER_LINE = 80;
6
+
7
+ function textLength(block: BaseBlock): number {
8
+ let length = 0;
9
+
10
+ for (const node of block.content) length += node.insert.length;
11
+ return length;
12
+ }
13
+
14
+ /**
15
+ * Оценка по умолчанию под стандартные стили: строки текста × высота строки + отступы блока.
16
+ * Ошибка оценки сказывается только на длине полосы прокрутки: отрисованный блок измеряется точно.
17
+ */
18
+ export const estimateBlockHeight: BlockHeightEstimate = (block) => {
19
+ const lines = Math.max(1, Math.ceil(textLength(block) / CHARS_PER_LINE));
20
+
21
+ switch (block.flavour) {
22
+ case 'block:heading':
23
+ return 20 + 34 * lines;
24
+ case 'block:code': {
25
+ let breaks = 0;
26
+
27
+ for (const node of block.content)
28
+ for (const char of node.insert) if (char === '\n') breaks += 1;
29
+
30
+ return 40 + 21 * (breaks + 1);
31
+ }
32
+ case 'block:image':
33
+ return 260;
34
+ case 'block:divider':
35
+ return 25;
36
+ case 'block:formula':
37
+ return 64;
38
+ case 'block:callout':
39
+ return 24 + 26 * lines;
40
+ default:
41
+ return 8 + 26 * lines;
42
+ }
43
+ };
44
+
45
+ export function estimateSubtree(
46
+ getBlock: (id: BlockId) => BaseBlock | undefined,
47
+ id: BlockId,
48
+ estimate: BlockHeightEstimate,
49
+ ): number {
50
+ let total = 0;
51
+ const queue: BlockId[] = [id];
52
+
53
+ for (let i = 0; i < queue.length; i++) {
54
+ const block = getBlock(queue[i]);
55
+
56
+ if (!block) continue;
57
+ total += estimate(block);
58
+ for (const child of block.children) queue.push(child);
59
+ }
60
+
61
+ return total;
62
+ }
@@ -0,0 +1,35 @@
1
+ import type { ChildListProps, DocumentRenderer } from '../DocumentRenderer';
2
+ import { estimateBlockHeight, type BlockHeightEstimate } from './estimate';
3
+ import { VirtualChildList, type ResolvedVirtualOptions } from './VirtualChildList';
4
+
5
+ export interface VirtualRendererOptions {
6
+ /** Контейнер с меньшим числом детей рисуется целиком. По умолчанию 100. */
7
+ readonly threshold?: number;
8
+ /** Запас над и под видимой областью, в котором блоки уже отрисованы, px. По умолчанию 1200. */
9
+ readonly overscan?: number;
10
+ /** Не больше стольких блоков подряд в окне (защита от нулевых высот). По умолчанию 400. */
11
+ readonly maxRendered?: number;
12
+ readonly estimateHeight?: BlockHeightEstimate;
13
+ }
14
+
15
+ /**
16
+ * Виртуальный рендерер: в DOM только блоки около видимой области, остальные заменены распорками известной
17
+ * (измеренной) или оценённой высоты. Подключение: `<DocumentView renderer={virtualRenderer()} />` (или
18
+ * `renderer="virtual"` с настройками по умолчанию). Создавайте рендерер один раз, вне компонента.
19
+ */
20
+ export function virtualRenderer(options: VirtualRendererOptions = {}): DocumentRenderer {
21
+ const resolved: ResolvedVirtualOptions = {
22
+ threshold: Math.max(1, options.threshold ?? 100),
23
+ overscan: Math.max(0, options.overscan ?? 1200),
24
+ maxRendered: Math.max(1, options.maxRendered ?? 400),
25
+ estimateHeight: options.estimateHeight ?? estimateBlockHeight,
26
+ };
27
+
28
+ function VirtualChildren(props: ChildListProps) {
29
+ return <VirtualChildList {...props} options={resolved} />;
30
+ }
31
+
32
+ return { ChildList: VirtualChildren };
33
+ }
34
+
35
+ export const defaultVirtualRenderer = virtualRenderer();
@@ -0,0 +1,119 @@
1
+ import { useId, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ import { useEditorView } from '../hooks/useEditorView';
4
+ import { useT } from '../hooks/useT';
5
+ import type { SlashMenuProps } from './types';
6
+
7
+ const ESTIMATED_LINE = 28;
8
+
9
+ export function DefaultSlashMenu({
10
+ items,
11
+ activeIndex,
12
+ position,
13
+ onSelect,
14
+ onHover,
15
+ }: SlashMenuProps) {
16
+ const t = useT();
17
+ const view = useEditorView();
18
+ const ref = useRef<HTMLDivElement>(null);
19
+ const [flip, setFlip] = useState(false);
20
+ const menuId = useId();
21
+ const optionId = (index: number) => `${menuId}-option-${index}`;
22
+
23
+ useLayoutEffect(() => {
24
+ const menu = ref.current;
25
+
26
+ if (!menu) return;
27
+
28
+ setFlip(
29
+ position.y + menu.offsetHeight > window.innerHeight &&
30
+ position.y - menu.offsetHeight - ESTIMATED_LINE > 0,
31
+ );
32
+ }, [position.x, position.y, items.length]);
33
+
34
+ useLayoutEffect(() => {
35
+ ref.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' });
36
+ }, [activeIndex, items]);
37
+
38
+ /*
39
+ * Фокус остаётся в тексте документа, а меню управляется с клавиатуры редактором (паттерн combobox ARIA):
40
+ * корень документа ссылается на меню и на выбранный пункт — так экранный чтец озвучивает выбор.
41
+ */
42
+ useLayoutEffect(() => {
43
+ const root = view.getRoot();
44
+
45
+ if (!root) return;
46
+
47
+ root.setAttribute('aria-controls', menuId);
48
+
49
+ if (items.length > 0) root.setAttribute('aria-activedescendant', optionId(activeIndex));
50
+ else root.removeAttribute('aria-activedescendant');
51
+
52
+ return () => {
53
+ root.removeAttribute('aria-controls');
54
+ root.removeAttribute('aria-activedescendant');
55
+ };
56
+ }, [view, menuId, activeIndex, items.length]);
57
+
58
+ return (
59
+ <div
60
+ ref={ref}
61
+ id={menuId}
62
+ className="inkbrick-slash-menu"
63
+ data-inkbrick-ui="slash-menu"
64
+ /*
65
+ * Прокручиваемая область должна быть доступна с клавиатуры. До меню фокус Tab-ом не доходит: в открытом
66
+ * меню Tab выбирает пункт (его перехватывает редактор), а клик по меню не уводит фокус из текста.
67
+ */
68
+ tabIndex={0}
69
+ role="listbox"
70
+ aria-label={t('slash.label')}
71
+ data-testid="slash-menu"
72
+ style={{
73
+ left: Math.max(
74
+ 8,
75
+ Math.min(position.x, window.innerWidth - (ref.current?.offsetWidth ?? 320) - 8),
76
+ ),
77
+ top: flip ? position.y - (ref.current?.offsetHeight ?? 0) - ESTIMATED_LINE : position.y + 4,
78
+ }}
79
+ onMouseDown={(event) => event.preventDefault()}
80
+ >
81
+ {items.length === 0 && (
82
+ <div className="inkbrick-slash-menu__description" data-inkbrick-part="empty">
83
+ {t('slash.empty')}
84
+ </div>
85
+ )}
86
+
87
+ {items.map((item, index) => (
88
+ <div
89
+ key={item.id}
90
+ id={optionId(index)}
91
+ role="option"
92
+ aria-selected={index === activeIndex}
93
+ data-slash-item={item.id}
94
+ data-inkbrick-part="item"
95
+ data-active={index === activeIndex || undefined}
96
+ className={`inkbrick-slash-menu__item${index === activeIndex ? ' inkbrick-slash-menu__item--selected' : ''}`}
97
+ onMouseEnter={() => onHover(index)}
98
+ onClick={() => onSelect(item)}
99
+ >
100
+ {item.icon !== undefined && (
101
+ <span className="inkbrick-slash-menu__icon" data-inkbrick-part="icon">
102
+ {item.icon}
103
+ </span>
104
+ )}
105
+ <div className="inkbrick-slash-menu__body" data-inkbrick-part="body">
106
+ <div className="inkbrick-slash-menu__header" data-inkbrick-part="title">
107
+ {item.title}
108
+ </div>
109
+ {item.description && (
110
+ <div className="inkbrick-slash-menu__description" data-inkbrick-part="description">
111
+ {item.description}
112
+ </div>
113
+ )}
114
+ </div>
115
+ </div>
116
+ ))}
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,66 @@
1
+ export interface SlashMenuState {
2
+ readonly open: boolean;
3
+ readonly blockId: string | null;
4
+ readonly slashOffset: number;
5
+ readonly query: string;
6
+ readonly activeIndex: number;
7
+ readonly position: { readonly x: number; readonly y: number } | null;
8
+ }
9
+
10
+ const CLOSED: SlashMenuState = Object.freeze({
11
+ open: false,
12
+ blockId: null,
13
+ slashOffset: 0,
14
+ query: '',
15
+ activeIndex: 0,
16
+ position: null,
17
+ });
18
+
19
+ export class SlashMenuController {
20
+ private state: SlashMenuState = CLOSED;
21
+
22
+ private readonly listeners = new Set<() => void>();
23
+
24
+ getState = (): SlashMenuState => this.state;
25
+
26
+ subscribe = (listener: () => void): (() => void) => {
27
+ this.listeners.add(listener);
28
+
29
+ return () => {
30
+ this.listeners.delete(listener);
31
+ };
32
+ };
33
+
34
+ open(blockId: string, slashOffset: number): void {
35
+ this.set({ ...CLOSED, open: true, blockId, slashOffset });
36
+ }
37
+
38
+ close(): void {
39
+ if (this.state.open) this.set(CLOSED);
40
+ }
41
+
42
+ update(patch: Partial<Pick<SlashMenuState, 'query' | 'activeIndex' | 'position'>>): void {
43
+ if (!this.state.open) return;
44
+
45
+ const next = { ...this.state, ...patch };
46
+ const same =
47
+ next.query === this.state.query &&
48
+ next.activeIndex === this.state.activeIndex &&
49
+ next.position?.x === this.state.position?.x &&
50
+ next.position?.y === this.state.position?.y;
51
+
52
+ if (!same) this.set(next);
53
+ }
54
+
55
+ move(delta: number, count: number): void {
56
+ if (!this.state.open || count === 0) return;
57
+
58
+ this.update({ activeIndex: (this.state.activeIndex + delta + count) % count });
59
+ }
60
+
61
+ private set(state: SlashMenuState): void {
62
+ this.state = state;
63
+
64
+ for (const listener of [...this.listeners]) listener();
65
+ }
66
+ }
@@ -0,0 +1,48 @@
1
+ import { createPortal } from 'react-dom';
2
+ import { useSyncExternalStore } from 'react';
3
+
4
+ import { useEditor } from '../hooks/useEditor';
5
+ import { useEditorView } from '../hooks/useEditorView';
6
+ import { useReadOnly } from '../hooks/useReadOnly';
7
+ import { useEditorUI } from '../ui/EditorUIContext';
8
+ import { applySlashItem } from './applySlashItem';
9
+ import { DefaultSlashMenu } from './DefaultSlashMenu';
10
+ import { visibleSlashItems } from './items';
11
+
12
+ export function SlashMenuHost() {
13
+ const editor = useEditor();
14
+ const view = useEditorView();
15
+ const { slots, slashItems } = useEditorUI();
16
+ const readOnly = useReadOnly();
17
+ const state = useSyncExternalStore(
18
+ view.slash.subscribe,
19
+ view.slash.getState,
20
+ view.slash.getState,
21
+ );
22
+
23
+ if (
24
+ readOnly ||
25
+ !state.open ||
26
+ !state.blockId ||
27
+ !state.position ||
28
+ typeof document === 'undefined'
29
+ ) {
30
+ return null;
31
+ }
32
+
33
+ const items = visibleSlashItems(editor, slashItems, state.blockId, state.query);
34
+ const Menu = slots.SlashMenu ?? DefaultSlashMenu;
35
+
36
+ return createPortal(
37
+ <Menu
38
+ items={items}
39
+ query={state.query}
40
+ activeIndex={Math.min(state.activeIndex, Math.max(items.length - 1, 0))}
41
+ position={state.position}
42
+ onSelect={(item) => applySlashItem(editor, view, item)}
43
+ onHover={(index) => view.slash.update({ activeIndex: index })}
44
+ onClose={() => view.slash.close()}
45
+ />,
46
+ document.body,
47
+ );
48
+ }