@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.
- package/CHANGELOG.md +12 -0
- package/lib/components/EditableHtml.js +4 -3
- package/lib/components/EditableHtml.js.map +1 -1
- package/lib/components/MenuBar.js +14 -15
- package/lib/components/MenuBar.js.map +1 -1
- package/lib/extensions/heading-paragraph.js +61 -0
- package/lib/extensions/heading-paragraph.js.map +1 -0
- package/lib/utils/helper.js +58 -2
- package/lib/utils/helper.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/EditableHtml.test.jsx +122 -0
- package/src/__tests__/div-to-paragraph-conversion.test.jsx +125 -0
- package/src/components/EditableHtml.jsx +4 -2
- package/src/components/MenuBar.jsx +3 -2
- package/src/extensions/heading-paragraph.js +53 -0
- package/src/utils/__tests__/helper.test.js +126 -0
- package/src/utils/helper.js +54 -2
|
@@ -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":[]}
|
package/lib/utils/helper.js
CHANGED
|
@@ -7,11 +7,67 @@ exports.normalizeInitialMarkup = void 0;
|
|
|
7
7
|
var escapeHtml = function escapeHtml(str) {
|
|
8
8
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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)
|
|
15
|
-
|
|
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
|
package/lib/utils/helper.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"helper.js","names":["escapeHtml","str","String","replace","normalizeInitialMarkup","exports","markup","trimmed","trim","looksLikeHtml","test"
|
|
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, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\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.
|
|
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.
|
|
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.
|
|
23
|
-
"@pie-lib/render-ui": "^6.1.0-next.
|
|
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": "
|
|
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().
|
|
293
|
-
isActive: (state) => state.
|
|
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
|
+
});
|