Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/super-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"eventemitter3": "^5.0.1",
"he": "^1.2.0",
"jszip": "^3.10.1",
"marked": "^16.2.0",
"prosemirror-commands": "^1.5.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
Expand Down
14 changes: 12 additions & 2 deletions packages/super-editor/src/components/SuperEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import Ruler from './rulers/Ruler.vue';
import GenericPopover from './popovers/GenericPopover.vue';
import LinkInput from './toolbar/LinkInput.vue';
import { checkNodeSpecificClicks } from './cursor-helpers.js';
import { getFileObject } from '@harbour-enterprises/common';
import BlankDOCX from '@harbour-enterprises/common/data/blank.docx?url';

const emit = defineEmits(['editor-ready', 'editor-click', 'editor-keydown', 'comments-loaded', 'selection-update']);

const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const props = defineProps({
documentId: {
type: String,
Expand Down Expand Up @@ -44,6 +47,8 @@ const editor = shallowRef(null);
const editorWrapper = ref(null);
const editorElem = ref(null);

const fileSource = ref(null);

/**
* Generic popover controls including state, open and close functions
*/
Expand Down Expand Up @@ -95,8 +100,13 @@ const pollForMetaMapData = (ydoc, retries = 10, interval = 500) => {
};

const loadNewFileData = async () => {
fileSource.value = props.fileSource;
if (!fileSource.value || fileSource.value.type !== DOCX) {
Comment thread
caio-pizzol marked this conversation as resolved.
fileSource.value = await getFileObject(BlankDOCX, 'blank.docx', DOCX);
}

try {
const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(props.fileSource);
const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(fileSource.value);
return { content: docx, media, mediaFiles, fonts };
} catch (err) {
console.debug('Error loading new file data:', err);
Expand Down Expand Up @@ -137,7 +147,7 @@ const initEditor = async ({ content, media = {}, mediaFiles = {}, fonts = {} } =
editor.value = new Editor({
mode: 'docx',
element: editorElem.value,
fileSource: props.fileSource,
fileSource: fileSource.value,
extensions: getExtensions(),
externalExtensions: props.options.externalExtensions,
documentId: props.documentId,
Expand Down
30 changes: 9 additions & 21 deletions packages/super-editor/src/core/Editor.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { DOMParser, DOMSerializer } from 'prosemirror-model';

Check warning on line 3 in packages/super-editor/src/core/Editor.js

View workflow job for this annotation

GitHub Actions / Lint & Format Check

'DOMParser' is defined but never used
import { yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
import { helpers } from '@core/index.js';
import { EventEmitter } from './EventEmitter.js';
Expand Down Expand Up @@ -37,6 +37,7 @@
import { createLinkedChildEditor } from '@core/child-editor/index.js';
import { unflattenListsInHtml } from './inputRules/html/html-helpers.js';
import { SuperValidator } from '@core/super-validator/index.js';
import { createDocFromMarkdown, createDocFromHTML } from '@core/helpers/index.js';

/**
* @typedef {Object} FieldValue
Expand Down Expand Up @@ -131,6 +132,7 @@
* @property {boolean} [suppressDefaultDocxStyles] - Prevent default styles from being applied in docx mode
* @property {boolean} [jsonOverride] - Whether to override content with provided json
* @property {string} [html] - HTML content to initialize the editor with
* @property {string} [markdown] - Markdown content to initialize the editor with
*/

/**
Expand Down Expand Up @@ -949,8 +951,12 @@
// Perform any additional document processing prior to finalizing the doc here
doc = this.#prepareDocumentForImport(doc);

// Check for markdown BEFORE html (since markdown gets converted to HTML)
if (this.options.markdown) {
doc = createDocFromMarkdown(this.options.markdown, this.schema);
}
// If we have a new doc, and have html data, we initialize from html
if (this.options.html) doc = this.#createDocFromHTML(this.options.html);
else if (this.options.html) doc = createDocFromHTML(this.options.html, this.schema);
else if (this.options.jsonOverride) doc = this.schema.nodeFromJSON(this.options.jsonOverride);

if (fragment) doc = yXmlFragmentToProseMirrorRootNode(fragment, this.schema);
Expand All @@ -960,7 +966,7 @@
// If we are in HTML mode, we initialize from either content or html (or blank)
else if (mode === 'text' || mode === 'html') {
if (loadFromSchema) doc = this.schema.nodeFromJSON(content);
else if (content) doc = this.#createDocFromHTML(content);
else if (content) doc = createDocFromHTML(content, this.schema);
else doc = this.schema.topNodeType.createAndFill();
}
} catch (err) {
Expand All @@ -971,24 +977,6 @@
return doc;
}

/**
* Create a document from HTML content
* @private
* @param {string} content - HTML content
* @returns {Object} Document node
*/
#createDocFromHTML(content) {
let parsedContent = content;
if (typeof content === 'string') {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
parsedContent = tempDiv;
tempDiv.remove();
}

return DOMParser.fromSchema(this.schema).parse(parsedContent);
}

/**
* Create the PM editor view
* @private
Expand Down Expand Up @@ -1831,7 +1819,7 @@
if (!targetNode || !html) return;
const start = targetNode.pos;
const end = start + targetNode.node.nodeSize;
const htmlNode = this.#createDocFromHTML(html);
const htmlNode = createDocFromHTML(html, this.schema);
tr.replaceWith(start, end, htmlNode);
dispatch(tr);
}
Expand Down
21 changes: 21 additions & 0 deletions packages/super-editor/src/core/helpers/importHtml.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// @ts-check
import { DOMParser } from 'prosemirror-model';

/**
* Create a document from HTML content
* @private
* @param {string} content - HTML content
* @returns {Object} Document node
*/
export function createDocFromHTML(content, schema) {
let parsedContent;
if (typeof content === 'string') {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
parsedContent = tempDiv;
} else {
parsedContent = content;
}

return DOMParser.fromSchema(schema).parse(parsedContent);
}
Comment thread
caio-pizzol marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { convertMarkdownToHTML } from './importMarkdown.js';

describe('markdown to DOCX integration', () => {
it('converts complete markdown document with headings and lists', () => {
const markdown = `# Main Title

Text before list.

- Bullet item
- Another item

## Section 2

More text here.

1. Numbered item
2. Second item`;

const html = convertMarkdownToHTML(markdown);

// Verify all elements are converted
expect(html).toContain('<h1>Main Title</h1>');
expect(html).toContain('<h2>Section 2</h2>');
expect(html).toContain('<ul>');
expect(html).toContain('<ol>');

// Verify spacing is added between paragraphs and lists
expect(html).toContain('</p>\n<p>&nbsp;</p>\n<ul>');
expect(html).toContain('</p>\n<p>&nbsp;</p>\n<ol>');
});
});
36 changes: 36 additions & 0 deletions packages/super-editor/src/core/helpers/importMarkdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @ts-check
import { marked } from 'marked';
import { createDocFromHTML } from './importHtml.js';

// Configure marked once
marked.use({
breaks: false, // Use proper paragraphs, not <br> tags
gfm: true, // GitHub Flavored Markdown support
});

/**
* Create a ProseMirror document from Markdown content
* @param {string} markdown - Markdown content
* @param {Object} schema - ProseMirror schema
* @returns {Object} Document node
*/
export function createDocFromMarkdown(markdown, schema) {
const html = convertMarkdownToHTML(markdown);
return createDocFromHTML(html, schema);
}

/**
* Convert Markdown to HTML with SuperDoc/DOCX compatibility
* @param {string} markdown - Markdown content
* @returns {string} HTML content
*/
export function convertMarkdownToHTML(markdown) {
let html = marked.parse(markdown, { async: false });

// Add spacing between paragraphs and lists for proper DOCX rendering
return html
.replace(/<\/p>\n<ul>/g, '</p>\n<p>&nbsp;</p>\n<ul>')
.replace(/<\/p>\n<ol>/g, '</p>\n<p>&nbsp;</p>\n<ol>')
.replace(/<\/ul>\n<h/g, '</ul>\n<p>&nbsp;</p>\n<h')
.replace(/<\/ol>\n<h/g, '</ol>\n<p>&nbsp;</p>\n<h');
}
34 changes: 34 additions & 0 deletions packages/super-editor/src/core/helpers/importMarkdown.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, it, expect, vi } from 'vitest';
import { createDocFromMarkdown, convertMarkdownToHTML } from './importMarkdown.js';
import { createDocFromHTML } from './importHtml.js';

vi.mock('../../core/helpers/importHtml.js', () => ({
createDocFromHTML: vi.fn(),
}));

describe('markdown import', () => {
it('converts markdown to HTML with proper spacing', () => {
const markdown = `# Heading

Paragraph text

- List item`;

const html = convertMarkdownToHTML(markdown);

expect(html).toContain('<h1>Heading</h1>');
expect(html).toContain('<p>Paragraph text</p>');
expect(html).toContain('</p>\n<p>&nbsp;</p>\n<ul>'); // Spacing before list
});

it('creates ProseMirror doc from markdown', () => {
const mockSchema = { nodes: {} };
const mockDoc = { type: 'doc' };
createDocFromHTML.mockReturnValue(mockDoc);

const result = createDocFromMarkdown('# Test', mockSchema);

expect(createDocFromHTML).toHaveBeenCalledWith(expect.stringContaining('<h1>Test</h1>'), mockSchema);
expect(result).toBe(mockDoc);
});
});
2 changes: 2 additions & 0 deletions packages/super-editor/src/core/helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ export * from './posToDOMRect.js';
export * from './findMark.js';
export * from './isInTable.js';
export * from './documentSectionHelpers.js';
export * from './importHtml.js';
export * from './importMarkdown.js';
25 changes: 25 additions & 0 deletions packages/super-editor/src/core/super-converter/exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function exportSchemaToJson(params) {
const router = {
doc: translateDocumentNode,
body: translateBodyNode,
heading: translateHeadingNode,
paragraph: translateParagraphNode,
text: translateTextNode,
bulletList: translateList,
Expand Down Expand Up @@ -169,6 +170,30 @@ const generateDefaultHeaderFooter = (type, id) => {
};
};

/**
* Translate a heading node to a paragraph with Word heading style
*
* @param {ExportParams} params The parameters object containing the heading node
* @returns {XmlReadyNode} JSON of the XML-ready paragraph node with heading style
*/
function translateHeadingNode(params) {
const { node } = params;
const { level = 1, ...otherAttrs } = node.attrs;

// Convert heading to paragraph with appropriate Word heading style
const paragraphNode = {
type: 'paragraph',
content: node.content,
attrs: {
...otherAttrs,
styleId: `Heading${level}`, // Maps to Heading1, Heading2, etc. in Word
},
};

// Use existing paragraph translator with the modified node
return translateParagraphNode({ ...params, node: paragraphNode });
}
Comment thread
harbournick marked this conversation as resolved.

/**
* Translate a paragraph node
*
Expand Down
2 changes: 1 addition & 1 deletion packages/super-editor/src/dev/components/BasicUpload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const onFileChange = (event) => {

<template>
<div>
<input type="file" id="fileInput" @change="onFileChange" accept=".docx" />
<input type="file" id="fileInput" @change="onFileChange" accept=".docx .md" />
</div>
</template>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ onMounted(async () => {
</div>
<div class="dev-app__header-upload">
Upload docx
<BasicUpload @file-change="handleNewFile" accept=".docx" />
<BasicUpload @file-change="handleNewFile" />
</div>
</div>
<div class="dev-app__header-side dev-app__header-side--right">
Expand Down
2 changes: 1 addition & 1 deletion packages/super-editor/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import { Extension } from '@core/Extension.js';
import { Plugin } from 'prosemirror-state';
import { Mark } from '@core/Mark.js';
import SlashMenu from './components/slash-menu/SlashMenu.vue';
import { BasicUpload } from '@harbour-enterprises/common';

import SuperEditor from './components/SuperEditor.vue';
import BasicUpload from './dev/components/BasicUpload.vue';
import Toolbar from './components/toolbar/Toolbar.vue';
import SuperInput from './components/SuperInput.vue';
import AIWriter from './components/toolbar/AIWriter.vue';
Expand Down
25 changes: 25 additions & 0 deletions packages/super-editor/src/tests/export/headingNodeExporter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { exportSchemaToJson } from '../../core/super-converter/exporter.js';

describe('heading node export', () => {
it('converts heading to paragraph with Word heading style', () => {
const mockParams = {
node: {
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Test' }],
},
editor: {
extensionService: { extensions: [] },
},
};

const result = exportSchemaToJson(mockParams);

// Check it's a paragraph with Heading2 style
expect(result.name).toBe('w:p');
const pPr = result.elements.find((el) => el?.name === 'w:pPr');
const pStyle = pPr.elements.find((el) => el?.name === 'w:pStyle');
expect(pStyle.attributes['w:val']).toBe('Heading2');
});
});
Loading
Loading