@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,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
+ }