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.
@@ -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 = /!\[([^\]]*)\]\(([^)]+)\)/g;
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
- lines.push(`![${caption}](${url})`);
267
+ const size = width ? ` =${width}x*` : "";
268
+ lines.push(`![${caption}](${url}${size})`);
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(name, url);
277
+ const displayUrl = caption || resolveFileDisplayUrl(url);
275
278
  lines.push(`[![${name}](${displayUrl})](${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 = (fileName: string) => string;
1
+ export type FileDisplayUrlResolver = (fileUrl: string) => string;
2
2
  export declare function setFileDisplayUrlResolver(fn: FileDisplayUrlResolver | null): void;
3
- export declare function resolveFileDisplayUrl(fileName: string, fallbackUrl: string): string;
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(fileName, fallbackUrl) {
6
- var _a;
5
+ export function resolveFileDisplayUrl(fileUrl) {
7
6
  if (resolver) {
8
- return resolver(fileName);
7
+ return resolver(fileUrl);
9
8
  }
10
- const ext = ((_a = fileName.split(".").pop()) === null || _a === void 0 ? void 0 : _a.toLowerCase()) || "";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.31",
3
+ "version": "0.4.33",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
@@ -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 = /!\[([^\]]*)\]\(([^)]+)\)/g;
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("![](/attachments/test.png)");
1431
1431
  });
1432
1432
 
1433
+ it("parses image with size suffix =WIDTHxHEIGHT", () => {
1434
+ const markdown = "![caption](http://localhost:3000/attachments/img.png =200x100)";
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 = "![](http://localhost:3000/attachments/img.png =150x*)";
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("![test](/attachments/test.png =300x*)");
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("![](/attachments/test.png)");
1487
+ expect(markdown).not.toContain("=");
1488
+ });
1489
+
1490
+ it("round-trips image with size preserving width", () => {
1491
+ const markdown = "![photo](/attachments/photo.png =400x250)";
1492
+ const blocks = markdownToBlocks(markdown);
1493
+ const roundTripMarkdown = blocksToMarkdown(blocks as CustomEditorBlock[]);
1494
+ expect(roundTripMarkdown).toContain("![photo](/attachments/photo.png =400x*)");
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 resolver", () => {
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("[![report.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)");
1600
+ });
1541
1601
 
1542
- setFileDisplayUrlResolver(null);
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("[![](/images/file-type-icons/file.svg)](https://example.com/file.pdf)");
1543
1619
  });
1544
1620
 
1545
- it("falls back to url when no resolver is set", () => {
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: "file.pdf",
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("[![file.pdf](/images/file-type-icons/pdf.svg)](https://example.com/file.pdf)");
1641
+ expect(md).toBe("[![](/custom-icons/file.svg)](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 into a file block", () => {
1667
+ it("parses file markdown and stores display url in caption", () => {
1586
1668
  const markdown = "[![report.pdf](/images/file-type-icons/pdf.svg)](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/url)](https://example.com/url)";
1678
+ const markdown = "[![](/images/file-type-icons/json.svg)](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/url");
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 through serialize and parse", () => {
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("[![doc.xlsx](/images/file-type-icons/xlsx.svg)](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
- setFileDisplayUrlResolver(null);
1720
+ it("round-trips file blocks without name", () => {
1721
+ const markdown = "[![](/images/file-type-icons/json.svg)](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
- lines.push(`![${caption}](${url})`);
345
+ const size = width ? ` =${width}x*` : "";
346
+ lines.push(`![${caption}](${url}${size})`);
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(name, url);
355
+ const displayUrl = caption || resolveFileDisplayUrl(url);
353
356
  lines.push(`[![${name}](${displayUrl})](${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 = (fileName: string) => string;
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(fileName: string, fallbackUrl: string): string {
9
+ export function resolveFileDisplayUrl(fileUrl: string): string {
10
10
  if (resolver) {
11
- return resolver(fileName);
11
+ return resolver(fileUrl);
12
12
  }
13
- const ext = fileName.split(".").pop()?.toLowerCase() || "";
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
  }