@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,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
useSyncExternalStore,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
|
|
11
|
+
import { MAX_FORMULA_LENGTH } from '@inkbrick/document';
|
|
12
|
+
|
|
13
|
+
import type { EditorView } from '../editing/EditorView';
|
|
14
|
+
import { useEditor } from '../hooks/useEditor';
|
|
15
|
+
import { useEditorView } from '../hooks/useEditorView';
|
|
16
|
+
import { useReadOnly } from '../hooks/useReadOnly';
|
|
17
|
+
import { useT } from '../hooks/useT';
|
|
18
|
+
import { Icon } from '../icons/Icon';
|
|
19
|
+
import { FormulaView } from '../math/FormulaView';
|
|
20
|
+
import { applyInlineFormula } from './actions';
|
|
21
|
+
import { LazyFormulaPalette } from './LazyFormulaPalette';
|
|
22
|
+
import { useSnippetInsert } from './snippet';
|
|
23
|
+
import type { FormulaEditorState } from './FormulaController';
|
|
24
|
+
|
|
25
|
+
const WIDTH = 340;
|
|
26
|
+
|
|
27
|
+
function close(view: EditorView, state: FormulaEditorState) {
|
|
28
|
+
view.formula.close();
|
|
29
|
+
view.placeSelection(
|
|
30
|
+
{ blockId: state.blockId, offset: state.mode === 'edit' ? state.to : state.from },
|
|
31
|
+
{ blockId: state.blockId, offset: state.to },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Body({ state }: { state: FormulaEditorState }) {
|
|
36
|
+
const editor = useEditor();
|
|
37
|
+
const view = useEditorView();
|
|
38
|
+
const t = useT();
|
|
39
|
+
const [draft, setDraft] = useState(state.latex);
|
|
40
|
+
const [error, setError] = useState(false);
|
|
41
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
42
|
+
const field = useRef<HTMLTextAreaElement>(null);
|
|
43
|
+
const [top, setTop] = useState<number | null>(null);
|
|
44
|
+
const insertSnippet = useSnippetInsert(field, draft, setDraft);
|
|
45
|
+
|
|
46
|
+
const settle = useCallback((result: 'ready' | 'error') => setError(result === 'error'), []);
|
|
47
|
+
const width = Math.min(WIDTH, window.innerWidth - 16);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
field.current?.focus();
|
|
51
|
+
field.current?.select();
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
const height = ref.current?.offsetHeight ?? 0;
|
|
56
|
+
const below = state.position.y + 8;
|
|
57
|
+
|
|
58
|
+
setTop(
|
|
59
|
+
below + height > window.innerHeight && state.position.top - height - 8 > 0
|
|
60
|
+
? state.position.top - height - 8
|
|
61
|
+
: below,
|
|
62
|
+
);
|
|
63
|
+
}, [state.position.y, state.position.top, draft]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
67
|
+
if (event.target instanceof Node && ref.current?.contains(event.target)) return;
|
|
68
|
+
|
|
69
|
+
close(view, state);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
73
|
+
|
|
74
|
+
return () => document.removeEventListener('pointerdown', onPointerDown, true);
|
|
75
|
+
}, [view, state]);
|
|
76
|
+
|
|
77
|
+
const apply = () => {
|
|
78
|
+
if (applyInlineFormula(editor, view, state, draft)) view.formula.close();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const remove = () => {
|
|
82
|
+
if (applyInlineFormula(editor, view, state, '')) view.formula.close();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
ref={ref}
|
|
88
|
+
className="formula-popover"
|
|
89
|
+
data-inkbrick-ui="formula-popover"
|
|
90
|
+
data-testid="formula-popover"
|
|
91
|
+
role="dialog"
|
|
92
|
+
aria-label={t('formula.inline.title')}
|
|
93
|
+
style={{
|
|
94
|
+
left: Math.max(8, Math.min(state.position.x - width / 2, window.innerWidth - width - 8)),
|
|
95
|
+
top: top ?? state.position.y + 8,
|
|
96
|
+
width,
|
|
97
|
+
}}
|
|
98
|
+
onMouseDown={(event) => {
|
|
99
|
+
if (!(event.target instanceof HTMLTextAreaElement)) event.preventDefault();
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<textarea
|
|
103
|
+
ref={field}
|
|
104
|
+
className="formula-popover__source"
|
|
105
|
+
data-inkbrick-part="formula-source"
|
|
106
|
+
aria-label={t('formula.source')}
|
|
107
|
+
aria-invalid={error || undefined}
|
|
108
|
+
placeholder={t('formula.placeholder')}
|
|
109
|
+
spellCheck={false}
|
|
110
|
+
rows={2}
|
|
111
|
+
maxLength={MAX_FORMULA_LENGTH}
|
|
112
|
+
value={draft}
|
|
113
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
114
|
+
onKeyDown={(event) => {
|
|
115
|
+
if (event.key === 'Escape') {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
close(view, state);
|
|
118
|
+
} else if (event.key === 'Enter' && !event.shiftKey) {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
apply();
|
|
121
|
+
}
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
<div className="formula-popover__preview" data-inkbrick-part="formula-preview">
|
|
125
|
+
<FormulaView latex={draft} showError onSettled={settle} />
|
|
126
|
+
</div>
|
|
127
|
+
<LazyFormulaPalette onInsert={insertSnippet} />
|
|
128
|
+
<div className="formula-popover__actions" data-inkbrick-part="formula-actions">
|
|
129
|
+
{state.mode === 'edit' && (
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
data-inkbrick-part="button"
|
|
133
|
+
data-danger=""
|
|
134
|
+
data-action="remove"
|
|
135
|
+
aria-label={t('formula.remove')}
|
|
136
|
+
title={t('formula.remove')}
|
|
137
|
+
onClick={remove}
|
|
138
|
+
>
|
|
139
|
+
<Icon name="trash" />
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
<span className="formula-popover__spacer" />
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
data-inkbrick-part="button"
|
|
146
|
+
data-action="cancel"
|
|
147
|
+
onClick={() => close(view, state)}
|
|
148
|
+
>
|
|
149
|
+
{t('formula.cancel')}
|
|
150
|
+
</button>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
data-inkbrick-part="button"
|
|
154
|
+
data-primary=""
|
|
155
|
+
data-action="apply"
|
|
156
|
+
disabled={state.mode === 'insert' && draft.trim() === ''}
|
|
157
|
+
onClick={apply}
|
|
158
|
+
>
|
|
159
|
+
{t('formula.apply')}
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function FormulaPopover() {
|
|
167
|
+
const view = useEditorView();
|
|
168
|
+
const readOnly = useReadOnly();
|
|
169
|
+
const state = useSyncExternalStore(
|
|
170
|
+
view.formula.subscribe,
|
|
171
|
+
view.formula.getState,
|
|
172
|
+
view.formula.getState,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (!state.open || readOnly || typeof document === 'undefined') return null;
|
|
176
|
+
|
|
177
|
+
return createPortal(
|
|
178
|
+
<Body key={`${state.mode}:${state.blockId}:${state.from}:${state.to}`} state={state} />,
|
|
179
|
+
document.body,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useState, type ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { FormulaPaletteProps } from './FormulaPalette';
|
|
4
|
+
|
|
5
|
+
type Palette = ComponentType<FormulaPaletteProps>;
|
|
6
|
+
|
|
7
|
+
let loaded: Palette | undefined;
|
|
8
|
+
let loading: Promise<Palette> | undefined;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Загружает палитру заранее. Палитра (около 300 символов и шаблонов) не нужна для первой отрисовки, но должна
|
|
12
|
+
* быть готова к первому редактору формулы: иначе Tab из поля кода, нажатый до её появления, уводит фокус из редактора.
|
|
13
|
+
*/
|
|
14
|
+
export function preloadFormulaPalette(): Promise<Palette> {
|
|
15
|
+
return (loading ??= import('./FormulaPalette').then(
|
|
16
|
+
(module) => (loaded = module.FormulaPalette),
|
|
17
|
+
));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Палитра, загружаемая по требованию. Без React.lazy: Suspense придерживает показ загруженного содержимого
|
|
22
|
+
* (~300 мс), а палитра должна появиться сразу, как только модуль готов.
|
|
23
|
+
*/
|
|
24
|
+
export function LazyFormulaPalette(props: FormulaPaletteProps) {
|
|
25
|
+
const [Palette, setPalette] = useState<Palette | undefined>(() => loaded);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (Palette) return;
|
|
29
|
+
|
|
30
|
+
let active = true;
|
|
31
|
+
|
|
32
|
+
void preloadFormulaPalette().then((component) => {
|
|
33
|
+
if (active) setPalette(() => component);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
active = false;
|
|
38
|
+
};
|
|
39
|
+
}, [Palette]);
|
|
40
|
+
|
|
41
|
+
return Palette ? <Palette {...props} /> : null;
|
|
42
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { flushSync } from 'react-dom';
|
|
2
|
+
|
|
3
|
+
import { FORMULA_CHAR, MAX_FORMULA_LENGTH } from '@inkbrick/document';
|
|
4
|
+
import type { Editor } from '@inkbrick/engine';
|
|
5
|
+
|
|
6
|
+
import type { EditorView } from '../editing/EditorView';
|
|
7
|
+
import { getOffsetIn, readDocSelection } from '../editing/selection';
|
|
8
|
+
import { isFormattable } from '../toolbar/formatting';
|
|
9
|
+
import type { FormulaEditorState } from './FormulaController';
|
|
10
|
+
|
|
11
|
+
function anchorOf(rect: DOMRect, fallback: Element | null) {
|
|
12
|
+
const box =
|
|
13
|
+
rect.width === 0 && rect.height === 0 && fallback ? fallback.getBoundingClientRect() : rect;
|
|
14
|
+
|
|
15
|
+
return { x: box.left + box.width / 2, y: box.bottom, top: box.top };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function selectedLatex(editor: Editor, blockId: string, from: number, to: number): string {
|
|
19
|
+
const block = editor.store.getBlock(blockId);
|
|
20
|
+
|
|
21
|
+
if (!block || from === to) return '';
|
|
22
|
+
|
|
23
|
+
const text = block.content.map((node) => node.insert).join('');
|
|
24
|
+
|
|
25
|
+
return text.slice(from, to).split(FORMULA_CHAR).join('').trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Открывает окошко вставки встроенной формулы на месте выделения (курсора). Выделенный текст становится
|
|
30
|
+
* заготовкой кода. Возвращает false, если вставлять некуда: выделение вне редактора, из нескольких блоков или в коде.
|
|
31
|
+
*/
|
|
32
|
+
export function openInlineFormula(editor: Editor, view: EditorView): boolean {
|
|
33
|
+
const root = view.getRoot();
|
|
34
|
+
const selection = root ? readDocSelection(root) : null;
|
|
35
|
+
|
|
36
|
+
if (!selection || selection.from.blockId !== selection.to.blockId) return false;
|
|
37
|
+
|
|
38
|
+
if (!isFormattable(editor, selection)) return false;
|
|
39
|
+
|
|
40
|
+
const block = editor.store.getBlock(selection.from.blockId);
|
|
41
|
+
|
|
42
|
+
if (!block || editor.schema.getBlockSchema(block.flavour)?.inline === false) return false;
|
|
43
|
+
|
|
44
|
+
const native = globalThis.getSelection();
|
|
45
|
+
const rect =
|
|
46
|
+
native && native.rangeCount > 0 ? native.getRangeAt(0).getBoundingClientRect() : new DOMRect();
|
|
47
|
+
const { blockId } = selection.from;
|
|
48
|
+
|
|
49
|
+
view.toolbar.hide();
|
|
50
|
+
view.formula.open({
|
|
51
|
+
mode: 'insert',
|
|
52
|
+
blockId,
|
|
53
|
+
from: selection.from.offset,
|
|
54
|
+
to: selection.to.offset,
|
|
55
|
+
latex: selectedLatex(editor, blockId, selection.from.offset, selection.to.offset),
|
|
56
|
+
position: anchorOf(rect, view.getElement(blockId) ?? null),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function openFormulaAt(view: EditorView, host: Element): boolean {
|
|
63
|
+
const editable = host.closest<HTMLElement>('.editable[data-id]');
|
|
64
|
+
const blockId = editable?.dataset.id;
|
|
65
|
+
|
|
66
|
+
if (!editable || !blockId) return false;
|
|
67
|
+
|
|
68
|
+
const offset = getOffsetIn(editable, host, 0);
|
|
69
|
+
|
|
70
|
+
view.toolbar.hide();
|
|
71
|
+
view.formula.open({
|
|
72
|
+
mode: 'edit',
|
|
73
|
+
blockId,
|
|
74
|
+
from: offset,
|
|
75
|
+
to: offset + 1,
|
|
76
|
+
latex: host.getAttribute('data-latex') ?? '',
|
|
77
|
+
position: anchorOf(host.getBoundingClientRect(), editable),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Применяет результат окошка: вставляет формулу, меняет код существующей или (пустой код при правке) удаляет её.
|
|
85
|
+
* Курсор ставится сразу после формулы. Возвращает false, если команда отклонена (пустой код при вставке, слишком
|
|
86
|
+
* длинная формула).
|
|
87
|
+
*/
|
|
88
|
+
export function applyInlineFormula(
|
|
89
|
+
editor: Editor,
|
|
90
|
+
view: EditorView,
|
|
91
|
+
state: FormulaEditorState,
|
|
92
|
+
latex: string,
|
|
93
|
+
): boolean {
|
|
94
|
+
const code = latex.trim();
|
|
95
|
+
const { blockId, from, to } = state;
|
|
96
|
+
|
|
97
|
+
if (code.length > MAX_FORMULA_LENGTH) return false;
|
|
98
|
+
|
|
99
|
+
if (state.mode === 'insert' && code === '') return false;
|
|
100
|
+
|
|
101
|
+
flushSync(() => {
|
|
102
|
+
if (state.mode === 'insert') {
|
|
103
|
+
editor.dispatch({ type: 'insertFormula', payload: { blockId, from, to, latex: code } });
|
|
104
|
+
} else if (code === '') {
|
|
105
|
+
editor.dispatch({ type: 'deleteText', payload: { blockId, from, to } });
|
|
106
|
+
} else {
|
|
107
|
+
editor.dispatch({
|
|
108
|
+
type: 'setInlineAttributes',
|
|
109
|
+
payload: {
|
|
110
|
+
from: { blockId, offset: from },
|
|
111
|
+
to: { blockId, offset: to },
|
|
112
|
+
attributes: { formula: code },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
view.placeCaret(blockId, state.mode === 'edit' && code === '' ? from : Math.min(from, to) + 1);
|
|
119
|
+
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Markdown-шорткат: при вводе закрывающего `$` проверяет, не набрано ли `$код$`. Код не должен быть пустым и
|
|
125
|
+
* не должен начинаться или кончаться пробелом (так «$5 и $6» остаётся ценой, а не формулой).
|
|
126
|
+
* `before` — текст блока до курсора; возвращает начало `$` и код формулы.
|
|
127
|
+
*/
|
|
128
|
+
export function matchInlineFormula(before: string): { from: number; latex: string } | null {
|
|
129
|
+
const start = before.lastIndexOf('$');
|
|
130
|
+
|
|
131
|
+
if (start < 0 || before[start - 1] === '\\') return null;
|
|
132
|
+
|
|
133
|
+
const latex = before.slice(start + 1);
|
|
134
|
+
|
|
135
|
+
if (latex === '' || /^\s|\s$/.test(latex) || latex.includes(FORMULA_CHAR)) return null;
|
|
136
|
+
|
|
137
|
+
return { from: start, latex };
|
|
138
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, type RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Метка места курсора в шаблоне: удаляется при вставке; выделенный текст подставляется на её место. */
|
|
4
|
+
export const CARET = '⌖';
|
|
5
|
+
|
|
6
|
+
export interface SnippetResult {
|
|
7
|
+
readonly value: string;
|
|
8
|
+
readonly caret: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Вставляет шаблон в текст поля на место выделения `[start, end)`. Выделенный текст подставляется на место метки `⌖`
|
|
13
|
+
* (так `x` + «√» даёт `\sqrt{x}`), курсор встаёт на метку (после подставленного текста) или в конец шаблона.
|
|
14
|
+
*/
|
|
15
|
+
export function applySnippet(
|
|
16
|
+
value: string,
|
|
17
|
+
start: number,
|
|
18
|
+
end: number,
|
|
19
|
+
snippet: string,
|
|
20
|
+
): SnippetResult {
|
|
21
|
+
const from = Math.max(0, Math.min(start, end, value.length));
|
|
22
|
+
const to = Math.max(from, Math.min(Math.max(start, end), value.length));
|
|
23
|
+
const selected = value.slice(from, to);
|
|
24
|
+
const marker = snippet.indexOf(CARET);
|
|
25
|
+
const body = snippet.split(CARET).join('');
|
|
26
|
+
|
|
27
|
+
if (marker < 0) {
|
|
28
|
+
return { value: value.slice(0, from) + body + value.slice(to), caret: from + body.length };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const before = snippet.slice(0, marker).split(CARET).join('');
|
|
32
|
+
const after = snippet
|
|
33
|
+
.slice(marker + 1)
|
|
34
|
+
.split(CARET)
|
|
35
|
+
.join('');
|
|
36
|
+
const inserted = before + selected + after;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
value: value.slice(0, from) + inserted + value.slice(to),
|
|
40
|
+
caret: from + before.length + selected.length,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useSnippetInsert(
|
|
45
|
+
field: RefObject<HTMLTextAreaElement | null>,
|
|
46
|
+
value: string,
|
|
47
|
+
setValue: (value: string) => void,
|
|
48
|
+
) {
|
|
49
|
+
const caret = useRef<number | null>(null);
|
|
50
|
+
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
const element = field.current;
|
|
53
|
+
|
|
54
|
+
if (caret.current === null || !element) return;
|
|
55
|
+
|
|
56
|
+
element.setSelectionRange(caret.current, caret.current);
|
|
57
|
+
caret.current = null;
|
|
58
|
+
}, [field, value]);
|
|
59
|
+
|
|
60
|
+
return (snippet: string) => {
|
|
61
|
+
const element = field.current;
|
|
62
|
+
const start = element?.selectionStart ?? value.length;
|
|
63
|
+
const end = element?.selectionEnd ?? value.length;
|
|
64
|
+
const result = applySnippet(value, start, end, snippet);
|
|
65
|
+
|
|
66
|
+
caret.current = result.caret;
|
|
67
|
+
setValue(result.value);
|
|
68
|
+
element?.focus();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { CARET } from './snippet';
|
|
2
|
+
|
|
3
|
+
export { CARET };
|
|
4
|
+
|
|
5
|
+
export type SymbolCategoryId =
|
|
6
|
+
'functions' | 'structures' | 'greek' | 'operators' | 'relations' | 'arrows' | 'sets';
|
|
7
|
+
|
|
8
|
+
export interface MathSymbol {
|
|
9
|
+
readonly label: string;
|
|
10
|
+
readonly snippet: string;
|
|
11
|
+
readonly title?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SymbolCategory {
|
|
15
|
+
readonly id: SymbolCategoryId;
|
|
16
|
+
readonly icon: string;
|
|
17
|
+
readonly items: readonly MathSymbol[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sym = (label: string, command: string): MathSymbol => ({ label, snippet: `${command} ` });
|
|
21
|
+
|
|
22
|
+
const greek = (names: readonly [string, string][]): MathSymbol[] =>
|
|
23
|
+
names.map(([label, name]) => sym(label, `\\${name}`));
|
|
24
|
+
|
|
25
|
+
const fn = (name: string, label = name): MathSymbol => ({ label, snippet: `\\${name}(${CARET})` });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Палитра математических символов и шаблонов для редактора формул. Это данные: код LaTeX после вставки правится
|
|
29
|
+
* как обычный текст. Состав подобран из того, что поддерживает Temml/KaTeX.
|
|
30
|
+
*/
|
|
31
|
+
export const SYMBOL_CATEGORIES: readonly SymbolCategory[] = [
|
|
32
|
+
{
|
|
33
|
+
id: 'functions',
|
|
34
|
+
icon: 'sin',
|
|
35
|
+
items: [
|
|
36
|
+
fn('sin'),
|
|
37
|
+
fn('cos'),
|
|
38
|
+
fn('tan'),
|
|
39
|
+
fn('cot'),
|
|
40
|
+
fn('sec'),
|
|
41
|
+
fn('csc'),
|
|
42
|
+
fn('arcsin', 'asin'),
|
|
43
|
+
fn('arccos', 'acos'),
|
|
44
|
+
fn('arctan', 'atan'),
|
|
45
|
+
fn('sinh'),
|
|
46
|
+
fn('cosh'),
|
|
47
|
+
fn('tanh'),
|
|
48
|
+
fn('log'),
|
|
49
|
+
fn('ln'),
|
|
50
|
+
fn('exp'),
|
|
51
|
+
{ label: 'log₍ₙ₎', snippet: `\\log_{${CARET}}`, title: '\\log_{ }' },
|
|
52
|
+
{ label: 'lim', snippet: `\\lim_{${CARET} \\to \\infty}` },
|
|
53
|
+
fn('max'),
|
|
54
|
+
fn('min'),
|
|
55
|
+
fn('det'),
|
|
56
|
+
fn('gcd'),
|
|
57
|
+
fn('arg'),
|
|
58
|
+
fn('deg'),
|
|
59
|
+
{ label: 'mod', snippet: '\\bmod ' },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'structures',
|
|
64
|
+
icon: 'a/b',
|
|
65
|
+
items: [
|
|
66
|
+
{ label: 'a/b', snippet: `\\frac{${CARET}}{ }`, title: '\\frac{ }{ }' },
|
|
67
|
+
{ label: '√', snippet: `\\sqrt{${CARET}}` },
|
|
68
|
+
{ label: 'ⁿ√', snippet: `\\sqrt[n]{${CARET}}` },
|
|
69
|
+
{ label: 'xⁿ', snippet: `^{${CARET}}`, title: '^{ }' },
|
|
70
|
+
{ label: 'xₙ', snippet: `_{${CARET}}`, title: '_{ }' },
|
|
71
|
+
{ label: 'xₙⁿ', snippet: `_{${CARET}}^{ }` },
|
|
72
|
+
{ label: '∑', snippet: `\\sum_{i=1}^{n} ${CARET}`, title: '\\sum_{i=1}^{n}' },
|
|
73
|
+
{ label: '∏', snippet: `\\prod_{i=1}^{n} ${CARET}`, title: '\\prod_{i=1}^{n}' },
|
|
74
|
+
{ label: '∫', snippet: `\\int_{a}^{b} ${CARET} \\, dx`, title: '\\int_{a}^{b} \\, dx' },
|
|
75
|
+
{ label: '∬', snippet: `\\iint ${CARET} \\, dA` },
|
|
76
|
+
{ label: '∮', snippet: `\\oint ${CARET} \\, d\\ell` },
|
|
77
|
+
{ label: 'ⁿ⁄ₖ', snippet: `\\binom{n}{k}` },
|
|
78
|
+
{ label: '(a)', snippet: `\\left( ${CARET} \\right)`, title: '\\left( \\right)' },
|
|
79
|
+
{ label: '[a]', snippet: `\\left[ ${CARET} \\right]`, title: '\\left[ \\right]' },
|
|
80
|
+
{ label: '{a}', snippet: `\\left\\{ ${CARET} \\right\\}`, title: '\\left\\{ \\right\\}' },
|
|
81
|
+
{ label: '|a|', snippet: `\\left| ${CARET} \\right|`, title: '\\left| \\right|' },
|
|
82
|
+
{ label: '‖a‖', snippet: `\\left\\| ${CARET} \\right\\|`, title: '\\left\\| \\right\\|' },
|
|
83
|
+
{ label: '⌊a⌋', snippet: `\\lfloor ${CARET} \\rfloor` },
|
|
84
|
+
{ label: '⌈a⌉', snippet: `\\lceil ${CARET} \\rceil` },
|
|
85
|
+
{ label: 'v⃗', snippet: `\\vec{${CARET}}` },
|
|
86
|
+
{ label: 'ā', snippet: `\\bar{${CARET}}` },
|
|
87
|
+
{ label: 'â', snippet: `\\hat{${CARET}}` },
|
|
88
|
+
{ label: 'ȧ', snippet: `\\dot{${CARET}}` },
|
|
89
|
+
{ label: 'a͞b', snippet: `\\overline{${CARET}}` },
|
|
90
|
+
{
|
|
91
|
+
label: '(⋮)',
|
|
92
|
+
snippet: `\\begin{pmatrix} ${CARET} & \\\\ & \\end{pmatrix}`,
|
|
93
|
+
title: 'pmatrix',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
label: '[⋮]',
|
|
97
|
+
snippet: `\\begin{bmatrix} ${CARET} & \\\\ & \\end{bmatrix}`,
|
|
98
|
+
title: 'bmatrix',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
label: '{⋮',
|
|
102
|
+
snippet: `\\begin{cases} ${CARET} & \\text{if } \\\\ & \\text{otherwise} \\end{cases}`,
|
|
103
|
+
title: 'cases',
|
|
104
|
+
},
|
|
105
|
+
{ label: 'text', snippet: `\\text{${CARET}}` },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'greek',
|
|
110
|
+
icon: 'αβ',
|
|
111
|
+
items: [
|
|
112
|
+
...greek([
|
|
113
|
+
['α', 'alpha'],
|
|
114
|
+
['β', 'beta'],
|
|
115
|
+
['γ', 'gamma'],
|
|
116
|
+
['δ', 'delta'],
|
|
117
|
+
['ε', 'epsilon'],
|
|
118
|
+
['ζ', 'zeta'],
|
|
119
|
+
['η', 'eta'],
|
|
120
|
+
['θ', 'theta'],
|
|
121
|
+
['ι', 'iota'],
|
|
122
|
+
['κ', 'kappa'],
|
|
123
|
+
['λ', 'lambda'],
|
|
124
|
+
['μ', 'mu'],
|
|
125
|
+
['ν', 'nu'],
|
|
126
|
+
['ξ', 'xi'],
|
|
127
|
+
['π', 'pi'],
|
|
128
|
+
['ρ', 'rho'],
|
|
129
|
+
['σ', 'sigma'],
|
|
130
|
+
['τ', 'tau'],
|
|
131
|
+
['υ', 'upsilon'],
|
|
132
|
+
['φ', 'phi'],
|
|
133
|
+
['χ', 'chi'],
|
|
134
|
+
['ψ', 'psi'],
|
|
135
|
+
['ω', 'omega'],
|
|
136
|
+
['ϵ', 'varepsilon'],
|
|
137
|
+
['ϑ', 'vartheta'],
|
|
138
|
+
['ϕ', 'varphi'],
|
|
139
|
+
['ϱ', 'varrho'],
|
|
140
|
+
['ς', 'varsigma'],
|
|
141
|
+
['Γ', 'Gamma'],
|
|
142
|
+
['Δ', 'Delta'],
|
|
143
|
+
['Θ', 'Theta'],
|
|
144
|
+
['Λ', 'Lambda'],
|
|
145
|
+
['Ξ', 'Xi'],
|
|
146
|
+
['Π', 'Pi'],
|
|
147
|
+
['Σ', 'Sigma'],
|
|
148
|
+
['Φ', 'Phi'],
|
|
149
|
+
['Ψ', 'Psi'],
|
|
150
|
+
['Ω', 'Omega'],
|
|
151
|
+
]),
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'operators',
|
|
156
|
+
icon: '±',
|
|
157
|
+
items: [
|
|
158
|
+
sym('±', '\\pm'),
|
|
159
|
+
sym('∓', '\\mp'),
|
|
160
|
+
sym('×', '\\times'),
|
|
161
|
+
sym('÷', '\\div'),
|
|
162
|
+
sym('·', '\\cdot'),
|
|
163
|
+
sym('∗', '\\ast'),
|
|
164
|
+
sym('∘', '\\circ'),
|
|
165
|
+
sym('⊕', '\\oplus'),
|
|
166
|
+
sym('⊗', '\\otimes'),
|
|
167
|
+
sym('∞', '\\infty'),
|
|
168
|
+
sym('∂', '\\partial'),
|
|
169
|
+
sym('∇', '\\nabla'),
|
|
170
|
+
sym('′', "'"),
|
|
171
|
+
sym('°', '^\\circ'),
|
|
172
|
+
sym('ℓ', '\\ell'),
|
|
173
|
+
sym('ℏ', '\\hbar'),
|
|
174
|
+
sym('…', '\\ldots'),
|
|
175
|
+
sym('⋯', '\\cdots'),
|
|
176
|
+
sym('⋮', '\\vdots'),
|
|
177
|
+
sym('⋱', '\\ddots'),
|
|
178
|
+
sym('†', '\\dagger'),
|
|
179
|
+
sym('%', '\\%'),
|
|
180
|
+
sym('∠', '\\angle'),
|
|
181
|
+
sym('△', '\\triangle'),
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'relations',
|
|
186
|
+
icon: '≤',
|
|
187
|
+
items: [
|
|
188
|
+
sym('≠', '\\neq'),
|
|
189
|
+
sym('≈', '\\approx'),
|
|
190
|
+
sym('≡', '\\equiv'),
|
|
191
|
+
sym('∼', '\\sim'),
|
|
192
|
+
sym('≃', '\\simeq'),
|
|
193
|
+
sym('≅', '\\cong'),
|
|
194
|
+
sym('∝', '\\propto'),
|
|
195
|
+
sym('≤', '\\leq'),
|
|
196
|
+
sym('≥', '\\geq'),
|
|
197
|
+
sym('≪', '\\ll'),
|
|
198
|
+
sym('≫', '\\gg'),
|
|
199
|
+
sym('<', '<'),
|
|
200
|
+
sym('>', '>'),
|
|
201
|
+
sym('∈', '\\in'),
|
|
202
|
+
sym('∉', '\\notin'),
|
|
203
|
+
sym('∋', '\\ni'),
|
|
204
|
+
sym('⊂', '\\subset'),
|
|
205
|
+
sym('⊆', '\\subseteq'),
|
|
206
|
+
sym('⊃', '\\supset'),
|
|
207
|
+
sym('⊇', '\\supseteq'),
|
|
208
|
+
sym('∣', '\\mid'),
|
|
209
|
+
sym('∥', '\\parallel'),
|
|
210
|
+
sym('⊥', '\\perp'),
|
|
211
|
+
sym(':=', ':='),
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: 'arrows',
|
|
216
|
+
icon: '→',
|
|
217
|
+
items: [
|
|
218
|
+
sym('→', '\\to'),
|
|
219
|
+
sym('←', '\\leftarrow'),
|
|
220
|
+
sym('↔', '\\leftrightarrow'),
|
|
221
|
+
sym('⇒', '\\Rightarrow'),
|
|
222
|
+
sym('⇐', '\\Leftarrow'),
|
|
223
|
+
sym('⇔', '\\Leftrightarrow'),
|
|
224
|
+
sym('↦', '\\mapsto'),
|
|
225
|
+
sym('↑', '\\uparrow'),
|
|
226
|
+
sym('↓', '\\downarrow'),
|
|
227
|
+
sym('⟶', '\\longrightarrow'),
|
|
228
|
+
sym('⟹', '\\Longrightarrow'),
|
|
229
|
+
sym('⇌', '\\rightleftharpoons'),
|
|
230
|
+
sym('↪', '\\hookrightarrow'),
|
|
231
|
+
sym('∴', '\\therefore'),
|
|
232
|
+
sym('∵', '\\because'),
|
|
233
|
+
sym('↗', '\\nearrow'),
|
|
234
|
+
sym('↘', '\\searrow'),
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'sets',
|
|
239
|
+
icon: 'ℝ',
|
|
240
|
+
items: [
|
|
241
|
+
sym('∅', '\\emptyset'),
|
|
242
|
+
sym('ℕ', '\\mathbb{N}'),
|
|
243
|
+
sym('ℤ', '\\mathbb{Z}'),
|
|
244
|
+
sym('ℚ', '\\mathbb{Q}'),
|
|
245
|
+
sym('ℝ', '\\mathbb{R}'),
|
|
246
|
+
sym('ℂ', '\\mathbb{C}'),
|
|
247
|
+
sym('∪', '\\cup'),
|
|
248
|
+
sym('∩', '\\cap'),
|
|
249
|
+
sym('∖', '\\setminus'),
|
|
250
|
+
sym('∀', '\\forall'),
|
|
251
|
+
sym('∃', '\\exists'),
|
|
252
|
+
sym('∄', '\\nexists'),
|
|
253
|
+
sym('¬', '\\neg'),
|
|
254
|
+
sym('∧', '\\land'),
|
|
255
|
+
sym('∨', '\\lor'),
|
|
256
|
+
sym('⊤', '\\top'),
|
|
257
|
+
sym('⊢', '\\vdash'),
|
|
258
|
+
sym('⊨', '\\models'),
|
|
259
|
+
sym('ℵ', '\\aleph'),
|
|
260
|
+
sym('𝒫', '\\mathcal{P}'),
|
|
261
|
+
sym('𝔼', '\\mathbb{E}'),
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
];
|