@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,77 @@
|
|
|
1
|
+
export const MAX_DEPTH = 60;
|
|
2
|
+
|
|
3
|
+
export const SKIP_TAGS = new Set([
|
|
4
|
+
'MATH',
|
|
5
|
+
'SCRIPT',
|
|
6
|
+
'STYLE',
|
|
7
|
+
'HEAD',
|
|
8
|
+
'TEMPLATE',
|
|
9
|
+
'NOSCRIPT',
|
|
10
|
+
'IFRAME',
|
|
11
|
+
'OBJECT',
|
|
12
|
+
'EMBED',
|
|
13
|
+
'SVG',
|
|
14
|
+
'CANVAS',
|
|
15
|
+
'IMG',
|
|
16
|
+
'VIDEO',
|
|
17
|
+
'AUDIO',
|
|
18
|
+
'TITLE',
|
|
19
|
+
'META',
|
|
20
|
+
'LINK',
|
|
21
|
+
'INPUT',
|
|
22
|
+
'BUTTON',
|
|
23
|
+
'SELECT',
|
|
24
|
+
'TEXTAREA',
|
|
25
|
+
'FORM',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const BLOCK_TAGS = new Set([
|
|
29
|
+
'P',
|
|
30
|
+
'DIV',
|
|
31
|
+
'H1',
|
|
32
|
+
'H2',
|
|
33
|
+
'H3',
|
|
34
|
+
'H4',
|
|
35
|
+
'H5',
|
|
36
|
+
'H6',
|
|
37
|
+
'UL',
|
|
38
|
+
'OL',
|
|
39
|
+
'LI',
|
|
40
|
+
'BLOCKQUOTE',
|
|
41
|
+
'PRE',
|
|
42
|
+
'HR',
|
|
43
|
+
'TABLE',
|
|
44
|
+
'TR',
|
|
45
|
+
'SECTION',
|
|
46
|
+
'ARTICLE',
|
|
47
|
+
'HEADER',
|
|
48
|
+
'FOOTER',
|
|
49
|
+
'MAIN',
|
|
50
|
+
'ASIDE',
|
|
51
|
+
'NAV',
|
|
52
|
+
'FIGURE',
|
|
53
|
+
'FIGCAPTION',
|
|
54
|
+
'DL',
|
|
55
|
+
'DT',
|
|
56
|
+
'DD',
|
|
57
|
+
'ADDRESS',
|
|
58
|
+
'FIELDSET',
|
|
59
|
+
'BODY',
|
|
60
|
+
'HTML',
|
|
61
|
+
'TBODY',
|
|
62
|
+
'THEAD',
|
|
63
|
+
'TFOOT',
|
|
64
|
+
'CAPTION',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** Имя тега в верхнем регистре: у SVG/MathML элементов tagName строчный. */
|
|
68
|
+
export const tagOf = (element: Element): string => element.localName.toUpperCase();
|
|
69
|
+
|
|
70
|
+
export interface Style {
|
|
71
|
+
bold?: boolean;
|
|
72
|
+
italic?: boolean;
|
|
73
|
+
underline?: boolean;
|
|
74
|
+
strike?: boolean;
|
|
75
|
+
code?: boolean;
|
|
76
|
+
link?: string;
|
|
77
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Fragment, type FragmentBlock, sanitizeFragment } from '@inkbrick/engine';
|
|
2
|
+
import { collect } from './html/blocks';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HTML из буфера обмена → фрагмент Inkbrick.
|
|
6
|
+
*
|
|
7
|
+
* Разметка НИКОГДА не вставляется в DOM: HTML разбирается в изолированном документе (DOMParser: скрипты не
|
|
8
|
+
* выполняются, картинки не загружаются), из него читается только структура и текст. Неизвестные теги
|
|
9
|
+
* сводятся к тексту, ссылки проходят проверку, стили учитываются только для жирного/курсива/подчёркивания.
|
|
10
|
+
* Понимает разметку Google Docs и Word (списки `mso-list`, обёртки `<b style="font-weight:normal">`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MAX_HTML = 2_000_000;
|
|
14
|
+
|
|
15
|
+
export function embeddedFragment(html: string): Fragment | null {
|
|
16
|
+
if (!html.includes('data-inkbrick-fragment')) return null;
|
|
17
|
+
|
|
18
|
+
const doc = new DOMParser().parseFromString(html.slice(0, MAX_HTML), 'text/html');
|
|
19
|
+
const raw = doc.querySelector('[data-inkbrick-fragment]')?.getAttribute('data-inkbrick-fragment');
|
|
20
|
+
|
|
21
|
+
if (!raw) return null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return sanitizeFragment(JSON.parse(decodeURIComponent(raw)));
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function htmlToFragment(html: string): Fragment | null {
|
|
31
|
+
if (!html || html.length > MAX_HTML) return null;
|
|
32
|
+
|
|
33
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
34
|
+
const blocks: FragmentBlock[] = [];
|
|
35
|
+
|
|
36
|
+
collect(doc.body, {}, blocks, 0);
|
|
37
|
+
|
|
38
|
+
const sanitized = sanitizeFragment({ blocks: blocks.map((b) => ({ ...b, complete: true })) });
|
|
39
|
+
|
|
40
|
+
return sanitized;
|
|
41
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
import { normalizeLinkUrl } from '@inkbrick/document';
|
|
4
|
+
import {
|
|
5
|
+
FRAGMENT_MIME,
|
|
6
|
+
fragmentToHtml,
|
|
7
|
+
fragmentToPlainText,
|
|
8
|
+
sanitizeFragment,
|
|
9
|
+
type CaretResult,
|
|
10
|
+
type Fragment,
|
|
11
|
+
} from '@inkbrick/engine';
|
|
12
|
+
|
|
13
|
+
import { blockAtPoint, probePoint } from '../block-handle/geometry';
|
|
14
|
+
import { createInputActions } from '../editing/inputActions';
|
|
15
|
+
import { readDocSelection, type DocSelection } from '../editing/selection';
|
|
16
|
+
import { useEditor } from '../hooks/useEditor';
|
|
17
|
+
import { useEditorView } from '../hooks/useEditorView';
|
|
18
|
+
import { insertImageFiles } from '../images/insertImages';
|
|
19
|
+
import { isAcceptedImage } from '../images/upload';
|
|
20
|
+
import { setLinkInSelection } from '../toolbar/formatting';
|
|
21
|
+
import { useEditorUI } from '../ui/EditorUIContext';
|
|
22
|
+
import { embeddedFragment, htmlToFragment } from './htmlToFragment';
|
|
23
|
+
|
|
24
|
+
const URL_ONLY = /^(https?:\/\/|mailto:)\S+$/i;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Текстовое поле внутри пользовательского компонента (подпись картинки, свои input) работает с буфером как обычно.
|
|
28
|
+
* Смотрим на сфокусированный элемент: у события copy цель может быть кнопкой, внутри которой начинается
|
|
29
|
+
* выделение (значок callout, панель картинки), и это не повод отказываться от копирования блоков.
|
|
30
|
+
*/
|
|
31
|
+
function isForeignControl(_target: EventTarget | null): boolean {
|
|
32
|
+
const active = document.activeElement;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
active instanceof Element &&
|
|
36
|
+
active.closest('input, textarea, select, [data-inkbrick-ignore-input]') !== null
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJson(text: string): unknown {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(text);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Копирование, вырезание и вставка.
|
|
50
|
+
*
|
|
51
|
+
* Копирование кладёт в буфер три формата: простой текст, семантический HTML (для Word, Docs, почты) и точный
|
|
52
|
+
* фрагмент Inkbrick (типы, props, вложенность — для вставки в другой Inkbrick). Вставка выбирает лучший из доступных:
|
|
53
|
+
* точный фрагмент → HTML → простой текст. `Ctrl/Cmd+Shift+V` вставляет только текст. Всё, что пришло из
|
|
54
|
+
* буфера, считается недоверенным и проверяется. Копирование работает и в режиме только чтения.
|
|
55
|
+
*/
|
|
56
|
+
export function useClipboard(ref: RefObject<HTMLElement | null>, readOnly: boolean) {
|
|
57
|
+
const editor = useEditor();
|
|
58
|
+
const view = useEditorView();
|
|
59
|
+
const { images, slashItems } = useEditorUI();
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const root = ref.current;
|
|
63
|
+
|
|
64
|
+
if (!root) return;
|
|
65
|
+
|
|
66
|
+
let plainNext = false;
|
|
67
|
+
let plainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
+
|
|
69
|
+
const { exec, run, deleteSelection, insertAt } = createInputActions(
|
|
70
|
+
editor,
|
|
71
|
+
view,
|
|
72
|
+
root,
|
|
73
|
+
slashItems,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const writeSelection = (event: ClipboardEvent, cut: boolean) => {
|
|
77
|
+
if (isForeignControl(event.target)) return;
|
|
78
|
+
|
|
79
|
+
const selection = readDocSelection(root);
|
|
80
|
+
|
|
81
|
+
if (!selection || selection.collapsed || !event.clipboardData) return;
|
|
82
|
+
|
|
83
|
+
const fragment = editor.copyFragment(selection.from, selection.to);
|
|
84
|
+
|
|
85
|
+
if (fragment.blocks.length === 0) return;
|
|
86
|
+
|
|
87
|
+
const json = JSON.stringify(fragment);
|
|
88
|
+
|
|
89
|
+
event.clipboardData.setData('text/plain', fragmentToPlainText(fragment));
|
|
90
|
+
event.clipboardData.setData('text/html', fragmentToHtml(fragment, encodeURIComponent(json)));
|
|
91
|
+
event.clipboardData.setData(FRAGMENT_MIME, json);
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
|
|
94
|
+
if (cut && !readOnly) run(() => editor.batch(() => deleteSelection(selection)));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const pastePlain = (text: string, selection: DocSelection) => {
|
|
98
|
+
const trimmed = text.trim();
|
|
99
|
+
|
|
100
|
+
// Адрес поверх выделенного текста делает его ссылкой (как в Slack/Notion), а не заменяет.
|
|
101
|
+
if (
|
|
102
|
+
!selection.collapsed &&
|
|
103
|
+
selection.from.blockId === selection.to.blockId &&
|
|
104
|
+
URL_ONLY.test(trimmed) &&
|
|
105
|
+
normalizeLinkUrl(trimmed) &&
|
|
106
|
+
editor.store.getBlock(selection.from.blockId)?.flavour !== 'block:code' &&
|
|
107
|
+
setLinkInSelection(editor, view, selection, trimmed)
|
|
108
|
+
) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
113
|
+
|
|
114
|
+
run(() =>
|
|
115
|
+
editor.batch(() => {
|
|
116
|
+
let caret: CaretResult | undefined = selection.collapsed
|
|
117
|
+
? { blockId: selection.from.blockId, offset: selection.from.offset }
|
|
118
|
+
: deleteSelection(selection);
|
|
119
|
+
|
|
120
|
+
if (!caret) return undefined;
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
* Где Enter вставляет перевод строки (код), текст вставляется целиком: деление по строкам вывело бы
|
|
124
|
+
* из блока на первой же пустой строке.
|
|
125
|
+
*/
|
|
126
|
+
const flavour = editor.store.getBlock(caret.blockId)?.flavour ?? '';
|
|
127
|
+
|
|
128
|
+
if (editor.schema.getBlockSchema(flavour)?.enter?.mode === 'newline') {
|
|
129
|
+
return insertAt(caret.blockId, caret.offset, lines.join('\n'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
caret = insertAt(caret.blockId, caret.offset, lines[0]);
|
|
133
|
+
|
|
134
|
+
for (const line of lines.slice(1)) {
|
|
135
|
+
if (!caret) return undefined;
|
|
136
|
+
|
|
137
|
+
const next: CaretResult | undefined = exec<CaretResult>({
|
|
138
|
+
type: 'splitBlock',
|
|
139
|
+
payload: { blockId: caret.blockId, offset: caret.offset },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!next) return caret;
|
|
143
|
+
|
|
144
|
+
caret = insertAt(next.blockId, next.offset, line);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return caret;
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const pasteFragment = (fragment: Fragment, selection: DocSelection): boolean => {
|
|
153
|
+
// Проверяем заранее: иначе после удаления выделения вставка могла бы отказать.
|
|
154
|
+
if (!editor.can('pasteFragment', { at: selection.from, fragment })) return false;
|
|
155
|
+
|
|
156
|
+
run(() =>
|
|
157
|
+
editor.batch(() => {
|
|
158
|
+
const caret: CaretResult | undefined = selection.collapsed
|
|
159
|
+
? { blockId: selection.from.blockId, offset: selection.from.offset }
|
|
160
|
+
: deleteSelection(selection);
|
|
161
|
+
|
|
162
|
+
return caret
|
|
163
|
+
? exec<CaretResult>({ type: 'pasteFragment', payload: { at: caret, fragment } })
|
|
164
|
+
: undefined;
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return true;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
172
|
+
if (isForeignControl(event.target)) return;
|
|
173
|
+
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
|
|
176
|
+
const data = event.clipboardData;
|
|
177
|
+
const selection = readDocSelection(root);
|
|
178
|
+
|
|
179
|
+
if (readOnly || !data || !selection) return;
|
|
180
|
+
|
|
181
|
+
const text = data.getData('text/plain');
|
|
182
|
+
|
|
183
|
+
/*
|
|
184
|
+
* Картинка в буфере (снимок экрана, «копировать изображение»): загружаем файл. Если рядом есть текст — это
|
|
185
|
+
* обычная вставка (например, из таблицы), и файлы игнорируются.
|
|
186
|
+
*/
|
|
187
|
+
const files = [...data.files].filter((file) => isAcceptedImage(file, images));
|
|
188
|
+
|
|
189
|
+
if (files.length > 0 && text.trim() === '') {
|
|
190
|
+
plainNext = false;
|
|
191
|
+
|
|
192
|
+
insertImageFiles(editor, view, images, files, selection.to.blockId);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const plainOnly = plainNext;
|
|
197
|
+
|
|
198
|
+
plainNext = false;
|
|
199
|
+
|
|
200
|
+
const inCode = editor.store.getBlock(selection.from.blockId)?.flavour === 'block:code';
|
|
201
|
+
|
|
202
|
+
if (!plainOnly && !inCode) {
|
|
203
|
+
const json = data.getData(FRAGMENT_MIME);
|
|
204
|
+
const html = data.getData('text/html');
|
|
205
|
+
|
|
206
|
+
const fragment =
|
|
207
|
+
(json ? sanitizeFragment(parseJson(json)) : null) ??
|
|
208
|
+
(html ? (embeddedFragment(html) ?? htmlToFragment(html)) : null);
|
|
209
|
+
|
|
210
|
+
if (fragment && pasteFragment(fragment, selection)) return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (text) pastePlain(text, selection);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
217
|
+
// В режиме чтения Backspace на документе ничего не делает (в WebKit он иначе уходит назад по истории).
|
|
218
|
+
if (readOnly && event.key === 'Backspace' && !isForeignControl(event.target)) {
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (
|
|
224
|
+
readOnly &&
|
|
225
|
+
(event.ctrlKey || event.metaKey) &&
|
|
226
|
+
!event.shiftKey &&
|
|
227
|
+
event.key.toLowerCase() === 'a'
|
|
228
|
+
) {
|
|
229
|
+
const selection = globalThis.getSelection();
|
|
230
|
+
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
selection?.removeAllRanges();
|
|
233
|
+
|
|
234
|
+
const range = document.createRange();
|
|
235
|
+
range.selectNodeContents(root);
|
|
236
|
+
selection?.addRange(range);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'v') {
|
|
241
|
+
plainNext = true;
|
|
242
|
+
clearTimeout(plainTimer);
|
|
243
|
+
plainTimer = setTimeout(() => {
|
|
244
|
+
plainNext = false;
|
|
245
|
+
}, 1000);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Перетаскивание картинок из файлового менеджера. Без обработки браузер открыл бы файл вместо редактора.
|
|
250
|
+
const hasFiles = (event: DragEvent) =>
|
|
251
|
+
!!event.dataTransfer && [...event.dataTransfer.types].includes('Files');
|
|
252
|
+
|
|
253
|
+
const onDragOver = (event: DragEvent) => {
|
|
254
|
+
if (hasFiles(event)) event.preventDefault();
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const onDrop = (event: DragEvent) => {
|
|
258
|
+
if (!hasFiles(event)) return;
|
|
259
|
+
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
|
|
262
|
+
const files = [...(event.dataTransfer?.files ?? [])].filter((file) =>
|
|
263
|
+
isAcceptedImage(file, images),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
if (readOnly || files.length === 0) return;
|
|
267
|
+
|
|
268
|
+
const probe = probePoint(view, event.clientX, event.clientY);
|
|
269
|
+
const target =
|
|
270
|
+
(probe ? blockAtPoint(editor, view, probe.x, probe.y) : null) ??
|
|
271
|
+
readDocSelection(root)?.to.blockId;
|
|
272
|
+
|
|
273
|
+
if (target) insertImageFiles(editor, view, images, files, target);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const onCopy = (event: ClipboardEvent) => writeSelection(event, false);
|
|
277
|
+
const onCut = (event: ClipboardEvent) => writeSelection(event, true);
|
|
278
|
+
|
|
279
|
+
root.addEventListener('copy', onCopy);
|
|
280
|
+
root.addEventListener('cut', onCut);
|
|
281
|
+
root.addEventListener('paste', onPaste);
|
|
282
|
+
root.addEventListener('keydown', onKeyDown);
|
|
283
|
+
root.addEventListener('dragover', onDragOver);
|
|
284
|
+
root.addEventListener('drop', onDrop);
|
|
285
|
+
|
|
286
|
+
return () => {
|
|
287
|
+
clearTimeout(plainTimer);
|
|
288
|
+
root.removeEventListener('copy', onCopy);
|
|
289
|
+
root.removeEventListener('cut', onCut);
|
|
290
|
+
root.removeEventListener('paste', onPaste);
|
|
291
|
+
root.removeEventListener('keydown', onKeyDown);
|
|
292
|
+
root.removeEventListener('dragover', onDragOver);
|
|
293
|
+
root.removeEventListener('drop', onDrop);
|
|
294
|
+
};
|
|
295
|
+
}, [editor, view, ref, readOnly, images, slashItems]);
|
|
296
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ALIASES, LANGUAGES } from './languages';
|
|
2
|
+
import { scanCss } from './scan/css';
|
|
3
|
+
import { scanGeneric } from './scan/generic';
|
|
4
|
+
import { scanHtml } from './scan/html';
|
|
5
|
+
import { scanMarkdown } from './scan/markdown';
|
|
6
|
+
import { type Emit } from './scan/shared';
|
|
7
|
+
import { scanYaml } from './scan/yaml';
|
|
8
|
+
import type { CodeHighlighter, HighlightToken } from './types';
|
|
9
|
+
|
|
10
|
+
/** Длиннее этого текста блок не подсвечивается: подсветка пересчитывается на каждое нажатие клавиши. */
|
|
11
|
+
export const HIGHLIGHT_LIMIT = 100_000;
|
|
12
|
+
|
|
13
|
+
export function normalizeLanguage(language: string): string {
|
|
14
|
+
const name = language.trim().toLowerCase();
|
|
15
|
+
|
|
16
|
+
return ALIASES[name] ?? name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Встроенный подсветчик: без зависимостей, по правилам для распространённых языков (JavaScript/TypeScript, Python,
|
|
21
|
+
* Go, Rust, Java, C/C++, Bash, SQL, JSON, YAML, HTML/XML, CSS, Markdown). Это не полноценный разбор, а быстрая
|
|
22
|
+
* лексическая раскраска; для точности подключите свой `CodeHighlighter` (Shiki, Prism, highlight.js).
|
|
23
|
+
* Неизвестный язык, `plain` и слишком длинный текст дают пустой список токенов.
|
|
24
|
+
*/
|
|
25
|
+
export const highlightCode: CodeHighlighter = (code, language) => {
|
|
26
|
+
const name = normalizeLanguage(language);
|
|
27
|
+
|
|
28
|
+
if (code.length === 0 || code.length > HIGHLIGHT_LIMIT) return [];
|
|
29
|
+
|
|
30
|
+
const tokens: HighlightToken[] = [];
|
|
31
|
+
const emit: Emit = (from, to, type) => {
|
|
32
|
+
if (to > from) tokens.push({ from, to, type });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
switch (name) {
|
|
36
|
+
case 'html':
|
|
37
|
+
scanHtml(code, 0, code.length, emit);
|
|
38
|
+
break;
|
|
39
|
+
case 'css':
|
|
40
|
+
scanCss(code, 0, code.length, emit);
|
|
41
|
+
break;
|
|
42
|
+
case 'markdown':
|
|
43
|
+
scanMarkdown(code, 0, code.length, emit);
|
|
44
|
+
break;
|
|
45
|
+
case 'yaml':
|
|
46
|
+
scanYaml(code, 0, code.length, emit);
|
|
47
|
+
break;
|
|
48
|
+
default: {
|
|
49
|
+
const lang = LANGUAGES[name];
|
|
50
|
+
|
|
51
|
+
if (lang) scanGeneric(code, lang, 0, code.length, emit);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return tokens;
|
|
56
|
+
};
|