@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,173 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { MAX_FORMULA_LENGTH } from '@inkbrick/document';
|
|
4
|
+
|
|
5
|
+
import { useT } from '../hooks/useT';
|
|
6
|
+
import { LazyFormulaPalette } from '../formula/LazyFormulaPalette';
|
|
7
|
+
import { useSnippetInsert } from '../formula/snippet';
|
|
8
|
+
import { FormulaView } from '../math/FormulaView';
|
|
9
|
+
import type { BlockViewProps } from '../ui/types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Формула отдельной строкой. Блок без редактируемого текста («остров»): код LaTeX хранится в `props.latex`,
|
|
13
|
+
* а правится в собственном поле. Клик по формуле открывает поле; `Ctrl/Cmd+Enter` или потеря фокуса применяют код,
|
|
14
|
+
* `Esc` отменяет. Пустой блок сразу открывается для ввода.
|
|
15
|
+
*/
|
|
16
|
+
export function FormulaBlock({ block, editor, selected, readOnly }: BlockViewProps) {
|
|
17
|
+
const t = useT();
|
|
18
|
+
const latex = typeof block.props.latex === 'string' ? block.props.latex : '';
|
|
19
|
+
const [editing, setEditing] = useState(latex === '' && !readOnly);
|
|
20
|
+
const [draft, setDraft] = useState(latex);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const field = useRef<HTMLTextAreaElement>(null);
|
|
23
|
+
const container = useRef<HTMLDivElement>(null);
|
|
24
|
+
const insertSnippet = useSnippetInsert(field, draft, setDraft);
|
|
25
|
+
// Escape отменяет ввод, а blur после него не должен сохранить набранное.
|
|
26
|
+
const cancelled = useRef(false);
|
|
27
|
+
const born = useRef(Date.now());
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!editing) setDraft(latex);
|
|
31
|
+
}, [latex, editing]);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!editing) return;
|
|
35
|
+
|
|
36
|
+
// После Esc поле удаляется, а blur при удалении шлёт не каждый браузер (Firefox — нет): флаг сбрасываем здесь.
|
|
37
|
+
cancelled.current = false;
|
|
38
|
+
// Окно «фокус отняли программно» отсчитывается от установки фокуса, а не от отрисовки: на медленной машине
|
|
39
|
+
// отрисовка нового блока сама может занять больше этого окна.
|
|
40
|
+
born.current = Date.now();
|
|
41
|
+
field.current?.focus();
|
|
42
|
+
|
|
43
|
+
// Сразу после создания блока редактор переносит курсор в следующий блок и забирает фокус: возвращаем его в поле.
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
if (Date.now() - born.current < 300) field.current?.focus();
|
|
46
|
+
}, 0);
|
|
47
|
+
|
|
48
|
+
return () => clearTimeout(timer);
|
|
49
|
+
}, [editing]);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (readOnly) setEditing(false);
|
|
53
|
+
}, [readOnly]);
|
|
54
|
+
|
|
55
|
+
const settle = useCallback((state: 'ready' | 'error', message?: string) => {
|
|
56
|
+
setError(state === 'error' ? (message ?? '') : null);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const commit = () => {
|
|
60
|
+
if (cancelled.current) {
|
|
61
|
+
cancelled.current = false;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Блок только что создан и пуст: фокус отняли программно (курсор перенесён дальше) — поле остаётся открытым.
|
|
66
|
+
if (latex === '' && draft === '' && Date.now() - born.current < 300) {
|
|
67
|
+
setTimeout(() => field.current?.focus(), 0);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (draft !== latex) {
|
|
72
|
+
editor.dispatch({
|
|
73
|
+
type: 'setBlockProps',
|
|
74
|
+
payload: { blockId: block.id, props: { latex: draft } },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setEditing(false);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const cancel = () => {
|
|
82
|
+
cancelled.current = true;
|
|
83
|
+
setDraft(latex);
|
|
84
|
+
setEditing(false);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
className="block block-formula"
|
|
90
|
+
data-inkbrick-block="formula"
|
|
91
|
+
data-block-id={block.id}
|
|
92
|
+
data-selected={selected || undefined}
|
|
93
|
+
contentEditable={false}
|
|
94
|
+
>
|
|
95
|
+
{editing ? (
|
|
96
|
+
<div
|
|
97
|
+
ref={container}
|
|
98
|
+
className="formula-editor"
|
|
99
|
+
data-inkbrick-part="formula-editor"
|
|
100
|
+
// Поле закрывается, когда фокус ушёл из редактора целиком; переход на кнопки палитры его не закрывает.
|
|
101
|
+
onBlur={(event) => {
|
|
102
|
+
if (
|
|
103
|
+
event.relatedTarget instanceof Node &&
|
|
104
|
+
container.current?.contains(event.relatedTarget)
|
|
105
|
+
)
|
|
106
|
+
return;
|
|
107
|
+
|
|
108
|
+
commit();
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<textarea
|
|
112
|
+
ref={field}
|
|
113
|
+
className="formula-source"
|
|
114
|
+
data-inkbrick-part="formula-source"
|
|
115
|
+
aria-label={t('formula.source')}
|
|
116
|
+
aria-invalid={error !== null || undefined}
|
|
117
|
+
placeholder={t('formula.placeholder')}
|
|
118
|
+
spellCheck={false}
|
|
119
|
+
rows={Math.min(8, Math.max(2, draft.split('\n').length))}
|
|
120
|
+
maxLength={MAX_FORMULA_LENGTH}
|
|
121
|
+
value={draft}
|
|
122
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
123
|
+
onKeyDown={(event) => {
|
|
124
|
+
if (event.key === 'Escape') {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
cancel();
|
|
127
|
+
(event.target as HTMLTextAreaElement).blur();
|
|
128
|
+
} else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
(event.target as HTMLTextAreaElement).blur();
|
|
131
|
+
}
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
<div className="formula-preview" data-inkbrick-part="formula-preview">
|
|
135
|
+
<FormulaView latex={draft} display showError onSettled={settle} />
|
|
136
|
+
</div>
|
|
137
|
+
<LazyFormulaPalette onInsert={insertSnippet} />
|
|
138
|
+
<div className="formula-hint" data-inkbrick-part="formula-hint">
|
|
139
|
+
{t('formula.hint')}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
) : latex === '' ? (
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className="formula-empty"
|
|
146
|
+
data-inkbrick-part="formula-empty"
|
|
147
|
+
disabled={readOnly}
|
|
148
|
+
onClick={() => setEditing(true)}
|
|
149
|
+
>
|
|
150
|
+
{t('formula.empty')}
|
|
151
|
+
</button>
|
|
152
|
+
) : (
|
|
153
|
+
<div
|
|
154
|
+
className="formula-display"
|
|
155
|
+
data-inkbrick-part="formula-display"
|
|
156
|
+
role={readOnly ? undefined : 'button'}
|
|
157
|
+
tabIndex={readOnly ? undefined : 0}
|
|
158
|
+
aria-label={readOnly ? undefined : t('formula.edit')}
|
|
159
|
+
title={readOnly ? undefined : t('formula.edit')}
|
|
160
|
+
onClick={() => !readOnly && setEditing(true)}
|
|
161
|
+
onKeyDown={(event) => {
|
|
162
|
+
if (!readOnly && (event.key === 'Enter' || event.key === ' ')) {
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
setEditing(true);
|
|
165
|
+
}
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
<FormulaView latex={latex} display showError />
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getBlockAlign } from '@inkbrick/document';
|
|
2
|
+
|
|
3
|
+
import { Editable } from './Editable';
|
|
4
|
+
import type { BlockProps } from './BlockProps';
|
|
5
|
+
|
|
6
|
+
export function HeadingBlock({ block, selected }: BlockProps) {
|
|
7
|
+
const level = typeof block.props.level === 'number' ? block.props.level : 1;
|
|
8
|
+
const align = getBlockAlign(block) ?? 'left';
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
className={`block block-heading level-${level}`}
|
|
13
|
+
data-inkbrick-block="heading"
|
|
14
|
+
data-level={level}
|
|
15
|
+
data-block-id={block.id}
|
|
16
|
+
data-selected={selected || undefined}
|
|
17
|
+
data-align={align}
|
|
18
|
+
>
|
|
19
|
+
<Editable block={block} />
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
import { isSafeImageUrl } from '@inkbrick/document';
|
|
4
|
+
import type { Editor } from '@inkbrick/engine';
|
|
5
|
+
|
|
6
|
+
import { useEditorView } from '../hooks/useEditorView';
|
|
7
|
+
import { useT } from '../hooks/useT';
|
|
8
|
+
import type { MessageKey } from '../i18n/en';
|
|
9
|
+
import { Icon, type IconName } from '../icons/Icon';
|
|
10
|
+
import { formatImageError } from '../images/ImageError';
|
|
11
|
+
import { fillImageBlock } from '../images/insertImages';
|
|
12
|
+
import { normalizeImageUrl } from '../images/upload';
|
|
13
|
+
import { DEFAULT_IMAGE_TYPES } from '../images/types';
|
|
14
|
+
import { useEditorUI } from '../ui/EditorUIContext';
|
|
15
|
+
import type { BlockViewProps } from '../ui/types';
|
|
16
|
+
|
|
17
|
+
const WIDTHS = [25, 50, 75, 100];
|
|
18
|
+
const ALIGNS: ReadonlyArray<{ value: string; label: MessageKey; icon: IconName }> = [
|
|
19
|
+
{ value: 'left', label: 'image.align.left', icon: 'align-left' },
|
|
20
|
+
{ value: 'center', label: 'image.align.center', icon: 'align-center' },
|
|
21
|
+
{ value: 'right', label: 'image.align.right', icon: 'align-right' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const setProps = (editor: Editor, blockId: string, props: Record<string, unknown>) =>
|
|
25
|
+
editor.dispatch({ type: 'setBlockProps', payload: { blockId, props: props as never } });
|
|
26
|
+
|
|
27
|
+
/** Поле, которое сохраняется по Enter/потере фокуса, а не на каждую букву (одна запись в истории). */
|
|
28
|
+
function CommitInput({
|
|
29
|
+
value,
|
|
30
|
+
label,
|
|
31
|
+
placeholder,
|
|
32
|
+
className,
|
|
33
|
+
part,
|
|
34
|
+
onCommit,
|
|
35
|
+
}: {
|
|
36
|
+
value: string;
|
|
37
|
+
label: string;
|
|
38
|
+
placeholder: string;
|
|
39
|
+
className: string;
|
|
40
|
+
part: string;
|
|
41
|
+
onCommit: (value: string) => void;
|
|
42
|
+
}) {
|
|
43
|
+
const [draft, setDraft] = useState(value);
|
|
44
|
+
// Последнее сохранённое/известное значение: Enter сохраняет и тут же снимает фокус, а blur не должен сохранить второй раз.
|
|
45
|
+
const saved = useRef(value);
|
|
46
|
+
const cancelled = useRef(false);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
saved.current = value;
|
|
50
|
+
setDraft(value);
|
|
51
|
+
}, [value]);
|
|
52
|
+
|
|
53
|
+
const commit = () => {
|
|
54
|
+
if (cancelled.current) {
|
|
55
|
+
cancelled.current = false;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (draft !== saved.current) {
|
|
60
|
+
saved.current = draft;
|
|
61
|
+
onCommit(draft);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<input
|
|
67
|
+
className={className}
|
|
68
|
+
data-inkbrick-part={part}
|
|
69
|
+
type="text"
|
|
70
|
+
aria-label={label}
|
|
71
|
+
placeholder={placeholder}
|
|
72
|
+
value={draft}
|
|
73
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
74
|
+
onBlur={commit}
|
|
75
|
+
onKeyDown={(event) => {
|
|
76
|
+
if (event.key === 'Enter') {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
commit();
|
|
79
|
+
(event.target as HTMLInputElement).blur();
|
|
80
|
+
} else if (event.key === 'Escape') {
|
|
81
|
+
cancelled.current = true;
|
|
82
|
+
setDraft(saved.current);
|
|
83
|
+
(event.target as HTMLInputElement).blur();
|
|
84
|
+
}
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ImagePicker({ blockId, editor }: { blockId: string; editor: Editor }) {
|
|
91
|
+
const view = useEditorView();
|
|
92
|
+
const t = useT();
|
|
93
|
+
const { images } = useEditorUI();
|
|
94
|
+
const [url, setUrl] = useState('');
|
|
95
|
+
const [linkError, setLinkError] = useState<MessageKey | null>(null);
|
|
96
|
+
const uploadError = useSyncExternalStore(
|
|
97
|
+
view.uploads.subscribe,
|
|
98
|
+
() => view.uploads.errorDetailsOf(blockId),
|
|
99
|
+
() => undefined,
|
|
100
|
+
);
|
|
101
|
+
const error = linkError ? t(linkError) : uploadError ? formatImageError(uploadError, t) : null;
|
|
102
|
+
const file = useRef<HTMLInputElement>(null);
|
|
103
|
+
|
|
104
|
+
const embed = () => {
|
|
105
|
+
const normalized = normalizeImageUrl(url);
|
|
106
|
+
|
|
107
|
+
if (!normalized) {
|
|
108
|
+
setLinkError('image.error.invalidLink');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
editor.dispatch({
|
|
114
|
+
type: 'setBlockProps',
|
|
115
|
+
payload: { blockId, props: { src: normalized } },
|
|
116
|
+
}) === undefined
|
|
117
|
+
) {
|
|
118
|
+
setLinkError('image.error.unsupportedAddress');
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const upload = async (selected: File | undefined) => {
|
|
123
|
+
if (!selected) return;
|
|
124
|
+
|
|
125
|
+
setLinkError(null);
|
|
126
|
+
await fillImageBlock(editor, view, images, blockId, selected);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="image-picker" data-inkbrick-part="picker" data-testid="image-picker">
|
|
131
|
+
<div className="image-picker__row" data-inkbrick-part="picker-row">
|
|
132
|
+
<input
|
|
133
|
+
className="image-picker__input"
|
|
134
|
+
data-inkbrick-part="picker-input"
|
|
135
|
+
type="text"
|
|
136
|
+
aria-label={t('image.url.label')}
|
|
137
|
+
aria-invalid={linkError !== null}
|
|
138
|
+
placeholder={t('image.url.placeholder')}
|
|
139
|
+
value={url}
|
|
140
|
+
onChange={(event) => {
|
|
141
|
+
setUrl(event.target.value);
|
|
142
|
+
setLinkError(null);
|
|
143
|
+
view.uploads.clearError(blockId);
|
|
144
|
+
}}
|
|
145
|
+
onKeyDown={(event) => {
|
|
146
|
+
if (event.key === 'Enter') {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
embed();
|
|
149
|
+
}
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
className="image-picker__button"
|
|
155
|
+
data-inkbrick-part="picker-button"
|
|
156
|
+
onClick={embed}
|
|
157
|
+
>
|
|
158
|
+
{t('image.embed')}
|
|
159
|
+
</button>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className="image-picker__button"
|
|
163
|
+
onClick={() => file.current?.click()}
|
|
164
|
+
>
|
|
165
|
+
{t('image.upload')}
|
|
166
|
+
</button>
|
|
167
|
+
<input
|
|
168
|
+
ref={file}
|
|
169
|
+
type="file"
|
|
170
|
+
hidden
|
|
171
|
+
data-testid="image-file"
|
|
172
|
+
accept={(images.acceptedTypes ?? DEFAULT_IMAGE_TYPES).join(',')}
|
|
173
|
+
onChange={(event) => {
|
|
174
|
+
void upload(event.target.files?.[0]);
|
|
175
|
+
event.target.value = '';
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
{error && (
|
|
180
|
+
<div className="image-picker__error" data-inkbrick-part="picker-error" role="alert">
|
|
181
|
+
{error}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function ImageBlock({ block, editor, selected, readOnly }: BlockViewProps) {
|
|
189
|
+
const view = useEditorView();
|
|
190
|
+
const t = useT();
|
|
191
|
+
const uploading = useSyncExternalStore(
|
|
192
|
+
view.uploads.subscribe,
|
|
193
|
+
() => view.uploads.has(block.id),
|
|
194
|
+
() => false,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const { src, alt, caption } = block.props as { src?: string; alt?: string; caption?: string };
|
|
198
|
+
const width = typeof block.props.width === 'number' ? block.props.width : 100;
|
|
199
|
+
const align = typeof block.props.align === 'string' ? block.props.align : 'center';
|
|
200
|
+
const address = typeof src === 'string' ? src : '';
|
|
201
|
+
|
|
202
|
+
const [failed, setFailed] = useState(false);
|
|
203
|
+
|
|
204
|
+
useEffect(() => setFailed(false), [address]);
|
|
205
|
+
|
|
206
|
+
let content;
|
|
207
|
+
|
|
208
|
+
if (uploading) {
|
|
209
|
+
content = (
|
|
210
|
+
<div className="image-status" data-inkbrick-part="status" data-state="loading" role="status">
|
|
211
|
+
{t('image.status.uploading')}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
} else if (address === '') {
|
|
215
|
+
content = readOnly ? (
|
|
216
|
+
<div className="image-status" data-inkbrick-part="status" data-state="empty">
|
|
217
|
+
{t('image.status.none')}
|
|
218
|
+
</div>
|
|
219
|
+
) : (
|
|
220
|
+
<ImagePicker blockId={block.id} editor={editor} />
|
|
221
|
+
);
|
|
222
|
+
} else if (!isSafeImageUrl(address)) {
|
|
223
|
+
content = (
|
|
224
|
+
<div
|
|
225
|
+
className="image-status image-status--error"
|
|
226
|
+
data-inkbrick-part="status"
|
|
227
|
+
data-state="error"
|
|
228
|
+
>
|
|
229
|
+
{t('image.status.unsupported')}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
} else if (failed) {
|
|
233
|
+
content = (
|
|
234
|
+
<div
|
|
235
|
+
className="image-status image-status--error"
|
|
236
|
+
data-inkbrick-part="status"
|
|
237
|
+
data-state="error"
|
|
238
|
+
role="alert"
|
|
239
|
+
>
|
|
240
|
+
{t('image.status.failed')}
|
|
241
|
+
{!readOnly && (
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
className="image-picker__button"
|
|
245
|
+
data-inkbrick-part="picker-button"
|
|
246
|
+
onClick={() => setProps(editor, block.id, { src: '' })}
|
|
247
|
+
>
|
|
248
|
+
{t('image.status.retry')}
|
|
249
|
+
</button>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
} else {
|
|
254
|
+
content = (
|
|
255
|
+
<figure
|
|
256
|
+
className="image-figure"
|
|
257
|
+
data-inkbrick-part="figure"
|
|
258
|
+
data-align={align}
|
|
259
|
+
style={{ width: `${width}%` }}
|
|
260
|
+
>
|
|
261
|
+
<img
|
|
262
|
+
src={address}
|
|
263
|
+
alt={alt ?? ''}
|
|
264
|
+
loading="lazy"
|
|
265
|
+
decoding="async"
|
|
266
|
+
draggable={false}
|
|
267
|
+
data-inkbrick-part="image"
|
|
268
|
+
referrerPolicy="no-referrer"
|
|
269
|
+
onError={() => setFailed(true)}
|
|
270
|
+
/>
|
|
271
|
+
|
|
272
|
+
{!readOnly && (
|
|
273
|
+
<div
|
|
274
|
+
className="image-tools"
|
|
275
|
+
data-inkbrick-part="tools"
|
|
276
|
+
role="toolbar"
|
|
277
|
+
aria-label={t('image.options')}
|
|
278
|
+
>
|
|
279
|
+
{ALIGNS.map(({ value, label, icon }) => (
|
|
280
|
+
<button
|
|
281
|
+
key={value}
|
|
282
|
+
type="button"
|
|
283
|
+
className="image-tools__button"
|
|
284
|
+
data-inkbrick-part="tool"
|
|
285
|
+
data-pressed={align === value || undefined}
|
|
286
|
+
aria-label={t(label)}
|
|
287
|
+
aria-pressed={align === value}
|
|
288
|
+
title={t(label)}
|
|
289
|
+
onClick={() => setProps(editor, block.id, { align: value })}
|
|
290
|
+
>
|
|
291
|
+
<Icon name={icon} />
|
|
292
|
+
</button>
|
|
293
|
+
))}
|
|
294
|
+
<select
|
|
295
|
+
className="image-tools__select"
|
|
296
|
+
data-inkbrick-part="width"
|
|
297
|
+
aria-label={t('image.width')}
|
|
298
|
+
value={WIDTHS.includes(width) ? width : ''}
|
|
299
|
+
onChange={(event) =>
|
|
300
|
+
setProps(editor, block.id, { width: Number(event.target.value) })
|
|
301
|
+
}
|
|
302
|
+
>
|
|
303
|
+
{!WIDTHS.includes(width) && <option value="">{width}%</option>}
|
|
304
|
+
{WIDTHS.map((w) => (
|
|
305
|
+
<option key={w} value={w}>
|
|
306
|
+
{w}%
|
|
307
|
+
</option>
|
|
308
|
+
))}
|
|
309
|
+
</select>
|
|
310
|
+
<CommitInput
|
|
311
|
+
className="image-tools__alt"
|
|
312
|
+
part="alt"
|
|
313
|
+
label={t('image.alt')}
|
|
314
|
+
placeholder={t('image.alt')}
|
|
315
|
+
value={alt ?? ''}
|
|
316
|
+
onCommit={(value) => setProps(editor, block.id, { alt: value })}
|
|
317
|
+
/>
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
className="image-tools__button"
|
|
321
|
+
data-inkbrick-part="tool"
|
|
322
|
+
aria-label={t('image.replace')}
|
|
323
|
+
title={t('image.replace')}
|
|
324
|
+
onClick={() => setProps(editor, block.id, { src: '' })}
|
|
325
|
+
>
|
|
326
|
+
<Icon name="replace" />
|
|
327
|
+
</button>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
|
|
331
|
+
{readOnly ? (
|
|
332
|
+
caption ? (
|
|
333
|
+
<figcaption className="image-caption" data-inkbrick-part="caption">
|
|
334
|
+
{caption}
|
|
335
|
+
</figcaption>
|
|
336
|
+
) : null
|
|
337
|
+
) : (
|
|
338
|
+
<CommitInput
|
|
339
|
+
className="image-caption image-caption--input"
|
|
340
|
+
part="caption"
|
|
341
|
+
label={t('image.caption.label')}
|
|
342
|
+
placeholder={t('image.caption.placeholder')}
|
|
343
|
+
value={caption ?? ''}
|
|
344
|
+
onCommit={(value) => setProps(editor, block.id, { caption: value })}
|
|
345
|
+
/>
|
|
346
|
+
)}
|
|
347
|
+
</figure>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<div
|
|
353
|
+
className="block block-image"
|
|
354
|
+
data-inkbrick-block="image"
|
|
355
|
+
data-block-id={block.id}
|
|
356
|
+
data-selected={selected || undefined}
|
|
357
|
+
contentEditable={false}
|
|
358
|
+
>
|
|
359
|
+
{content}
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getBlockAlign } from '@inkbrick/document';
|
|
2
|
+
|
|
3
|
+
import { BlockChildren } from './BlockChildren';
|
|
4
|
+
import { useBlock } from '../hooks/useBlock';
|
|
5
|
+
import { useChildren } from '../hooks/useChildren';
|
|
6
|
+
import { useEditor } from '../hooks/useEditor';
|
|
7
|
+
import { useT } from '../hooks/useT';
|
|
8
|
+
import { Editable } from './Editable';
|
|
9
|
+
import type { BlockProps } from './BlockProps';
|
|
10
|
+
|
|
11
|
+
function useOrderedNumber(block: BlockProps['block']): number {
|
|
12
|
+
const { store } = useEditor();
|
|
13
|
+
const parent = useBlock(block.parentId ?? '');
|
|
14
|
+
|
|
15
|
+
if (!parent) return 1;
|
|
16
|
+
|
|
17
|
+
let number = 1;
|
|
18
|
+
|
|
19
|
+
for (let i = parent.children.indexOf(block.id) - 1; i >= 0; i--) {
|
|
20
|
+
const sibling = store.getBlock(parent.children[i]);
|
|
21
|
+
|
|
22
|
+
if (sibling?.flavour !== 'block:list' || sibling.props.type !== 'ordered') break;
|
|
23
|
+
|
|
24
|
+
number++;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ListBlock({ block, selected, readOnly }: BlockProps) {
|
|
31
|
+
const editor = useEditor();
|
|
32
|
+
const t = useT();
|
|
33
|
+
const children = useChildren(block.id);
|
|
34
|
+
const orderedNumber = useOrderedNumber(block);
|
|
35
|
+
|
|
36
|
+
const type = typeof block.props.type === 'string' ? block.props.type : 'bullet';
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
className="block-list-item"
|
|
41
|
+
data-inkbrick-block="list"
|
|
42
|
+
data-align={getBlockAlign(block) ?? 'left'}
|
|
43
|
+
data-block-id={block.id}
|
|
44
|
+
data-list-type={type}
|
|
45
|
+
data-checked={type === 'todo' ? block.props.checked === true : undefined}
|
|
46
|
+
data-selected={selected || undefined}
|
|
47
|
+
>
|
|
48
|
+
<div className="block block-list" data-inkbrick-part="row" data-list-type={type}>
|
|
49
|
+
{type === 'todo' ? (
|
|
50
|
+
<input
|
|
51
|
+
className="list-marker list-checkbox"
|
|
52
|
+
data-inkbrick-part="checkbox"
|
|
53
|
+
type="checkbox"
|
|
54
|
+
aria-label={t('block.list.done')}
|
|
55
|
+
contentEditable={false}
|
|
56
|
+
disabled={readOnly}
|
|
57
|
+
checked={block.props.checked === true}
|
|
58
|
+
onChange={(event) =>
|
|
59
|
+
editor.dispatch({
|
|
60
|
+
type: 'setBlockProps',
|
|
61
|
+
payload: { blockId: block.id, props: { checked: event.target.checked } },
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
/>
|
|
65
|
+
) : (
|
|
66
|
+
<span
|
|
67
|
+
className="list-marker"
|
|
68
|
+
data-inkbrick-part="marker"
|
|
69
|
+
contentEditable={false}
|
|
70
|
+
aria-hidden="true"
|
|
71
|
+
>
|
|
72
|
+
{type === 'ordered' ? `${orderedNumber}.` : '•'}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
<Editable block={block} />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{children.length > 0 && (
|
|
80
|
+
<div className="block-nested-children" data-inkbrick-part="children">
|
|
81
|
+
<BlockChildren id={block.id} />
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { BlockChildren } from './BlockChildren';
|
|
2
|
+
import type { BlockProps } from './BlockProps';
|
|
3
|
+
|
|
4
|
+
export function NoteBlock({ block }: BlockProps) {
|
|
5
|
+
return (
|
|
6
|
+
<section className="page-content" data-inkbrick-block="note" data-block-id={block.id}>
|
|
7
|
+
<BlockChildren id={block.id} />
|
|
8
|
+
</section>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { BlockChildren } from './BlockChildren';
|
|
2
|
+
import type { BlockProps } from './BlockProps';
|
|
3
|
+
|
|
4
|
+
export function PageBlock({ block }: BlockProps) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="page" data-inkbrick-block="page" data-block-id={block.id}>
|
|
7
|
+
<BlockChildren id={block.id} />
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { getBlockAlign } from '@inkbrick/document';
|
|
2
|
+
|
|
3
|
+
import { Editable } from './Editable';
|
|
4
|
+
import type { BlockProps } from './BlockProps';
|
|
5
|
+
|
|
6
|
+
export function ParagraphBlock({ block, selected }: BlockProps) {
|
|
7
|
+
const align = getBlockAlign(block) ?? 'left';
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
className="block block-paragraph"
|
|
12
|
+
data-inkbrick-block="paragraph"
|
|
13
|
+
data-block-id={block.id}
|
|
14
|
+
data-selected={selected || undefined}
|
|
15
|
+
data-align={align}
|
|
16
|
+
>
|
|
17
|
+
<Editable block={block} />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|