@pie-lib/editable-html-tip-tap 2.1.0-next.0 → 2.1.0-next.2

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,61 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.HeadingParagraph = void 0;
7
+ var _core = require("@tiptap/core");
8
+ /**
9
+ * HeadingParagraph extension
10
+ *
11
+ * Renders as <p data-heading="heading1"> in the HTML output instead of <h3>.
12
+ * This provides semantic heading styling without using a native heading element,
13
+ * as required by PIE's accessibility conventions.
14
+ */
15
+ var HeadingParagraph = exports.HeadingParagraph = _core.Node.create({
16
+ name: 'headingParagraph',
17
+ group: 'block',
18
+ content: 'inline*',
19
+ defining: true,
20
+ addAttributes: function addAttributes() {
21
+ return {
22
+ 'data-heading': {
23
+ "default": 'heading1',
24
+ parseHTML: function parseHTML(element) {
25
+ return element.getAttribute('data-heading');
26
+ },
27
+ renderHTML: function renderHTML(attributes) {
28
+ return {
29
+ 'data-heading': attributes['data-heading']
30
+ };
31
+ }
32
+ }
33
+ };
34
+ },
35
+ parseHTML: function parseHTML() {
36
+ return [{
37
+ tag: 'p[data-heading]'
38
+ }];
39
+ },
40
+ renderHTML: function renderHTML(_ref) {
41
+ var HTMLAttributes = _ref.HTMLAttributes;
42
+ return ['p', (0, _core.mergeAttributes)(HTMLAttributes), 0];
43
+ },
44
+ addCommands: function addCommands() {
45
+ return {
46
+ toggleHeadingParagraph: function toggleHeadingParagraph() {
47
+ return function (_ref2) {
48
+ var commands = _ref2.commands,
49
+ editor = _ref2.editor;
50
+ if (editor.isActive('headingParagraph')) {
51
+ return commands.setNode('paragraph');
52
+ }
53
+ return commands.setNode('headingParagraph', {
54
+ 'data-heading': 'heading1'
55
+ });
56
+ };
57
+ }
58
+ };
59
+ }
60
+ });
61
+ //# sourceMappingURL=heading-paragraph.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"heading-paragraph.js","names":["_core","require","HeadingParagraph","exports","Node","create","name","group","content","defining","addAttributes","parseHTML","element","getAttribute","renderHTML","attributes","tag","_ref","HTMLAttributes","mergeAttributes","addCommands","toggleHeadingParagraph","_ref2","commands","editor","isActive","setNode"],"sources":["../../src/extensions/heading-paragraph.js"],"sourcesContent":["import { Node, mergeAttributes } from '@tiptap/core';\n\n/**\n * HeadingParagraph extension\n *\n * Renders as <p data-heading=\"heading1\"> in the HTML output instead of <h3>.\n * This provides semantic heading styling without using a native heading element,\n * as required by PIE's accessibility conventions.\n */\nexport const HeadingParagraph = Node.create({\n name: 'headingParagraph',\n\n group: 'block',\n\n content: 'inline*',\n\n defining: true,\n\n addAttributes() {\n return {\n 'data-heading': {\n default: 'heading1',\n parseHTML: (element) => element.getAttribute('data-heading'),\n renderHTML: (attributes) => ({\n 'data-heading': attributes['data-heading'],\n }),\n },\n };\n },\n\n parseHTML() {\n return [\n { tag: 'p[data-heading]' },\n ];\n },\n\n renderHTML({ HTMLAttributes }) {\n return ['p', mergeAttributes(HTMLAttributes), 0];\n },\n\n addCommands() {\n return {\n toggleHeadingParagraph:\n () =>\n ({ commands, editor }) => {\n if (editor.isActive('headingParagraph')) {\n return commands.setNode('paragraph');\n }\n return commands.setNode('headingParagraph', { 'data-heading': 'heading1' });\n },\n };\n },\n});\n"],"mappings":";;;;;;AAAA,IAAAA,KAAA,GAAAC,OAAA;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,IAAMC,gBAAgB,GAAAC,OAAA,CAAAD,gBAAA,GAAGE,UAAI,CAACC,MAAM,CAAC;EAC1CC,IAAI,EAAE,kBAAkB;EAExBC,KAAK,EAAE,OAAO;EAEdC,OAAO,EAAE,SAAS;EAElBC,QAAQ,EAAE,IAAI;EAEdC,aAAa,WAAbA,aAAaA,CAAA,EAAG;IACd,OAAO;MACL,cAAc,EAAE;QACd,WAAS,UAAU;QACnBC,SAAS,EAAE,SAAXA,SAASA,CAAGC,OAAO;UAAA,OAAKA,OAAO,CAACC,YAAY,CAAC,cAAc,CAAC;QAAA;QAC5DC,UAAU,EAAE,SAAZA,UAAUA,CAAGC,UAAU;UAAA,OAAM;YAC3B,cAAc,EAAEA,UAAU,CAAC,cAAc;UAC3C,CAAC;QAAA;MACH;IACF,CAAC;EACH,CAAC;EAEDJ,SAAS,WAATA,SAASA,CAAA,EAAG;IACV,OAAO,CACL;MAAEK,GAAG,EAAE;IAAkB,CAAC,CAC3B;EACH,CAAC;EAEDF,UAAU,WAAVA,UAAUA,CAAAG,IAAA,EAAqB;IAAA,IAAlBC,cAAc,GAAAD,IAAA,CAAdC,cAAc;IACzB,OAAO,CAAC,GAAG,EAAE,IAAAC,qBAAe,EAACD,cAAc,CAAC,EAAE,CAAC,CAAC;EAClD,CAAC;EAEDE,WAAW,WAAXA,WAAWA,CAAA,EAAG;IACZ,OAAO;MACLC,sBAAsB,EACpB,SADFA,sBAAsBA,CAAA;QAAA,OAEpB,UAAAC,KAAA,EAA0B;UAAA,IAAvBC,QAAQ,GAAAD,KAAA,CAARC,QAAQ;YAAEC,MAAM,GAAAF,KAAA,CAANE,MAAM;UACjB,IAAIA,MAAM,CAACC,QAAQ,CAAC,kBAAkB,CAAC,EAAE;YACvC,OAAOF,QAAQ,CAACG,OAAO,CAAC,WAAW,CAAC;UACtC;UACA,OAAOH,QAAQ,CAACG,OAAO,CAAC,kBAAkB,EAAE;YAAE,cAAc,EAAE;UAAW,CAAC,CAAC;QAC7E,CAAC;MAAA;IACL,CAAC;EACH;AACF,CAAC,CAAC","ignoreList":[]}
@@ -7,11 +7,67 @@ exports.normalizeInitialMarkup = void 0;
7
7
  var escapeHtml = function escapeHtml(str) {
8
8
  return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
9
9
  };
10
+
11
+ /**
12
+ * Converts consecutive div elements into a single paragraph with line breaks.
13
+ * Example: "<div>A</div><div>B</div>" becomes "<p>A<br>B</p>"
14
+ */
15
+ var convertConsecutiveDivsToParagraph = function convertConsecutiveDivsToParagraph(html) {
16
+ // Create a temporary element to parse the HTML
17
+ var temp = document.createElement('div');
18
+ temp.innerHTML = html;
19
+
20
+ // Get all top-level children
21
+ var children = Array.from(temp.children);
22
+
23
+ // Only convert if there are 2 or more divs
24
+ if (children.length < 2) {
25
+ return html;
26
+ }
27
+
28
+ // Check if all children are divs with simple content (text or inline elements)
29
+ var allDivs = children.every(function (child) {
30
+ return child.tagName === 'DIV';
31
+ });
32
+ if (!allDivs) {
33
+ return html;
34
+ }
35
+
36
+ // Check if divs have no attributes (only convert plain divs)
37
+ var hasNoAttributes = children.every(function (div) {
38
+ return div.attributes.length === 0;
39
+ });
40
+ if (!hasNoAttributes) {
41
+ return html;
42
+ }
43
+
44
+ // Check if divs contain only simple content (no nested block elements)
45
+ var hasOnlySimpleContent = children.every(function (div) {
46
+ return Array.from(div.children).every(function (child) {
47
+ var tag = child.tagName;
48
+ // Allow inline elements and br tags
49
+ return ['SPAN', 'B', 'I', 'EM', 'STRONG', 'U', 'SUB', 'SUP', 'A', 'CODE', 'BR'].includes(tag);
50
+ });
51
+ });
52
+ if (!hasOnlySimpleContent) {
53
+ return html;
54
+ }
55
+
56
+ // Convert to paragraph with br tags
57
+ var contents = children.map(function (div) {
58
+ return div.innerHTML;
59
+ });
60
+ return "<p>".concat(contents.join('<br>'), "</p>");
61
+ };
10
62
  var normalizeInitialMarkup = exports.normalizeInitialMarkup = function normalizeInitialMarkup(markup) {
11
63
  var trimmed = String(markup !== null && markup !== void 0 ? markup : '').trim();
12
64
  if (!trimmed) return '<div></div>';
13
65
  var looksLikeHtml = /<[^>]+>/.test(trimmed);
14
- if (looksLikeHtml) return trimmed;
15
- return "<div>".concat(escapeHtml(trimmed), "</div>");
66
+ if (!looksLikeHtml) {
67
+ return "<div>".concat(escapeHtml(trimmed), "</div>");
68
+ }
69
+
70
+ // Apply the div-to-paragraph transformation
71
+ return convertConsecutiveDivsToParagraph(trimmed);
16
72
  };
17
73
  //# sourceMappingURL=helper.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"helper.js","names":["escapeHtml","str","String","replace","normalizeInitialMarkup","exports","markup","trimmed","trim","looksLikeHtml","test","concat"],"sources":["../../src/utils/helper.js"],"sourcesContent":["const escapeHtml = (str) =>\n String(str)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n\nexport const normalizeInitialMarkup = (markup) => {\n const trimmed = String(markup ?? '').trim();\n if (!trimmed) return '<div></div>';\n\n const looksLikeHtml = /<[^>]+>/.test(trimmed);\n if (looksLikeHtml) return trimmed;\n\n return `<div>${escapeHtml(trimmed)}</div>`;\n};\n"],"mappings":";;;;;;AAAA,IAAMA,UAAU,GAAG,SAAbA,UAAUA,CAAIC,GAAG;EAAA,OACrBC,MAAM,CAACD,GAAG,CAAC,CACRE,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CACtBA,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CACrBA,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CACrBA,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CACvBA,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;AAAA;AAEpB,IAAMC,sBAAsB,GAAAC,OAAA,CAAAD,sBAAA,GAAG,SAAzBA,sBAAsBA,CAAIE,MAAM,EAAK;EAChD,IAAMC,OAAO,GAAGL,MAAM,CAACI,MAAM,aAANA,MAAM,cAANA,MAAM,GAAI,EAAE,CAAC,CAACE,IAAI,CAAC,CAAC;EAC3C,IAAI,CAACD,OAAO,EAAE,OAAO,aAAa;EAElC,IAAME,aAAa,GAAG,SAAS,CAACC,IAAI,CAACH,OAAO,CAAC;EAC7C,IAAIE,aAAa,EAAE,OAAOF,OAAO;EAEjC,eAAAI,MAAA,CAAeX,UAAU,CAACO,OAAO,CAAC;AACpC,CAAC","ignoreList":[]}
1
+ {"version":3,"file":"helper.js","names":["escapeHtml","str","String","replace","convertConsecutiveDivsToParagraph","html","temp","document","createElement","innerHTML","children","Array","from","length","allDivs","every","child","tagName","hasNoAttributes","div","attributes","hasOnlySimpleContent","tag","includes","contents","map","concat","join","normalizeInitialMarkup","exports","markup","trimmed","trim","looksLikeHtml","test"],"sources":["../../src/utils/helper.js"],"sourcesContent":["const escapeHtml = (str) =>\n String(str)\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n\n/**\n * Converts consecutive div elements into a single paragraph with line breaks.\n * Example: \"<div>A</div><div>B</div>\" becomes \"<p>A<br>B</p>\"\n */\nconst convertConsecutiveDivsToParagraph = (html) => {\n // Create a temporary element to parse the HTML\n const temp = document.createElement('div');\n temp.innerHTML = html;\n\n // Get all top-level children\n const children = Array.from(temp.children);\n\n // Only convert if there are 2 or more divs\n if (children.length < 2) {\n return html;\n }\n\n // Check if all children are divs with simple content (text or inline elements)\n const allDivs = children.every((child) => child.tagName === 'DIV');\n\n if (!allDivs) {\n return html;\n }\n\n // Check if divs have no attributes (only convert plain divs)\n const hasNoAttributes = children.every((div) => div.attributes.length === 0);\n\n if (!hasNoAttributes) {\n return html;\n }\n\n // Check if divs contain only simple content (no nested block elements)\n const hasOnlySimpleContent = children.every((div) => {\n return Array.from(div.children).every((child) => {\n const tag = child.tagName;\n // Allow inline elements and br tags\n return ['SPAN', 'B', 'I', 'EM', 'STRONG', 'U', 'SUB', 'SUP', 'A', 'CODE', 'BR'].includes(tag);\n });\n });\n\n if (!hasOnlySimpleContent) {\n return html;\n }\n\n // Convert to paragraph with br tags\n const contents = children.map((div) => div.innerHTML);\n return `<p>${contents.join('<br>')}</p>`;\n};\n\nexport const normalizeInitialMarkup = (markup) => {\n const trimmed = String(markup ?? '').trim();\n if (!trimmed) return '<div></div>';\n\n const looksLikeHtml = /<[^>]+>/.test(trimmed);\n if (!looksLikeHtml) {\n return `<div>${escapeHtml(trimmed)}</div>`;\n }\n\n // Apply the div-to-paragraph transformation\n return convertConsecutiveDivsToParagraph(trimmed);\n};\n"],"mappings":";;;;;;AAAA,IAAMA,UAAU,GAAG,SAAbA,UAAUA,CAAIC,GAAG;EAAA,OACrBC,MAAM,CAACD,GAAG,CAAC,CACRE,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CACtBA,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CACrBA,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CACrBA,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CACvBA,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;AAAA;;AAE3B;AACA;AACA;AACA;AACA,IAAMC,iCAAiC,GAAG,SAApCA,iCAAiCA,CAAIC,IAAI,EAAK;EAClD;EACA,IAAMC,IAAI,GAAGC,QAAQ,CAACC,aAAa,CAAC,KAAK,CAAC;EAC1CF,IAAI,CAACG,SAAS,GAAGJ,IAAI;;EAErB;EACA,IAAMK,QAAQ,GAAGC,KAAK,CAACC,IAAI,CAACN,IAAI,CAACI,QAAQ,CAAC;;EAE1C;EACA,IAAIA,QAAQ,CAACG,MAAM,GAAG,CAAC,EAAE;IACvB,OAAOR,IAAI;EACb;;EAEA;EACA,IAAMS,OAAO,GAAGJ,QAAQ,CAACK,KAAK,CAAC,UAACC,KAAK;IAAA,OAAKA,KAAK,CAACC,OAAO,KAAK,KAAK;EAAA,EAAC;EAElE,IAAI,CAACH,OAAO,EAAE;IACZ,OAAOT,IAAI;EACb;;EAEA;EACA,IAAMa,eAAe,GAAGR,QAAQ,CAACK,KAAK,CAAC,UAACI,GAAG;IAAA,OAAKA,GAAG,CAACC,UAAU,CAACP,MAAM,KAAK,CAAC;EAAA,EAAC;EAE5E,IAAI,CAACK,eAAe,EAAE;IACpB,OAAOb,IAAI;EACb;;EAEA;EACA,IAAMgB,oBAAoB,GAAGX,QAAQ,CAACK,KAAK,CAAC,UAACI,GAAG,EAAK;IACnD,OAAOR,KAAK,CAACC,IAAI,CAACO,GAAG,CAACT,QAAQ,CAAC,CAACK,KAAK,CAAC,UAACC,KAAK,EAAK;MAC/C,IAAMM,GAAG,GAAGN,KAAK,CAACC,OAAO;MACzB;MACA,OAAO,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,CAACM,QAAQ,CAACD,GAAG,CAAC;IAC/F,CAAC,CAAC;EACJ,CAAC,CAAC;EAEF,IAAI,CAACD,oBAAoB,EAAE;IACzB,OAAOhB,IAAI;EACb;;EAEA;EACA,IAAMmB,QAAQ,GAAGd,QAAQ,CAACe,GAAG,CAAC,UAACN,GAAG;IAAA,OAAKA,GAAG,CAACV,SAAS;EAAA,EAAC;EACrD,aAAAiB,MAAA,CAAaF,QAAQ,CAACG,IAAI,CAAC,MAAM,CAAC;AACpC,CAAC;AAEM,IAAMC,sBAAsB,GAAAC,OAAA,CAAAD,sBAAA,GAAG,SAAzBA,sBAAsBA,CAAIE,MAAM,EAAK;EAChD,IAAMC,OAAO,GAAG7B,MAAM,CAAC4B,MAAM,aAANA,MAAM,cAANA,MAAM,GAAI,EAAE,CAAC,CAACE,IAAI,CAAC,CAAC;EAC3C,IAAI,CAACD,OAAO,EAAE,OAAO,aAAa;EAElC,IAAME,aAAa,GAAG,SAAS,CAACC,IAAI,CAACH,OAAO,CAAC;EAC7C,IAAI,CAACE,aAAa,EAAE;IAClB,eAAAP,MAAA,CAAe1B,UAAU,CAAC+B,OAAO,CAAC;EACpC;;EAEA;EACA,OAAO3B,iCAAiC,CAAC2B,OAAO,CAAC;AACnD,CAAC","ignoreList":[]}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "2.1.0-next.0",
6
+ "version": "2.1.0-next.2",
7
7
  "description": "",
8
8
  "license": "ISC",
9
9
  "main": "lib/index.js",
@@ -16,11 +16,11 @@
16
16
  "@dnd-kit/utilities": "3.2.2",
17
17
  "@mui/icons-material": "^7.3.4",
18
18
  "@mui/material": "^7.3.4",
19
- "@pie-lib/drag": "^4.0.2-next.0",
19
+ "@pie-lib/drag": "^4.0.2-next.1",
20
20
  "@pie-lib/math-input": "^8.1.0-next.0",
21
21
  "@pie-lib/math-rendering": "^5.0.2-next.0",
22
- "@pie-lib/math-toolbar": "^3.0.2-next.0",
23
- "@pie-lib/render-ui": "^6.1.0-next.0",
22
+ "@pie-lib/math-toolbar": "^3.0.2-next.1",
23
+ "@pie-lib/render-ui": "^6.1.0-next.1",
24
24
  "@tiptap/core": "3.0.9",
25
25
  "@tiptap/extension-character-count": "3.0.9",
26
26
  "@tiptap/extension-color": "3.0.9",
@@ -59,6 +59,6 @@
59
59
  "peerDependencies": {
60
60
  "react": "^18.2.0"
61
61
  },
62
- "gitHead": "2cf9410d6d89313791f1c763904bdfbceb6b1742",
62
+ "gitHead": "7fc2719f4e3886ce25e5d9e2afd87367798da650",
63
63
  "scripts": {}
64
64
  }
@@ -349,4 +349,126 @@ describe('EditableHtml', () => {
349
349
 
350
350
  jest.useRealTimers();
351
351
  });
352
+
353
+ describe('onUpdate callback', () => {
354
+ it('calls onChange when transaction.isDone is true', async () => {
355
+ const onChange = jest.fn();
356
+ const markup = '<p>Initial content</p>';
357
+
358
+ render(<EditableHtml {...defaultProps} markup={markup} onChange={onChange} />);
359
+
360
+ await waitFor(() => {
361
+ expect(useEditor).toHaveBeenCalled();
362
+ });
363
+
364
+ const editorConfig = useEditor.mock.calls[useEditor.mock.calls.length - 1][0];
365
+ const mockEditor = {
366
+ getHTML: jest.fn(() => '<p>Updated content</p>'),
367
+ };
368
+
369
+ const mockTransaction = {
370
+ isDone: true,
371
+ };
372
+
373
+ editorConfig.onUpdate({ editor: mockEditor, transaction: mockTransaction });
374
+
375
+ expect(onChange).toHaveBeenCalledWith('<p>Updated content</p>');
376
+ });
377
+
378
+ it('calls onChange when markup differs from editor HTML', async () => {
379
+ const onChange = jest.fn();
380
+ const markup = '<p>Initial content</p>';
381
+
382
+ render(<EditableHtml {...defaultProps} markup={markup} onChange={onChange} />);
383
+
384
+ await waitFor(() => {
385
+ expect(useEditor).toHaveBeenCalled();
386
+ });
387
+
388
+ const editorConfig = useEditor.mock.calls[useEditor.mock.calls.length - 1][0];
389
+ const mockEditor = {
390
+ getHTML: jest.fn(() => '<p>Different content</p>'),
391
+ };
392
+
393
+ const mockTransaction = {
394
+ isDone: false,
395
+ };
396
+
397
+ editorConfig.onUpdate({ editor: mockEditor, transaction: mockTransaction });
398
+
399
+ expect(onChange).toHaveBeenCalledWith('<p>Different content</p>');
400
+ });
401
+
402
+ it('does not call onChange when transaction.isDone is false and markup matches editor HTML', async () => {
403
+ const onChange = jest.fn();
404
+ const markup = '<p>Same content</p>';
405
+
406
+ render(<EditableHtml {...defaultProps} markup={markup} onChange={onChange} />);
407
+
408
+ await waitFor(() => {
409
+ expect(useEditor).toHaveBeenCalled();
410
+ });
411
+
412
+ const editorConfig = useEditor.mock.calls[useEditor.mock.calls.length - 1][0];
413
+ const mockEditor = {
414
+ getHTML: jest.fn(() => '<p>Same content</p>'),
415
+ };
416
+
417
+ const mockTransaction = {
418
+ isDone: false,
419
+ };
420
+
421
+ editorConfig.onUpdate({ editor: mockEditor, transaction: mockTransaction });
422
+
423
+ expect(onChange).not.toHaveBeenCalled();
424
+ });
425
+
426
+ it('calls onChange when transaction.isDone is true even if markup matches', async () => {
427
+ const onChange = jest.fn();
428
+ const markup = '<p>Same content</p>';
429
+
430
+ render(<EditableHtml {...defaultProps} markup={markup} onChange={onChange} />);
431
+
432
+ await waitFor(() => {
433
+ expect(useEditor).toHaveBeenCalled();
434
+ });
435
+
436
+ const editorConfig = useEditor.mock.calls[useEditor.mock.calls.length - 1][0];
437
+ const mockEditor = {
438
+ getHTML: jest.fn(() => '<p>Same content</p>'),
439
+ };
440
+
441
+ const mockTransaction = {
442
+ isDone: true,
443
+ };
444
+
445
+ editorConfig.onUpdate({ editor: mockEditor, transaction: mockTransaction });
446
+
447
+ expect(onChange).toHaveBeenCalledWith('<p>Same content</p>');
448
+ });
449
+
450
+ it('does not call onChange when onChange is not provided', async () => {
451
+ const markup = '<p>Content</p>';
452
+
453
+ render(<EditableHtml {...defaultProps} markup={markup} onChange={undefined} />);
454
+
455
+ await waitFor(() => {
456
+ expect(useEditor).toHaveBeenCalled();
457
+ });
458
+
459
+ const editorConfig = useEditor.mock.calls[useEditor.mock.calls.length - 1][0];
460
+ const mockEditor = {
461
+ getHTML: jest.fn(() => '<p>Updated content</p>'),
462
+ };
463
+
464
+ const mockTransaction = {
465
+ isDone: true,
466
+ };
467
+
468
+ // Should not throw error when onChange is undefined
469
+ expect(() => {
470
+ editorConfig.onUpdate({ editor: mockEditor, transaction: mockTransaction });
471
+ }).not.toThrow();
472
+ });
473
+ });
352
474
  });
@@ -0,0 +1,125 @@
1
+ import React from 'react';
2
+ import { render, waitFor } from '@testing-library/react';
3
+ import { EditableHtml } from '../components/EditableHtml';
4
+
5
+ describe('Div to Paragraph Conversion', () => {
6
+ it('converts consecutive divs to paragraph with br tags', async () => {
7
+ const markup = '<div>A</div><div>B</div>';
8
+ const { container } = render(<EditableHtml markup={markup} onChange={() => {}} pluginProps={{}} />);
9
+
10
+ // Wait for the editor to initialize
11
+ await waitFor(() => {
12
+ const prosemirror = container.querySelector('.ProseMirror');
13
+ expect(prosemirror).toBeInTheDocument();
14
+ });
15
+
16
+ // Check that the content was converted to a paragraph
17
+ const paragraph = container.querySelector('.ProseMirror p');
18
+ expect(paragraph).toBeInTheDocument();
19
+
20
+ // Check that br tag is present
21
+ const br = container.querySelector('.ProseMirror p br');
22
+ expect(br).toBeInTheDocument();
23
+
24
+ // Verify the text content
25
+ expect(paragraph.textContent).toBe('AB');
26
+ });
27
+
28
+ it('converts three consecutive divs correctly', async () => {
29
+ const markup = '<div>First</div><div>Second</div><div>Third</div>';
30
+ const { container } = render(<EditableHtml markup={markup} onChange={() => {}} pluginProps={{}} />);
31
+
32
+ await waitFor(() => {
33
+ const prosemirror = container.querySelector('.ProseMirror');
34
+ expect(prosemirror).toBeInTheDocument();
35
+ });
36
+
37
+ const paragraph = container.querySelector('.ProseMirror p');
38
+ expect(paragraph).toBeInTheDocument();
39
+
40
+ // Should have 2 br tags (between 3 items)
41
+ const brTags = container.querySelectorAll('.ProseMirror p br');
42
+ expect(brTags.length).toBe(2);
43
+
44
+ expect(paragraph.textContent).toBe('FirstSecondThird');
45
+ });
46
+
47
+ it('does not convert single div', async () => {
48
+ const markup = '<div>Single</div>';
49
+ const { container } = render(<EditableHtml markup={markup} onChange={() => {}} pluginProps={{}} />);
50
+
51
+ await waitFor(() => {
52
+ const prosemirror = container.querySelector('.ProseMirror');
53
+ expect(prosemirror).toBeInTheDocument();
54
+ });
55
+
56
+ // Should remain as a div
57
+ const div = container.querySelector('.ProseMirror div');
58
+ expect(div).toBeInTheDocument();
59
+ expect(div.textContent).toBe('Single');
60
+ });
61
+
62
+ it('does not convert divs with attributes', async () => {
63
+ const markup = '<div class="test">A</div><div>B</div>';
64
+ const { container } = render(<EditableHtml markup={markup} onChange={() => {}} pluginProps={{}} />);
65
+
66
+ await waitFor(() => {
67
+ const prosemirror = container.querySelector('.ProseMirror');
68
+ expect(prosemirror).toBeInTheDocument();
69
+ });
70
+
71
+ // Should remain as divs since one has an attribute
72
+ const divs = container.querySelectorAll('.ProseMirror div');
73
+ expect(divs.length).toBeGreaterThanOrEqual(2);
74
+ });
75
+
76
+ it('handles divs with inline formatting', async () => {
77
+ const markup = '<div><strong>Bold</strong></div><div><em>Italic</em></div>';
78
+ const { container } = render(<EditableHtml markup={markup} onChange={() => {}} pluginProps={{}} />);
79
+
80
+ await waitFor(() => {
81
+ const prosemirror = container.querySelector('.ProseMirror');
82
+ expect(prosemirror).toBeInTheDocument();
83
+ });
84
+
85
+ // Should be converted to paragraph
86
+ const paragraph = container.querySelector('.ProseMirror p');
87
+ expect(paragraph).toBeInTheDocument();
88
+
89
+ // Check that formatting is preserved
90
+ const strong = container.querySelector('.ProseMirror p strong');
91
+ const em = container.querySelector('.ProseMirror p em');
92
+ expect(strong).toBeInTheDocument();
93
+ expect(em).toBeInTheDocument();
94
+ });
95
+
96
+ it('does not convert mixed element types', async () => {
97
+ const markup = '<div>A</div><p>B</p>';
98
+ const { container } = render(<EditableHtml markup={markup} onChange={() => {}} pluginProps={{}} />);
99
+
100
+ await waitFor(() => {
101
+ const prosemirror = container.querySelector('.ProseMirror');
102
+ expect(prosemirror).toBeInTheDocument();
103
+ });
104
+
105
+ // Should have both div and paragraph
106
+ const div = container.querySelector('.ProseMirror div');
107
+ const p = container.querySelector('.ProseMirror p');
108
+ expect(div).toBeInTheDocument();
109
+ expect(p).toBeInTheDocument();
110
+ });
111
+
112
+ it('does not convert divs with nested block elements', async () => {
113
+ const markup = '<div><div>Nested</div></div><div>B</div>';
114
+ const { container } = render(<EditableHtml markup={markup} onChange={() => {}} pluginProps={{}} />);
115
+
116
+ await waitFor(() => {
117
+ const prosemirror = container.querySelector('.ProseMirror');
118
+ expect(prosemirror).toBeInTheDocument();
119
+ });
120
+
121
+ // Should remain as divs
122
+ const divs = container.querySelectorAll('.ProseMirror > div');
123
+ expect(divs.length).toBeGreaterThanOrEqual(1);
124
+ });
125
+ });
@@ -30,6 +30,7 @@ import { ImageUploadNode } from '../extensions/image';
30
30
  import { Media } from '../extensions/media';
31
31
  import { CSSMark } from '../extensions/css';
32
32
  import { ExtendedListItem } from '../extensions/extended-list-item';
33
+ import { HeadingParagraph } from '../extensions/heading-paragraph';
33
34
 
34
35
  import EditorContainer from './TiptapContainer';
35
36
  import { valueToSize } from '../utils/size';
@@ -153,7 +154,7 @@ export const EditableHtml = (props) => {
153
154
 
154
155
  const extensions = [
155
156
  TextAlign.configure({
156
- types: ['heading', 'paragraph', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'th'],
157
+ types: ['heading', 'paragraph', 'div', 'headingParagraph', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'td', 'th'],
157
158
  alignments: ['left', 'right', 'center', 'justify'],
158
159
  }),
159
160
  TextStyleKit,
@@ -168,6 +169,7 @@ export const EditableHtml = (props) => {
168
169
  }),
169
170
  ExtendedListItem,
170
171
  DivNode,
172
+ HeadingParagraph,
171
173
  EnsureEmptyRootIsDiv,
172
174
  EnsureListItemContentIsDiv,
173
175
  Placeholder.configure({
@@ -286,7 +288,7 @@ export const EditableHtml = (props) => {
286
288
  editable: !props.disabled,
287
289
  content: normalizeInitialMarkup(props.markup),
288
290
  onUpdate: ({ editor, transaction }) => {
289
- if (transaction.isDone) {
291
+ if (transaction.isDone || props.markup !== editor.getHTML()) {
290
292
  props.onChange?.(editor.getHTML());
291
293
  }
292
294
  },
@@ -122,6 +122,7 @@ function MenuBar({
122
122
  isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
123
123
  isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
124
124
  isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
125
+ isHeadingParagraph: ctx.editor.isActive('headingParagraph') ?? false,
125
126
  isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
126
127
  isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
127
128
  isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,
@@ -289,8 +290,8 @@ function MenuBar({
289
290
  {
290
291
  icon: <HeadingIcon />,
291
292
  hidden: () => !activePlugins?.includes('h3'),
292
- onClick: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
293
- isActive: (state) => state.isHeading3,
293
+ onClick: (editor) => editor.chain().focus().toggleHeadingParagraph().run(),
294
+ isActive: (state) => state.isHeadingParagraph,
294
295
  },
295
296
  {
296
297
  icon: <Functions />,
@@ -0,0 +1,53 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * HeadingParagraph extension
5
+ *
6
+ * Renders as <p data-heading="heading1"> in the HTML output instead of <h3>.
7
+ * This provides semantic heading styling without using a native heading element,
8
+ * as required by PIE's accessibility conventions.
9
+ */
10
+ export const HeadingParagraph = Node.create({
11
+ name: 'headingParagraph',
12
+
13
+ group: 'block',
14
+
15
+ content: 'inline*',
16
+
17
+ defining: true,
18
+
19
+ addAttributes() {
20
+ return {
21
+ 'data-heading': {
22
+ default: 'heading1',
23
+ parseHTML: (element) => element.getAttribute('data-heading'),
24
+ renderHTML: (attributes) => ({
25
+ 'data-heading': attributes['data-heading'],
26
+ }),
27
+ },
28
+ };
29
+ },
30
+
31
+ parseHTML() {
32
+ return [
33
+ { tag: 'p[data-heading]' },
34
+ ];
35
+ },
36
+
37
+ renderHTML({ HTMLAttributes }) {
38
+ return ['p', mergeAttributes(HTMLAttributes), 0];
39
+ },
40
+
41
+ addCommands() {
42
+ return {
43
+ toggleHeadingParagraph:
44
+ () =>
45
+ ({ commands, editor }) => {
46
+ if (editor.isActive('headingParagraph')) {
47
+ return commands.setNode('paragraph');
48
+ }
49
+ return commands.setNode('headingParagraph', { 'data-heading': 'heading1' });
50
+ },
51
+ };
52
+ },
53
+ });