@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,18 @@
|
|
|
1
|
+
import { getBlockAlign } from '@inkbrick/document';
|
|
2
|
+
|
|
3
|
+
import { Editable } from './Editable';
|
|
4
|
+
import type { BlockProps } from './BlockProps';
|
|
5
|
+
|
|
6
|
+
export function QuoteBlock({ block, selected }: BlockProps) {
|
|
7
|
+
return (
|
|
8
|
+
<blockquote
|
|
9
|
+
className="block block-quote"
|
|
10
|
+
data-inkbrick-block="quote"
|
|
11
|
+
data-align={getBlockAlign(block) ?? 'left'}
|
|
12
|
+
data-block-id={block.id}
|
|
13
|
+
data-selected={selected || undefined}
|
|
14
|
+
>
|
|
15
|
+
<Editable block={block} />
|
|
16
|
+
</blockquote>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useT } from '../hooks/useT';
|
|
2
|
+
import type { UnknownBlockViewProps } from '../ui/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Стандартная заглушка для блока с незарегистрированным flavour.
|
|
6
|
+
* Не редактируется; данные блока остаются в документе нетронутыми.
|
|
7
|
+
*/
|
|
8
|
+
export function UnknownBlockView({ block, children }: UnknownBlockViewProps) {
|
|
9
|
+
const t = useT();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
className="block block-unknown"
|
|
14
|
+
data-inkbrick-block="unknown"
|
|
15
|
+
role="note"
|
|
16
|
+
contentEditable={false}
|
|
17
|
+
data-block-id={block.id}
|
|
18
|
+
data-unknown-flavour={block.flavour}
|
|
19
|
+
>
|
|
20
|
+
<div className="block-unknown__message" data-inkbrick-part="message">
|
|
21
|
+
{t('block.unknown.title')} <code>{block.flavour}</code>. {t('block.unknown.hint')}{' '}
|
|
22
|
+
<code>components</code>.
|
|
23
|
+
</div>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { BlockProps as DocumentBlockProps } from '@inkbrick/document';
|
|
4
|
+
import type { BlockController, BlockDefinition, TypedBlock } from '@inkbrick/engine';
|
|
5
|
+
|
|
6
|
+
import type { BlockComponent, BlockComponentRegistry, BlockViewProps } from '../ui/types';
|
|
7
|
+
|
|
8
|
+
export type CustomBlockViewProps<TProps extends DocumentBlockProps = DocumentBlockProps> =
|
|
9
|
+
BlockViewProps<TypedBlock<TProps> & { props: TProps }>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Пользовательский блок целиком: схема документа, React-компонент и (необязательно) контроллер поведения.
|
|
13
|
+
* Схема и контроллер нужны движку (`new Editor({ blocks: [...] })`), компонент — UI (`<EditorProvider blocks>`).
|
|
14
|
+
*/
|
|
15
|
+
export interface ReactBlockDefinition<
|
|
16
|
+
TProps extends DocumentBlockProps = DocumentBlockProps,
|
|
17
|
+
> extends BlockDefinition<TProps> {
|
|
18
|
+
readonly component: ComponentType<CustomBlockViewProps<TProps>>;
|
|
19
|
+
readonly controller?: BlockController<TProps>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Объявляет пользовательский блок одним объектом:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* export const counterBlock = defineBlock<CounterProps>({ schema, component: CounterView, controller });
|
|
27
|
+
* const editor = new Editor({ blocks: [counterBlock] });
|
|
28
|
+
* <EditorProvider editor={editor} blocks={[counterBlock]}>…
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Объект создавайте один раз (вне компонента).
|
|
32
|
+
*/
|
|
33
|
+
export function defineBlock<TProps extends DocumentBlockProps = DocumentBlockProps>(
|
|
34
|
+
definition: ReactBlockDefinition<TProps>,
|
|
35
|
+
): ReactBlockDefinition<TProps> {
|
|
36
|
+
return definition;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function blockComponentsOf(
|
|
40
|
+
blocks: readonly ReactBlockDefinition<any>[] | undefined,
|
|
41
|
+
): BlockComponentRegistry {
|
|
42
|
+
const registry: Record<string, BlockComponent> = {};
|
|
43
|
+
|
|
44
|
+
for (const block of blocks ?? []) {
|
|
45
|
+
registry[block.schema.flavour] = block.component as unknown as BlockComponent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return registry;
|
|
49
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { type InlineNode, isSafeImageUrl, normalizeInlineNodes } from '@inkbrick/document';
|
|
2
|
+
import type { FragmentBlock } from '@inkbrick/engine';
|
|
3
|
+
import { cleanInline, inlineOf } from './inline';
|
|
4
|
+
import { isHidden, styleOf } from './style';
|
|
5
|
+
import { BLOCK_TAGS, MAX_DEPTH, SKIP_TAGS, type Style, tagOf } from './tags';
|
|
6
|
+
|
|
7
|
+
interface Item {
|
|
8
|
+
block: FragmentBlock;
|
|
9
|
+
level: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Адрес картинки из вставленного HTML: только абсолютные http(s) и data:-адреса (относительные пути чужого сайта бессмысленны). */
|
|
13
|
+
function pastedImageSrc(src: string | null): string | null {
|
|
14
|
+
const value = (src ?? '').trim();
|
|
15
|
+
|
|
16
|
+
return value !== '' && !value.startsWith('/') && isSafeImageUrl(value) ? value : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function imageBlock(img: Element, caption = ''): FragmentBlock | null {
|
|
20
|
+
const src = pastedImageSrc(img.getAttribute('src'));
|
|
21
|
+
|
|
22
|
+
if (!src) return null;
|
|
23
|
+
|
|
24
|
+
return block('block:image', [], {
|
|
25
|
+
src,
|
|
26
|
+
alt: (img.getAttribute('alt') ?? '').slice(0, 1000),
|
|
27
|
+
caption: caption.slice(0, 2000),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const block = (
|
|
32
|
+
flavour: string,
|
|
33
|
+
content: InlineNode[],
|
|
34
|
+
props: Record<string, unknown> = {},
|
|
35
|
+
): FragmentBlock => ({
|
|
36
|
+
flavour,
|
|
37
|
+
props,
|
|
38
|
+
content,
|
|
39
|
+
children: [],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Есть ли внутри блочный элемент. Обход в глубину с выходом на первом совпадении, без рекурсии. */
|
|
43
|
+
function hasBlockDescendant(element: Element): boolean {
|
|
44
|
+
const stack: Element[] = [];
|
|
45
|
+
|
|
46
|
+
for (let child = element.lastElementChild; child; child = child.previousElementSibling)
|
|
47
|
+
stack.push(child);
|
|
48
|
+
|
|
49
|
+
while (stack.length > 0) {
|
|
50
|
+
const current = stack.pop()!;
|
|
51
|
+
const tag = tagOf(current);
|
|
52
|
+
|
|
53
|
+
if (BLOCK_TAGS.has(tag) && !SKIP_TAGS.has(tag)) return true;
|
|
54
|
+
|
|
55
|
+
for (let child = current.lastElementChild; child; child = child.previousElementSibling)
|
|
56
|
+
stack.push(child);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function wordListLevel(element: HTMLElement): number | null {
|
|
63
|
+
const style = element.getAttribute('style') ?? '';
|
|
64
|
+
const match = /mso-list:[^;]*level(\d+)/i.exec(style);
|
|
65
|
+
|
|
66
|
+
if (match) return Number(match[1]);
|
|
67
|
+
|
|
68
|
+
return /^MsoListParagraph/i.test(element.className) ? 1 : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function wordListMarker(element: HTMLElement): string {
|
|
72
|
+
for (const span of element.querySelectorAll('span')) {
|
|
73
|
+
if (/mso-list:\s*ignore/i.test(span.getAttribute('style') ?? '')) return span.textContent ?? '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function listItems(
|
|
80
|
+
list: HTMLElement,
|
|
81
|
+
ordered: boolean,
|
|
82
|
+
style: Style,
|
|
83
|
+
depth: number,
|
|
84
|
+
): FragmentBlock[] {
|
|
85
|
+
const result: FragmentBlock[] = [];
|
|
86
|
+
|
|
87
|
+
for (const child of Array.from(list.children) as HTMLElement[]) {
|
|
88
|
+
if (SKIP_TAGS.has(tagOf(child)) || isHidden(child)) continue;
|
|
89
|
+
|
|
90
|
+
// <ul><ul>…</ul></ul> (Google Docs): вложенный список без пункта-родителя — потомок предыдущего пункта.
|
|
91
|
+
if (tagOf(child) === 'UL' || tagOf(child) === 'OL') {
|
|
92
|
+
const nested = listItems(child, tagOf(child) === 'OL', style, depth + 1);
|
|
93
|
+
const parent = result[result.length - 1];
|
|
94
|
+
|
|
95
|
+
if (parent) parent.children.push(...nested);
|
|
96
|
+
else result.push(...nested);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (tagOf(child) !== 'LI') continue;
|
|
101
|
+
|
|
102
|
+
const content: InlineNode[] = [];
|
|
103
|
+
const children: FragmentBlock[] = [];
|
|
104
|
+
const itemStyle = styleOf(child, style);
|
|
105
|
+
|
|
106
|
+
const checkbox = child.querySelector(
|
|
107
|
+
':scope > input[type="checkbox"], :scope > p > input[type="checkbox"]',
|
|
108
|
+
) as HTMLInputElement | null;
|
|
109
|
+
const todo = !!checkbox || /task-list-item/.test(child.className);
|
|
110
|
+
|
|
111
|
+
child.childNodes.forEach((node) => {
|
|
112
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
113
|
+
const element = node as HTMLElement;
|
|
114
|
+
|
|
115
|
+
if (tagOf(element) === 'UL' || tagOf(element) === 'OL') {
|
|
116
|
+
children.push(...listItems(element, tagOf(element) === 'OL', itemStyle, depth + 1));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
inlineOf(node, itemStyle, content, depth + 1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const props: Record<string, unknown> = { type: todo ? 'todo' : ordered ? 'ordered' : 'bullet' };
|
|
125
|
+
|
|
126
|
+
if (todo) props.checked = !!checkbox?.checked || checkbox?.hasAttribute('checked') === true;
|
|
127
|
+
|
|
128
|
+
const item = block('block:list', cleanInline(content), props);
|
|
129
|
+
item.children = children;
|
|
130
|
+
result.push(item);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pushWordItem(
|
|
137
|
+
out: FragmentBlock[],
|
|
138
|
+
stack: Item[],
|
|
139
|
+
item: FragmentBlock,
|
|
140
|
+
level: number,
|
|
141
|
+
): void {
|
|
142
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= level) stack.pop();
|
|
143
|
+
|
|
144
|
+
const parent = stack[stack.length - 1];
|
|
145
|
+
|
|
146
|
+
if (parent) parent.block.children.push(item);
|
|
147
|
+
else out.push(item);
|
|
148
|
+
|
|
149
|
+
stack.push({ block: item, level });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function collect(container: Node, style: Style, out: FragmentBlock[], depth: number): void {
|
|
153
|
+
if (depth > MAX_DEPTH) return;
|
|
154
|
+
|
|
155
|
+
let run: InlineNode[] = [];
|
|
156
|
+
let wordStack: Item[] = [];
|
|
157
|
+
|
|
158
|
+
const flush = () => {
|
|
159
|
+
const content = cleanInline(run);
|
|
160
|
+
|
|
161
|
+
if (content.length > 0) out.push(block('block:paragraph', content));
|
|
162
|
+
|
|
163
|
+
run = [];
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const endWordList = () => {
|
|
167
|
+
wordStack = [];
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
container.childNodes.forEach((node) => {
|
|
171
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
172
|
+
inlineOf(node, style, run, depth + 1);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
177
|
+
|
|
178
|
+
const element = node as HTMLElement;
|
|
179
|
+
|
|
180
|
+
if (SKIP_TAGS.has(tagOf(element)) || isHidden(element)) return;
|
|
181
|
+
|
|
182
|
+
const tag = tagOf(element);
|
|
183
|
+
const next = styleOf(element, style);
|
|
184
|
+
|
|
185
|
+
const level = tag === 'P' ? wordListLevel(element) : null;
|
|
186
|
+
|
|
187
|
+
if (level !== null) {
|
|
188
|
+
flush();
|
|
189
|
+
|
|
190
|
+
const marker = wordListMarker(element).trim();
|
|
191
|
+
const content: InlineNode[] = [];
|
|
192
|
+
|
|
193
|
+
element.childNodes.forEach((child) => inlineOf(child, next, content, depth + 1));
|
|
194
|
+
|
|
195
|
+
pushWordItem(
|
|
196
|
+
out,
|
|
197
|
+
wordStack,
|
|
198
|
+
block('block:list', cleanInline(content), {
|
|
199
|
+
type: /^(\d+|[a-z]|[ivxlc]+)[.)]/i.test(marker) ? 'ordered' : 'bullet',
|
|
200
|
+
}),
|
|
201
|
+
level,
|
|
202
|
+
);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
endWordList();
|
|
207
|
+
|
|
208
|
+
if (/^H[1-6]$/.test(tag)) {
|
|
209
|
+
flush();
|
|
210
|
+
|
|
211
|
+
const content: InlineNode[] = [];
|
|
212
|
+
element.childNodes.forEach((child) => inlineOf(child, next, content, depth + 1));
|
|
213
|
+
|
|
214
|
+
const cleaned = cleanInline(content);
|
|
215
|
+
|
|
216
|
+
if (cleaned.length > 0) out.push(block('block:heading', cleaned, { level: Number(tag[1]) }));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (tag === 'IMG') {
|
|
221
|
+
flush();
|
|
222
|
+
|
|
223
|
+
const image = imageBlock(element);
|
|
224
|
+
|
|
225
|
+
if (image) out.push(image);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
switch (tag) {
|
|
230
|
+
case 'FIGURE': {
|
|
231
|
+
const img = element.querySelector('img');
|
|
232
|
+
|
|
233
|
+
if (img) {
|
|
234
|
+
flush();
|
|
235
|
+
|
|
236
|
+
const caption = (element.querySelector('figcaption')?.textContent ?? '')
|
|
237
|
+
.replace(/\s+/g, ' ')
|
|
238
|
+
.trim();
|
|
239
|
+
const image = imageBlock(img, caption);
|
|
240
|
+
|
|
241
|
+
if (image) out.push(image);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'ASIDE': {
|
|
248
|
+
const tone = element.getAttribute('data-callout');
|
|
249
|
+
|
|
250
|
+
if (tone !== null) {
|
|
251
|
+
flush();
|
|
252
|
+
|
|
253
|
+
const content: InlineNode[] = [];
|
|
254
|
+
element.childNodes.forEach((child) => inlineOf(child, next, content, depth + 1));
|
|
255
|
+
const cleaned = cleanInline(content);
|
|
256
|
+
|
|
257
|
+
if (cleaned.length > 0) {
|
|
258
|
+
out.push(
|
|
259
|
+
block(
|
|
260
|
+
'block:callout',
|
|
261
|
+
cleaned,
|
|
262
|
+
/^(info|warning|success|danger)$/.test(tone) ? { tone } : {},
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case 'UL':
|
|
272
|
+
case 'OL':
|
|
273
|
+
flush();
|
|
274
|
+
out.push(...listItems(element, tag === 'OL', next, depth + 1));
|
|
275
|
+
return;
|
|
276
|
+
|
|
277
|
+
case 'LI':
|
|
278
|
+
flush();
|
|
279
|
+
out.push(
|
|
280
|
+
...listItems(element.parentElement ?? element, false, next, depth + 1).slice(0, 1),
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
|
|
284
|
+
case 'PRE': {
|
|
285
|
+
flush();
|
|
286
|
+
|
|
287
|
+
const code = element.querySelector('code');
|
|
288
|
+
const language =
|
|
289
|
+
/language-([\w+#-]+)/.exec(code?.className ?? element.className)?.[1] ??
|
|
290
|
+
element.getAttribute('data-language') ??
|
|
291
|
+
'plain';
|
|
292
|
+
const text = (element.textContent ?? '').replace(/\n$/, '');
|
|
293
|
+
|
|
294
|
+
out.push(block('block:code', text ? [{ insert: text }] : [], { language }));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case 'HR':
|
|
299
|
+
flush();
|
|
300
|
+
out.push(block('block:divider', []));
|
|
301
|
+
return;
|
|
302
|
+
|
|
303
|
+
case 'BLOCKQUOTE': {
|
|
304
|
+
flush();
|
|
305
|
+
|
|
306
|
+
if (hasBlockDescendant(element)) {
|
|
307
|
+
const inner: FragmentBlock[] = [];
|
|
308
|
+
collect(element, next, inner, depth + 1);
|
|
309
|
+
|
|
310
|
+
out.push(
|
|
311
|
+
...inner.map((b) =>
|
|
312
|
+
b.flavour === 'block:paragraph' ? { ...b, flavour: 'block:quote' } : b,
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
} else {
|
|
316
|
+
const content: InlineNode[] = [];
|
|
317
|
+
element.childNodes.forEach((child) => inlineOf(child, next, content, depth + 1));
|
|
318
|
+
const cleaned = cleanInline(content);
|
|
319
|
+
|
|
320
|
+
if (cleaned.length > 0) out.push(block('block:quote', cleaned));
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
case 'TABLE': {
|
|
326
|
+
flush();
|
|
327
|
+
|
|
328
|
+
// Таблиц в Inkbrick нет: каждая строка — абзац, ячейки через табуляцию.
|
|
329
|
+
element.querySelectorAll('tr').forEach((row) => {
|
|
330
|
+
const cells: InlineNode[] = [];
|
|
331
|
+
|
|
332
|
+
row.querySelectorAll(':scope > td, :scope > th').forEach((cell, index) => {
|
|
333
|
+
const content: InlineNode[] = [];
|
|
334
|
+
|
|
335
|
+
if (index > 0) cells.push({ insert: '\t' });
|
|
336
|
+
cell.childNodes.forEach((child) => inlineOf(child, next, content, depth + 1));
|
|
337
|
+
cells.push(...cleanInline(content));
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const cleaned = normalizeInlineNodes(cells);
|
|
341
|
+
|
|
342
|
+
if (cleaned.length > 0) out.push(block('block:paragraph', cleaned));
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/*
|
|
349
|
+
* Любой контейнер с блочными потомками (в том числе обёртка Google Docs `<b style="font-weight:normal">`)
|
|
350
|
+
* разбираем по блокам, сохраняя унаследованный стиль.
|
|
351
|
+
*/
|
|
352
|
+
if (hasBlockDescendant(element)) {
|
|
353
|
+
flush();
|
|
354
|
+
collect(element, next, out, depth + 1);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (BLOCK_TAGS.has(tag)) {
|
|
359
|
+
flush();
|
|
360
|
+
|
|
361
|
+
const content: InlineNode[] = [];
|
|
362
|
+
element.childNodes.forEach((child) => inlineOf(child, next, content, depth + 1));
|
|
363
|
+
const cleaned = cleanInline(content);
|
|
364
|
+
|
|
365
|
+
if (cleaned.length > 0) out.push(block('block:paragraph', cleaned));
|
|
366
|
+
|
|
367
|
+
element.querySelectorAll('img').forEach((img) => {
|
|
368
|
+
const image = imageBlock(img);
|
|
369
|
+
|
|
370
|
+
if (image) out.push(image);
|
|
371
|
+
});
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
inlineOf(element, style, run, depth + 1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
flush();
|
|
379
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type InlineNode, normalizeInlineNodes } from '@inkbrick/document';
|
|
2
|
+
import { attributesOf, isHidden, styleOf } from './style';
|
|
3
|
+
import { BLOCK_TAGS, MAX_DEPTH, SKIP_TAGS, type Style, tagOf } from './tags';
|
|
4
|
+
|
|
5
|
+
/** Приводит к виду, который показал бы браузер: сворачивает пробелы между узлами, обрезает края. */
|
|
6
|
+
export function cleanInline(nodes: InlineNode[], preserve = false): InlineNode[] {
|
|
7
|
+
if (preserve) return normalizeInlineNodes(nodes.filter((n) => n.insert.length > 0));
|
|
8
|
+
|
|
9
|
+
const cleaned: InlineNode[] = [];
|
|
10
|
+
let previousSpace = true; // пробелы в начале блока отбрасываем
|
|
11
|
+
|
|
12
|
+
for (const node of nodes) {
|
|
13
|
+
let text = '';
|
|
14
|
+
|
|
15
|
+
for (const ch of node.insert.replace(/\u00a0/g, ' ')) {
|
|
16
|
+
if (ch === '\n') {
|
|
17
|
+
text = text.replace(/ $/, '') + ch;
|
|
18
|
+
previousSpace = true;
|
|
19
|
+
} else if (/\s/.test(ch)) {
|
|
20
|
+
if (!previousSpace) text += ' ';
|
|
21
|
+
previousSpace = true;
|
|
22
|
+
} else {
|
|
23
|
+
text += ch;
|
|
24
|
+
previousSpace = false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (text)
|
|
29
|
+
cleaned.push({ insert: text, ...(node.attributes ? { attributes: node.attributes } : {}) });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
while (cleaned.length > 0) {
|
|
33
|
+
const last = cleaned[cleaned.length - 1];
|
|
34
|
+
const trimmed = last.insert.replace(/[ \n]+$/, '');
|
|
35
|
+
|
|
36
|
+
if (trimmed === '') cleaned.pop();
|
|
37
|
+
else {
|
|
38
|
+
last.insert = trimmed;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
while (cleaned.length > 0 && /^\n+/.test(cleaned[0].insert)) {
|
|
44
|
+
cleaned[0].insert = cleaned[0].insert.replace(/^\n+/, '');
|
|
45
|
+
if (cleaned[0].insert === '') cleaned.shift();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return normalizeInlineNodes(cleaned);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function inlineOf(
|
|
52
|
+
node: Node,
|
|
53
|
+
style: Style,
|
|
54
|
+
out: InlineNode[],
|
|
55
|
+
depth: number,
|
|
56
|
+
preserve = false,
|
|
57
|
+
): void {
|
|
58
|
+
if (depth > MAX_DEPTH) return;
|
|
59
|
+
|
|
60
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
61
|
+
const raw = node.textContent ?? '';
|
|
62
|
+
const text = preserve ? raw : raw.replace(/[\t\n\r\f ]+/g, ' ');
|
|
63
|
+
const attributes = attributesOf(style);
|
|
64
|
+
|
|
65
|
+
if (text) out.push({ insert: text, ...(attributes ? { attributes } : {}) });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
70
|
+
|
|
71
|
+
const element = node as HTMLElement;
|
|
72
|
+
|
|
73
|
+
if (SKIP_TAGS.has(tagOf(element)) || isHidden(element)) return;
|
|
74
|
+
|
|
75
|
+
// Номера/маркеры Word (`mso-list:Ignore`) — не текст.
|
|
76
|
+
if (/mso-list:\s*ignore/i.test(element.getAttribute('style') ?? '')) return;
|
|
77
|
+
|
|
78
|
+
if (tagOf(element) === 'BR') {
|
|
79
|
+
out.push({ insert: '\n' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const next = styleOf(element, style);
|
|
84
|
+
const isBlock = BLOCK_TAGS.has(tagOf(element));
|
|
85
|
+
|
|
86
|
+
// Блочный элемент внутри inline-контекста (абзацы внутри пункта списка) отделяется переводом строки.
|
|
87
|
+
if (isBlock && out.length > 0) out.push({ insert: '\n' });
|
|
88
|
+
|
|
89
|
+
element.childNodes.forEach((child) => inlineOf(child, next, out, depth + 1, preserve));
|
|
90
|
+
|
|
91
|
+
if (isBlock) out.push({ insert: '\n' });
|
|
92
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type TextAttributes, isSafeUrl, normalizeLinkUrl } from '@inkbrick/document';
|
|
2
|
+
import { type Style, tagOf } from './tags';
|
|
3
|
+
|
|
4
|
+
export function weight(value: string): boolean | undefined {
|
|
5
|
+
const v = value.trim().toLowerCase();
|
|
6
|
+
|
|
7
|
+
if (v === '') return undefined;
|
|
8
|
+
if (v === 'bold' || v === 'bolder') return true;
|
|
9
|
+
if (v === 'normal' || v === 'lighter') return false;
|
|
10
|
+
|
|
11
|
+
const n = Number(v);
|
|
12
|
+
|
|
13
|
+
return Number.isNaN(n) ? undefined : n >= 600;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function styleOf(element: HTMLElement, base: Style): Style {
|
|
17
|
+
const style: Style = { ...base };
|
|
18
|
+
|
|
19
|
+
switch (tagOf(element)) {
|
|
20
|
+
case 'B':
|
|
21
|
+
case 'STRONG':
|
|
22
|
+
style.bold = true;
|
|
23
|
+
break;
|
|
24
|
+
case 'I':
|
|
25
|
+
case 'EM':
|
|
26
|
+
case 'CITE':
|
|
27
|
+
case 'DFN':
|
|
28
|
+
style.italic = true;
|
|
29
|
+
break;
|
|
30
|
+
case 'U':
|
|
31
|
+
case 'INS':
|
|
32
|
+
style.underline = true;
|
|
33
|
+
break;
|
|
34
|
+
case 'S':
|
|
35
|
+
case 'DEL':
|
|
36
|
+
case 'STRIKE':
|
|
37
|
+
style.strike = true;
|
|
38
|
+
break;
|
|
39
|
+
case 'CODE':
|
|
40
|
+
case 'KBD':
|
|
41
|
+
case 'SAMP':
|
|
42
|
+
case 'TT':
|
|
43
|
+
style.code = true;
|
|
44
|
+
break;
|
|
45
|
+
case 'A': {
|
|
46
|
+
const href = element.getAttribute('href');
|
|
47
|
+
const link = href ? normalizeLinkUrl(href) : null;
|
|
48
|
+
|
|
49
|
+
if (link) style.link = link;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Встроенные стили (Google Docs / Word передают форматирование ими) переопределяют теги.
|
|
55
|
+
const css = element.style;
|
|
56
|
+
|
|
57
|
+
if (css) {
|
|
58
|
+
const bold = weight(css.fontWeight ?? '');
|
|
59
|
+
if (bold !== undefined) style.bold = bold;
|
|
60
|
+
|
|
61
|
+
const italic = (css.fontStyle ?? '').toLowerCase();
|
|
62
|
+
if (italic === 'italic' || italic === 'oblique') style.italic = true;
|
|
63
|
+
else if (italic === 'normal') style.italic = false;
|
|
64
|
+
|
|
65
|
+
const decoration = `${css.textDecorationLine ?? ''} ${css.textDecoration ?? ''}`.toLowerCase();
|
|
66
|
+
if (decoration.includes('underline')) style.underline = true;
|
|
67
|
+
if (decoration.includes('line-through')) style.strike = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return style;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function attributesOf(style: Style): TextAttributes | undefined {
|
|
74
|
+
const attributes: TextAttributes = {};
|
|
75
|
+
|
|
76
|
+
if (style.bold) attributes.bold = true;
|
|
77
|
+
if (style.italic) attributes.italic = true;
|
|
78
|
+
if (style.underline) attributes.underline = true;
|
|
79
|
+
if (style.strike) attributes.strike = true;
|
|
80
|
+
if (style.code) attributes.code = true;
|
|
81
|
+
if (style.link && isSafeUrl(style.link)) attributes.link = style.link;
|
|
82
|
+
|
|
83
|
+
return Object.keys(attributes).length > 0 ? attributes : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isHidden(element: HTMLElement): boolean {
|
|
87
|
+
return (
|
|
88
|
+
element.hasAttribute('hidden') ||
|
|
89
|
+
element.style?.display === 'none' ||
|
|
90
|
+
element.style?.visibility === 'hidden'
|
|
91
|
+
);
|
|
92
|
+
}
|