testomatio-editor-blocks 0.4.31 → 0.4.33
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/package/editor/blocks/fileBlock.d.ts +40 -0
- package/package/editor/blocks/fileBlock.js +35 -0
- package/package/editor/blocks/markdown.js +1 -1
- package/package/editor/customMarkdownConverter.js +9 -3
- package/package/editor/customSchema.d.ts +40 -40
- package/package/editor/customSchema.js +2 -0
- package/package/editor/fileDisplayUrl.d.ts +2 -2
- package/package/editor/fileDisplayUrl.js +3 -8
- package/package.json +1 -1
- package/src/editor/blocks/fileBlock.tsx +87 -0
- package/src/editor/blocks/markdown.ts +1 -1
- package/src/editor/customMarkdownConverter.test.ts +109 -22
- package/src/editor/customMarkdownConverter.ts +9 -3
- package/src/editor/customSchema.tsx +2 -0
- package/src/editor/fileDisplayUrl.ts +4 -8
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare const fileBlock: {
|
|
2
|
+
config: {
|
|
3
|
+
type: "file";
|
|
4
|
+
propSchema: {
|
|
5
|
+
backgroundColor: {
|
|
6
|
+
default: "default";
|
|
7
|
+
};
|
|
8
|
+
name: {
|
|
9
|
+
default: "";
|
|
10
|
+
};
|
|
11
|
+
url: {
|
|
12
|
+
default: "";
|
|
13
|
+
};
|
|
14
|
+
caption: {
|
|
15
|
+
default: "";
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
content: "none";
|
|
19
|
+
isFileBlock: true;
|
|
20
|
+
};
|
|
21
|
+
implementation: import("@blocknote/core").TiptapBlockImplementation<{
|
|
22
|
+
type: "file";
|
|
23
|
+
propSchema: {
|
|
24
|
+
backgroundColor: {
|
|
25
|
+
default: "default";
|
|
26
|
+
};
|
|
27
|
+
name: {
|
|
28
|
+
default: "";
|
|
29
|
+
};
|
|
30
|
+
url: {
|
|
31
|
+
default: "";
|
|
32
|
+
};
|
|
33
|
+
caption: {
|
|
34
|
+
default: "";
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
content: "none";
|
|
38
|
+
isFileBlock: true;
|
|
39
|
+
}, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
|
|
40
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { fileBlockConfig, fileParse } from "@blocknote/core";
|
|
3
|
+
import { createReactBlockSpec } from "@blocknote/react";
|
|
4
|
+
import { useCallback } from "react";
|
|
5
|
+
import { resolveFileDisplayUrl } from "../fileDisplayUrl";
|
|
6
|
+
function FileIcon(props) {
|
|
7
|
+
const { caption, url } = props.block.props;
|
|
8
|
+
const iconUrl = caption || (url ? resolveFileDisplayUrl(url) : "");
|
|
9
|
+
if (iconUrl) {
|
|
10
|
+
return _jsx("img", { src: iconUrl, alt: "", width: 24, height: 24, style: { display: "block" } });
|
|
11
|
+
}
|
|
12
|
+
return (_jsx("svg", { width: 24, height: 24, viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z" }) }));
|
|
13
|
+
}
|
|
14
|
+
function FileBlockRender(props) {
|
|
15
|
+
const addFileButtonMouseDownHandler = useCallback((event) => event.preventDefault(), []);
|
|
16
|
+
const addFileButtonClickHandler = useCallback(() => {
|
|
17
|
+
props.editor.transact((tr) => tr.setMeta(props.editor.filePanel.plugins[0], {
|
|
18
|
+
block: props.block,
|
|
19
|
+
}));
|
|
20
|
+
}, [props.block, props.editor]);
|
|
21
|
+
if (props.block.props.url === "") {
|
|
22
|
+
return (_jsx("div", { className: "bn-file-block-content-wrapper", children: _jsxs("div", { className: "bn-add-file-button", onMouseDown: addFileButtonMouseDownHandler, onClick: addFileButtonClickHandler, children: [_jsx("div", { className: "bn-add-file-button-icon", children: _jsx(FileIcon, { block: props.block }) }), _jsx("div", { className: "bn-add-file-button-text", children: "Add file" })] }) }));
|
|
23
|
+
}
|
|
24
|
+
return (_jsx("div", { className: "bn-file-block-content-wrapper", children: _jsxs("div", { className: "bn-file-name-with-icon", contentEditable: false, draggable: false, children: [_jsx("div", { className: "bn-file-icon", children: _jsx(FileIcon, { block: props.block }) }), _jsx("p", { className: "bn-file-name", children: props.block.props.name || props.block.props.url })] }) }));
|
|
25
|
+
}
|
|
26
|
+
export const fileBlock = createReactBlockSpec(fileBlockConfig, {
|
|
27
|
+
render: FileBlockRender,
|
|
28
|
+
parse: fileParse,
|
|
29
|
+
toExternalHTML: (props) => {
|
|
30
|
+
if (!props.block.props.url) {
|
|
31
|
+
return _jsx("p", { children: "Add file" });
|
|
32
|
+
}
|
|
33
|
+
return (_jsx("a", { href: props.block.props.url, children: props.block.props.name || props.block.props.url }));
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+)
|
|
1
|
+
const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+?)(?:\s+=\d+x(?:\d+|\*))?\)/g;
|
|
2
2
|
const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
|
|
3
3
|
const INLINE_SEGMENT_REGEX = /(\*\*\*[^*]+\*\*\*|___[^_]+___|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/;
|
|
4
4
|
export function escapeHtml(text) {
|
|
@@ -262,16 +262,19 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
|
|
|
262
262
|
case "image": {
|
|
263
263
|
const url = block.props.url || "";
|
|
264
264
|
const caption = block.props.caption || "";
|
|
265
|
+
const width = block.props.previewWidth;
|
|
265
266
|
if (url) {
|
|
266
|
-
|
|
267
|
+
const size = width ? ` =${width}x*` : "";
|
|
268
|
+
lines.push(``);
|
|
267
269
|
}
|
|
268
270
|
return flattenWithBlankLine(lines, true);
|
|
269
271
|
}
|
|
270
272
|
case "file": {
|
|
271
273
|
const url = block.props.url || "";
|
|
272
274
|
const name = block.props.name || "";
|
|
275
|
+
const caption = block.props.caption || "";
|
|
273
276
|
if (url) {
|
|
274
|
-
const displayUrl = resolveFileDisplayUrl(
|
|
277
|
+
const displayUrl = caption || resolveFileDisplayUrl(url);
|
|
275
278
|
lines.push(`[](${url})`);
|
|
276
279
|
}
|
|
277
280
|
return flattenWithBlankLine(lines, true);
|
|
@@ -1231,20 +1234,23 @@ export function markdownToBlocks(markdown) {
|
|
|
1231
1234
|
props: {
|
|
1232
1235
|
name: fileMatch[1] || "",
|
|
1233
1236
|
url: fileMatch[3],
|
|
1237
|
+
caption: fileMatch[2] || "",
|
|
1234
1238
|
},
|
|
1235
1239
|
children: [],
|
|
1236
1240
|
});
|
|
1237
1241
|
index += 1;
|
|
1238
1242
|
continue;
|
|
1239
1243
|
}
|
|
1240
|
-
const imageMatch = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
|
|
1244
|
+
const imageMatch = line.trim().match(/^!\[([^\]]*)\]\(([^)]+?)(?:\s+=(\d+)x(\d+|\*))?\)$/);
|
|
1241
1245
|
if (imageMatch) {
|
|
1246
|
+
const width = imageMatch[3] ? parseInt(imageMatch[3], 10) : undefined;
|
|
1242
1247
|
blocks.push({
|
|
1243
1248
|
type: "image",
|
|
1244
1249
|
props: {
|
|
1245
1250
|
url: imageMatch[2],
|
|
1246
1251
|
caption: imageMatch[1] || "",
|
|
1247
1252
|
name: "",
|
|
1253
|
+
...(width ? { previewWidth: width } : {}),
|
|
1248
1254
|
},
|
|
1249
1255
|
children: [],
|
|
1250
1256
|
});
|
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import { BlockNoteSchema } from "@blocknote/core";
|
|
2
2
|
import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
|
|
3
3
|
export declare const customSchema: BlockNoteSchema<import("@blocknote/core").BlockSchemaFromSpecs<{
|
|
4
|
+
file: {
|
|
5
|
+
config: {
|
|
6
|
+
type: "file";
|
|
7
|
+
propSchema: {
|
|
8
|
+
backgroundColor: {
|
|
9
|
+
default: "default";
|
|
10
|
+
};
|
|
11
|
+
name: {
|
|
12
|
+
default: "";
|
|
13
|
+
};
|
|
14
|
+
url: {
|
|
15
|
+
default: "";
|
|
16
|
+
};
|
|
17
|
+
caption: {
|
|
18
|
+
default: "";
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
content: "none";
|
|
22
|
+
isFileBlock: true;
|
|
23
|
+
};
|
|
24
|
+
implementation: import("@blocknote/core").TiptapBlockImplementation<{
|
|
25
|
+
type: "file";
|
|
26
|
+
propSchema: {
|
|
27
|
+
backgroundColor: {
|
|
28
|
+
default: "default";
|
|
29
|
+
};
|
|
30
|
+
name: {
|
|
31
|
+
default: "";
|
|
32
|
+
};
|
|
33
|
+
url: {
|
|
34
|
+
default: "";
|
|
35
|
+
};
|
|
36
|
+
caption: {
|
|
37
|
+
default: "";
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
content: "none";
|
|
41
|
+
isFileBlock: true;
|
|
42
|
+
}, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
|
|
43
|
+
};
|
|
4
44
|
testStep: {
|
|
5
45
|
config: {
|
|
6
46
|
readonly type: "testStep";
|
|
@@ -343,46 +383,6 @@ export declare const customSchema: BlockNoteSchema<import("@blocknote/core").Blo
|
|
|
343
383
|
};
|
|
344
384
|
}, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
|
|
345
385
|
};
|
|
346
|
-
file: {
|
|
347
|
-
config: {
|
|
348
|
-
type: "file";
|
|
349
|
-
propSchema: {
|
|
350
|
-
backgroundColor: {
|
|
351
|
-
default: "default";
|
|
352
|
-
};
|
|
353
|
-
name: {
|
|
354
|
-
default: "";
|
|
355
|
-
};
|
|
356
|
-
url: {
|
|
357
|
-
default: "";
|
|
358
|
-
};
|
|
359
|
-
caption: {
|
|
360
|
-
default: "";
|
|
361
|
-
};
|
|
362
|
-
};
|
|
363
|
-
content: "none";
|
|
364
|
-
isFileBlock: true;
|
|
365
|
-
};
|
|
366
|
-
implementation: import("@blocknote/core").TiptapBlockImplementation<{
|
|
367
|
-
type: "file";
|
|
368
|
-
propSchema: {
|
|
369
|
-
backgroundColor: {
|
|
370
|
-
default: "default";
|
|
371
|
-
};
|
|
372
|
-
name: {
|
|
373
|
-
default: "";
|
|
374
|
-
};
|
|
375
|
-
url: {
|
|
376
|
-
default: "";
|
|
377
|
-
};
|
|
378
|
-
caption: {
|
|
379
|
-
default: "";
|
|
380
|
-
};
|
|
381
|
-
};
|
|
382
|
-
content: "none";
|
|
383
|
-
isFileBlock: true;
|
|
384
|
-
}, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
|
|
385
|
-
};
|
|
386
386
|
image: {
|
|
387
387
|
config: {
|
|
388
388
|
type: "image";
|
|
@@ -2,10 +2,12 @@ import { defaultBlockSpecs } from "@blocknote/core";
|
|
|
2
2
|
import { BlockNoteSchema } from "@blocknote/core";
|
|
3
3
|
import { stepBlock } from "./blocks/step";
|
|
4
4
|
import { snippetBlock } from "./blocks/snippet";
|
|
5
|
+
import { fileBlock } from "./blocks/fileBlock";
|
|
5
6
|
import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
|
|
6
7
|
export const customSchema = BlockNoteSchema.create({
|
|
7
8
|
blockSpecs: {
|
|
8
9
|
...defaultBlockSpecs,
|
|
10
|
+
file: fileBlock,
|
|
9
11
|
testStep: stepBlock,
|
|
10
12
|
snippet: snippetBlock,
|
|
11
13
|
},
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export type FileDisplayUrlResolver = (
|
|
1
|
+
export type FileDisplayUrlResolver = (fileUrl: string) => string;
|
|
2
2
|
export declare function setFileDisplayUrlResolver(fn: FileDisplayUrlResolver | null): void;
|
|
3
|
-
export declare function resolveFileDisplayUrl(
|
|
3
|
+
export declare function resolveFileDisplayUrl(fileUrl: string): string;
|
|
@@ -2,14 +2,9 @@ let resolver = null;
|
|
|
2
2
|
export function setFileDisplayUrlResolver(fn) {
|
|
3
3
|
resolver = fn;
|
|
4
4
|
}
|
|
5
|
-
export function resolveFileDisplayUrl(
|
|
6
|
-
var _a;
|
|
5
|
+
export function resolveFileDisplayUrl(fileUrl) {
|
|
7
6
|
if (resolver) {
|
|
8
|
-
return resolver(
|
|
7
|
+
return resolver(fileUrl);
|
|
9
8
|
}
|
|
10
|
-
|
|
11
|
-
if (ext) {
|
|
12
|
-
return `/images/file-type-icons/${ext}.svg`;
|
|
13
|
-
}
|
|
14
|
-
return fallbackUrl;
|
|
9
|
+
return `/images/file-type-icons/file.svg`;
|
|
15
10
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { fileBlockConfig, fileParse } from "@blocknote/core";
|
|
2
|
+
import { createReactBlockSpec } from "@blocknote/react";
|
|
3
|
+
import type { ReactCustomBlockRenderProps } from "@blocknote/react";
|
|
4
|
+
import { useCallback } from "react";
|
|
5
|
+
import { resolveFileDisplayUrl } from "../fileDisplayUrl";
|
|
6
|
+
|
|
7
|
+
function FileIcon(props: { block: { props: { caption?: string; url?: string } } }) {
|
|
8
|
+
const { caption, url } = props.block.props;
|
|
9
|
+
const iconUrl = caption || (url ? resolveFileDisplayUrl(url) : "");
|
|
10
|
+
|
|
11
|
+
if (iconUrl) {
|
|
12
|
+
return <img src={iconUrl} alt="" width={24} height={24} style={{ display: "block" }} />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<svg width={24} height={24} viewBox="0 0 24 24" fill="currentColor">
|
|
17
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z" />
|
|
18
|
+
</svg>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function FileBlockRender(
|
|
23
|
+
props: ReactCustomBlockRenderProps<typeof fileBlockConfig, any, any>,
|
|
24
|
+
) {
|
|
25
|
+
const addFileButtonMouseDownHandler = useCallback(
|
|
26
|
+
(event: React.MouseEvent) => event.preventDefault(),
|
|
27
|
+
[],
|
|
28
|
+
);
|
|
29
|
+
const addFileButtonClickHandler = useCallback(() => {
|
|
30
|
+
props.editor.transact((tr: any) =>
|
|
31
|
+
tr.setMeta(props.editor.filePanel!.plugins[0], {
|
|
32
|
+
block: props.block,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}, [props.block, props.editor]);
|
|
36
|
+
|
|
37
|
+
if (props.block.props.url === "") {
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
className="bn-file-block-content-wrapper"
|
|
41
|
+
>
|
|
42
|
+
<div
|
|
43
|
+
className="bn-add-file-button"
|
|
44
|
+
onMouseDown={addFileButtonMouseDownHandler}
|
|
45
|
+
onClick={addFileButtonClickHandler}
|
|
46
|
+
>
|
|
47
|
+
<div className="bn-add-file-button-icon">
|
|
48
|
+
<FileIcon block={props.block} />
|
|
49
|
+
</div>
|
|
50
|
+
<div className="bn-add-file-button-text">Add file</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="bn-file-block-content-wrapper">
|
|
58
|
+
<div
|
|
59
|
+
className="bn-file-name-with-icon"
|
|
60
|
+
contentEditable={false}
|
|
61
|
+
draggable={false}
|
|
62
|
+
>
|
|
63
|
+
<div className="bn-file-icon">
|
|
64
|
+
<FileIcon block={props.block} />
|
|
65
|
+
</div>
|
|
66
|
+
<p className="bn-file-name">
|
|
67
|
+
{props.block.props.name || props.block.props.url}
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const fileBlock = createReactBlockSpec(fileBlockConfig, {
|
|
75
|
+
render: FileBlockRender,
|
|
76
|
+
parse: fileParse,
|
|
77
|
+
toExternalHTML: (props) => {
|
|
78
|
+
if (!props.block.props.url) {
|
|
79
|
+
return <p>Add file</p>;
|
|
80
|
+
}
|
|
81
|
+
return (
|
|
82
|
+
<a href={props.block.props.url}>
|
|
83
|
+
{props.block.props.name || props.block.props.url}
|
|
84
|
+
</a>
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+)
|
|
1
|
+
const IMAGE_MARKDOWN_REGEX = /!\[([^\]]*)\]\(([^)]+?)(?:\s+=\d+x(?:\d+|\*))?\)/g;
|
|
2
2
|
const MARKDOWN_ESCAPE_REGEX = /([*_\\])/g;
|
|
3
3
|
const INLINE_SEGMENT_REGEX =
|
|
4
4
|
/(\*\*\*[^*]+\*\*\*|___[^_]+___|\*\*[^*]+\*\*|__[^_]+__|\*[^*]+\*|_[^_]+_|<u>[^<]+<\/u>)/;
|
|
@@ -1430,6 +1430,70 @@ describe("markdownToBlocks", () => {
|
|
|
1430
1430
|
expect(roundTripMarkdown).toContain("");
|
|
1431
1431
|
});
|
|
1432
1432
|
|
|
1433
|
+
it("parses image with size suffix =WIDTHxHEIGHT", () => {
|
|
1434
|
+
const markdown = "";
|
|
1435
|
+
const blocks = markdownToBlocks(markdown);
|
|
1436
|
+
|
|
1437
|
+
const imageBlocks = blocks.filter(block => block.type === "image");
|
|
1438
|
+
expect(imageBlocks.length).toBe(1);
|
|
1439
|
+
expect((imageBlocks[0].props as any).url).toBe("http://localhost:3000/attachments/img.png");
|
|
1440
|
+
expect((imageBlocks[0].props as any).caption).toBe("caption");
|
|
1441
|
+
expect((imageBlocks[0].props as any).previewWidth).toBe(200);
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it("parses image with size suffix =WIDTHx*", () => {
|
|
1445
|
+
const markdown = "";
|
|
1446
|
+
const blocks = markdownToBlocks(markdown);
|
|
1447
|
+
|
|
1448
|
+
const imageBlocks = blocks.filter(block => block.type === "image");
|
|
1449
|
+
expect(imageBlocks.length).toBe(1);
|
|
1450
|
+
expect((imageBlocks[0].props as any).url).toBe("http://localhost:3000/attachments/img.png");
|
|
1451
|
+
expect((imageBlocks[0].props as any).previewWidth).toBe(150);
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
it("serializes image block with previewWidth to =WIDTHx*", () => {
|
|
1455
|
+
const blocks: any[] = [
|
|
1456
|
+
{
|
|
1457
|
+
type: "image",
|
|
1458
|
+
props: {
|
|
1459
|
+
url: "/attachments/test.png",
|
|
1460
|
+
caption: "test",
|
|
1461
|
+
name: "",
|
|
1462
|
+
previewWidth: 300,
|
|
1463
|
+
},
|
|
1464
|
+
content: [],
|
|
1465
|
+
children: [],
|
|
1466
|
+
},
|
|
1467
|
+
];
|
|
1468
|
+
const markdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
|
|
1469
|
+
expect(markdown).toContain("");
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
it("serializes image block without previewWidth normally", () => {
|
|
1473
|
+
const blocks: any[] = [
|
|
1474
|
+
{
|
|
1475
|
+
type: "image",
|
|
1476
|
+
props: {
|
|
1477
|
+
url: "/attachments/test.png",
|
|
1478
|
+
caption: "",
|
|
1479
|
+
name: "",
|
|
1480
|
+
},
|
|
1481
|
+
content: [],
|
|
1482
|
+
children: [],
|
|
1483
|
+
},
|
|
1484
|
+
];
|
|
1485
|
+
const markdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
|
|
1486
|
+
expect(markdown).toContain("");
|
|
1487
|
+
expect(markdown).not.toContain("=");
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
it("round-trips image with size preserving width", () => {
|
|
1491
|
+
const markdown = "";
|
|
1492
|
+
const blocks = markdownToBlocks(markdown);
|
|
1493
|
+
const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
|
|
1494
|
+
expect(roundTripMarkdown).toContain("");
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1433
1497
|
it("removes malformed image blocks through post-processing", () => {
|
|
1434
1498
|
// Simulate the malformed blocks you're seeing
|
|
1435
1499
|
const malformedBlocks: any[] = [
|
|
@@ -1516,12 +1580,7 @@ describe("markdownToBlocks", () => {
|
|
|
1516
1580
|
});
|
|
1517
1581
|
|
|
1518
1582
|
describe("file block serialization", () => {
|
|
1519
|
-
it("serializes a file block using display url
|
|
1520
|
-
setFileDisplayUrlResolver((name: string) => {
|
|
1521
|
-
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
1522
|
-
return `/images/file-type-icons/${ext}.svg`;
|
|
1523
|
-
});
|
|
1524
|
-
|
|
1583
|
+
it("serializes a file block using caption as display url", () => {
|
|
1525
1584
|
const blocks: CustomEditorBlock[] = [
|
|
1526
1585
|
{
|
|
1527
1586
|
id: "1",
|
|
@@ -1530,7 +1589,7 @@ describe("file block serialization", () => {
|
|
|
1530
1589
|
...baseProps,
|
|
1531
1590
|
url: "https://example.com/file.pdf",
|
|
1532
1591
|
name: "report.pdf",
|
|
1533
|
-
caption: "",
|
|
1592
|
+
caption: "/images/file-type-icons/pdf.svg",
|
|
1534
1593
|
},
|
|
1535
1594
|
content: undefined as any,
|
|
1536
1595
|
children: [],
|
|
@@ -1538,11 +1597,32 @@ describe("file block serialization", () => {
|
|
|
1538
1597
|
];
|
|
1539
1598
|
const md = blocksToMarkdown(blocks);
|
|
1540
1599
|
expect(md).toBe("[](https://example.com/file.pdf)");
|
|
1600
|
+
});
|
|
1541
1601
|
|
|
1542
|
-
|
|
1602
|
+
it("derives icon from url when no caption is set", () => {
|
|
1603
|
+
const blocks: CustomEditorBlock[] = [
|
|
1604
|
+
{
|
|
1605
|
+
id: "1",
|
|
1606
|
+
type: "file",
|
|
1607
|
+
props: {
|
|
1608
|
+
...baseProps,
|
|
1609
|
+
url: "https://example.com/file.pdf",
|
|
1610
|
+
name: "",
|
|
1611
|
+
caption: "",
|
|
1612
|
+
},
|
|
1613
|
+
content: undefined as any,
|
|
1614
|
+
children: [],
|
|
1615
|
+
},
|
|
1616
|
+
];
|
|
1617
|
+
const md = blocksToMarkdown(blocks);
|
|
1618
|
+
expect(md).toBe("[](https://example.com/file.pdf)");
|
|
1543
1619
|
});
|
|
1544
1620
|
|
|
1545
|
-
it("
|
|
1621
|
+
it("uses custom resolver when set", () => {
|
|
1622
|
+
setFileDisplayUrlResolver(() => {
|
|
1623
|
+
return `/custom-icons/file.svg`;
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1546
1626
|
const blocks: CustomEditorBlock[] = [
|
|
1547
1627
|
{
|
|
1548
1628
|
id: "1",
|
|
@@ -1550,7 +1630,7 @@ describe("file block serialization", () => {
|
|
|
1550
1630
|
props: {
|
|
1551
1631
|
...baseProps,
|
|
1552
1632
|
url: "https://example.com/file.pdf",
|
|
1553
|
-
name: "
|
|
1633
|
+
name: "",
|
|
1554
1634
|
caption: "",
|
|
1555
1635
|
},
|
|
1556
1636
|
content: undefined as any,
|
|
@@ -1558,7 +1638,9 @@ describe("file block serialization", () => {
|
|
|
1558
1638
|
},
|
|
1559
1639
|
];
|
|
1560
1640
|
const md = blocksToMarkdown(blocks);
|
|
1561
|
-
expect(md).toBe("[](https://example.com/file.pdf)");
|
|
1642
|
+
|
|
1643
|
+
setFileDisplayUrlResolver(null);
|
|
1562
1644
|
});
|
|
1563
1645
|
|
|
1564
1646
|
it("outputs nothing when url is empty", () => {
|
|
@@ -1582,22 +1664,24 @@ describe("file block serialization", () => {
|
|
|
1582
1664
|
});
|
|
1583
1665
|
|
|
1584
1666
|
describe("file block parsing", () => {
|
|
1585
|
-
it("parses file markdown
|
|
1667
|
+
it("parses file markdown and stores display url in caption", () => {
|
|
1586
1668
|
const markdown = "[](https://example.com/file.pdf)";
|
|
1587
1669
|
const blocks = markdownToBlocks(markdown);
|
|
1588
1670
|
expect(blocks).toHaveLength(1);
|
|
1589
1671
|
expect(blocks[0].type).toBe("file");
|
|
1590
1672
|
expect((blocks[0].props as any).name).toBe("report.pdf");
|
|
1591
1673
|
expect((blocks[0].props as any).url).toBe("https://example.com/file.pdf");
|
|
1674
|
+
expect((blocks[0].props as any).caption).toBe("/images/file-type-icons/pdf.svg");
|
|
1592
1675
|
});
|
|
1593
1676
|
|
|
1594
1677
|
it("parses file markdown with empty name", () => {
|
|
1595
|
-
const markdown = "[](https://example.com/file.json)";
|
|
1596
1679
|
const blocks = markdownToBlocks(markdown);
|
|
1597
1680
|
expect(blocks).toHaveLength(1);
|
|
1598
1681
|
expect(blocks[0].type).toBe("file");
|
|
1599
1682
|
expect((blocks[0].props as any).name).toBe("");
|
|
1600
|
-
expect((blocks[0].props as any).url).toBe("https://example.com/
|
|
1683
|
+
expect((blocks[0].props as any).url).toBe("https://example.com/file.json");
|
|
1684
|
+
expect((blocks[0].props as any).caption).toBe("/images/file-type-icons/json.svg");
|
|
1601
1685
|
});
|
|
1602
1686
|
|
|
1603
1687
|
it("does not confuse file blocks with image blocks", () => {
|
|
@@ -1607,12 +1691,7 @@ describe("file block parsing", () => {
|
|
|
1607
1691
|
expect(blocks[0].type).toBe("image");
|
|
1608
1692
|
});
|
|
1609
1693
|
|
|
1610
|
-
it("round-trips file blocks
|
|
1611
|
-
setFileDisplayUrlResolver((name: string) => {
|
|
1612
|
-
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
1613
|
-
return `/images/file-type-icons/${ext}.svg`;
|
|
1614
|
-
});
|
|
1615
|
-
|
|
1694
|
+
it("round-trips file blocks preserving icon url in caption", () => {
|
|
1616
1695
|
const blocks: CustomEditorBlock[] = [
|
|
1617
1696
|
{
|
|
1618
1697
|
id: "1",
|
|
@@ -1621,19 +1700,27 @@ describe("file block parsing", () => {
|
|
|
1621
1700
|
...baseProps,
|
|
1622
1701
|
url: "https://example.com/doc.xlsx",
|
|
1623
1702
|
name: "doc.xlsx",
|
|
1624
|
-
caption: "",
|
|
1703
|
+
caption: "/images/file-type-icons/xlsx.svg",
|
|
1625
1704
|
},
|
|
1626
1705
|
content: undefined as any,
|
|
1627
1706
|
children: [],
|
|
1628
1707
|
},
|
|
1629
1708
|
];
|
|
1630
1709
|
const md = blocksToMarkdown(blocks);
|
|
1710
|
+
expect(md).toBe("[](https://example.com/doc.xlsx)");
|
|
1711
|
+
|
|
1631
1712
|
const parsed = markdownToBlocks(md);
|
|
1632
1713
|
expect(parsed).toHaveLength(1);
|
|
1633
1714
|
expect(parsed[0].type).toBe("file");
|
|
1634
1715
|
expect((parsed[0].props as any).url).toBe("https://example.com/doc.xlsx");
|
|
1635
1716
|
expect((parsed[0].props as any).name).toBe("doc.xlsx");
|
|
1717
|
+
expect((parsed[0].props as any).caption).toBe("/images/file-type-icons/xlsx.svg");
|
|
1718
|
+
});
|
|
1636
1719
|
|
|
1637
|
-
|
|
1720
|
+
it("round-trips file blocks without name", () => {
|
|
1721
|
+
const markdown = "[](https://example.com/data.json)";
|
|
1722
|
+
const parsed = markdownToBlocks(markdown);
|
|
1723
|
+
const md = blocksToMarkdown(parsed as CustomEditorBlock[]);
|
|
1724
|
+
expect(md).toBe(markdown);
|
|
1638
1725
|
});
|
|
1639
1726
|
});
|
|
@@ -340,16 +340,19 @@ function serializeBlock(
|
|
|
340
340
|
case "image": {
|
|
341
341
|
const url = (block.props as any).url || "";
|
|
342
342
|
const caption = (block.props as any).caption || "";
|
|
343
|
+
const width = (block.props as any).previewWidth;
|
|
343
344
|
if (url) {
|
|
344
|
-
|
|
345
|
+
const size = width ? ` =${width}x*` : "";
|
|
346
|
+
lines.push(``);
|
|
345
347
|
}
|
|
346
348
|
return flattenWithBlankLine(lines, true);
|
|
347
349
|
}
|
|
348
350
|
case "file": {
|
|
349
351
|
const url = (block.props as any).url || "";
|
|
350
352
|
const name = (block.props as any).name || "";
|
|
353
|
+
const caption = (block.props as any).caption || "";
|
|
351
354
|
if (url) {
|
|
352
|
-
const displayUrl = resolveFileDisplayUrl(
|
|
355
|
+
const displayUrl = caption || resolveFileDisplayUrl(url);
|
|
353
356
|
lines.push(`[](${url})`);
|
|
354
357
|
}
|
|
355
358
|
return flattenWithBlankLine(lines, true);
|
|
@@ -1473,6 +1476,7 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1473
1476
|
props: {
|
|
1474
1477
|
name: fileMatch[1] || "",
|
|
1475
1478
|
url: fileMatch[3],
|
|
1479
|
+
caption: fileMatch[2] || "",
|
|
1476
1480
|
},
|
|
1477
1481
|
children: [],
|
|
1478
1482
|
} as CustomPartialBlock);
|
|
@@ -1480,14 +1484,16 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
|
|
|
1480
1484
|
continue;
|
|
1481
1485
|
}
|
|
1482
1486
|
|
|
1483
|
-
const imageMatch = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
|
|
1487
|
+
const imageMatch = line.trim().match(/^!\[([^\]]*)\]\(([^)]+?)(?:\s+=(\d+)x(\d+|\*))?\)$/);
|
|
1484
1488
|
if (imageMatch) {
|
|
1489
|
+
const width = imageMatch[3] ? parseInt(imageMatch[3], 10) : undefined;
|
|
1485
1490
|
blocks.push({
|
|
1486
1491
|
type: "image",
|
|
1487
1492
|
props: {
|
|
1488
1493
|
url: imageMatch[2],
|
|
1489
1494
|
caption: imageMatch[1] || "",
|
|
1490
1495
|
name: "",
|
|
1496
|
+
...(width ? { previewWidth: width } : {}),
|
|
1491
1497
|
},
|
|
1492
1498
|
children: [],
|
|
1493
1499
|
} as CustomPartialBlock);
|
|
@@ -2,11 +2,13 @@ import { defaultBlockSpecs } from "@blocknote/core";
|
|
|
2
2
|
import { BlockNoteSchema } from "@blocknote/core";
|
|
3
3
|
import { stepBlock } from "./blocks/step";
|
|
4
4
|
import { snippetBlock } from "./blocks/snippet";
|
|
5
|
+
import { fileBlock } from "./blocks/fileBlock";
|
|
5
6
|
import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
|
|
6
7
|
|
|
7
8
|
export const customSchema = BlockNoteSchema.create({
|
|
8
9
|
blockSpecs: {
|
|
9
10
|
...defaultBlockSpecs,
|
|
11
|
+
file: fileBlock,
|
|
10
12
|
testStep: stepBlock,
|
|
11
13
|
snippet: snippetBlock,
|
|
12
14
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FileDisplayUrlResolver = (
|
|
1
|
+
export type FileDisplayUrlResolver = (fileUrl: string) => string;
|
|
2
2
|
|
|
3
3
|
let resolver: FileDisplayUrlResolver | null = null;
|
|
4
4
|
|
|
@@ -6,13 +6,9 @@ export function setFileDisplayUrlResolver(fn: FileDisplayUrlResolver | null) {
|
|
|
6
6
|
resolver = fn;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export function resolveFileDisplayUrl(
|
|
9
|
+
export function resolveFileDisplayUrl(fileUrl: string): string {
|
|
10
10
|
if (resolver) {
|
|
11
|
-
return resolver(
|
|
11
|
+
return resolver(fileUrl);
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
if (ext) {
|
|
15
|
-
return `/images/file-type-icons/${ext}.svg`;
|
|
16
|
-
}
|
|
17
|
-
return fallbackUrl;
|
|
13
|
+
return `/images/file-type-icons/file.svg`;
|
|
18
14
|
}
|