@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,57 @@
|
|
|
1
|
+
import { flushSync } from 'react-dom';
|
|
2
|
+
|
|
3
|
+
import type { BlockProps } from '@inkbrick/document';
|
|
4
|
+
import type { CaretResult, Editor } from '@inkbrick/engine';
|
|
5
|
+
|
|
6
|
+
import type { EditorView } from '../editing/EditorView';
|
|
7
|
+
import { readDocSelection } from '../editing/selection';
|
|
8
|
+
import type { SlashMenuItem } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Применяет пункт меню: удаляет «/запрос» и превращает блок в выбранный тип —
|
|
12
|
+
* одним шагом undo. Если превращение невозможно, текст остаётся нетронутым.
|
|
13
|
+
*/
|
|
14
|
+
export function applySlashItem(editor: Editor, view: EditorView, item: SlashMenuItem): void {
|
|
15
|
+
const state = view.slash.getState();
|
|
16
|
+
|
|
17
|
+
if (!state.open || !state.blockId) return;
|
|
18
|
+
|
|
19
|
+
const { blockId, slashOffset } = state;
|
|
20
|
+
const root = view.getRoot();
|
|
21
|
+
const selection = root ? readDocSelection(root) : null;
|
|
22
|
+
const end =
|
|
23
|
+
selection?.collapsed && selection.from.blockId === blockId
|
|
24
|
+
? selection.from.offset
|
|
25
|
+
: slashOffset + 1 + state.query.length;
|
|
26
|
+
|
|
27
|
+
view.slash.close();
|
|
28
|
+
|
|
29
|
+
let caret: CaretResult | undefined;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
flushSync(() => {
|
|
33
|
+
caret = editor.batch(() => {
|
|
34
|
+
editor.dispatch({ type: 'deleteText', payload: { blockId, from: slashOffset, to: end } });
|
|
35
|
+
|
|
36
|
+
const result = editor.dispatch({
|
|
37
|
+
type: 'convertBlock',
|
|
38
|
+
payload: {
|
|
39
|
+
blockId,
|
|
40
|
+
flavour: item.flavour,
|
|
41
|
+
props: item.props as BlockProps | undefined,
|
|
42
|
+
offset: slashOffset,
|
|
43
|
+
},
|
|
44
|
+
}) as CaretResult | undefined;
|
|
45
|
+
|
|
46
|
+
if (!result) throw new Error('convert-failed');
|
|
47
|
+
|
|
48
|
+
return result;
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (!(error instanceof Error) || error.message !== 'convert-failed') throw error;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (caret) view.placeCaret(caret.blockId, caret.offset);
|
|
57
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Icon } from '../icons/Icon';
|
|
2
|
+
import type { TFunction } from '../i18n/translate';
|
|
3
|
+
import type { MessageKey } from '../i18n/en';
|
|
4
|
+
import type { SlashMenuItem } from './types';
|
|
5
|
+
|
|
6
|
+
export const defaultSlashItems: readonly SlashMenuItem[] = Object.freeze([
|
|
7
|
+
{
|
|
8
|
+
id: 'text',
|
|
9
|
+
title: 'Text',
|
|
10
|
+
description: 'Plain paragraph',
|
|
11
|
+
keywords: ['paragraph', 'p'],
|
|
12
|
+
icon: <Icon name="text" />,
|
|
13
|
+
flavour: 'block:paragraph',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: 'heading-1',
|
|
17
|
+
title: 'Heading 1',
|
|
18
|
+
description: 'Large section heading',
|
|
19
|
+
keywords: ['h1', 'title'],
|
|
20
|
+
icon: <Icon name="heading-1" />,
|
|
21
|
+
flavour: 'block:heading',
|
|
22
|
+
props: { level: 1 },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'heading-2',
|
|
26
|
+
title: 'Heading 2',
|
|
27
|
+
description: 'Medium section heading',
|
|
28
|
+
keywords: ['h2', 'subtitle'],
|
|
29
|
+
icon: <Icon name="heading-2" />,
|
|
30
|
+
flavour: 'block:heading',
|
|
31
|
+
props: { level: 2 },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'heading-3',
|
|
35
|
+
title: 'Heading 3',
|
|
36
|
+
description: 'Small section heading',
|
|
37
|
+
keywords: ['h3'],
|
|
38
|
+
icon: <Icon name="heading-3" />,
|
|
39
|
+
flavour: 'block:heading',
|
|
40
|
+
props: { level: 3 },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'heading-4',
|
|
44
|
+
title: 'Heading 4',
|
|
45
|
+
description: 'Subsection heading',
|
|
46
|
+
keywords: ['h4'],
|
|
47
|
+
icon: <Icon name="heading-4" />,
|
|
48
|
+
flavour: 'block:heading',
|
|
49
|
+
props: { level: 4 },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'heading-5',
|
|
53
|
+
title: 'Heading 5',
|
|
54
|
+
description: 'Minor subsection heading',
|
|
55
|
+
keywords: ['h5'],
|
|
56
|
+
icon: <Icon name="heading-5" />,
|
|
57
|
+
flavour: 'block:heading',
|
|
58
|
+
props: { level: 5 },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'heading-6',
|
|
62
|
+
title: 'Heading 6',
|
|
63
|
+
description: 'Smallest heading',
|
|
64
|
+
keywords: ['h6'],
|
|
65
|
+
icon: <Icon name="heading-6" />,
|
|
66
|
+
flavour: 'block:heading',
|
|
67
|
+
props: { level: 6 },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'bullet-list',
|
|
71
|
+
title: 'Bulleted list',
|
|
72
|
+
description: 'Simple bulleted list',
|
|
73
|
+
keywords: ['ul', 'unordered', 'list'],
|
|
74
|
+
icon: <Icon name="list" />,
|
|
75
|
+
flavour: 'block:list',
|
|
76
|
+
props: { type: 'bullet' },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'numbered-list',
|
|
80
|
+
title: 'Numbered list',
|
|
81
|
+
description: 'List with numbering',
|
|
82
|
+
keywords: ['ol', 'ordered', 'list'],
|
|
83
|
+
icon: <Icon name="list-ordered" />,
|
|
84
|
+
flavour: 'block:list',
|
|
85
|
+
props: { type: 'ordered' },
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'todo-list',
|
|
89
|
+
title: 'To-do list',
|
|
90
|
+
description: 'Track tasks with checkboxes',
|
|
91
|
+
keywords: ['todo', 'task', 'checkbox', 'check'],
|
|
92
|
+
icon: <Icon name="list-todo" />,
|
|
93
|
+
flavour: 'block:list',
|
|
94
|
+
props: { type: 'todo', checked: false },
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'quote',
|
|
98
|
+
title: 'Quote',
|
|
99
|
+
description: 'Capture a quote',
|
|
100
|
+
keywords: ['blockquote', 'citation'],
|
|
101
|
+
icon: <Icon name="quote" />,
|
|
102
|
+
flavour: 'block:quote',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'code',
|
|
106
|
+
title: 'Code',
|
|
107
|
+
description: 'Code snippet with monospace font',
|
|
108
|
+
keywords: ['snippet', 'pre', 'program'],
|
|
109
|
+
icon: <Icon name="code" />,
|
|
110
|
+
flavour: 'block:code',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'callout',
|
|
114
|
+
title: 'Callout',
|
|
115
|
+
description: 'Highlight important information',
|
|
116
|
+
keywords: ['info', 'note', 'warning', 'tip', 'alert'],
|
|
117
|
+
icon: <Icon name="info" />,
|
|
118
|
+
flavour: 'block:callout',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'image',
|
|
122
|
+
title: 'Image',
|
|
123
|
+
description: 'Upload or embed an image',
|
|
124
|
+
keywords: ['picture', 'photo', 'img', 'upload'],
|
|
125
|
+
icon: <Icon name="image" />,
|
|
126
|
+
flavour: 'block:image',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'formula',
|
|
130
|
+
title: 'Formula',
|
|
131
|
+
description: 'Display a LaTeX formula',
|
|
132
|
+
keywords: ['math', 'equation', 'latex', 'tex'],
|
|
133
|
+
icon: <Icon name="sigma" />,
|
|
134
|
+
flavour: 'block:formula',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'divider',
|
|
138
|
+
title: 'Divider',
|
|
139
|
+
description: 'Visually separate sections',
|
|
140
|
+
keywords: ['hr', 'line', 'separator', 'rule'],
|
|
141
|
+
icon: <Icon name="divider" />,
|
|
142
|
+
flavour: 'block:divider',
|
|
143
|
+
},
|
|
144
|
+
] satisfies SlashMenuItem[]);
|
|
145
|
+
|
|
146
|
+
const messageKey = (id: string, part: 'title' | 'description' | 'keywords') =>
|
|
147
|
+
`slash.item.${id}.${part}` as MessageKey;
|
|
148
|
+
|
|
149
|
+
const splitKeywords = (value: string) =>
|
|
150
|
+
value
|
|
151
|
+
.split(',')
|
|
152
|
+
.map((word) => word.trim())
|
|
153
|
+
.filter(Boolean);
|
|
154
|
+
|
|
155
|
+
export function localizeSlashItems(t: TFunction): readonly SlashMenuItem[] {
|
|
156
|
+
return defaultSlashItems.map((item) => ({
|
|
157
|
+
...item,
|
|
158
|
+
title: t(messageKey(item.id, 'title')),
|
|
159
|
+
description: t(messageKey(item.id, 'description')),
|
|
160
|
+
// Английские слова остаются в поиске: «h1», «todo» и т. п. работают на любом языке.
|
|
161
|
+
keywords: [
|
|
162
|
+
...new Set([...(item.keywords ?? []), ...splitKeywords(t(messageKey(item.id, 'keywords')))]),
|
|
163
|
+
],
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Итоговый список пунктов: `false` — меню выключено; массив — пункты приложения
|
|
169
|
+
* дополняют стандартные (одинаковый id заменяет стандартный пункт).
|
|
170
|
+
*/
|
|
171
|
+
export function resolveSlashItems(
|
|
172
|
+
custom: readonly SlashMenuItem[] | false | undefined,
|
|
173
|
+
t?: TFunction,
|
|
174
|
+
): readonly SlashMenuItem[] {
|
|
175
|
+
if (custom === false) return [];
|
|
176
|
+
|
|
177
|
+
const defaults = t ? localizeSlashItems(t) : defaultSlashItems;
|
|
178
|
+
|
|
179
|
+
if (!custom || custom.length === 0) return defaults;
|
|
180
|
+
|
|
181
|
+
const overrides = new Map(custom.map((item) => [item.id, item]));
|
|
182
|
+
const merged = defaults.map((item) => overrides.get(item.id) ?? item);
|
|
183
|
+
const extra = custom.filter((item) => !defaultSlashItems.some((d) => d.id === item.id));
|
|
184
|
+
|
|
185
|
+
return [...merged, ...extra];
|
|
186
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SlashMenuItem } from './types';
|
|
2
|
+
|
|
3
|
+
function score(item: SlashMenuItem, query: string): number {
|
|
4
|
+
const title = item.title.toLowerCase();
|
|
5
|
+
const words = [
|
|
6
|
+
title,
|
|
7
|
+
...title.split(/\s+/),
|
|
8
|
+
...(item.keywords ?? []).map((k) => k.toLowerCase()),
|
|
9
|
+
item.id.toLowerCase(),
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
if (title.startsWith(query)) return 4;
|
|
13
|
+
if (words.some((w) => w.startsWith(query))) return 3;
|
|
14
|
+
if (words.some((w) => w.includes(query))) return 1;
|
|
15
|
+
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function filterSlashItems(items: readonly SlashMenuItem[], query: string): SlashMenuItem[] {
|
|
20
|
+
const q = query.trim().toLowerCase();
|
|
21
|
+
|
|
22
|
+
if (q === '') return [...items];
|
|
23
|
+
|
|
24
|
+
return items
|
|
25
|
+
.map((item, index) => ({ item, index, score: score(item, q) }))
|
|
26
|
+
.filter((entry) => entry.score > 0)
|
|
27
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
28
|
+
.map((entry) => entry.item);
|
|
29
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Editor } from '@inkbrick/engine';
|
|
2
|
+
|
|
3
|
+
import { filterSlashItems } from './filter';
|
|
4
|
+
import type { SlashMenuItem } from './types';
|
|
5
|
+
|
|
6
|
+
export function availableSlashItems(
|
|
7
|
+
editor: Editor,
|
|
8
|
+
items: readonly SlashMenuItem[],
|
|
9
|
+
blockId: string,
|
|
10
|
+
): SlashMenuItem[] {
|
|
11
|
+
const block = editor.store.getBlock(blockId);
|
|
12
|
+
const parent = block?.parentId ? editor.store.getBlock(block.parentId) : undefined;
|
|
13
|
+
|
|
14
|
+
if (!block || !parent) return [];
|
|
15
|
+
|
|
16
|
+
return items.filter(
|
|
17
|
+
(item) =>
|
|
18
|
+
editor.schema.hasBlock(item.flavour) &&
|
|
19
|
+
editor.schema.validateRelation(parent.flavour, item.flavour),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function visibleSlashItems(
|
|
24
|
+
editor: Editor,
|
|
25
|
+
items: readonly SlashMenuItem[],
|
|
26
|
+
blockId: string,
|
|
27
|
+
query: string,
|
|
28
|
+
): SlashMenuItem[] {
|
|
29
|
+
return filterSlashItems(availableSlashItems(editor, items, blockId), query);
|
|
30
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Editor } from '@inkbrick/engine';
|
|
2
|
+
|
|
3
|
+
import type { EditorView } from '../editing/EditorView';
|
|
4
|
+
import { readDocSelection } from '../editing/selection';
|
|
5
|
+
import { visibleSlashItems } from './items';
|
|
6
|
+
import type { SlashMenuItem } from './types';
|
|
7
|
+
|
|
8
|
+
function caretPosition(view: EditorView, blockId: string): { x: number; y: number } | null {
|
|
9
|
+
const selection = globalThis.getSelection();
|
|
10
|
+
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
|
11
|
+
const rect = range?.getClientRects()[0];
|
|
12
|
+
|
|
13
|
+
if (rect && (rect.width > 0 || rect.height > 0)) return { x: rect.left, y: rect.bottom };
|
|
14
|
+
|
|
15
|
+
const element = view.getElement(blockId);
|
|
16
|
+
|
|
17
|
+
if (!element) return null;
|
|
18
|
+
|
|
19
|
+
const box = element.getBoundingClientRect();
|
|
20
|
+
|
|
21
|
+
return { x: box.left, y: box.bottom };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Сверяет меню с текущим состоянием документа и выделением: обновляет запрос и позицию
|
|
26
|
+
* или закрывает меню, если каретка ушла от «/» или «/» удалён.
|
|
27
|
+
*/
|
|
28
|
+
export function syncSlashMenu(
|
|
29
|
+
editor: Editor,
|
|
30
|
+
view: EditorView,
|
|
31
|
+
items: readonly SlashMenuItem[],
|
|
32
|
+
): void {
|
|
33
|
+
const state = view.slash.getState();
|
|
34
|
+
|
|
35
|
+
if (!state.open || !state.blockId) return;
|
|
36
|
+
|
|
37
|
+
const root = view.getRoot();
|
|
38
|
+
const selection = root ? readDocSelection(root) : null;
|
|
39
|
+
const block = editor.store.getBlock(state.blockId);
|
|
40
|
+
const text = block ? editor.engine.inline.toPlainText(block.content) : '';
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
!selection ||
|
|
44
|
+
!selection.collapsed ||
|
|
45
|
+
selection.from.blockId !== state.blockId ||
|
|
46
|
+
selection.from.offset <= state.slashOffset ||
|
|
47
|
+
text[state.slashOffset] !== '/'
|
|
48
|
+
) {
|
|
49
|
+
view.slash.close();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const query = text.slice(state.slashOffset + 1, selection.from.offset);
|
|
54
|
+
const visible = visibleSlashItems(editor, items, state.blockId, query);
|
|
55
|
+
|
|
56
|
+
if (visible.length === 0 && /\s$/.test(query)) {
|
|
57
|
+
view.slash.close();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
view.slash.update({
|
|
62
|
+
query,
|
|
63
|
+
activeIndex:
|
|
64
|
+
query !== state.query ? 0 : Math.min(state.activeIndex, Math.max(visible.length - 1, 0)),
|
|
65
|
+
position: caretPosition(view, state.blockId),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SlashMenuItem {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly title: string;
|
|
6
|
+
readonly description?: string;
|
|
7
|
+
readonly keywords?: readonly string[];
|
|
8
|
+
readonly icon?: ReactNode;
|
|
9
|
+
readonly flavour: string;
|
|
10
|
+
readonly props?: Readonly<Record<string, unknown>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SlashMenuProps {
|
|
14
|
+
readonly items: readonly SlashMenuItem[];
|
|
15
|
+
readonly query: string;
|
|
16
|
+
readonly activeIndex: number;
|
|
17
|
+
readonly position: { readonly x: number; readonly y: number };
|
|
18
|
+
readonly onSelect: (item: SlashMenuItem) => void;
|
|
19
|
+
readonly onHover: (index: number) => void;
|
|
20
|
+
readonly onClose: () => void;
|
|
21
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, type FormEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
import { ALIGNMENTS, COLOR_NAMES, isColorName, type Alignment } from '@inkbrick/document';
|
|
4
|
+
|
|
5
|
+
import { useT } from '../hooks/useT';
|
|
6
|
+
import { Icon, type IconName } from '../icons/Icon';
|
|
7
|
+
import type { InlineFormat, ToolbarPanel, ToolbarProps } from './types';
|
|
8
|
+
|
|
9
|
+
const BUTTONS: readonly { format: InlineFormat; icon: IconName }[] = [
|
|
10
|
+
{ format: 'bold', icon: 'bold' },
|
|
11
|
+
{ format: 'italic', icon: 'italic' },
|
|
12
|
+
{ format: 'underline', icon: 'underline' },
|
|
13
|
+
{ format: 'strike', icon: 'strike' },
|
|
14
|
+
{ format: 'code', icon: 'code' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const ALIGN_ICONS: Record<Alignment, IconName> = {
|
|
18
|
+
left: 'align-left',
|
|
19
|
+
center: 'align-center',
|
|
20
|
+
right: 'align-right',
|
|
21
|
+
justify: 'align-justify',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const PANELS: readonly { panel: ToolbarPanel; icon: IconName }[] = [
|
|
25
|
+
{ panel: 'color', icon: 'baseline' },
|
|
26
|
+
{ panel: 'highlight', icon: 'highlighter' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function DefaultToolbar({
|
|
30
|
+
formats,
|
|
31
|
+
position,
|
|
32
|
+
linkOpen,
|
|
33
|
+
panel,
|
|
34
|
+
onToggle,
|
|
35
|
+
onOpenLink,
|
|
36
|
+
onApplyLink,
|
|
37
|
+
onCancelLink,
|
|
38
|
+
onTogglePanel,
|
|
39
|
+
onApplyColor,
|
|
40
|
+
onAlign,
|
|
41
|
+
onInsertFormula,
|
|
42
|
+
}: ToolbarProps) {
|
|
43
|
+
const t = useT();
|
|
44
|
+
const input = useRef<HTMLInputElement>(null);
|
|
45
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
46
|
+
const [value, setValue] = useState('');
|
|
47
|
+
const [invalid, setInvalid] = useState(false);
|
|
48
|
+
const [below, setBelow] = useState(false);
|
|
49
|
+
const [width, setWidth] = useState(0);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!linkOpen) return;
|
|
53
|
+
|
|
54
|
+
setValue(formats.link ?? '');
|
|
55
|
+
setInvalid(false);
|
|
56
|
+
input.current?.focus();
|
|
57
|
+
input.current?.select();
|
|
58
|
+
}, [linkOpen]);
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* У верхнего края окна показываем под выделением. На сенсорных экранах — всегда под ним: сверху системное
|
|
62
|
+
* меню выделения («Копировать», «Вставить») закрыло бы toolbar.
|
|
63
|
+
*/
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
const coarse = window.matchMedia?.('(pointer: coarse)').matches ?? false;
|
|
66
|
+
|
|
67
|
+
setBelow(coarse || position.y - (ref.current?.offsetHeight ?? 40) - 12 < 0);
|
|
68
|
+
setWidth(ref.current?.offsetWidth ?? 0);
|
|
69
|
+
}, [position.x, position.y, linkOpen, panel]);
|
|
70
|
+
|
|
71
|
+
const left = Math.max(8, Math.min(position.x - width / 2, window.innerWidth - width - 8));
|
|
72
|
+
|
|
73
|
+
const submit = (event: FormEvent) => {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
|
|
76
|
+
if (!onApplyLink(value)) setInvalid(true);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const colorKind = panel === 'highlight' ? 'backgroundColor' : 'color';
|
|
80
|
+
const currentColor = panel === 'highlight' ? formats.backgroundColor : formats.color;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
ref={ref}
|
|
85
|
+
className="inkbrick-toolbar"
|
|
86
|
+
role="toolbar"
|
|
87
|
+
aria-label={t('toolbar.label')}
|
|
88
|
+
data-inkbrick-ui="toolbar"
|
|
89
|
+
data-placement={below ? 'below' : 'above'}
|
|
90
|
+
data-testid="toolbar"
|
|
91
|
+
style={{
|
|
92
|
+
left,
|
|
93
|
+
top: position.y,
|
|
94
|
+
transform: below ? 'translateY(28px)' : 'translateY(calc(-100% - 8px))',
|
|
95
|
+
}}
|
|
96
|
+
onMouseDown={(event) => {
|
|
97
|
+
if (event.target !== input.current) event.preventDefault();
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{linkOpen ? (
|
|
101
|
+
<form className="inkbrick-toolbar__link" data-inkbrick-part="link-form" onSubmit={submit}>
|
|
102
|
+
<input
|
|
103
|
+
ref={input}
|
|
104
|
+
data-inkbrick-part="input"
|
|
105
|
+
data-invalid={invalid || undefined}
|
|
106
|
+
type="text"
|
|
107
|
+
aria-label={t('toolbar.link.url')}
|
|
108
|
+
aria-invalid={invalid}
|
|
109
|
+
placeholder={t('toolbar.link.placeholder')}
|
|
110
|
+
value={value}
|
|
111
|
+
onChange={(event) => {
|
|
112
|
+
setValue(event.target.value);
|
|
113
|
+
setInvalid(false);
|
|
114
|
+
}}
|
|
115
|
+
onKeyDown={(event) => {
|
|
116
|
+
if (event.key === 'Escape') {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
onCancelLink();
|
|
119
|
+
}
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
<button
|
|
123
|
+
type="submit"
|
|
124
|
+
className="inkbrick-toolbar__button"
|
|
125
|
+
data-inkbrick-part="button"
|
|
126
|
+
aria-label={t('toolbar.link.apply')}
|
|
127
|
+
title={t('toolbar.link.apply')}
|
|
128
|
+
>
|
|
129
|
+
<Icon name="check" />
|
|
130
|
+
</button>
|
|
131
|
+
{formats.link && (
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
className="inkbrick-toolbar__button"
|
|
135
|
+
data-inkbrick-part="button"
|
|
136
|
+
aria-label={t('toolbar.link.remove')}
|
|
137
|
+
title={t('toolbar.link.remove')}
|
|
138
|
+
onClick={() => onApplyLink(null)}
|
|
139
|
+
>
|
|
140
|
+
<Icon name="unlink" />
|
|
141
|
+
</button>
|
|
142
|
+
)}
|
|
143
|
+
</form>
|
|
144
|
+
) : (
|
|
145
|
+
<>
|
|
146
|
+
{BUTTONS.map(({ format, icon }) => (
|
|
147
|
+
<button
|
|
148
|
+
key={format}
|
|
149
|
+
type="button"
|
|
150
|
+
className="inkbrick-toolbar__button"
|
|
151
|
+
data-inkbrick-part="button"
|
|
152
|
+
data-format={format}
|
|
153
|
+
data-pressed={formats[format] || undefined}
|
|
154
|
+
aria-label={t(`toolbar.${format}.label`)}
|
|
155
|
+
aria-pressed={formats[format]}
|
|
156
|
+
title={t(`toolbar.${format}.title`)}
|
|
157
|
+
onClick={() => onToggle(format)}
|
|
158
|
+
>
|
|
159
|
+
<Icon name={icon} />
|
|
160
|
+
</button>
|
|
161
|
+
))}
|
|
162
|
+
<span
|
|
163
|
+
className="inkbrick-toolbar__separator"
|
|
164
|
+
data-inkbrick-part="separator"
|
|
165
|
+
role="separator"
|
|
166
|
+
/>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
className="inkbrick-toolbar__button"
|
|
170
|
+
data-inkbrick-part="button"
|
|
171
|
+
data-format="link"
|
|
172
|
+
data-pressed={formats.link !== undefined || undefined}
|
|
173
|
+
aria-label={t('toolbar.link.label')}
|
|
174
|
+
aria-pressed={formats.link !== undefined}
|
|
175
|
+
title={t('toolbar.link.title')}
|
|
176
|
+
onClick={onOpenLink}
|
|
177
|
+
>
|
|
178
|
+
<Icon name="link" />
|
|
179
|
+
</button>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
className="inkbrick-toolbar__button"
|
|
183
|
+
data-inkbrick-part="button"
|
|
184
|
+
data-format="formula"
|
|
185
|
+
aria-label={t('toolbar.formula.label')}
|
|
186
|
+
title={t('toolbar.formula.title')}
|
|
187
|
+
onClick={onInsertFormula}
|
|
188
|
+
>
|
|
189
|
+
<Icon name="sigma" />
|
|
190
|
+
</button>
|
|
191
|
+
<span
|
|
192
|
+
className="inkbrick-toolbar__separator"
|
|
193
|
+
data-inkbrick-part="separator"
|
|
194
|
+
role="separator"
|
|
195
|
+
/>
|
|
196
|
+
{PANELS.map(({ panel: name, icon }) => {
|
|
197
|
+
const current = name === 'color' ? formats.color : formats.backgroundColor;
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<button
|
|
201
|
+
key={name}
|
|
202
|
+
type="button"
|
|
203
|
+
className="inkbrick-toolbar__button"
|
|
204
|
+
data-inkbrick-part="button"
|
|
205
|
+
data-format={name}
|
|
206
|
+
data-pressed={panel === name || undefined}
|
|
207
|
+
data-current-color={isColorName(current) ? current : undefined}
|
|
208
|
+
aria-label={t(`toolbar.${name}.label`)}
|
|
209
|
+
aria-haspopup="true"
|
|
210
|
+
aria-expanded={panel === name}
|
|
211
|
+
title={t(`toolbar.${name}.title`)}
|
|
212
|
+
onClick={() => onTogglePanel(name)}
|
|
213
|
+
>
|
|
214
|
+
<Icon name={icon} />
|
|
215
|
+
<span className="inkbrick-toolbar__indicator" data-inkbrick-part="indicator" />
|
|
216
|
+
</button>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
<span
|
|
220
|
+
className="inkbrick-toolbar__separator"
|
|
221
|
+
data-inkbrick-part="separator"
|
|
222
|
+
role="separator"
|
|
223
|
+
/>
|
|
224
|
+
{ALIGNMENTS.map((align) => (
|
|
225
|
+
<button
|
|
226
|
+
key={align}
|
|
227
|
+
type="button"
|
|
228
|
+
className="inkbrick-toolbar__button"
|
|
229
|
+
data-inkbrick-part="button"
|
|
230
|
+
data-format={`align-${align}`}
|
|
231
|
+
data-pressed={formats.align === align || undefined}
|
|
232
|
+
aria-label={t(`toolbar.align.${align}.label`)}
|
|
233
|
+
aria-pressed={formats.align === align}
|
|
234
|
+
title={t(`toolbar.align.${align}.title`)}
|
|
235
|
+
onClick={() => onAlign(align)}
|
|
236
|
+
>
|
|
237
|
+
<Icon name={ALIGN_ICONS[align]} />
|
|
238
|
+
</button>
|
|
239
|
+
))}
|
|
240
|
+
{panel && (
|
|
241
|
+
<div
|
|
242
|
+
className="inkbrick-toolbar__panel"
|
|
243
|
+
data-inkbrick-part="panel"
|
|
244
|
+
data-kind={panel}
|
|
245
|
+
role="group"
|
|
246
|
+
aria-label={t(`toolbar.${panel}.label`)}
|
|
247
|
+
>
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
data-inkbrick-part="swatch"
|
|
251
|
+
data-color=""
|
|
252
|
+
data-pressed={currentColor === undefined || undefined}
|
|
253
|
+
aria-label={t(
|
|
254
|
+
panel === 'color' ? 'toolbar.color.default' : 'toolbar.highlight.none',
|
|
255
|
+
)}
|
|
256
|
+
title={t(panel === 'color' ? 'toolbar.color.default' : 'toolbar.highlight.none')}
|
|
257
|
+
onClick={() => onApplyColor(colorKind, null)}
|
|
258
|
+
>
|
|
259
|
+
<Icon name="x" />
|
|
260
|
+
</button>
|
|
261
|
+
{COLOR_NAMES.map((name) => (
|
|
262
|
+
<button
|
|
263
|
+
key={name}
|
|
264
|
+
type="button"
|
|
265
|
+
data-inkbrick-part="swatch"
|
|
266
|
+
data-color={name}
|
|
267
|
+
data-pressed={currentColor === name || undefined}
|
|
268
|
+
aria-label={t(`color.${name}`)}
|
|
269
|
+
title={t(`color.${name}`)}
|
|
270
|
+
onClick={() => onApplyColor(colorKind, name)}
|
|
271
|
+
>
|
|
272
|
+
{panel === 'color' ? 'A' : null}
|
|
273
|
+
</button>
|
|
274
|
+
))}
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
</>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|