Skip to content

Commit 580ee55

Browse files
authored
Merge pull request #693 from Harbour-Enterprises/feature/editor-sections
Feature: Document sections
2 parents c53ace0 + d49b3f4 commit 580ee55

21 files changed

Lines changed: 686 additions & 34 deletions

File tree

packages/super-editor/src/assets/styles/extensions/_all.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
@import './comments.css';
66
@import './list-items.css';
77
@import './noderesizer.css';
8+
@import './document-section.css';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
.sd-document-section-block {
2+
background-color: #fafafa;
3+
border: 1px solid #ababab;
4+
border-radius: 4px;
5+
position: relative;
6+
}
7+
.sd-document-section-block-info {
8+
position: absolute;
9+
top: -19px;
10+
left: -1px;
11+
max-width: 100px;
12+
min-width: 0;
13+
height: 18px;
14+
border: 1px solid #ababab;
15+
border-bottom: none;
16+
border-radius: 6px 6px 0 0;
17+
padding: 0 8px;
18+
align-items: center;
19+
font-size: 10px;
20+
display: none;
21+
z-index: 100;
22+
background-color: #fafafa;
23+
}
24+
25+
.sd-document-section-block:hover {
26+
border-radius: 0 4px 4px 4px;
27+
}
28+
29+
.sd-document-section-block:hover .sd-document-section-block-info {
30+
display: flex;
31+
align-items: center;
32+
}
33+
34+
.sd-document-section-block-info span {
35+
max-width: 100%;
36+
overflow: hidden;
37+
white-space: nowrap;
38+
text-overflow: ellipsis;
39+
}

packages/super-editor/src/components/slash-menu/constants.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const ICONS = {
2929
cut: scissorsIconSvg,
3030
copy: copyIconSvg,
3131
paste: pasteIconSvg,
32+
addDocumentSection: plusIconSvg,
33+
removeDocumentSection: trashIconSvg,
3234
};
3335

3436
// Table actions constant
@@ -52,6 +54,8 @@ export const TEXTS = {
5254
cut: 'Cut',
5355
copy: 'Copy',
5456
paste: 'Paste',
57+
removeDocumentSection: 'Remove section',
58+
createDocumentSection: 'Create section',
5559
};
5660

5761
export const tableActionsOptions = [

packages/super-editor/src/components/slash-menu/menuItems.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const isModuleEnabled = (editorOptions, moduleName) => {
4545
* requiresSelection?: boolean,
4646
* requiresClipboard?: boolean
4747
* requiresTableParent?: boolean
48+
* requiredSectionParent?: boolean,
4849
* requiresModule?: string
4950
* }>
5051
* }>} Array of menu sections
@@ -53,6 +54,7 @@ export function getItems(context) {
5354
const { editor, selectedText, trigger, clipboardContent } = context;
5455

5556
const isInTable = selectionHasNodeOrMark(editor.view.state, 'table', { requireEnds: true });
57+
const isInSectionNode = selectionHasNodeOrMark(editor.view.state, 'documentSection', { requireEnds: true });
5658

5759
const sections = [
5860
{
@@ -74,6 +76,30 @@ export function getItems(context) {
7476
},
7577
],
7678
},
79+
{
80+
id: 'document-sections',
81+
items: [
82+
{
83+
id: 'insert-document-section',
84+
label: TEXTS.createDocumentSection,
85+
icon: ICONS.addDocumentSection,
86+
action: (editor) => {
87+
editor.commands.createDocumentSection();
88+
},
89+
allowedTriggers: [TRIGGERS.click],
90+
},
91+
{
92+
id: 'remove-section',
93+
label: TEXTS.removeDocumentSection,
94+
icon: ICONS.removeDocumentSection,
95+
action: (editor) => {
96+
editor.commands.removeSectionAtSelection();
97+
},
98+
allowedTriggers: [TRIGGERS.click],
99+
requiresSectionParent: true,
100+
},
101+
],
102+
},
77103
{
78104
id: 'general',
79105
items: [
@@ -189,6 +215,8 @@ export function getItems(context) {
189215
// If the item requires a table parent and there is no table parent, return false
190216
// Or if we are in a table, do not show 'insert table'
191217
if ((item.requiresTableParent && !isInTable) || (item.id === 'insert-table' && isInTable)) return false;
218+
// If the item requires a section parent and there is no section parent, return false
219+
if (item.requiresSectionParent && !isInSectionNode) return false;
192220
return true;
193221
});
194222

packages/super-editor/src/core/InputRule.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,7 @@ export function isWordHtml(html) {
240240
* @returns {Boolean} Returns true if the paste was handled.
241241
*/
242242
export function handleHtmlPaste(html, editor) {
243-
const flatHtml = flattenListsInHtml(html, editor);
244-
245-
const htmlWithPtSizing = convertEmToPt(flatHtml);
246-
const cleanedHtml = sanitizeHtml(htmlWithPtSizing);
243+
const cleanedHtml = htmlHandler(html, editor);
247244
const doc = PMDOMParser.fromSchema(editor.schema).parse(cleanedHtml);
248245

249246
const { dispatch, state } = editor.view;
@@ -269,6 +266,20 @@ export function handleHtmlPaste(html, editor) {
269266
return true;
270267
}
271268

269+
/**
270+
* Handle HTML content before it is inserted into the editor.
271+
* This function is used to clean and sanitize HTML content,
272+
* converting em units to pt and removing unnecessary tags.
273+
* @param {String} html The HTML string to be processed.
274+
* @param {Editor} editor The editor instance.
275+
* @returns {String} The processed HTML string.
276+
*/
277+
export function htmlHandler(html, editor) {
278+
const flatHtml = flattenListsInHtml(html, editor);
279+
const htmlWithPtSizing = convertEmToPt(flatHtml);
280+
return sanitizeHtml(htmlWithPtSizing);
281+
}
282+
272283
/**
273284
* Process the HTML string to convert em units to pt units in font-size
274285
*
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { DOMSerializer } from 'prosemirror-model';
2+
3+
/**
4+
* Get all sections in the editor document.
5+
* This function traverses the document and collects all nodes of the specified section type.
6+
* @param {Editor} editor - The editor instance to search within.
7+
* @returns {Array} An array of objects containing the node and its position in the document
8+
*/
9+
const getAllSections = (editor) => {
10+
if (!editor) return [];
11+
const type = editor.schema.nodes.documentSection;
12+
if (!type) return [];
13+
14+
const sections = [];
15+
const { state } = editor;
16+
state.doc.descendants((node, pos) => {
17+
if (node.type.name === type.name) {
18+
sections.push({ node, pos });
19+
}
20+
});
21+
return sections;
22+
};
23+
24+
/**
25+
* Export all sections to HTML format.
26+
* This function retrieves all sections in the editor and converts them to HTML.
27+
* @param {Editor} editor - The editor instance containing the sections.
28+
* @returns {Array} An array of objects containing section details and their HTML representation.
29+
*/
30+
export const exportSectionsToHTML = (editor) => {
31+
const sections = getAllSections(editor);
32+
33+
const processedSections = new Set();
34+
const result = [];
35+
sections.forEach(({ node }) => {
36+
const { attrs } = node;
37+
const { id, title, description } = attrs;
38+
if (processedSections.has(id)) return;
39+
processedSections.add(id);
40+
41+
const html = getHTMLFromNode(node, editor);
42+
result.push({
43+
id,
44+
title,
45+
description,
46+
html,
47+
});
48+
});
49+
return result;
50+
};
51+
52+
/**
53+
* Get HTML representation of a ProseMirror node.
54+
* @param {Node} node - The ProseMirror node to convert.
55+
* @param {Editor} editor - The editor instance used for serialization.
56+
* @returns {String} The HTML representation of the node's content.
57+
*/
58+
const getHTMLFromNode = (node, editor) => {
59+
const tempDocument = document.implementation.createHTMLDocument();
60+
const container = tempDocument.createElement('div');
61+
const fragment = DOMSerializer.fromSchema(editor.schema).serializeFragment(node.content);
62+
container.appendChild(fragment);
63+
let html = container.innerHTML;
64+
return html;
65+
};
66+
67+
/**
68+
* Export all sections to JSON format.
69+
* This function retrieves all sections in the editor and converts them to JSON.
70+
* @param {Editor} editor - The editor instance containing the sections.
71+
* @returns {Array} An array of objects containing section details and their JSON representation.
72+
*/
73+
export const exportSectionsToJSON = (editor) => {
74+
const sections = getAllSections(editor);
75+
const processedSections = new Set();
76+
const result = [];
77+
sections.forEach(({ node }) => {
78+
const { attrs } = node;
79+
const { id, title, description } = attrs;
80+
if (processedSections.has(id)) return;
81+
processedSections.add(id);
82+
83+
result.push({
84+
id,
85+
title,
86+
description,
87+
content: node.toJSON(),
88+
});
89+
});
90+
return result;
91+
};
92+
93+
/**
94+
* Get a linked section editor by its ID.
95+
* This function creates a child editor for a specific section, allowing for editing of that section's content.
96+
* @param {String} id - The ID of the section to link to.
97+
* @param {Object} options - Options for the child editor.
98+
* @param {Editor} editor - The parent editor instance.
99+
* @returns {Editor|null} The child editor instance for the linked section, or null if the section is not found.
100+
*/
101+
export const getLinkedSectionEditor = (id, options, editor) => {
102+
const sections = getAllSections(editor);
103+
const section = sections.find((s) => s.node.attrs.id === id);
104+
if (!section) return null;
105+
106+
const child = editor.createChildEditor({
107+
...options,
108+
onUpdate: ({ editor: childEditor, transaction }) => {
109+
const isFromtLinkedParent = transaction.getMeta('fromLinkedParent');
110+
if (isFromtLinkedParent) return; // Prevent feedback loop
111+
112+
// 1. Get updated content from child editor
113+
const updatedContent = childEditor.state.doc.content;
114+
115+
// 2. Find the section node and its position in the parent
116+
const sectionNode = getAllSections(editor)?.find((s) => s.node.attrs.id === id);
117+
if (!sectionNode) return;
118+
119+
const { pos, node } = sectionNode;
120+
121+
// 3. Create a new node with the same type and attrs, but updated content
122+
const newNode = node.type.create(node.attrs, updatedContent, node.marks);
123+
124+
// 4. Replace the old node with the new node in the parent editor
125+
const tr = editor.state.tr.replaceWith(pos, pos + node.nodeSize, newNode);
126+
tr.setMeta('fromLinkedChild', true); // Prevent feedback loop
127+
editor.view.dispatch(tr);
128+
},
129+
});
130+
131+
editor.on('update', ({ transaction }) => {
132+
const isFromLinkedChild = transaction.getMeta('fromLinkedChild');
133+
if (isFromLinkedChild) return; // Prevent feedback loop
134+
135+
const sectionNode = getAllSections(editor)?.find((s) => s.node.attrs.id === id);
136+
if (!sectionNode) return;
137+
138+
// Only update if content is actually different
139+
const sectionContent = sectionNode.node.content;
140+
141+
const json = {
142+
type: 'doc',
143+
content: sectionContent.content.map((node) => node.toJSON()),
144+
};
145+
146+
const childTr = child.state.tr;
147+
childTr.setMeta('fromLinkedParent', true); // Prevent feedback loop
148+
childTr.replaceWith(0, child.state.doc.content.size, child.schema.nodeFromJSON(json));
149+
child.view.dispatch(childTr);
150+
});
151+
152+
return child;
153+
};
154+
155+
/**
156+
* SectionHelpers provides utility functions for working with sections in the editor.
157+
* It includes methods to retrieve all sections and manage section-related data.
158+
*/
159+
export const SectionHelpers = {
160+
getAllSections,
161+
exportSectionsToHTML,
162+
exportSectionsToJSON,
163+
getLinkedSectionEditor,
164+
};

packages/super-editor/src/core/helpers/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export * from './findChildren.js';
2424
export * from './posToDOMRect.js';
2525
export * from './findMark.js';
2626
export * from './isInTable.js';
27+
export * from './documentSectionHelpers.js';

packages/super-editor/src/core/super-converter/exporter.js

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ import { generateDocxRandomId } from '@helpers/generateDocxRandomId.js';
1717
import { DEFAULT_DOCX_DEFS } from './exporter-docx-defs.js';
1818
import { TrackDeleteMarkName, TrackFormatMarkName, TrackInsertMarkName } from '@extensions/track-changes/constants.js';
1919
import { carbonCopy } from '../utilities/carbonCopy.js';
20-
import { baseBulletList, baseOrderedListDef } from './v2/exporter/helpers/base-list.definitions.js';
2120
import { translateCommentNode } from './v2/exporter/commentsExporter.js';
2221
import { createColGroup } from '@extensions/table/tableHelpers/createColGroup.js';
2322
import { sanitizeHtml } from '../InputRule.js';
2423
import { ListHelpers } from '@helpers/list-numbering-helpers.js';
24+
import { translateChildNodes, baseBulletList, baseOrderedListDef } from './v2/exporter/helpers/index.js';
25+
import { translateDocumentSection } from './v2/exporter/index.js';
2526

2627
/**
2728
* @typedef {Object} ExportParams
@@ -93,6 +94,7 @@ export function exportSchemaToJson(params) {
9394
shapeTextbox: translateShapeTextbox,
9495
contentBlock: translateContentBlock,
9596
structuredContent: translateStructuredContent,
97+
documentSection: translateDocumentSection,
9698
'page-number': translatePageNumberNode,
9799
'total-page-number': translateTotalPageNumberNode,
98100
};
@@ -191,11 +193,13 @@ export function translateParagraphNode(params) {
191193
attributes['w:rsidRDefault'] = params.node.attrs.rsidRDefault;
192194
}
193195

194-
return {
196+
const result = {
195197
name: 'w:p',
196198
elements,
197199
attributes,
198200
};
201+
202+
return result;
199203
}
200204

201205
/**
@@ -375,31 +379,6 @@ function processAttributes(attrs) {
375379
return processedAttrs;
376380
}
377381

378-
/**
379-
* Process child nodes, ignoring any that are not valid
380-
*
381-
* @param {SchemaNode[]} nodes The input nodes
382-
* @returns {XmlReadyNode[]} The processed child nodes
383-
*/
384-
function translateChildNodes(params) {
385-
const { content: nodes } = params.node;
386-
if (!nodes) return [];
387-
388-
const translatedNodes = [];
389-
nodes.forEach((node) => {
390-
let translatedNode = exportSchemaToJson({ ...params, node });
391-
392-
const nodeType = translatedNode?.name || translatedNode?.type;
393-
// if (nodeType !== 'w:sdt') translatedNode = isolateAnnotations(translatedNode);
394-
395-
if (translatedNode instanceof Array) translatedNodes.push(...translatedNode);
396-
else translatedNodes.push(translatedNode);
397-
});
398-
399-
// Filter out any null nodes
400-
return translatedNodes.filter((n) => n);
401-
}
402-
403382
/**
404383
* Process nodes to isolate sdt annotations from simple text nodes
405384
* since having sdt annotation with text run in one paragraph inside table cell

0 commit comments

Comments
 (0)