@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.
Files changed (131) hide show
  1. package/dist/FormulaPalette-WFRPRHS5.js +320 -0
  2. package/dist/FormulaPalette-WFRPRHS5.js.map +1 -0
  3. package/dist/chunk-TPUX4C45.js +2309 -0
  4. package/dist/chunk-TPUX4C45.js.map +1 -0
  5. package/dist/index.d.ts +1431 -0
  6. package/dist/index.js +4880 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/mathCss-FLPE6QBM.js +361 -0
  9. package/dist/mathCss-FLPE6QBM.js.map +1 -0
  10. package/package.json +46 -3
  11. package/src/block-handle/BlockHandleController.ts +93 -0
  12. package/src/block-handle/BlockHandleHost.tsx +108 -0
  13. package/src/block-handle/DefaultBlockHandle.tsx +71 -0
  14. package/src/block-handle/DefaultBlockMenu.tsx +114 -0
  15. package/src/block-handle/DropIndicatorHost.tsx +58 -0
  16. package/src/block-handle/actions.tsx +146 -0
  17. package/src/block-handle/blockDrag.ts +215 -0
  18. package/src/block-handle/geometry.ts +64 -0
  19. package/src/block-handle/gripGesture.ts +108 -0
  20. package/src/block-handle/types.ts +65 -0
  21. package/src/block-handle/useBlockDrag.ts +21 -0
  22. package/src/block-handle/useBlockHandles.ts +125 -0
  23. package/src/blocks/BlockChildren.tsx +17 -0
  24. package/src/blocks/BlockProps.ts +5 -0
  25. package/src/blocks/CalloutBlock.tsx +61 -0
  26. package/src/blocks/CodeBlock.tsx +96 -0
  27. package/src/blocks/DividerBlock.tsx +15 -0
  28. package/src/blocks/Editable.tsx +84 -0
  29. package/src/blocks/FormulaBlock.tsx +173 -0
  30. package/src/blocks/HeadingBlock.tsx +22 -0
  31. package/src/blocks/ImageBlock.tsx +362 -0
  32. package/src/blocks/ListBlock.tsx +86 -0
  33. package/src/blocks/NoteBlock.tsx +10 -0
  34. package/src/blocks/PageBlock.tsx +10 -0
  35. package/src/blocks/ParagraphBlock.tsx +20 -0
  36. package/src/blocks/QuoteBlock.tsx +18 -0
  37. package/src/blocks/UnknownBlockView.tsx +27 -0
  38. package/src/blocks/defineBlock.ts +49 -0
  39. package/src/clipboard/html/blocks.ts +379 -0
  40. package/src/clipboard/html/inline.ts +92 -0
  41. package/src/clipboard/html/style.ts +92 -0
  42. package/src/clipboard/html/tags.ts +77 -0
  43. package/src/clipboard/htmlToFragment.ts +41 -0
  44. package/src/clipboard/useClipboard.ts +296 -0
  45. package/src/code/highlight.ts +56 -0
  46. package/src/code/languages.ts +236 -0
  47. package/src/code/scan/css.ts +164 -0
  48. package/src/code/scan/generic.ts +328 -0
  49. package/src/code/scan/html.ts +129 -0
  50. package/src/code/scan/markdown.ts +58 -0
  51. package/src/code/scan/shared.ts +81 -0
  52. package/src/code/scan/yaml.ts +34 -0
  53. package/src/code/types.ts +34 -0
  54. package/src/context/EditorContext.ts +9 -0
  55. package/src/context/EditorProvider.tsx +172 -0
  56. package/src/controllers/adapters.tsx +63 -0
  57. package/src/editing/BlockSelectionController.ts +27 -0
  58. package/src/editing/CaretController.ts +27 -0
  59. package/src/editing/EditorView.ts +108 -0
  60. package/src/editing/ViewportController.ts +63 -0
  61. package/src/editing/beforeInput.ts +276 -0
  62. package/src/editing/deleteAtBoundary.ts +16 -0
  63. package/src/editing/inputActions.ts +109 -0
  64. package/src/editing/renderCode.ts +69 -0
  65. package/src/editing/renderInline.ts +144 -0
  66. package/src/editing/selection.ts +262 -0
  67. package/src/editing/useEditorInput.ts +223 -0
  68. package/src/editing/useSelectionState.ts +46 -0
  69. package/src/formula/FormulaController.ts +51 -0
  70. package/src/formula/FormulaPalette.tsx +79 -0
  71. package/src/formula/FormulaPopover.tsx +181 -0
  72. package/src/formula/LazyFormulaPalette.tsx +42 -0
  73. package/src/formula/actions.ts +138 -0
  74. package/src/formula/snippet.ts +70 -0
  75. package/src/formula/symbols.ts +264 -0
  76. package/src/hooks/useBlock.ts +21 -0
  77. package/src/hooks/useBlockController.ts +30 -0
  78. package/src/hooks/useChildren.ts +16 -0
  79. package/src/hooks/useDocument.ts +16 -0
  80. package/src/hooks/useEditor.ts +13 -0
  81. package/src/hooks/useEditorView.ts +13 -0
  82. package/src/hooks/useHistory.ts +46 -0
  83. package/src/hooks/useReadOnly.ts +21 -0
  84. package/src/hooks/useT.ts +10 -0
  85. package/src/i18n/en.ts +182 -0
  86. package/src/i18n/ru.ts +174 -0
  87. package/src/i18n/translate.ts +77 -0
  88. package/src/icons/ICONS_LICENSE.md +50 -0
  89. package/src/icons/Icon.tsx +41 -0
  90. package/src/icons/lucide.ts +234 -0
  91. package/src/images/ImageError.ts +39 -0
  92. package/src/images/UploadTracker.ts +49 -0
  93. package/src/images/insertImages.ts +142 -0
  94. package/src/images/types.ts +22 -0
  95. package/src/images/upload.ts +62 -0
  96. package/src/index.ts +102 -0
  97. package/src/keymap/defaultKeymap.ts +189 -0
  98. package/src/keymap/keymap.ts +42 -0
  99. package/src/keymap/shortcut.ts +105 -0
  100. package/src/keymap/types.ts +40 -0
  101. package/src/math/FormulaView.tsx +62 -0
  102. package/src/math/mathCss.ts +4 -0
  103. package/src/math/renderer.ts +198 -0
  104. package/src/registry/defaultBlockComponents.ts +26 -0
  105. package/src/registry/resolveBlockComponent.ts +14 -0
  106. package/src/renderer/BlockRenderer.tsx +63 -0
  107. package/src/renderer/DocumentRenderer.tsx +33 -0
  108. package/src/renderer/DocumentView.tsx +94 -0
  109. package/src/renderer/virtual/VirtualChildList.tsx +355 -0
  110. package/src/renderer/virtual/estimate.ts +62 -0
  111. package/src/renderer/virtual/virtualRenderer.tsx +35 -0
  112. package/src/slash/DefaultSlashMenu.tsx +119 -0
  113. package/src/slash/SlashMenuController.ts +66 -0
  114. package/src/slash/SlashMenuHost.tsx +48 -0
  115. package/src/slash/applySlashItem.ts +57 -0
  116. package/src/slash/defaultSlashItems.tsx +186 -0
  117. package/src/slash/filter.ts +29 -0
  118. package/src/slash/items.ts +30 -0
  119. package/src/slash/syncSlashMenu.ts +67 -0
  120. package/src/slash/types.ts +21 -0
  121. package/src/toolbar/DefaultToolbar.tsx +281 -0
  122. package/src/toolbar/ToolbarController.ts +100 -0
  123. package/src/toolbar/ToolbarHost.tsx +81 -0
  124. package/src/toolbar/formatting.ts +75 -0
  125. package/src/toolbar/rich.ts +58 -0
  126. package/src/toolbar/syncToolbar.ts +39 -0
  127. package/src/toolbar/types.ts +21 -0
  128. package/src/ui/EditorUIContext.ts +26 -0
  129. package/src/ui/presentation.ts +24 -0
  130. package/src/ui/types.ts +70 -0
  131. package/README.md +0 -3
@@ -0,0 +1,108 @@
1
+ import type { BlockDragApi } from './blockDrag';
2
+
3
+ /** Сколько пикселей мышью нужно протянуть, чтобы клик стал переносом. */
4
+ export const DRAG_THRESHOLD = 5;
5
+ /** Сколько держать палец на хваталке, чтобы начать перенос. */
6
+ export const LONG_PRESS_MS = 350;
7
+ /** Дрожание пальца, которое ещё не считается движением до начала переноса. */
8
+ export const TOUCH_SLOP = 10;
9
+
10
+ export interface GripGestureOptions {
11
+ readonly onTap: () => void;
12
+ }
13
+
14
+ /**
15
+ * Встроенный жест хваталки поверх публичного API переноса.
16
+ * Мышь: протягивание дальше порога — перенос, иначе клик. Палец и перо: долгое нажатие — перенос,
17
+ * короткое касание — клик, сдвиг до долгого нажатия отменяет жест. Возвращает функцию отмены.
18
+ */
19
+ export function startGripGesture(
20
+ drag: BlockDragApi,
21
+ blockId: string,
22
+ event: PointerEvent,
23
+ { onTap }: GripGestureOptions,
24
+ ): () => void {
25
+ const touch = event.pointerType === 'touch' || event.pointerType === 'pen';
26
+ const pointerId = event.pointerId;
27
+ const startX = event.clientX;
28
+ const startY = event.clientY;
29
+ let dragging = false;
30
+ let aborted = false;
31
+ let timer: ReturnType<typeof setTimeout> | undefined;
32
+
33
+ const begin = () => {
34
+ if (dragging || aborted) return;
35
+
36
+ dragging = true;
37
+ drag.start(blockId);
38
+
39
+ if (touch) navigator.vibrate?.(10);
40
+ };
41
+
42
+ const finish = () => {
43
+ clearTimeout(timer);
44
+ document.removeEventListener('pointermove', onMove);
45
+ document.removeEventListener('pointerup', onUp);
46
+ document.removeEventListener('pointercancel', onCancel);
47
+ document.removeEventListener('keydown', onKey);
48
+ };
49
+
50
+ const onMove = (move: PointerEvent) => {
51
+ if (move.pointerId !== pointerId) return;
52
+
53
+ const distance = Math.hypot(move.clientX - startX, move.clientY - startY);
54
+
55
+ if (!dragging) {
56
+ if (touch) {
57
+ if (distance > TOUCH_SLOP) {
58
+ aborted = true;
59
+ finish();
60
+ }
61
+
62
+ return;
63
+ }
64
+
65
+ if (distance > DRAG_THRESHOLD) begin();
66
+ }
67
+
68
+ if (dragging) {
69
+ move.preventDefault();
70
+ drag.update(move.clientX, move.clientY);
71
+ }
72
+ };
73
+
74
+ const onUp = (up: PointerEvent) => {
75
+ if (up.pointerId !== pointerId) return;
76
+
77
+ finish();
78
+
79
+ if (dragging) drag.drop();
80
+ else if (!aborted) onTap();
81
+ };
82
+
83
+ const onCancel = (cancel: PointerEvent) => {
84
+ if (cancel.pointerId !== pointerId) return;
85
+
86
+ abort();
87
+ };
88
+
89
+ const onKey = (key: KeyboardEvent) => {
90
+ if (key.key === 'Escape') abort();
91
+ };
92
+
93
+ const abort = () => {
94
+ aborted = true;
95
+ finish();
96
+
97
+ if (dragging) drag.cancel();
98
+ };
99
+
100
+ if (touch) timer = setTimeout(begin, LONG_PRESS_MS);
101
+
102
+ document.addEventListener('pointermove', onMove, { passive: false });
103
+ document.addEventListener('pointerup', onUp);
104
+ document.addEventListener('pointercancel', onCancel);
105
+ document.addEventListener('keydown', onKey);
106
+
107
+ return abort;
108
+ }
@@ -0,0 +1,65 @@
1
+ import type {
2
+ CSSProperties,
3
+ MouseEvent as ReactMouseEvent,
4
+ ReactNode,
5
+ PointerEvent as ReactPointerEvent,
6
+ } from 'react';
7
+
8
+ import type { BaseBlock } from '@inkbrick/document';
9
+
10
+ import type { DropIndicatorRect, DropPosition } from './BlockHandleController';
11
+
12
+ /**
13
+ * Props встроенного жеста для элемента-«хваталки»: клик открывает меню, протягивание мышью
14
+ * или долгое нажатие пальцем переносит блок. Свой DnD может их не вешать (см. `useBlockDrag`).
15
+ */
16
+ export interface GripProps {
17
+ readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
18
+ /** Долгое нажатие пальцем не должно открывать системное контекстное меню. */
19
+ readonly onContextMenu: (event: ReactMouseEvent<HTMLElement>) => void;
20
+ /** `touch-action: none`: палец на хваталке не прокручивает страницу. */
21
+ readonly style: CSSProperties;
22
+ readonly 'data-inkbrick-grip': '';
23
+ }
24
+
25
+ export interface BlockHandleProps {
26
+ readonly blockId: string;
27
+ /** Левый верхний угол блока в координатах окна (для position: fixed). */
28
+ readonly position: { readonly x: number; readonly y: number };
29
+ readonly canAdd: boolean;
30
+ readonly dragging: boolean;
31
+ readonly menuOpen: boolean;
32
+ readonly onAdd: () => void;
33
+ /** Открывает меню действий (например, по клавиатуре). Обычный клик по «хваталке» открывает его сам. */
34
+ readonly onOpenMenu: () => void;
35
+ /**
36
+ * Повесьте на элемент-«хваталку»: клик открывает меню, протягивание мышью или долгое нажатие пальцем
37
+ * переносит блок. Для своего DnD не вешайте их, а вызовите `onOpenMenu` по клику и `useBlockDrag()` для переноса.
38
+ */
39
+ readonly gripProps: GripProps;
40
+ }
41
+
42
+ export interface DropIndicatorProps {
43
+ readonly blockId: string;
44
+ readonly targetId: string;
45
+ readonly position: DropPosition;
46
+ /** Где рисовать линию, в координатах окна (для position: fixed). */
47
+ readonly indicator: DropIndicatorRect;
48
+ }
49
+
50
+ export interface BlockMenuAction {
51
+ readonly id: string;
52
+ readonly title: string;
53
+ readonly icon?: ReactNode;
54
+ readonly disabled?: boolean;
55
+ readonly danger?: boolean;
56
+ readonly run: () => void;
57
+ }
58
+
59
+ export interface BlockMenuProps {
60
+ readonly block: BaseBlock;
61
+ readonly position: { readonly x: number; readonly y: number };
62
+ readonly actions: readonly BlockMenuAction[];
63
+ readonly turnInto: readonly BlockMenuAction[];
64
+ readonly onClose: () => void;
65
+ }
@@ -0,0 +1,21 @@
1
+ import { useSyncExternalStore } from 'react';
2
+
3
+ import { useEditor } from '../hooks/useEditor';
4
+ import { useEditorView } from '../hooks/useEditorView';
5
+ import type { DragState } from './BlockHandleController';
6
+ import { getBlockDrag, type BlockDragApi } from './blockDrag';
7
+
8
+ /**
9
+ * API перетаскивания блоков текущего редактора: на нём строится своя система DnD
10
+ * (dnd-kit, pragmatic-drag-and-drop и т. п.). Объект стабилен между рендерами.
11
+ * Работает внутри `<EditorProvider>`, в том числе в компоненте `slots.BlockHandle`.
12
+ */
13
+ export function useBlockDrag(): BlockDragApi {
14
+ return getBlockDrag(useEditor(), useEditorView());
15
+ }
16
+
17
+ export function useBlockDragState(): DragState | null {
18
+ const drag = useBlockDrag();
19
+
20
+ return useSyncExternalStore(drag.subscribe, drag.getState, drag.getState);
21
+ }
@@ -0,0 +1,125 @@
1
+ import { useEffect, type RefObject } from 'react';
2
+
3
+ import { useEditor } from '../hooks/useEditor';
4
+ import { useEditorView } from '../hooks/useEditorView';
5
+ import { blockAtPoint, blockElement, hasHandle, probePoint, rowRect } from './geometry';
6
+
7
+ /**
8
+ * Следит за указателем: показывает ручку у блока под курсором (в том числе в полосе слева от текста),
9
+ * на сенсорном экране — у блока, которого коснулись; закрывает меню по клику снаружи/Esc/прокрутке
10
+ * и скрывает ручку при наборе текста. Перетаскивание запускается из ручки (см. BlockHandleHost).
11
+ */
12
+ export function useBlockHandles(ref: RefObject<HTMLElement | null>, enabled: boolean) {
13
+ const editor = useEditor();
14
+ const view = useEditorView();
15
+
16
+ useEffect(() => {
17
+ const root = ref.current;
18
+
19
+ if (!root || !enabled) return;
20
+
21
+ const showAt = (event: PointerEvent) => {
22
+ const probe = probePoint(view, event.clientX, event.clientY);
23
+
24
+ if (!probe) {
25
+ view.blocks.setHover(null);
26
+ return;
27
+ }
28
+
29
+ const blockId = blockAtPoint(editor, view, probe.x, probe.y);
30
+
31
+ if (!blockId) return;
32
+
33
+ const wrapper = blockElement(view, blockId);
34
+ const row = rowRect(view, blockId);
35
+
36
+ if (wrapper && row) {
37
+ view.blocks.setHover({
38
+ blockId,
39
+ x: Math.round(wrapper.getBoundingClientRect().left),
40
+ y: Math.round(row.top),
41
+ });
42
+ }
43
+ };
44
+
45
+ const onUiTarget = (event: Event) =>
46
+ event.target instanceof Element && !!event.target.closest('[data-inkbrick-block-ui]');
47
+
48
+ const onMove = (event: PointerEvent) => {
49
+ const { menu, drag } = view.blocks.getState();
50
+
51
+ // Палец «наводится» только касанием (см. onPointerUp); движение пальца — это прокрутка.
52
+ if (event.pointerType === 'touch') return;
53
+
54
+ if (drag || menu || view.pointerDown) return;
55
+
56
+ if (onUiTarget(event)) return;
57
+
58
+ showAt(event);
59
+ };
60
+
61
+ const onPointerUp = (event: PointerEvent) => {
62
+ if (event.pointerType === 'mouse' || onUiTarget(event)) return;
63
+
64
+ const { menu, drag } = view.blocks.getState();
65
+
66
+ if (!drag && !menu) showAt(event);
67
+ };
68
+
69
+ const onPointerDown = (event: PointerEvent) => {
70
+ if (onUiTarget(event)) return;
71
+
72
+ view.blocks.closeMenu();
73
+ };
74
+
75
+ const hide = (event: Event) => {
76
+ // Прокрутка самого меню (оно может не помещаться в окно) не должна его закрывать.
77
+ if (onUiTarget(event)) return;
78
+ // Автопрокрутка при переносе: элемент-хваталка (в том числе у стороннего DnD) должен остаться.
79
+ if (view.blocks.getState().drag) return;
80
+
81
+ view.blocks.setHover(null);
82
+ view.blocks.closeMenu();
83
+ };
84
+
85
+ const onKeyDown = (event: KeyboardEvent) => {
86
+ if (event.key === 'Escape') view.blocks.closeMenu();
87
+
88
+ if (view.blocks.getState().hover && !event.metaKey && !event.ctrlKey)
89
+ view.blocks.setHover(null);
90
+ };
91
+
92
+ const onLeave = (event: PointerEvent) => {
93
+ // Палец «уходит» со страницы после каждого касания — ручка должна остаться.
94
+ if (event.pointerType === 'touch') return;
95
+ if (!event.relatedTarget) view.blocks.setHover(null);
96
+ };
97
+
98
+ // Блок исчез из документа (удалён, undo) — ручка и меню не должны остаться висеть.
99
+ const unsubscribe = editor.store.subscribe(() => {
100
+ const { hover, menu } = view.blocks.getState();
101
+
102
+ if (hover && !hasHandle(editor, hover.blockId)) view.blocks.setHover(null);
103
+ if (menu && !hasHandle(editor, menu.blockId)) view.blocks.closeMenu();
104
+ });
105
+
106
+ document.addEventListener('pointermove', onMove);
107
+ document.addEventListener('pointerdown', onPointerDown);
108
+ document.addEventListener('pointerup', onPointerUp);
109
+ document.addEventListener('pointerout', onLeave);
110
+ document.addEventListener('keydown', onKeyDown);
111
+ window.addEventListener('scroll', hide, true);
112
+
113
+ return () => {
114
+ unsubscribe();
115
+ document.removeEventListener('pointermove', onMove);
116
+ document.removeEventListener('pointerdown', onPointerDown);
117
+ document.removeEventListener('pointerup', onPointerUp);
118
+ document.removeEventListener('pointerout', onLeave);
119
+ document.removeEventListener('keydown', onKeyDown);
120
+ window.removeEventListener('scroll', hide, true);
121
+ view.blocks.setHover(null);
122
+ view.blocks.closeMenu();
123
+ };
124
+ }, [editor, view, ref, enabled]);
125
+ }
@@ -0,0 +1,17 @@
1
+ import { useContext } from 'react';
2
+
3
+ import { DocumentRendererContext } from '../renderer/DocumentRenderer';
4
+ import { useChildren } from '../hooks/useChildren';
5
+
6
+ import type { BlockId } from '@inkbrick/document';
7
+
8
+ /**
9
+ * Выводит дочерние блоки `id` — для пользовательских компонентов-контейнеров. Как именно (все сразу или только
10
+ * видимые), решает рендерер документа (`<DocumentView renderer>`).
11
+ */
12
+ export function BlockChildren({ id }: { readonly id: BlockId }) {
13
+ const children = useChildren(id);
14
+ const { ChildList } = useContext(DocumentRendererContext);
15
+
16
+ return <ChildList parentId={id} ids={children} />;
17
+ }
@@ -0,0 +1,5 @@
1
+ import type { BlockViewProps } from '../ui/types';
2
+
3
+ /** Props встроенных компонентов; пользовательским компонентам доступны также `editor`, `selected` и `readOnly`. */
4
+ export type BlockProps = Pick<BlockViewProps, 'block'> &
5
+ Partial<Pick<BlockViewProps, 'selected' | 'readOnly'>>;
@@ -0,0 +1,61 @@
1
+ import { CALLOUT_TONES, getBlockAlign } from '@inkbrick/document';
2
+
3
+ import { useT } from '../hooks/useT';
4
+ import { Icon, type IconName } from '../icons/Icon';
5
+
6
+ import { Editable } from './Editable';
7
+ import type { BlockViewProps } from '../ui/types';
8
+
9
+ const ICONS: Record<(typeof CALLOUT_TONES)[number], IconName> = {
10
+ info: 'info',
11
+ warning: 'warning',
12
+ success: 'success',
13
+ danger: 'danger',
14
+ };
15
+
16
+ type Tone = (typeof CALLOUT_TONES)[number];
17
+
18
+ const isTone = (value: unknown): value is Tone => CALLOUT_TONES.includes(value as Tone);
19
+
20
+ export function CalloutBlock({ block, editor, selected, readOnly }: BlockViewProps) {
21
+ const t = useT();
22
+ const tone: Tone = isTone(block.props.tone) ? block.props.tone : 'info';
23
+ const icon =
24
+ typeof block.props.icon === 'string' && block.props.icon !== '' ? (
25
+ block.props.icon
26
+ ) : (
27
+ <Icon name={ICONS[tone]} />
28
+ );
29
+
30
+ const nextTone = CALLOUT_TONES[(CALLOUT_TONES.indexOf(tone) + 1) % CALLOUT_TONES.length];
31
+
32
+ return (
33
+ <div
34
+ className="block block-callout"
35
+ data-inkbrick-block="callout"
36
+ data-align={getBlockAlign(block) ?? 'left'}
37
+ data-tone={tone}
38
+ data-block-id={block.id}
39
+ data-selected={selected || undefined}
40
+ >
41
+ <button
42
+ type="button"
43
+ className="callout-icon"
44
+ data-inkbrick-part="icon"
45
+ contentEditable={false}
46
+ disabled={readOnly}
47
+ aria-label={t('block.callout.label', { tone: t(`block.callout.tone.${tone}`) })}
48
+ title={t('block.callout.change')}
49
+ onClick={() =>
50
+ editor.dispatch({
51
+ type: 'setBlockProps',
52
+ payload: { blockId: block.id, props: { tone: nextTone } },
53
+ })
54
+ }
55
+ >
56
+ {icon}
57
+ </button>
58
+ <Editable block={block} />
59
+ </div>
60
+ );
61
+ }
@@ -0,0 +1,96 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ import { useT } from '../hooks/useT';
4
+ import { Icon } from '../icons/Icon';
5
+ import { Editable } from './Editable';
6
+ import type { BlockViewProps } from '../ui/types';
7
+
8
+ /**
9
+ * Языки в выпадающем списке. Редактор хранит только `language`; подсветку делает `highlighter` провайдера
10
+ * (по умолчанию встроенный — он знает эти языки и их псевдонимы).
11
+ */
12
+ export const CODE_LANGUAGES = [
13
+ 'plain',
14
+ 'typescript',
15
+ 'javascript',
16
+ 'json',
17
+ 'html',
18
+ 'css',
19
+ 'python',
20
+ 'bash',
21
+ 'sql',
22
+ 'go',
23
+ 'rust',
24
+ 'java',
25
+ 'c',
26
+ 'cpp',
27
+ 'markdown',
28
+ 'yaml',
29
+ ] as const;
30
+
31
+ export function CodeBlock({ block, editor, selected, readOnly }: BlockViewProps) {
32
+ const t = useT();
33
+ const language = typeof block.props.language === 'string' ? block.props.language : 'plain';
34
+ const languages = CODE_LANGUAGES.includes(language as (typeof CODE_LANGUAGES)[number])
35
+ ? CODE_LANGUAGES
36
+ : [...CODE_LANGUAGES, language];
37
+
38
+ const [copied, setCopied] = useState(false);
39
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
40
+
41
+ useEffect(() => () => clearTimeout(timer.current), []);
42
+
43
+ const copy = () => {
44
+ const text = block.content.map((node) => node.insert).join('');
45
+
46
+ void navigator.clipboard?.writeText(text).then(() => {
47
+ setCopied(true);
48
+ clearTimeout(timer.current);
49
+ timer.current = setTimeout(() => setCopied(false), 1500);
50
+ });
51
+ };
52
+
53
+ return (
54
+ <div
55
+ className="block block-code"
56
+ data-inkbrick-block="code"
57
+ data-block-id={block.id}
58
+ data-language={language}
59
+ data-selected={selected || undefined}
60
+ >
61
+ <div className="code-toolbar" data-inkbrick-part="toolbar" contentEditable={false}>
62
+ <select
63
+ className="code-language"
64
+ data-inkbrick-part="language"
65
+ aria-label={t('block.code.language')}
66
+ disabled={readOnly}
67
+ value={language}
68
+ onChange={(event) =>
69
+ editor.dispatch({
70
+ type: 'setBlockProps',
71
+ payload: { blockId: block.id, props: { language: event.target.value } },
72
+ })
73
+ }
74
+ >
75
+ {languages.map((name) => (
76
+ <option key={name} value={name}>
77
+ {name}
78
+ </option>
79
+ ))}
80
+ </select>
81
+ <button
82
+ type="button"
83
+ className="code-copy"
84
+ data-inkbrick-part="copy"
85
+ data-state={copied ? 'copied' : undefined}
86
+ aria-label={copied ? t('block.code.copied') : t('block.code.copy')}
87
+ title={copied ? t('block.code.copied') : t('block.code.copy')}
88
+ onClick={copy}
89
+ >
90
+ <Icon name={copied ? 'check' : 'copy'} />
91
+ </button>
92
+ </div>
93
+ <Editable block={block} />
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,15 @@
1
+ import type { BlockProps } from './BlockProps';
2
+
3
+ export function DividerBlock({ block, selected }: BlockProps) {
4
+ return (
5
+ <div
6
+ className="block block-divider"
7
+ data-inkbrick-block="divider"
8
+ data-block-id={block.id}
9
+ data-selected={selected || undefined}
10
+ contentEditable={false}
11
+ >
12
+ <hr data-inkbrick-part="rule" />
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,84 @@
1
+ import { useLayoutEffect, useRef, useSyncExternalStore } from 'react';
2
+
3
+ import type { BaseBlock } from '@inkbrick/document';
4
+
5
+ import { renderCode } from '../editing/renderCode';
6
+ import { renderInline } from '../editing/renderInline';
7
+ import { useEditorView } from '../hooks/useEditorView';
8
+ import { useReadOnly } from '../hooks/useReadOnly';
9
+ import { useT } from '../hooks/useT';
10
+ import { useEditorUI } from '../ui/EditorUIContext';
11
+
12
+ export interface EditableProps {
13
+ readonly block: BaseBlock;
14
+ /**
15
+ * Собственная подсказка пустого блока (показывается всегда, пока блок пуст). Не нужна: по умолчанию редактор
16
+ * сам показывает одну общую подсказку («Нажмите / …») в пустом блоке, где стоит каретка.
17
+ */
18
+ readonly placeholder?: string;
19
+ }
20
+
21
+ /**
22
+ * Текст блока. Сам по себе не является contenteditable: редактируемым элементом
23
+ * служит корень `<DocumentView>`, благодаря чему выделение может охватывать несколько блоков.
24
+ * Содержимое рисуется из `block.content`, а ввод обрабатывается корнем через движок.
25
+ *
26
+ * Атрибуты для стилизации: `data-empty`, `data-focused` (каретка в этом блоке), `data-hint` (текст общей
27
+ * подсказки; есть только там, где работает slash-меню), `data-placeholder` (своя подсказка, если задана).
28
+ */
29
+ export function Editable({ block, placeholder }: EditableProps) {
30
+ const ref = useRef<HTMLDivElement>(null);
31
+ const view = useEditorView();
32
+ const t = useT();
33
+ const readOnly = useReadOnly();
34
+ const { slashItems, highlighter, formulaRenderer } = useEditorUI();
35
+ const focused = useSyncExternalStore(
36
+ view.caret.subscribe,
37
+ () => view.caret.has(block.id),
38
+ () => false,
39
+ );
40
+
41
+ useLayoutEffect(() => view.register(block.id, ref.current!), [view, block.id]);
42
+
43
+ const isCode = block.flavour === 'block:code';
44
+ const language = typeof block.props.language === 'string' ? block.props.language : 'plain';
45
+
46
+ useLayoutEffect(() => {
47
+ const plain = block.content.every((node) => !node.attributes);
48
+
49
+ // Код — простой текст: подсветка только оборачивает его в <span data-inkbrick-token>, не меняя символов.
50
+ if (isCode && highlighter && plain) {
51
+ const text = block.content.map((node) => node.insert).join('');
52
+ let tokens: ReturnType<typeof highlighter> = [];
53
+
54
+ try {
55
+ tokens = highlighter(text, language);
56
+ } catch (error) {
57
+ // Ошибка подсветчика (в том числе чужого) не должна ронять редактор: показываем текст без подсветки.
58
+ console.error('[@inkbrick/react] The code highlighter failed:', error);
59
+ }
60
+
61
+ renderCode(ref.current!, text, tokens);
62
+ } else {
63
+ renderInline(ref.current!, block.content, { formulaRenderer });
64
+ }
65
+ }, [block.content, isCode, language, highlighter, formulaRenderer]);
66
+
67
+ const isEmpty = block.content.every((node) => node.insert.length === 0);
68
+
69
+ // Подсказка про «/» уместна там, где меню открывается: не в режиме чтения, не в коде и если меню не отключено.
70
+ const hinted = !readOnly && slashItems.length > 0 && block.flavour !== 'block:code';
71
+
72
+ return (
73
+ <div
74
+ ref={ref}
75
+ className="editable"
76
+ data-inkbrick-part="content"
77
+ data-id={block.id}
78
+ data-empty={isEmpty}
79
+ data-focused={focused || undefined}
80
+ data-hint={hinted ? t('editor.hint') : undefined}
81
+ data-placeholder={placeholder}
82
+ />
83
+ );
84
+ }