@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,71 @@
|
|
|
1
|
+
import { useT } from '../hooks/useT';
|
|
2
|
+
import { Icon } from '../icons/Icon';
|
|
3
|
+
import type { BlockHandleProps } from './types';
|
|
4
|
+
|
|
5
|
+
const HANDLE_WIDTH = 52;
|
|
6
|
+
|
|
7
|
+
export function DefaultBlockHandle({
|
|
8
|
+
blockId,
|
|
9
|
+
position,
|
|
10
|
+
canAdd,
|
|
11
|
+
dragging,
|
|
12
|
+
menuOpen,
|
|
13
|
+
onAdd,
|
|
14
|
+
onOpenMenu,
|
|
15
|
+
gripProps,
|
|
16
|
+
}: BlockHandleProps) {
|
|
17
|
+
const t = useT();
|
|
18
|
+
const left = position.x - HANDLE_WIDTH;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
className="inkbrick-block-handle"
|
|
23
|
+
data-inkbrick-ui="block-handle"
|
|
24
|
+
data-testid="block-handle"
|
|
25
|
+
data-block={blockId}
|
|
26
|
+
data-inkbrick-block-ui
|
|
27
|
+
// Слева от текста нет места (узкий экран): ручка ложится поверх текста отдельной плашкой.
|
|
28
|
+
data-compact={left < 2 || undefined}
|
|
29
|
+
style={{
|
|
30
|
+
left: Math.max(2, left),
|
|
31
|
+
top: position.y,
|
|
32
|
+
pointerEvents: dragging ? 'none' : undefined,
|
|
33
|
+
}}
|
|
34
|
+
// Не уводим фокус и выделение из редактора.
|
|
35
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
36
|
+
>
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
className="inkbrick-block-handle__button"
|
|
40
|
+
data-inkbrick-part="button"
|
|
41
|
+
aria-label={t('handle.add')}
|
|
42
|
+
title={t('handle.add')}
|
|
43
|
+
data-action="add"
|
|
44
|
+
disabled={!canAdd}
|
|
45
|
+
onClick={onAdd}
|
|
46
|
+
>
|
|
47
|
+
<Icon name="plus" />
|
|
48
|
+
</button>
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
className="inkbrick-block-handle__button inkbrick-block-handle__grip"
|
|
52
|
+
data-inkbrick-part="grip"
|
|
53
|
+
data-active={menuOpen || undefined}
|
|
54
|
+
aria-label={t('handle.grip')}
|
|
55
|
+
title={t('handle.grip')}
|
|
56
|
+
aria-haspopup="menu"
|
|
57
|
+
aria-expanded={menuOpen}
|
|
58
|
+
data-action="grip"
|
|
59
|
+
{...gripProps}
|
|
60
|
+
onKeyDown={(event) => {
|
|
61
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
onOpenMenu();
|
|
64
|
+
}
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<Icon name="grip" />
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useT } from '../hooks/useT';
|
|
4
|
+
import type { BlockMenuAction, BlockMenuProps } from './types';
|
|
5
|
+
|
|
6
|
+
export function DefaultBlockMenu({ position, actions, turnInto, onClose }: BlockMenuProps) {
|
|
7
|
+
const t = useT();
|
|
8
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
9
|
+
const [flip, setFlip] = useState(false);
|
|
10
|
+
|
|
11
|
+
const entries: BlockMenuAction[] = [...actions, ...turnInto].filter((entry) => !entry.disabled);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
ref.current
|
|
15
|
+
?.querySelector<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])')
|
|
16
|
+
?.focus();
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
const menu = ref.current;
|
|
21
|
+
|
|
22
|
+
if (menu)
|
|
23
|
+
setFlip(
|
|
24
|
+
position.y + menu.offsetHeight > window.innerHeight && position.y - menu.offsetHeight > 0,
|
|
25
|
+
);
|
|
26
|
+
}, [position.y]);
|
|
27
|
+
|
|
28
|
+
const move = (delta: number) => {
|
|
29
|
+
const items = [
|
|
30
|
+
...(ref.current?.querySelectorAll<HTMLElement>(
|
|
31
|
+
'[role="menuitem"]:not([aria-disabled="true"])',
|
|
32
|
+
) ?? []),
|
|
33
|
+
];
|
|
34
|
+
const index = items.indexOf(document.activeElement as HTMLElement);
|
|
35
|
+
|
|
36
|
+
items[(index + delta + items.length) % items.length]?.focus();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const renderItem = (action: BlockMenuAction) => (
|
|
40
|
+
<div
|
|
41
|
+
key={action.id}
|
|
42
|
+
role="menuitem"
|
|
43
|
+
tabIndex={action.disabled ? -1 : 0}
|
|
44
|
+
aria-disabled={action.disabled || undefined}
|
|
45
|
+
data-action={action.id}
|
|
46
|
+
data-inkbrick-part="item"
|
|
47
|
+
data-disabled={action.disabled || undefined}
|
|
48
|
+
data-danger={action.danger || undefined}
|
|
49
|
+
className={`inkbrick-block-menu__item${action.danger ? ' inkbrick-block-menu__item--danger' : ''}`}
|
|
50
|
+
onClick={() => !action.disabled && action.run()}
|
|
51
|
+
onKeyDown={(event) => {
|
|
52
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
if (!action.disabled) action.run();
|
|
55
|
+
}
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{action.icon !== undefined && (
|
|
59
|
+
<span className="inkbrick-block-menu__icon" data-inkbrick-part="icon">
|
|
60
|
+
{action.icon}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
{action.title}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
ref={ref}
|
|
70
|
+
className="inkbrick-block-menu"
|
|
71
|
+
data-inkbrick-ui="block-menu"
|
|
72
|
+
role="menu"
|
|
73
|
+
aria-label={t('menu.label')}
|
|
74
|
+
data-testid="block-menu"
|
|
75
|
+
data-inkbrick-block-ui
|
|
76
|
+
style={{
|
|
77
|
+
left: Math.max(
|
|
78
|
+
8,
|
|
79
|
+
Math.min(position.x, window.innerWidth - (ref.current?.offsetWidth ?? 232) - 8),
|
|
80
|
+
),
|
|
81
|
+
top: flip ? Math.max(8, position.y - (ref.current?.offsetHeight ?? 0) - 32) : position.y,
|
|
82
|
+
}}
|
|
83
|
+
onKeyDown={(event) => {
|
|
84
|
+
if (event.key === 'ArrowDown') {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
move(1);
|
|
87
|
+
} else if (event.key === 'ArrowUp') {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
move(-1);
|
|
90
|
+
} else if (event.key === 'Escape') {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
onClose();
|
|
93
|
+
}
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{actions.map(renderItem)}
|
|
97
|
+
|
|
98
|
+
{turnInto.length > 0 && (
|
|
99
|
+
<>
|
|
100
|
+
<div className="inkbrick-block-menu__label" data-inkbrick-part="label" role="separator">
|
|
101
|
+
{t('menu.turnInto')}
|
|
102
|
+
</div>
|
|
103
|
+
{turnInto.map(renderItem)}
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{entries.length === 0 && (
|
|
108
|
+
<div className="inkbrick-block-menu__label" data-inkbrick-part="label">
|
|
109
|
+
{t('menu.empty')}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
import { useEditorView } from '../hooks/useEditorView';
|
|
5
|
+
import { useReadOnly } from '../hooks/useReadOnly';
|
|
6
|
+
import { useEditorUI } from '../ui/EditorUIContext';
|
|
7
|
+
import { blockElement } from './geometry';
|
|
8
|
+
import type { DropIndicatorProps } from './types';
|
|
9
|
+
import { useBlockDragState } from './useBlockDrag';
|
|
10
|
+
|
|
11
|
+
export function DefaultDropIndicator({ indicator, position }: DropIndicatorProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className="inkbrick-drop-indicator"
|
|
15
|
+
data-inkbrick-ui="drop-indicator"
|
|
16
|
+
data-testid="drop-indicator"
|
|
17
|
+
data-position={position}
|
|
18
|
+
style={{ left: indicator.left, top: indicator.top, width: indicator.width }}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Линия вставки и затемнение переносимого блока. Рисуется по состоянию API переноса,
|
|
25
|
+
* поэтому работает и со встроенным перетаскиванием, и со своим (`useBlockDrag`), даже без ручек.
|
|
26
|
+
*/
|
|
27
|
+
export function DropIndicatorHost() {
|
|
28
|
+
const view = useEditorView();
|
|
29
|
+
const { slots } = useEditorUI();
|
|
30
|
+
const readOnly = useReadOnly();
|
|
31
|
+
const drag = useBlockDragState();
|
|
32
|
+
const draggedId = drag?.blockId;
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!draggedId) return;
|
|
36
|
+
|
|
37
|
+
const element = blockElement(view, draggedId);
|
|
38
|
+
|
|
39
|
+
element?.setAttribute('data-inkbrick-dragging', 'true');
|
|
40
|
+
|
|
41
|
+
return () => element?.removeAttribute('data-inkbrick-dragging');
|
|
42
|
+
}, [draggedId, view]);
|
|
43
|
+
|
|
44
|
+
if (readOnly || slots.DropIndicator === null || typeof document === 'undefined') return null;
|
|
45
|
+
if (!drag?.indicator || !drag.targetId || !drag.position) return null;
|
|
46
|
+
|
|
47
|
+
const Indicator = slots.DropIndicator ?? DefaultDropIndicator;
|
|
48
|
+
|
|
49
|
+
return createPortal(
|
|
50
|
+
<Indicator
|
|
51
|
+
blockId={drag.blockId}
|
|
52
|
+
targetId={drag.targetId}
|
|
53
|
+
position={drag.position}
|
|
54
|
+
indicator={drag.indicator}
|
|
55
|
+
/>,
|
|
56
|
+
document.body,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { flushSync } from 'react-dom';
|
|
2
|
+
|
|
3
|
+
import type { CaretResult, Editor } from '@inkbrick/engine';
|
|
4
|
+
|
|
5
|
+
import type { EditorView } from '../editing/EditorView';
|
|
6
|
+
import type { TFunction } from '../i18n/translate';
|
|
7
|
+
import { Icon } from '../icons/Icon';
|
|
8
|
+
import { availableSlashItems } from '../slash/items';
|
|
9
|
+
import type { SlashMenuItem } from '../slash/types';
|
|
10
|
+
import { syncSlashMenu } from '../slash/syncSlashMenu';
|
|
11
|
+
import { iconByName } from '../controllers/adapters';
|
|
12
|
+
import type { BlockMenuAction } from './types';
|
|
13
|
+
|
|
14
|
+
const isCaret = (value: unknown): value is CaretResult =>
|
|
15
|
+
typeof value === 'object' && value !== null && 'blockId' in value && 'offset' in value;
|
|
16
|
+
|
|
17
|
+
export function performBlockAction(view: EditorView, action: () => unknown): void {
|
|
18
|
+
let result: unknown;
|
|
19
|
+
|
|
20
|
+
flushSync(() => {
|
|
21
|
+
result = action();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
view.blocks.closeMenu();
|
|
25
|
+
|
|
26
|
+
if (isCaret(result) && view.getElement(result.blockId)) {
|
|
27
|
+
view.placeCaret(result.blockId, result.offset);
|
|
28
|
+
} else {
|
|
29
|
+
view.getRoot()?.focus({ preventScroll: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildBlockActions(
|
|
34
|
+
editor: Editor,
|
|
35
|
+
view: EditorView,
|
|
36
|
+
blockId: string,
|
|
37
|
+
slashItems: readonly SlashMenuItem[],
|
|
38
|
+
t: TFunction,
|
|
39
|
+
): { actions: BlockMenuAction[]; turnInto: BlockMenuAction[] } {
|
|
40
|
+
const previous = editor.document.getPreviousSibling(blockId);
|
|
41
|
+
const next = editor.document.getNextSibling(blockId);
|
|
42
|
+
const perform = (fn: () => unknown) => () => performBlockAction(view, fn);
|
|
43
|
+
|
|
44
|
+
const moveUp = previous && { blockId, targetId: previous.id, position: 'before' as const };
|
|
45
|
+
const moveDown = next && { blockId, targetId: next.id, position: 'after' as const };
|
|
46
|
+
|
|
47
|
+
const block = editor.store.getBlock(blockId);
|
|
48
|
+
const controller = block && editor.controllers.get(block.flavour);
|
|
49
|
+
const own = (controller?.menuItems ?? []).map<BlockMenuAction>((item) => {
|
|
50
|
+
const context = editor.controllers.contextOf(block!);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
id: item.id,
|
|
54
|
+
title: item.title,
|
|
55
|
+
icon: iconByName(item.icon),
|
|
56
|
+
danger: item.danger,
|
|
57
|
+
disabled: item.isDisabled ? item.isDisabled(context) : context.readOnly,
|
|
58
|
+
run: perform(() => item.run(context)),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const actions: BlockMenuAction[] = [
|
|
63
|
+
...own,
|
|
64
|
+
{
|
|
65
|
+
id: 'duplicate',
|
|
66
|
+
title: t('menu.duplicate'),
|
|
67
|
+
icon: <Icon name="copy" />,
|
|
68
|
+
disabled: !editor.can('duplicateBlock', { blockId }),
|
|
69
|
+
run: perform(() => editor.dispatch({ type: 'duplicateBlock', payload: { blockId } })),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'move-up',
|
|
73
|
+
title: t('menu.moveUp'),
|
|
74
|
+
icon: <Icon name="arrow-up" />,
|
|
75
|
+
disabled: !moveUp || !editor.can('moveBlock', moveUp),
|
|
76
|
+
run: perform(() => moveUp && editor.dispatch({ type: 'moveBlock', payload: moveUp })),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'move-down',
|
|
80
|
+
title: t('menu.moveDown'),
|
|
81
|
+
icon: <Icon name="arrow-down" />,
|
|
82
|
+
disabled: !moveDown || !editor.can('moveBlock', moveDown),
|
|
83
|
+
run: perform(() => moveDown && editor.dispatch({ type: 'moveBlock', payload: moveDown })),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'delete',
|
|
87
|
+
title: t('menu.delete'),
|
|
88
|
+
icon: <Icon name="trash" />,
|
|
89
|
+
danger: true,
|
|
90
|
+
run: perform(() => editor.execute('delete-block', { id: blockId })),
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const turnInto = availableSlashItems(editor, slashItems, blockId)
|
|
95
|
+
.filter((item) =>
|
|
96
|
+
editor.can('convertBlock', { blockId, flavour: item.flavour, props: item.props }),
|
|
97
|
+
)
|
|
98
|
+
.map<BlockMenuAction>((item) => ({
|
|
99
|
+
id: item.id,
|
|
100
|
+
title: item.title,
|
|
101
|
+
icon: item.icon,
|
|
102
|
+
run: perform(() =>
|
|
103
|
+
editor.dispatch({
|
|
104
|
+
type: 'convertBlock',
|
|
105
|
+
payload: { blockId, flavour: item.flavour, props: item.props as never },
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
return { actions, turnInto };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function addBlockBelow(
|
|
114
|
+
editor: Editor,
|
|
115
|
+
view: EditorView,
|
|
116
|
+
blockId: string,
|
|
117
|
+
slashItems: readonly SlashMenuItem[],
|
|
118
|
+
): void {
|
|
119
|
+
const block = editor.store.getBlock(blockId);
|
|
120
|
+
const parent = block?.parentId ? editor.store.getBlock(block.parentId) : undefined;
|
|
121
|
+
|
|
122
|
+
if (!block || !parent) return;
|
|
123
|
+
|
|
124
|
+
// Рядом с вложенным блоком вставляем блок того же типа: параграф «провалился» бы внутрь списка.
|
|
125
|
+
const nested = parent.flavour !== 'block:note';
|
|
126
|
+
const payload = {
|
|
127
|
+
flavour: nested ? block.flavour : 'block:paragraph',
|
|
128
|
+
afterId: blockId,
|
|
129
|
+
props: nested ? (block.props as never) : undefined,
|
|
130
|
+
content: [{ insert: '/' }],
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
let caret: CaretResult | undefined;
|
|
134
|
+
|
|
135
|
+
flushSync(() => {
|
|
136
|
+
caret = editor.dispatch({ type: 'insertBlock', payload }) as CaretResult | undefined;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
view.blocks.setHover(null);
|
|
140
|
+
|
|
141
|
+
if (!caret) return;
|
|
142
|
+
|
|
143
|
+
view.placeCaret(caret.blockId, 1);
|
|
144
|
+
view.slash.open(caret.blockId, 0);
|
|
145
|
+
syncSlashMenu(editor, view, slashItems);
|
|
146
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { flushSync } from 'react-dom';
|
|
2
|
+
|
|
3
|
+
import type { Editor } from '@inkbrick/engine';
|
|
4
|
+
|
|
5
|
+
import type { EditorView } from '../editing/EditorView';
|
|
6
|
+
import type { DragState, DropIndicatorRect, DropPosition } from './BlockHandleController';
|
|
7
|
+
import { blockAtPoint, blockElement, probePoint, rowRect } from './geometry';
|
|
8
|
+
|
|
9
|
+
export interface BlockDropTarget {
|
|
10
|
+
readonly targetId: string;
|
|
11
|
+
readonly position: DropPosition;
|
|
12
|
+
readonly indicator: DropIndicatorRect;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BlockRects {
|
|
16
|
+
/** Строка самого блока (его текст), без вложенных блоков. */
|
|
17
|
+
readonly row: DOMRect;
|
|
18
|
+
/** Весь блок вместе с вложенными блоками. */
|
|
19
|
+
readonly box: DOMRect;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Всё, что нужно своей системе перетаскивания блоков (dnd-kit, pragmatic-drag-and-drop, своя на pointer events):
|
|
24
|
+
* геометрия, проверка цели, линия вставки и сам перенос. Встроенное перетаскивание построено на этом же API.
|
|
25
|
+
*
|
|
26
|
+
* Обычный порядок: `start(blockId)` → на каждое движение `update(x, y)` → `drop()` или `cancel()`.
|
|
27
|
+
*/
|
|
28
|
+
export interface BlockDragApi {
|
|
29
|
+
/** Блок с ручкой под точкой окна; точку в полосе слева от текста прижимает к тексту. */
|
|
30
|
+
blockAtPoint(x: number, y: number): string | null;
|
|
31
|
+
blockRects(blockId: string): BlockRects | null;
|
|
32
|
+
/** Можно ли перенести блок `blockId` перед/после `targetId` (схема, вложенность, глубина). */
|
|
33
|
+
canMove(blockId: string, targetId: string, position: DropPosition): boolean;
|
|
34
|
+
/** Допустимое место вставки под точкой окна для блока `blockId`; верхняя половина строки — «перед». */
|
|
35
|
+
dropTargetAt(blockId: string, x: number, y: number): BlockDropTarget | null;
|
|
36
|
+
|
|
37
|
+
start(blockId: string): void;
|
|
38
|
+
/** Задаёт текущее место вставки (или убирает его) и рисует линию. Цель, которую движок не примет, отбрасывается. */
|
|
39
|
+
setTarget(target: BlockDropTarget | null): void;
|
|
40
|
+
/** Удобная связка `dropTargetAt` + `setTarget` с автопрокруткой у края окна. Возвращает найденную цель. */
|
|
41
|
+
update(x: number, y: number): BlockDropTarget | null;
|
|
42
|
+
/** Завершает перенос: применяет его к текущей цели одним шагом undo. `true`, если блок перенесён. */
|
|
43
|
+
drop(): boolean;
|
|
44
|
+
cancel(): void;
|
|
45
|
+
|
|
46
|
+
/** Переносит блок сразу, без жеста (например, из своего DnD без линии вставки). Один шаг undo. */
|
|
47
|
+
move(blockId: string, targetId: string, position: DropPosition): boolean;
|
|
48
|
+
autoScroll(clientY: number): void;
|
|
49
|
+
|
|
50
|
+
getState(): DragState | null;
|
|
51
|
+
subscribe(listener: () => void): () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const EDGE = 56;
|
|
55
|
+
|
|
56
|
+
const cache = new WeakMap<EditorView, BlockDragApi>();
|
|
57
|
+
|
|
58
|
+
export function getBlockDrag(editor: Editor, view: EditorView): BlockDragApi {
|
|
59
|
+
let api = cache.get(view);
|
|
60
|
+
|
|
61
|
+
if (!api) {
|
|
62
|
+
api = createBlockDrag(editor, view);
|
|
63
|
+
cache.set(view, api);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return api;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createBlockDrag(editor: Editor, view: EditorView): BlockDragApi {
|
|
70
|
+
const controller = view.blocks;
|
|
71
|
+
|
|
72
|
+
const markBody = (on: boolean) => {
|
|
73
|
+
if (typeof document === 'undefined') return;
|
|
74
|
+
|
|
75
|
+
document.body.classList.toggle('inkbrick-dragging', on);
|
|
76
|
+
|
|
77
|
+
if (on) document.body.setAttribute('data-inkbrick-dragging', '');
|
|
78
|
+
else document.body.removeAttribute('data-inkbrick-dragging');
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const canMove = (blockId: string, targetId: string, position: DropPosition) =>
|
|
82
|
+
blockId !== targetId && editor.can('moveBlock', { blockId, targetId, position });
|
|
83
|
+
|
|
84
|
+
const blockRects = (blockId: string): BlockRects | null => {
|
|
85
|
+
const wrapper = blockElement(view, blockId);
|
|
86
|
+
const row = rowRect(view, blockId);
|
|
87
|
+
|
|
88
|
+
return wrapper && row ? { row, box: wrapper.getBoundingClientRect() } : null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const api: BlockDragApi = {
|
|
92
|
+
blockAtPoint(x, y) {
|
|
93
|
+
const probe = probePoint(view, x, y);
|
|
94
|
+
|
|
95
|
+
return probe ? blockAtPoint(editor, view, probe.x, probe.y) : null;
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
blockRects,
|
|
99
|
+
canMove,
|
|
100
|
+
|
|
101
|
+
dropTargetAt(blockId, x, y) {
|
|
102
|
+
const targetId = api.blockAtPoint(x, y);
|
|
103
|
+
const rects = targetId ? blockRects(targetId) : null;
|
|
104
|
+
|
|
105
|
+
if (!targetId || !rects) return null;
|
|
106
|
+
|
|
107
|
+
const { row, box } = rects;
|
|
108
|
+
const position: DropPosition = y < row.top + row.height / 2 ? 'before' : 'after';
|
|
109
|
+
|
|
110
|
+
if (!canMove(blockId, targetId, position)) return null;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
targetId,
|
|
114
|
+
position,
|
|
115
|
+
// «После» — после всего поддерева цели (вместе с вложенными блоками).
|
|
116
|
+
indicator: {
|
|
117
|
+
left: box.left,
|
|
118
|
+
width: box.width,
|
|
119
|
+
top: position === 'before' ? row.top - 1 : box.bottom + 1,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
start(blockId) {
|
|
125
|
+
controller.startDrag(blockId);
|
|
126
|
+
markBody(true);
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
setTarget(target) {
|
|
130
|
+
const drag = controller.getState().drag;
|
|
131
|
+
|
|
132
|
+
if (!drag) return;
|
|
133
|
+
|
|
134
|
+
if (!target || !canMove(drag.blockId, target.targetId, target.position)) {
|
|
135
|
+
controller.updateDrag({ targetId: null, position: null, indicator: null });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
controller.updateDrag({
|
|
140
|
+
targetId: target.targetId,
|
|
141
|
+
position: target.position,
|
|
142
|
+
indicator: target.indicator,
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
update(x, y) {
|
|
147
|
+
const drag = controller.getState().drag;
|
|
148
|
+
|
|
149
|
+
if (!drag) return null;
|
|
150
|
+
|
|
151
|
+
api.autoScroll(y);
|
|
152
|
+
|
|
153
|
+
const target = api.dropTargetAt(drag.blockId, x, y);
|
|
154
|
+
|
|
155
|
+
api.setTarget(target);
|
|
156
|
+
|
|
157
|
+
return target;
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
drop() {
|
|
161
|
+
const drag = controller.getState().drag;
|
|
162
|
+
|
|
163
|
+
controller.endDrag();
|
|
164
|
+
markBody(false);
|
|
165
|
+
|
|
166
|
+
if (!drag?.targetId || !drag.position) return false;
|
|
167
|
+
|
|
168
|
+
return api.move(drag.blockId, drag.targetId, drag.position);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
cancel() {
|
|
172
|
+
controller.endDrag();
|
|
173
|
+
markBody(false);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
move(blockId, targetId, position) {
|
|
177
|
+
if (!canMove(blockId, targetId, position)) return false;
|
|
178
|
+
|
|
179
|
+
const payload = { blockId, targetId, position };
|
|
180
|
+
|
|
181
|
+
// Синхронно обновляем DOM: сразу после переноса можно мерить блоки на новых местах.
|
|
182
|
+
flushSync(() => {
|
|
183
|
+
editor.dispatch({ type: 'moveBlock', payload });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
controller.setHover(null);
|
|
187
|
+
|
|
188
|
+
return true;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
autoScroll(clientY) {
|
|
192
|
+
if (typeof window === 'undefined') return;
|
|
193
|
+
|
|
194
|
+
if (clientY < EDGE) window.scrollBy(0, -14);
|
|
195
|
+
else if (clientY > window.innerHeight - EDGE) window.scrollBy(0, 14);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
getState: () => controller.getState().drag,
|
|
199
|
+
|
|
200
|
+
subscribe: (listener) => {
|
|
201
|
+
let last = controller.getState().drag;
|
|
202
|
+
|
|
203
|
+
return controller.subscribe(() => {
|
|
204
|
+
const next = controller.getState().drag;
|
|
205
|
+
|
|
206
|
+
if (next !== last) {
|
|
207
|
+
last = next;
|
|
208
|
+
listener();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
return api;
|
|
215
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Editor } from '@inkbrick/engine';
|
|
2
|
+
|
|
3
|
+
import type { EditorView } from '../editing/EditorView';
|
|
4
|
+
|
|
5
|
+
/** Ширина полосы слева от текста, в которой ручка остаётся видимой. */
|
|
6
|
+
export const GUTTER = 72;
|
|
7
|
+
|
|
8
|
+
export function hasHandle(editor: Editor, blockId: string): boolean {
|
|
9
|
+
const block = editor.store.getBlock(blockId);
|
|
10
|
+
const parent = block?.parentId ? editor.store.getBlock(block.parentId) : undefined;
|
|
11
|
+
|
|
12
|
+
return !!parent?.parentId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function blockElement(view: EditorView, blockId: string): HTMLElement | null {
|
|
16
|
+
return (
|
|
17
|
+
view.getRoot()?.querySelector<HTMLElement>(`[data-block-id="${CSS.escape(blockId)}"]`) ?? null
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function blockAtPoint(
|
|
22
|
+
editor: Editor,
|
|
23
|
+
view: EditorView,
|
|
24
|
+
x: number,
|
|
25
|
+
y: number,
|
|
26
|
+
): string | null {
|
|
27
|
+
const root = view.getRoot();
|
|
28
|
+
const hit = root ? document.elementFromPoint(x, y) : null;
|
|
29
|
+
|
|
30
|
+
if (!root || !hit || !root.contains(hit)) return null;
|
|
31
|
+
|
|
32
|
+
let node: Element | null = hit.closest('[data-block-id]');
|
|
33
|
+
|
|
34
|
+
while (node && root.contains(node)) {
|
|
35
|
+
const id = node.getAttribute('data-block-id')!;
|
|
36
|
+
|
|
37
|
+
if (hasHandle(editor, id)) return id;
|
|
38
|
+
|
|
39
|
+
node = node.parentElement?.closest('[data-block-id]') ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Точка в колонке редактора: прижимает x к тексту, чтобы блок находился и при наведении в полосу слева. */
|
|
46
|
+
export function probePoint(
|
|
47
|
+
view: EditorView,
|
|
48
|
+
x: number,
|
|
49
|
+
y: number,
|
|
50
|
+
): { x: number; y: number } | null {
|
|
51
|
+
const root = view.getRoot();
|
|
52
|
+
|
|
53
|
+
if (!root) return null;
|
|
54
|
+
|
|
55
|
+
const rect = root.getBoundingClientRect();
|
|
56
|
+
|
|
57
|
+
if (x < rect.left - GUTTER || x > rect.right || y < rect.top || y > rect.bottom) return null;
|
|
58
|
+
|
|
59
|
+
return { x: Math.min(Math.max(x, rect.left + 12), rect.right - 12), y };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function rowRect(view: EditorView, blockId: string): DOMRect | null {
|
|
63
|
+
return (view.getElement(blockId) ?? blockElement(view, blockId))?.getBoundingClientRect() ?? null;
|
|
64
|
+
}
|