@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,276 @@
|
|
|
1
|
+
import type { CaretResult, Editor } from '@inkbrick/engine';
|
|
2
|
+
|
|
3
|
+
import { availableSlashItems } from '../slash/items';
|
|
4
|
+
import type { SlashMenuItem } from '../slash/types';
|
|
5
|
+
import { matchInlineFormula } from '../formula/actions';
|
|
6
|
+
import { deleteForwardAtEnd } from './deleteAtBoundary';
|
|
7
|
+
import type { EditorView } from './EditorView';
|
|
8
|
+
import type { InputActions } from './inputActions';
|
|
9
|
+
import { readDocSelection } from './selection';
|
|
10
|
+
|
|
11
|
+
const FORMAT_BY_INPUT_TYPE: Record<string, string> = {
|
|
12
|
+
formatBold: 'bold',
|
|
13
|
+
formatItalic: 'italic',
|
|
14
|
+
formatUnderline: 'underline',
|
|
15
|
+
formatStrikeThrough: 'strike',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function matchShortcut(prefix: string) {
|
|
19
|
+
if (/^#{1,6}$/.test(prefix)) {
|
|
20
|
+
return { flavour: 'block:heading', props: { level: prefix.length } };
|
|
21
|
+
}
|
|
22
|
+
if (prefix === '>') return { flavour: 'block:quote', props: {} };
|
|
23
|
+
if (prefix === '```') return { flavour: 'block:code', props: {} };
|
|
24
|
+
if (prefix === '---') return { flavour: 'block:divider', props: {} };
|
|
25
|
+
if (prefix === '$$') return { flavour: 'block:formula', props: {} };
|
|
26
|
+
if (/^[-*+]$/.test(prefix)) return { flavour: 'block:list', props: { type: 'bullet' } };
|
|
27
|
+
if (/^\d+\.$/.test(prefix)) return { flavour: 'block:list', props: { type: 'ordered' } };
|
|
28
|
+
if (prefix === '[]') return { flavour: 'block:list', props: { type: 'todo', checked: false } };
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Превращает `beforeinput` в команды движка: ввод, Enter, удаление (символ, слово, строка), история и формат из
|
|
34
|
+
* системного меню, Markdown-шорткаты, `$формула$` и открытие slash-меню. Событие уже отменено вызывающим кодом.
|
|
35
|
+
*/
|
|
36
|
+
export function applyBeforeInput(
|
|
37
|
+
event: InputEvent,
|
|
38
|
+
root: HTMLElement,
|
|
39
|
+
editor: Editor,
|
|
40
|
+
view: EditorView,
|
|
41
|
+
slashItems: readonly SlashMenuItem[],
|
|
42
|
+
actions: InputActions,
|
|
43
|
+
): void {
|
|
44
|
+
const {
|
|
45
|
+
exec,
|
|
46
|
+
run,
|
|
47
|
+
syncSlash,
|
|
48
|
+
blockText,
|
|
49
|
+
deleteSelection,
|
|
50
|
+
replaceSelection,
|
|
51
|
+
toggleFormat,
|
|
52
|
+
undo,
|
|
53
|
+
redo,
|
|
54
|
+
} = actions;
|
|
55
|
+
const selection = readDocSelection(root);
|
|
56
|
+
|
|
57
|
+
if (!selection) return;
|
|
58
|
+
|
|
59
|
+
const { from, collapsed } = selection;
|
|
60
|
+
const type = event.inputType;
|
|
61
|
+
const block = editor.store.getBlock(from.blockId);
|
|
62
|
+
const text = blockText(from.blockId);
|
|
63
|
+
|
|
64
|
+
switch (type) {
|
|
65
|
+
case 'insertText':
|
|
66
|
+
case 'insertReplacementText': {
|
|
67
|
+
const data = event.data ?? event.dataTransfer?.getData('text/plain') ?? '';
|
|
68
|
+
|
|
69
|
+
if (!data) return;
|
|
70
|
+
|
|
71
|
+
if (data === '$' && collapsed && block && block.flavour !== 'block:code') {
|
|
72
|
+
const formula = matchInlineFormula(text.slice(0, from.offset));
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
formula &&
|
|
76
|
+
editor.can('insertFormula', {
|
|
77
|
+
blockId: from.blockId,
|
|
78
|
+
from: formula.from,
|
|
79
|
+
to: from.offset,
|
|
80
|
+
latex: formula.latex,
|
|
81
|
+
})
|
|
82
|
+
) {
|
|
83
|
+
run(() =>
|
|
84
|
+
exec<CaretResult>({
|
|
85
|
+
type: 'insertFormula',
|
|
86
|
+
payload: {
|
|
87
|
+
blockId: from.blockId,
|
|
88
|
+
from: formula.from,
|
|
89
|
+
to: from.offset,
|
|
90
|
+
latex: formula.latex,
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (data === ' ' && collapsed && block?.flavour === 'block:paragraph') {
|
|
99
|
+
const shortcut = matchShortcut(text.slice(0, from.offset));
|
|
100
|
+
|
|
101
|
+
if (shortcut) {
|
|
102
|
+
run(() =>
|
|
103
|
+
editor.batch(() => {
|
|
104
|
+
exec({
|
|
105
|
+
type: 'deleteText',
|
|
106
|
+
payload: { blockId: from.blockId, from: 0, to: from.offset },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return exec<CaretResult>({
|
|
110
|
+
type: 'convertBlock',
|
|
111
|
+
payload: { blockId: from.blockId, ...shortcut, offset: 0 },
|
|
112
|
+
});
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const opensSlash =
|
|
120
|
+
data === '/' &&
|
|
121
|
+
collapsed &&
|
|
122
|
+
!!block &&
|
|
123
|
+
block.flavour !== 'block:code' &&
|
|
124
|
+
editor.schema.getBlockSchema(block.flavour)?.inline !== false &&
|
|
125
|
+
(from.offset === 0 || /\s/.test(text[from.offset - 1])) &&
|
|
126
|
+
availableSlashItems(editor, slashItems, from.blockId).length > 0;
|
|
127
|
+
|
|
128
|
+
run(() => replaceSelection(selection, data));
|
|
129
|
+
|
|
130
|
+
if (opensSlash) {
|
|
131
|
+
view.slash.open(from.blockId, from.offset);
|
|
132
|
+
syncSlash();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'insertParagraph':
|
|
139
|
+
run(() =>
|
|
140
|
+
editor.batch(() => {
|
|
141
|
+
const caret = collapsed
|
|
142
|
+
? { blockId: from.blockId, offset: from.offset }
|
|
143
|
+
: deleteSelection(selection);
|
|
144
|
+
|
|
145
|
+
return caret
|
|
146
|
+
? exec<CaretResult>({
|
|
147
|
+
type: 'splitBlock',
|
|
148
|
+
payload: { blockId: caret.blockId, offset: caret.offset },
|
|
149
|
+
})
|
|
150
|
+
: undefined;
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
|
|
155
|
+
case 'insertLineBreak':
|
|
156
|
+
run(() => replaceSelection(selection, '\n'));
|
|
157
|
+
return;
|
|
158
|
+
|
|
159
|
+
case 'deleteContentBackward':
|
|
160
|
+
run(() =>
|
|
161
|
+
collapsed
|
|
162
|
+
? exec<CaretResult>({
|
|
163
|
+
type: 'deleteBackward',
|
|
164
|
+
payload: { blockId: from.blockId, offset: from.offset },
|
|
165
|
+
})
|
|
166
|
+
: deleteSelection(selection),
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
|
|
170
|
+
case 'deleteContentForward':
|
|
171
|
+
run(() => {
|
|
172
|
+
if (!collapsed) return deleteSelection(selection);
|
|
173
|
+
|
|
174
|
+
if (from.offset < text.length) {
|
|
175
|
+
return exec<CaretResult>({
|
|
176
|
+
type: 'deleteText',
|
|
177
|
+
payload: { blockId: from.blockId, from: from.offset, to: from.offset + 1 },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return deleteForwardAtEnd(editor, from.blockId);
|
|
182
|
+
});
|
|
183
|
+
return;
|
|
184
|
+
|
|
185
|
+
case 'deleteWordBackward':
|
|
186
|
+
run(() => {
|
|
187
|
+
if (!collapsed) return deleteSelection(selection);
|
|
188
|
+
|
|
189
|
+
// Как в браузерах и Word: слово вместе с пробелами после него (пробел перед словом остаётся).
|
|
190
|
+
const start = text.slice(0, from.offset).replace(/(\S+\s*|\s+)$/, '').length;
|
|
191
|
+
|
|
192
|
+
return start < from.offset
|
|
193
|
+
? exec<CaretResult>({
|
|
194
|
+
type: 'deleteText',
|
|
195
|
+
payload: { blockId: from.blockId, from: start, to: from.offset },
|
|
196
|
+
})
|
|
197
|
+
: exec<CaretResult>({
|
|
198
|
+
type: 'deleteBackward',
|
|
199
|
+
payload: { blockId: from.blockId, offset: from.offset },
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
|
|
204
|
+
case 'deleteWordForward':
|
|
205
|
+
run(() => {
|
|
206
|
+
if (!collapsed) return deleteSelection(selection);
|
|
207
|
+
|
|
208
|
+
const rest = text.slice(from.offset);
|
|
209
|
+
const length = rest.length - rest.replace(/^(\s*[^\s]+|\s+)/, '').length;
|
|
210
|
+
|
|
211
|
+
return length > 0
|
|
212
|
+
? exec<CaretResult>({
|
|
213
|
+
type: 'deleteText',
|
|
214
|
+
payload: { blockId: from.blockId, from: from.offset, to: from.offset + length },
|
|
215
|
+
})
|
|
216
|
+
: undefined;
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
|
|
220
|
+
case 'deleteSoftLineBackward':
|
|
221
|
+
case 'deleteHardLineBackward':
|
|
222
|
+
run(() => {
|
|
223
|
+
if (!collapsed) return deleteSelection(selection);
|
|
224
|
+
|
|
225
|
+
return from.offset > 0
|
|
226
|
+
? exec<CaretResult>({
|
|
227
|
+
type: 'deleteText',
|
|
228
|
+
payload: { blockId: from.blockId, from: 0, to: from.offset },
|
|
229
|
+
})
|
|
230
|
+
: undefined;
|
|
231
|
+
});
|
|
232
|
+
return;
|
|
233
|
+
|
|
234
|
+
case 'deleteSoftLineForward':
|
|
235
|
+
case 'deleteHardLineForward':
|
|
236
|
+
run(() => {
|
|
237
|
+
if (!collapsed) return deleteSelection(selection);
|
|
238
|
+
|
|
239
|
+
return from.offset < text.length
|
|
240
|
+
? exec<CaretResult>({
|
|
241
|
+
type: 'deleteText',
|
|
242
|
+
payload: { blockId: from.blockId, from: from.offset, to: text.length },
|
|
243
|
+
})
|
|
244
|
+
: undefined;
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
|
|
248
|
+
case 'deleteByCut':
|
|
249
|
+
case 'deleteByDrag':
|
|
250
|
+
case 'deleteContent':
|
|
251
|
+
if (!collapsed) run(() => deleteSelection(selection));
|
|
252
|
+
return;
|
|
253
|
+
|
|
254
|
+
case 'historyUndo':
|
|
255
|
+
undo();
|
|
256
|
+
return;
|
|
257
|
+
|
|
258
|
+
case 'historyRedo':
|
|
259
|
+
redo();
|
|
260
|
+
return;
|
|
261
|
+
|
|
262
|
+
case 'formatBold':
|
|
263
|
+
case 'formatItalic':
|
|
264
|
+
case 'formatUnderline':
|
|
265
|
+
case 'formatStrikeThrough':
|
|
266
|
+
toggleFormat(FORMAT_BY_INPUT_TYPE[type]);
|
|
267
|
+
return;
|
|
268
|
+
|
|
269
|
+
default:
|
|
270
|
+
/*
|
|
271
|
+
* insertFromPaste обрабатывается в useClipboard; drop и всё неизвестное блокируем,
|
|
272
|
+
* чтобы DOM не разошёлся с моделью.
|
|
273
|
+
*/
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CaretResult, Editor } from '@inkbrick/engine';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Delete в конце блока: склеивает следующий блок с этим. Перед блоком без текста (картинка, разделитель)
|
|
5
|
+
* ничего не делает — так его не удалить случайно.
|
|
6
|
+
*/
|
|
7
|
+
export function deleteForwardAtEnd(editor: Editor, blockId: string): CaretResult | undefined {
|
|
8
|
+
const next = editor.document.getNextSibling(blockId);
|
|
9
|
+
|
|
10
|
+
if (!next || editor.schema.getBlockSchema(next.flavour)?.inline === false) return undefined;
|
|
11
|
+
|
|
12
|
+
return editor.dispatch({
|
|
13
|
+
type: 'deleteBackward',
|
|
14
|
+
payload: { blockId: next.id, offset: 0 },
|
|
15
|
+
}) as CaretResult | undefined;
|
|
16
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { flushSync } from 'react-dom';
|
|
2
|
+
|
|
3
|
+
import type { CaretResult, Editor, EditorIntent } from '@inkbrick/engine';
|
|
4
|
+
|
|
5
|
+
import { syncSlashMenu } from '../slash/syncSlashMenu';
|
|
6
|
+
import type { SlashMenuItem } from '../slash/types';
|
|
7
|
+
import { toggleFormatInSelection } from '../toolbar/formatting';
|
|
8
|
+
import type { EditorView } from './EditorView';
|
|
9
|
+
import { readDocSelection, type DocSelection } from './selection';
|
|
10
|
+
|
|
11
|
+
export interface InputActions {
|
|
12
|
+
exec<T>(intent: EditorIntent): T | undefined;
|
|
13
|
+
run(action: () => CaretResult | undefined): void;
|
|
14
|
+
syncSlash(): void;
|
|
15
|
+
blockText(blockId: string): string;
|
|
16
|
+
deleteSelection(selection: DocSelection): CaretResult | undefined;
|
|
17
|
+
insertAt(blockId: string, offset: number, text: string): CaretResult | undefined;
|
|
18
|
+
replaceSelection(selection: DocSelection, text: string): CaretResult | undefined;
|
|
19
|
+
toggleFormat(format: string): void;
|
|
20
|
+
undo(): void;
|
|
21
|
+
redo(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createInputActions(
|
|
25
|
+
editor: Editor,
|
|
26
|
+
view: EditorView,
|
|
27
|
+
root: HTMLElement,
|
|
28
|
+
slashItems: readonly SlashMenuItem[],
|
|
29
|
+
): InputActions {
|
|
30
|
+
const exec = <T>(intent: EditorIntent) => editor.dispatch(intent) as T | undefined;
|
|
31
|
+
|
|
32
|
+
const run = (action: () => CaretResult | undefined) => {
|
|
33
|
+
let caret: CaretResult | undefined;
|
|
34
|
+
|
|
35
|
+
flushSync(() => {
|
|
36
|
+
caret = action();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (caret) view.placeCaret(caret.blockId, caret.offset);
|
|
40
|
+
|
|
41
|
+
syncSlash();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const syncSlash = () => syncSlashMenu(editor, view, slashItems);
|
|
45
|
+
|
|
46
|
+
const blockText = (blockId: string) => {
|
|
47
|
+
const block = editor.store.getBlock(blockId);
|
|
48
|
+
return block ? editor.engine.inline.toPlainText(block.content) : '';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isSingleBlock = (selection: DocSelection) =>
|
|
52
|
+
selection.from.blockId === selection.to.blockId;
|
|
53
|
+
|
|
54
|
+
const deleteSelection = (selection: DocSelection) =>
|
|
55
|
+
isSingleBlock(selection)
|
|
56
|
+
? exec<CaretResult>({
|
|
57
|
+
type: 'deleteText',
|
|
58
|
+
payload: {
|
|
59
|
+
blockId: selection.from.blockId,
|
|
60
|
+
from: selection.from.offset,
|
|
61
|
+
to: selection.to.offset,
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
: exec<CaretResult>({
|
|
65
|
+
type: 'deleteRange',
|
|
66
|
+
payload: { from: selection.from, to: selection.to },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const insertAt = (blockId: string, offset: number, text: string) =>
|
|
70
|
+
exec<CaretResult>({
|
|
71
|
+
type: 'replaceText',
|
|
72
|
+
payload: { blockId, from: offset, to: offset, text },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const replaceSelection = (selection: DocSelection, text: string) => {
|
|
76
|
+
// Обычный ввод идёт без batch: иначе набор подряд не сливается в один шаг undo.
|
|
77
|
+
if (selection.collapsed) {
|
|
78
|
+
return insertAt(selection.from.blockId, selection.from.offset, text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return editor.batch(() => {
|
|
82
|
+
const caret = deleteSelection(selection);
|
|
83
|
+
|
|
84
|
+
return caret ? insertAt(caret.blockId, caret.offset, text) : undefined;
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const toggleFormat = (format: string) => {
|
|
89
|
+
const selection = readDocSelection(root);
|
|
90
|
+
|
|
91
|
+
if (selection) toggleFormatInSelection(editor, view, selection, format);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const undo = () => run(() => editor.undo()?.caret);
|
|
95
|
+
const redo = () => run(() => editor.redo()?.caret);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
exec,
|
|
99
|
+
run,
|
|
100
|
+
syncSlash,
|
|
101
|
+
blockText,
|
|
102
|
+
deleteSelection,
|
|
103
|
+
insertAt,
|
|
104
|
+
replaceSelection,
|
|
105
|
+
toggleFormat,
|
|
106
|
+
undo,
|
|
107
|
+
redo,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { HighlightToken } from '../code/types';
|
|
2
|
+
|
|
3
|
+
const TOKEN_TYPE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Приводит токены подсветчика к безопасному виду: границы внутри текста, без пересечений и пустых токенов,
|
|
7
|
+
* тип — короткое имя из строчных букв, цифр и дефисов (он попадает в атрибут разметки). Порядок не важен.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeTokens(
|
|
10
|
+
tokens: readonly HighlightToken[],
|
|
11
|
+
length: number,
|
|
12
|
+
): HighlightToken[] {
|
|
13
|
+
const valid = tokens
|
|
14
|
+
.filter(
|
|
15
|
+
(token) =>
|
|
16
|
+
Number.isInteger(token.from) &&
|
|
17
|
+
Number.isInteger(token.to) &&
|
|
18
|
+
token.from >= 0 &&
|
|
19
|
+
token.to <= length &&
|
|
20
|
+
token.from < token.to &&
|
|
21
|
+
TOKEN_TYPE.test(token.type),
|
|
22
|
+
)
|
|
23
|
+
.sort((a, b) => a.from - b.from || b.to - a.to);
|
|
24
|
+
|
|
25
|
+
const result: HighlightToken[] = [];
|
|
26
|
+
let end = 0;
|
|
27
|
+
|
|
28
|
+
for (const token of valid) {
|
|
29
|
+
if (token.from >= end) {
|
|
30
|
+
result.push(token);
|
|
31
|
+
end = token.to;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Рисует текст блока кода с подсветкой: токены становятся `<span data-inkbrick-token="тип">`, остальное — текстовые узлы.
|
|
40
|
+
* Текст в DOM всегда равен тексту модели (подсветка только оборачивает его), поэтому смещения курсора и выделения
|
|
41
|
+
* считаются как обычно. Как и в `renderInline`, пустой блок и завершающий перевод строки получают `<br>`.
|
|
42
|
+
*/
|
|
43
|
+
export function renderCode(
|
|
44
|
+
root: HTMLElement,
|
|
45
|
+
text: string,
|
|
46
|
+
tokens: readonly HighlightToken[],
|
|
47
|
+
): void {
|
|
48
|
+
const doc = globalThis.document;
|
|
49
|
+
const fragment = doc.createDocumentFragment();
|
|
50
|
+
let position = 0;
|
|
51
|
+
|
|
52
|
+
for (const token of normalizeTokens(tokens, text.length)) {
|
|
53
|
+
if (token.from > position)
|
|
54
|
+
fragment.appendChild(doc.createTextNode(text.slice(position, token.from)));
|
|
55
|
+
|
|
56
|
+
const span = doc.createElement('span');
|
|
57
|
+
|
|
58
|
+
span.setAttribute('data-inkbrick-token', token.type);
|
|
59
|
+
span.textContent = text.slice(token.from, token.to);
|
|
60
|
+
fragment.appendChild(span);
|
|
61
|
+
position = token.to;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (position < text.length) fragment.appendChild(doc.createTextNode(text.slice(position)));
|
|
65
|
+
|
|
66
|
+
if (text === '' || text.endsWith('\n')) fragment.appendChild(doc.createElement('br'));
|
|
67
|
+
|
|
68
|
+
root.replaceChildren(fragment);
|
|
69
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FORMULA_CHAR,
|
|
3
|
+
isColorName,
|
|
4
|
+
isSafeUrl,
|
|
5
|
+
safeCssColor,
|
|
6
|
+
type InlineNode,
|
|
7
|
+
type TextAttributes,
|
|
8
|
+
} from '@inkbrick/document';
|
|
9
|
+
|
|
10
|
+
import { ensureMathStyles, mountFormula, type FormulaRenderer } from '../math/renderer';
|
|
11
|
+
|
|
12
|
+
export interface RenderInlineOptions {
|
|
13
|
+
readonly formulaRenderer?: FormulaRenderer | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function wrap(node: Node, tag: keyof HTMLElementTagNameMap): HTMLElement {
|
|
17
|
+
const element = globalThis.document.createElement(tag);
|
|
18
|
+
element.appendChild(node);
|
|
19
|
+
return element;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Встроенная формула — нередактируемый «объект» из одного символа. Сама формула рисуется в теневом корне: так её
|
|
24
|
+
* внутренний текст (MathML) не попадает в `Range.toString()` и `textContent`, и смещения курсора остаются равными
|
|
25
|
+
* смещениям модели (объект занимает ровно один символ — `FORMULA_CHAR` в обычном DOM).
|
|
26
|
+
*/
|
|
27
|
+
function renderFormulaAtom(latex: string, options: RenderInlineOptions): HTMLElement {
|
|
28
|
+
const doc = globalThis.document;
|
|
29
|
+
const host = doc.createElement('span');
|
|
30
|
+
|
|
31
|
+
host.setAttribute('data-inkbrick-formula', '');
|
|
32
|
+
host.setAttribute('data-inkbrick-part', 'formula-inline');
|
|
33
|
+
host.setAttribute('data-latex', latex);
|
|
34
|
+
host.setAttribute('contenteditable', 'false');
|
|
35
|
+
host.textContent = FORMULA_CHAR;
|
|
36
|
+
|
|
37
|
+
const view = doc.createElement('span');
|
|
38
|
+
|
|
39
|
+
view.setAttribute('part', 'math');
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
43
|
+
|
|
44
|
+
ensureMathStyles(shadow);
|
|
45
|
+
shadow.appendChild(view);
|
|
46
|
+
} catch {
|
|
47
|
+
// Теневой DOM недоступен: исходный код показывает CSS (`[data-state='fallback']::after`), смещения не меняются.
|
|
48
|
+
host.setAttribute('data-state', 'fallback');
|
|
49
|
+
|
|
50
|
+
return host;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
mountFormula(view, latex, {
|
|
54
|
+
display: false,
|
|
55
|
+
renderer: options.formulaRenderer ?? null,
|
|
56
|
+
onSettled: (state) => host.setAttribute('data-state', state),
|
|
57
|
+
});
|
|
58
|
+
host.setAttribute('data-state', view.getAttribute('data-state') ?? 'loading');
|
|
59
|
+
|
|
60
|
+
return host;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderNode(
|
|
64
|
+
insert: string,
|
|
65
|
+
attributes: TextAttributes | undefined,
|
|
66
|
+
options: RenderInlineOptions,
|
|
67
|
+
): Node {
|
|
68
|
+
let node: Node;
|
|
69
|
+
|
|
70
|
+
if (typeof attributes?.formula === 'string') {
|
|
71
|
+
const fragment = globalThis.document.createDocumentFragment();
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < insert.length; i++)
|
|
74
|
+
fragment.appendChild(renderFormulaAtom(attributes.formula, options));
|
|
75
|
+
|
|
76
|
+
node = fragment;
|
|
77
|
+
} else {
|
|
78
|
+
node = globalThis.document.createTextNode(insert);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!attributes) return node;
|
|
82
|
+
|
|
83
|
+
if (attributes.code) node = wrap(node, 'code');
|
|
84
|
+
if (attributes.bold) node = wrap(node, 'strong');
|
|
85
|
+
if (attributes.italic) node = wrap(node, 'em');
|
|
86
|
+
if (attributes.underline) node = wrap(node, 'u');
|
|
87
|
+
if (attributes.strike) node = wrap(node, 's');
|
|
88
|
+
|
|
89
|
+
if (typeof attributes.link === 'string' && isSafeUrl(attributes.link)) {
|
|
90
|
+
const anchor = wrap(node, 'a') as HTMLAnchorElement;
|
|
91
|
+
anchor.href = attributes.link.trim();
|
|
92
|
+
anchor.rel = 'noopener noreferrer';
|
|
93
|
+
anchor.target = '_blank';
|
|
94
|
+
node = anchor;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const color = attributes.color;
|
|
98
|
+
const background = attributes.backgroundColor;
|
|
99
|
+
const safeColor = isColorName(color) ? null : safeCssColor(color);
|
|
100
|
+
const safeBackground = isColorName(background) ? null : safeCssColor(background);
|
|
101
|
+
|
|
102
|
+
if (isColorName(color) || isColorName(background) || safeColor || safeBackground) {
|
|
103
|
+
const span = wrap(node, 'span');
|
|
104
|
+
|
|
105
|
+
if (isColorName(color)) span.setAttribute('data-inkbrick-color', color);
|
|
106
|
+
else if (safeColor) span.style.color = safeColor;
|
|
107
|
+
|
|
108
|
+
if (isColorName(background)) span.setAttribute('data-inkbrick-bg', background);
|
|
109
|
+
else if (safeBackground) span.style.backgroundColor = safeBackground;
|
|
110
|
+
|
|
111
|
+
node = span;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return node;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Перерисовывает содержимое блока из модели. Содержимое contenteditable
|
|
119
|
+
* намеренно рисуется императивно, а не через React: браузер может менять DOM
|
|
120
|
+
* (например, при IME-вводе), и React не должен с этим конфликтовать.
|
|
121
|
+
*/
|
|
122
|
+
export function renderInline(
|
|
123
|
+
root: HTMLElement,
|
|
124
|
+
nodes: readonly InlineNode[],
|
|
125
|
+
options: RenderInlineOptions = {},
|
|
126
|
+
): void {
|
|
127
|
+
const fragment = globalThis.document.createDocumentFragment();
|
|
128
|
+
|
|
129
|
+
for (const { insert, attributes } of nodes) {
|
|
130
|
+
if (insert.length > 0) fragment.appendChild(renderNode(insert, attributes, options));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/*
|
|
134
|
+
* <br> нужен в двух случаях: пустой блок (иначе в нём нельзя поставить курсор)
|
|
135
|
+
* и завершающий перевод строки в white-space: pre-wrap (виден только с <br> после него).
|
|
136
|
+
* На смещения текста <br> не влияет: Range.toString() его игнорирует.
|
|
137
|
+
*/
|
|
138
|
+
const text = nodes.map((node) => node.insert).join('');
|
|
139
|
+
if (text === '' || text.endsWith('\n')) {
|
|
140
|
+
fragment.appendChild(globalThis.document.createElement('br'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
root.replaceChildren(fragment);
|
|
144
|
+
}
|