@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,262 @@
|
|
|
1
|
+
export interface DocPoint {
|
|
2
|
+
blockId: string;
|
|
3
|
+
offset: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface DocSelection {
|
|
7
|
+
from: DocPoint;
|
|
8
|
+
to: DocPoint;
|
|
9
|
+
collapsed: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const EDITABLE = '.editable[data-id]';
|
|
13
|
+
/** Блоки без текста (картинка, разделитель, неизвестный блок) — нередактируемые «острова» со своим id. */
|
|
14
|
+
const ISLAND = '[data-block-id][contenteditable="false"]';
|
|
15
|
+
|
|
16
|
+
export function getOffsetIn(editable: HTMLElement, node: Node, offset: number): number {
|
|
17
|
+
const range = globalThis.document.createRange();
|
|
18
|
+
|
|
19
|
+
range.selectNodeContents(editable);
|
|
20
|
+
range.setEnd(node, offset);
|
|
21
|
+
|
|
22
|
+
return range.toString().length;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function editableOf(node: Node): HTMLElement | null {
|
|
26
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
|
27
|
+
|
|
28
|
+
return element?.closest<HTMLElement>(EDITABLE) ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function textLength(editable: HTMLElement): number {
|
|
32
|
+
return editable.textContent?.length ?? 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function leafPoint(el: HTMLElement, where: 'start' | 'end'): DocPoint {
|
|
36
|
+
return el.matches(ISLAND)
|
|
37
|
+
? { blockId: el.getAttribute('data-block-id')!, offset: 0 }
|
|
38
|
+
: { blockId: el.dataset.id!, offset: where === 'start' ? 0 : textLength(el) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Точка вне текстовых блоков (на контейнере, у маркера списка и т.п.) переводится
|
|
43
|
+
* в ближайшую позицию текста: вперёд — в начало следующего блока, назад — в конец предыдущего.
|
|
44
|
+
*/
|
|
45
|
+
function resolveContainerPoint(
|
|
46
|
+
root: HTMLElement,
|
|
47
|
+
node: Node,
|
|
48
|
+
offset: number,
|
|
49
|
+
bias: 'forward' | 'backward',
|
|
50
|
+
includeIslands: boolean,
|
|
51
|
+
): DocPoint | null {
|
|
52
|
+
/*
|
|
53
|
+
* Граница непустого выделения может приходиться на блок без текста: иначе картинку или разделитель
|
|
54
|
+
* в начале/конце документа нельзя было бы выделить, скопировать и удалить. Курсор (свёрнутое выделение) —
|
|
55
|
+
* всегда в текст.
|
|
56
|
+
*/
|
|
57
|
+
const editables = [
|
|
58
|
+
...root.querySelectorAll<HTMLElement>(includeIslands ? `${EDITABLE}, ${ISLAND}` : EDITABLE),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
if (editables.length === 0) return null;
|
|
62
|
+
|
|
63
|
+
const probe = globalThis.document.createRange();
|
|
64
|
+
probe.setStart(node, offset);
|
|
65
|
+
probe.collapse(true);
|
|
66
|
+
|
|
67
|
+
let previous: HTMLElement | null = null;
|
|
68
|
+
|
|
69
|
+
for (const editable of editables) {
|
|
70
|
+
if (probe.comparePoint(editable, 0) >= 0) {
|
|
71
|
+
if (bias === 'forward') return leafPoint(editable, 'start');
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
previous = editable;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (bias === 'backward')
|
|
79
|
+
return previous ? leafPoint(previous, 'end') : leafPoint(editables[0], 'start');
|
|
80
|
+
|
|
81
|
+
return leafPoint(editables[editables.length - 1], 'end');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolvePoint(
|
|
85
|
+
root: HTMLElement,
|
|
86
|
+
node: Node,
|
|
87
|
+
offset: number,
|
|
88
|
+
bias: 'forward' | 'backward',
|
|
89
|
+
includeIslands: boolean,
|
|
90
|
+
): DocPoint | null {
|
|
91
|
+
const editable = editableOf(node);
|
|
92
|
+
|
|
93
|
+
if (editable && root.contains(editable)) {
|
|
94
|
+
return { blockId: editable.dataset.id!, offset: getOffsetIn(editable, node, offset) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return resolveContainerPoint(root, node, offset, bias, includeIslands);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function readDocSelection(root: HTMLElement): DocSelection | null {
|
|
101
|
+
const selection = globalThis.getSelection();
|
|
102
|
+
|
|
103
|
+
if (!selection || selection.rangeCount === 0) return null;
|
|
104
|
+
|
|
105
|
+
const range = selection.getRangeAt(0);
|
|
106
|
+
|
|
107
|
+
if (!root.contains(range.commonAncestorContainer)) return null;
|
|
108
|
+
|
|
109
|
+
const from = resolvePoint(
|
|
110
|
+
root,
|
|
111
|
+
range.startContainer,
|
|
112
|
+
range.startOffset,
|
|
113
|
+
'forward',
|
|
114
|
+
!range.collapsed,
|
|
115
|
+
);
|
|
116
|
+
let to = range.collapsed
|
|
117
|
+
? from
|
|
118
|
+
: resolvePoint(root, range.endContainer, range.endOffset, 'backward', true);
|
|
119
|
+
|
|
120
|
+
if (!from || !to) return null;
|
|
121
|
+
|
|
122
|
+
// Выделение «всего документа» (Ctrl+A) включает и крайние блоки без текста.
|
|
123
|
+
const remembered = fullSelections.get(root);
|
|
124
|
+
|
|
125
|
+
if (remembered && !range.collapsed && sameRange(remembered, range)) {
|
|
126
|
+
const leaves = root.querySelectorAll<HTMLElement>(`${EDITABLE}, ${ISLAND}`);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
from: leafPoint(leaves[0], 'start'),
|
|
130
|
+
to: leafPoint(leaves[leaves.length - 1], 'end'),
|
|
131
|
+
collapsed: false,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Выделение целиком вне текста могло «вывернуться» (from позже to) — считаем его курсором.
|
|
136
|
+
if (
|
|
137
|
+
from.blockId === to.blockId ? from.offset > to.offset : isAfter(root, from.blockId, to.blockId)
|
|
138
|
+
) {
|
|
139
|
+
to = from;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
from,
|
|
144
|
+
to,
|
|
145
|
+
collapsed: from.blockId === to.blockId && from.offset === to.offset,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isAfter(root: HTMLElement, a: string, b: string): boolean {
|
|
150
|
+
const elementA = root.querySelector(`.editable[data-id="${CSS.escape(a)}"]`);
|
|
151
|
+
const elementB = root.querySelector(`.editable[data-id="${CSS.escape(b)}"]`);
|
|
152
|
+
|
|
153
|
+
if (!elementA || !elementB) return false;
|
|
154
|
+
|
|
155
|
+
return !!(elementA.compareDocumentPosition(elementB) & Node.DOCUMENT_POSITION_PRECEDING);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function outsideFormula(node: Node, offset: number): { node: Node; offset: number } {
|
|
159
|
+
const host = node.parentElement?.closest('[data-inkbrick-formula]');
|
|
160
|
+
const parent = host?.parentNode;
|
|
161
|
+
|
|
162
|
+
if (!host || !parent) return { node, offset };
|
|
163
|
+
|
|
164
|
+
const index = Array.prototype.indexOf.call(parent.childNodes, host) as number;
|
|
165
|
+
|
|
166
|
+
return { node: parent, offset: offset === 0 ? index : index + 1 };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function findPoint(editable: HTMLElement, offset: number): { node: Node; offset: number } | null {
|
|
170
|
+
const walker = globalThis.document.createTreeWalker(editable, NodeFilter.SHOW_TEXT);
|
|
171
|
+
|
|
172
|
+
let remaining = offset;
|
|
173
|
+
let node = walker.nextNode();
|
|
174
|
+
|
|
175
|
+
while (node) {
|
|
176
|
+
const length = node.textContent?.length ?? 0;
|
|
177
|
+
|
|
178
|
+
if (remaining <= length) {
|
|
179
|
+
return outsideFormula(node, remaining);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
remaining -= length;
|
|
183
|
+
node = walker.nextNode();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Выделения, которые установил `selectAllText` (по одному на корень). Пока выделение браузера совпадает с запомненным,
|
|
191
|
+
* оно считается выделением всего документа — вместе с блоками без текста в начале и в конце, которые
|
|
192
|
+
* DOM-выделением не охватить: браузер сводит концы выделения к допустимым позициям каретки.
|
|
193
|
+
*/
|
|
194
|
+
const fullSelections = new WeakMap<HTMLElement, Range>();
|
|
195
|
+
|
|
196
|
+
function sameRange(a: Range, b: Range): boolean {
|
|
197
|
+
return (
|
|
198
|
+
a.startContainer === b.startContainer &&
|
|
199
|
+
a.startOffset === b.startOffset &&
|
|
200
|
+
a.endContainer === b.endContainer &&
|
|
201
|
+
a.endOffset === b.endOffset
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Выделяет весь документ (Ctrl/Cmd+A), включая блоки без текста. Нативный Ctrl+A схлопывается в каретку,
|
|
207
|
+
* если документ начинается с нередактируемого элемента (значок callout, маркер списка), поэтому выделение
|
|
208
|
+
* строим сами. false — в документе нечего выделять.
|
|
209
|
+
*/
|
|
210
|
+
export function selectAllText(root: HTMLElement): boolean {
|
|
211
|
+
const editables = root.querySelectorAll<HTMLElement>(EDITABLE);
|
|
212
|
+
|
|
213
|
+
if (editables.length === 0) {
|
|
214
|
+
if (!root.querySelector(ISLAND)) return false;
|
|
215
|
+
|
|
216
|
+
const selection = globalThis.getSelection();
|
|
217
|
+
const range = globalThis.document.createRange();
|
|
218
|
+
|
|
219
|
+
range.selectNodeContents(root);
|
|
220
|
+
selection?.removeAllRanges();
|
|
221
|
+
selection?.addRange(range);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
setDomSelection(
|
|
226
|
+
editables[0],
|
|
227
|
+
0,
|
|
228
|
+
editables[editables.length - 1],
|
|
229
|
+
textLength(editables[editables.length - 1]),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const selection = globalThis.getSelection();
|
|
233
|
+
|
|
234
|
+
if (selection && selection.rangeCount > 0)
|
|
235
|
+
fullSelections.set(root, selection.getRangeAt(0).cloneRange());
|
|
236
|
+
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function setDomSelection(
|
|
241
|
+
start: HTMLElement,
|
|
242
|
+
startOffset: number,
|
|
243
|
+
end: HTMLElement = start,
|
|
244
|
+
endOffset: number = startOffset,
|
|
245
|
+
): void {
|
|
246
|
+
const selection = globalThis.getSelection();
|
|
247
|
+
|
|
248
|
+
if (!selection) return;
|
|
249
|
+
|
|
250
|
+
const range = globalThis.document.createRange();
|
|
251
|
+
const from = findPoint(start, startOffset);
|
|
252
|
+
const to = start === end && startOffset === endOffset ? from : findPoint(end, endOffset);
|
|
253
|
+
|
|
254
|
+
if (from) range.setStart(from.node, from.offset);
|
|
255
|
+
else range.setStart(start, 0);
|
|
256
|
+
|
|
257
|
+
if (to) range.setEnd(to.node, to.offset);
|
|
258
|
+
else range.setEnd(end, 0);
|
|
259
|
+
|
|
260
|
+
selection.removeAllRanges();
|
|
261
|
+
selection.addRange(range);
|
|
262
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { CaretResult } from '@inkbrick/engine';
|
|
4
|
+
|
|
5
|
+
import { useEditor } from '../hooks/useEditor';
|
|
6
|
+
import { useEditorView } from '../hooks/useEditorView';
|
|
7
|
+
import { syncToolbar } from '../toolbar/syncToolbar';
|
|
8
|
+
import { openFormulaAt } from '../formula/actions';
|
|
9
|
+
import { handleKeyDown } from '../keymap/keymap';
|
|
10
|
+
import { useEditorUI } from '../ui/EditorUIContext';
|
|
11
|
+
import { applyBeforeInput } from './beforeInput';
|
|
12
|
+
import { createInputActions } from './inputActions';
|
|
13
|
+
import { readDocSelection } from './selection';
|
|
14
|
+
|
|
15
|
+
const NAVIGATION_KEYS = new Set([
|
|
16
|
+
'ArrowLeft',
|
|
17
|
+
'ArrowRight',
|
|
18
|
+
'ArrowUp',
|
|
19
|
+
'ArrowDown',
|
|
20
|
+
'Home',
|
|
21
|
+
'End',
|
|
22
|
+
'PageUp',
|
|
23
|
+
'PageDown',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function diffText(before: string, after: string) {
|
|
27
|
+
let start = 0;
|
|
28
|
+
while (start < before.length && start < after.length && before[start] === after[start]) start++;
|
|
29
|
+
|
|
30
|
+
let endBefore = before.length;
|
|
31
|
+
let endAfter = after.length;
|
|
32
|
+
while (endBefore > start && endAfter > start && before[endBefore - 1] === after[endAfter - 1]) {
|
|
33
|
+
endBefore--;
|
|
34
|
+
endAfter--;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { from: start, to: endBefore, text: after.slice(start, endAfter) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** События из полей ввода и кнопок внутри пользовательских компонентов редактор не перехватывает. */
|
|
41
|
+
function isForeignControl(target: EventTarget | null): boolean {
|
|
42
|
+
return (
|
|
43
|
+
target instanceof Element &&
|
|
44
|
+
target.closest('input, textarea, select, button, [data-inkbrick-ignore-input]') !== null
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Подключает ввод к корню документа. Все изменения текста перехватываются на beforeinput,
|
|
50
|
+
* превращаются в intent'ы движка, а выделение возвращается после перерисовки.
|
|
51
|
+
* Выделение может охватывать несколько блоков.
|
|
52
|
+
*/
|
|
53
|
+
export function useEditorInput(ref: RefObject<HTMLElement | null>, enabled = true) {
|
|
54
|
+
const editor = useEditor();
|
|
55
|
+
const view = useEditorView();
|
|
56
|
+
const { slashItems, keymap } = useEditorUI();
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const root = ref.current;
|
|
60
|
+
|
|
61
|
+
if (!root || !enabled) return;
|
|
62
|
+
|
|
63
|
+
let composing = false;
|
|
64
|
+
|
|
65
|
+
const actions = createInputActions(editor, view, root, slashItems);
|
|
66
|
+
const { exec, run, syncSlash, blockText, replaceSelection, toggleFormat, undo, redo } = actions;
|
|
67
|
+
|
|
68
|
+
const onBeforeInput = (event: InputEvent) => {
|
|
69
|
+
if (isForeignControl(event.target)) return;
|
|
70
|
+
|
|
71
|
+
// Во время IME-композиции браузер сам меняет DOM; модель синхронизируется на compositionend.
|
|
72
|
+
if (composing || event.isComposing) return;
|
|
73
|
+
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
|
|
76
|
+
applyBeforeInput(event, root, editor, view, slashItems, actions);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
80
|
+
if (isForeignControl(event.target)) return;
|
|
81
|
+
|
|
82
|
+
const handled = handleKeyDown(keymap, {
|
|
83
|
+
editor,
|
|
84
|
+
view,
|
|
85
|
+
root,
|
|
86
|
+
event,
|
|
87
|
+
slashItems,
|
|
88
|
+
selection: () => readDocSelection(root),
|
|
89
|
+
exec,
|
|
90
|
+
run,
|
|
91
|
+
replaceSelection,
|
|
92
|
+
undo,
|
|
93
|
+
redo,
|
|
94
|
+
toggleFormat,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Перемещение курсора — граница шага undo: следующий набор текста начнёт новый шаг.
|
|
98
|
+
if (!handled && NAVIGATION_KEYS.has(event.key)) editor.sealHistory();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Блок, в котором стоит каретка. Цель событий композиции — корень (contenteditable), а не блок. */
|
|
102
|
+
const editableAtCaret = () => {
|
|
103
|
+
const node = document.getSelection()?.anchorNode ?? null;
|
|
104
|
+
const element = node instanceof Element ? node : node?.parentElement;
|
|
105
|
+
const editable = element?.closest<HTMLElement>('.editable[data-id]');
|
|
106
|
+
|
|
107
|
+
return editable && root.contains(editable) ? editable : null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Блок, где началась композиция: к её концу каретка у некоторых клавиатур уже может быть в другом месте.
|
|
111
|
+
let compositionBlock: HTMLElement | null = null;
|
|
112
|
+
|
|
113
|
+
const onCompositionStart = () => {
|
|
114
|
+
composing = true;
|
|
115
|
+
compositionBlock = editableAtCaret();
|
|
116
|
+
// Набираемое слово ещё не в модели, и блок считается пустым: атрибут прячет подсказку поверх текста.
|
|
117
|
+
compositionBlock?.setAttribute('data-composing', '');
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* Клавиатуры телефонов набирают почти всё композицией: слово живёт только в DOM до compositionend,
|
|
122
|
+
* поэтому здесь его нужно обязательно перенести в модель — иначе следующая перерисовка его сотрёт.
|
|
123
|
+
*/
|
|
124
|
+
const onCompositionEnd = () => {
|
|
125
|
+
composing = false;
|
|
126
|
+
|
|
127
|
+
const started = compositionBlock?.isConnected ? compositionBlock : null;
|
|
128
|
+
const element = started ?? editableAtCaret();
|
|
129
|
+
const blockId = element?.dataset.id;
|
|
130
|
+
|
|
131
|
+
compositionBlock?.removeAttribute('data-composing');
|
|
132
|
+
compositionBlock = null;
|
|
133
|
+
|
|
134
|
+
if (!element || !blockId) return;
|
|
135
|
+
|
|
136
|
+
const { from, to, text } = diffText(blockText(blockId), element.textContent ?? '');
|
|
137
|
+
|
|
138
|
+
if (from === to && text === '') return;
|
|
139
|
+
|
|
140
|
+
run(() => exec<CaretResult>({ type: 'replaceText', payload: { blockId, from, to, text } }));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const prevent = (event: Event) => event.preventDefault();
|
|
144
|
+
const sealHistory = () => editor.sealHistory();
|
|
145
|
+
const closeSlash = () => view.slash.close();
|
|
146
|
+
const onPointerDown = () => {
|
|
147
|
+
view.pointerDown = true;
|
|
148
|
+
view.toolbar.closeLink();
|
|
149
|
+
view.toolbar.hide();
|
|
150
|
+
};
|
|
151
|
+
const onPointerUp = () => {
|
|
152
|
+
if (!view.pointerDown) return;
|
|
153
|
+
|
|
154
|
+
view.pointerDown = false;
|
|
155
|
+
// Выделение окончательно установится после mouseup.
|
|
156
|
+
setTimeout(() => syncToolbar(editor, view), 0);
|
|
157
|
+
};
|
|
158
|
+
const onClick = (event: MouseEvent) => {
|
|
159
|
+
const formula =
|
|
160
|
+
event.target instanceof Element ? event.target.closest('[data-inkbrick-formula]') : null;
|
|
161
|
+
|
|
162
|
+
if (formula && root.contains(formula)) {
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
openFormulaAt(view, formula);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!(event.ctrlKey || event.metaKey) || !(event.target instanceof Element)) return;
|
|
169
|
+
|
|
170
|
+
const anchor = event.target.closest<HTMLAnchorElement>('a[href]');
|
|
171
|
+
|
|
172
|
+
if (anchor && root.contains(anchor)) {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
window.open(anchor.href, '_blank', 'noopener,noreferrer');
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const onSelectionChange = () => {
|
|
178
|
+
if (view.slash.getState().open) syncSlash();
|
|
179
|
+
syncToolbar(editor, view);
|
|
180
|
+
};
|
|
181
|
+
const syncIfOpen = () => {
|
|
182
|
+
if (view.slash.getState().open) syncSlash();
|
|
183
|
+
if (view.toolbar.getState().visible) syncToolbar(editor, view);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
root.addEventListener('beforeinput', onBeforeInput);
|
|
187
|
+
root.addEventListener('keydown', onKeyDown);
|
|
188
|
+
root.addEventListener('compositionstart', onCompositionStart);
|
|
189
|
+
root.addEventListener('compositionend', onCompositionEnd);
|
|
190
|
+
root.addEventListener('drop', prevent);
|
|
191
|
+
root.addEventListener('pointerdown', sealHistory);
|
|
192
|
+
root.addEventListener('focusout', sealHistory);
|
|
193
|
+
root.addEventListener('pointerdown', closeSlash);
|
|
194
|
+
root.addEventListener('focusout', closeSlash);
|
|
195
|
+
root.addEventListener('pointerdown', onPointerDown);
|
|
196
|
+
root.addEventListener('click', onClick);
|
|
197
|
+
document.addEventListener('pointerup', onPointerUp);
|
|
198
|
+
document.addEventListener('pointercancel', onPointerUp);
|
|
199
|
+
document.addEventListener('selectionchange', onSelectionChange);
|
|
200
|
+
window.addEventListener('scroll', syncIfOpen, true);
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
root.removeEventListener('beforeinput', onBeforeInput);
|
|
204
|
+
root.removeEventListener('keydown', onKeyDown);
|
|
205
|
+
root.removeEventListener('compositionstart', onCompositionStart);
|
|
206
|
+
root.removeEventListener('compositionend', onCompositionEnd);
|
|
207
|
+
root.removeEventListener('drop', prevent);
|
|
208
|
+
root.removeEventListener('pointerdown', sealHistory);
|
|
209
|
+
root.removeEventListener('focusout', sealHistory);
|
|
210
|
+
root.removeEventListener('pointerdown', closeSlash);
|
|
211
|
+
root.removeEventListener('focusout', closeSlash);
|
|
212
|
+
root.removeEventListener('pointerdown', onPointerDown);
|
|
213
|
+
root.removeEventListener('click', onClick);
|
|
214
|
+
document.removeEventListener('pointerup', onPointerUp);
|
|
215
|
+
document.removeEventListener('pointercancel', onPointerUp);
|
|
216
|
+
document.removeEventListener('selectionchange', onSelectionChange);
|
|
217
|
+
window.removeEventListener('scroll', syncIfOpen, true);
|
|
218
|
+
compositionBlock?.removeAttribute('data-composing');
|
|
219
|
+
view.slash.close();
|
|
220
|
+
view.toolbar.hide();
|
|
221
|
+
};
|
|
222
|
+
}, [editor, view, ref, slashItems, keymap, enabled]);
|
|
223
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useEditor } from '../hooks/useEditor';
|
|
4
|
+
import { useEditorView } from '../hooks/useEditorView';
|
|
5
|
+
import { readDocSelection } from './selection';
|
|
6
|
+
|
|
7
|
+
export function useSelectionState(ref: RefObject<HTMLElement | null>) {
|
|
8
|
+
const editor = useEditor();
|
|
9
|
+
const view = useEditorView();
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const root = ref.current;
|
|
13
|
+
|
|
14
|
+
if (!root) return;
|
|
15
|
+
|
|
16
|
+
const update = () => {
|
|
17
|
+
const selection = readDocSelection(root);
|
|
18
|
+
|
|
19
|
+
view.selection.set(
|
|
20
|
+
selection && !selection.collapsed ? editor.blocksBetween(selection.from, selection.to) : [],
|
|
21
|
+
);
|
|
22
|
+
const caret = selection?.collapsed ? selection.from.blockId : null;
|
|
23
|
+
|
|
24
|
+
view.caret.set(caret);
|
|
25
|
+
editor.controllers.setFocusedBlock(caret);
|
|
26
|
+
view.viewport.setEdges(
|
|
27
|
+
!selection
|
|
28
|
+
? []
|
|
29
|
+
: selection.collapsed
|
|
30
|
+
? [selection.from.blockId]
|
|
31
|
+
: [selection.from.blockId, selection.to.blockId],
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
document.addEventListener('selectionchange', update);
|
|
36
|
+
update();
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
document.removeEventListener('selectionchange', update);
|
|
40
|
+
view.selection.set([]);
|
|
41
|
+
view.caret.set(null);
|
|
42
|
+
view.viewport.setEdges([]);
|
|
43
|
+
editor.controllers.setFocusedBlock(null);
|
|
44
|
+
};
|
|
45
|
+
}, [editor, view, ref]);
|
|
46
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type FormulaEditorMode = 'insert' | 'edit';
|
|
2
|
+
|
|
3
|
+
export interface FormulaEditorState {
|
|
4
|
+
readonly open: boolean;
|
|
5
|
+
readonly mode: FormulaEditorMode;
|
|
6
|
+
readonly blockId: string;
|
|
7
|
+
readonly from: number;
|
|
8
|
+
readonly to: number;
|
|
9
|
+
readonly latex: string;
|
|
10
|
+
readonly position: { readonly x: number; readonly y: number; readonly top: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const CLOSED: FormulaEditorState = Object.freeze({
|
|
14
|
+
open: false,
|
|
15
|
+
mode: 'insert',
|
|
16
|
+
blockId: '',
|
|
17
|
+
from: 0,
|
|
18
|
+
to: 0,
|
|
19
|
+
latex: '',
|
|
20
|
+
position: { x: 0, y: 0, top: 0 },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export class FormulaController {
|
|
24
|
+
private state: FormulaEditorState = CLOSED;
|
|
25
|
+
|
|
26
|
+
private readonly listeners = new Set<() => void>();
|
|
27
|
+
|
|
28
|
+
getState = (): FormulaEditorState => this.state;
|
|
29
|
+
|
|
30
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
31
|
+
this.listeners.add(listener);
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
this.listeners.delete(listener);
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
open(state: Omit<FormulaEditorState, 'open'>): void {
|
|
39
|
+
this.set({ ...state, open: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
close(): void {
|
|
43
|
+
if (this.state.open) this.set(CLOSED);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private set(state: FormulaEditorState): void {
|
|
47
|
+
this.state = state;
|
|
48
|
+
|
|
49
|
+
for (const listener of [...this.listeners]) listener();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { MessageKey } from '../i18n/en';
|
|
4
|
+
import { useT } from '../hooks/useT';
|
|
5
|
+
import { CARET } from './snippet';
|
|
6
|
+
import { SYMBOL_CATEGORIES, type SymbolCategoryId } from './symbols';
|
|
7
|
+
|
|
8
|
+
export interface FormulaPaletteProps {
|
|
9
|
+
readonly onInsert: (snippet: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Палитра математических символов и шаблонов: вкладки по категориям (функции, структуры, греческие буквы, операторы,
|
|
14
|
+
* отношения, стрелки, множества) и сетка кнопок. Нажатие вставляет код LaTeX в поле формулы; фокус из поля не уходит.
|
|
15
|
+
* Атрибуты: `data-inkbrick-part="formula-palette"`, `formula-palette-tabs`, `formula-palette-grid`, `formula-symbol`.
|
|
16
|
+
*/
|
|
17
|
+
export function FormulaPalette({ onInsert }: FormulaPaletteProps) {
|
|
18
|
+
const t = useT();
|
|
19
|
+
const [active, setActive] = useState<SymbolCategoryId>('functions');
|
|
20
|
+
const category = SYMBOL_CATEGORIES.find((item) => item.id === active) ?? SYMBOL_CATEGORIES[0];
|
|
21
|
+
const name = (id: SymbolCategoryId) => t(`formula.palette.${id}` as MessageKey);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className="formula-palette"
|
|
26
|
+
data-inkbrick-part="formula-palette"
|
|
27
|
+
role="group"
|
|
28
|
+
aria-label={t('formula.palette.label')}
|
|
29
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
30
|
+
>
|
|
31
|
+
<div
|
|
32
|
+
className="formula-palette__tabs"
|
|
33
|
+
data-inkbrick-part="formula-palette-tabs"
|
|
34
|
+
role="tablist"
|
|
35
|
+
>
|
|
36
|
+
{SYMBOL_CATEGORIES.map((item) => (
|
|
37
|
+
<button
|
|
38
|
+
key={item.id}
|
|
39
|
+
type="button"
|
|
40
|
+
role="tab"
|
|
41
|
+
data-inkbrick-part="formula-palette-tab"
|
|
42
|
+
data-category={item.id}
|
|
43
|
+
data-active={item.id === active || undefined}
|
|
44
|
+
aria-selected={item.id === active}
|
|
45
|
+
aria-label={name(item.id)}
|
|
46
|
+
title={name(item.id)}
|
|
47
|
+
onClick={() => setActive(item.id)}
|
|
48
|
+
>
|
|
49
|
+
{item.icon}
|
|
50
|
+
</button>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
<div
|
|
54
|
+
className="formula-palette__grid"
|
|
55
|
+
data-inkbrick-part="formula-palette-grid"
|
|
56
|
+
role="tabpanel"
|
|
57
|
+
aria-label={name(category.id)}
|
|
58
|
+
>
|
|
59
|
+
{category.items.map((item) => {
|
|
60
|
+
const code = item.title ?? item.snippet.split(CARET).join('').trim();
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
key={item.snippet}
|
|
65
|
+
type="button"
|
|
66
|
+
data-inkbrick-part="formula-symbol"
|
|
67
|
+
data-snippet={item.snippet}
|
|
68
|
+
aria-label={code}
|
|
69
|
+
title={code}
|
|
70
|
+
onClick={() => onInsert(item.snippet)}
|
|
71
|
+
>
|
|
72
|
+
{item.label}
|
|
73
|
+
</button>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|