diff --git a/demo-site/about.html b/demo-site/about.html index d6a0a9c..615cfa0 100644 --- a/demo-site/about.html +++ b/demo-site/about.html @@ -115,9 +115,9 @@

Test 2: Group Editing (.insertr-group)

-

This paragraph is part of a group.

-

Clicking anywhere in the group should open one markdown editor.

-

All content should be editable together as markdown.

+

This paragraph is part of a group.

+

Clicking anywhere should open one markdown editor with rich formatting.

+

All content should be editable together as markdown with proper HTML conversion.

diff --git a/lib/package-lock.json b/lib/package-lock.json index 39efa73..7d31e9e 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -8,6 +8,10 @@ "name": "@insertr/lib", "version": "1.0.0", "license": "MIT", + "dependencies": { + "marked": "^16.2.1", + "turndown": "^7.2.1" + }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.0", "@rollup/plugin-terser": "^0.4.0", @@ -65,6 +69,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", @@ -1367,6 +1377,18 @@ "node": ">=0.10.0" } }, + "node_modules/marked": { + "version": "16.2.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.2.1.tgz", + "integrity": "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/micromatch": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", @@ -2611,6 +2633,15 @@ "node": ">=0.6" } }, + "node_modules/turndown": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.1.tgz", + "integrity": "sha512-7YiPJw6rLClQL3oUKN3KgMaXeJJ2lAyZItclgKDurqnH61so4k4IH/qwmMva0zpuJc/FhRExBBnk7EbeFANlgQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", diff --git a/lib/package.json b/lib/package.json index 82fa9db..d753d98 100644 --- a/lib/package.json +++ b/lib/package.json @@ -32,7 +32,11 @@ "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.0", "@rollup/plugin-terser": "^0.4.0", - "rollup": "^3.0.0", - "live-server": "^1.2.2" + "live-server": "^1.2.2", + "rollup": "^3.0.0" + }, + "dependencies": { + "marked": "^16.2.1", + "turndown": "^7.2.1" } -} \ No newline at end of file +} diff --git a/lib/src/core/editor.js b/lib/src/core/editor.js index 97be7a6..c8ce87a 100644 --- a/lib/src/core/editor.js +++ b/lib/src/core/editor.js @@ -106,6 +106,12 @@ export class InsertrEditor { } updateElementContent(element, formData) { + // Skip updating group elements - they're handled by the form renderer + if (element.classList.contains('insertr-group')) { + console.log('🔄 Skipping group element update - handled by form renderer'); + return; + } + if (element.tagName.toLowerCase() === 'a') { // Update link element if (formData.text !== undefined) { diff --git a/lib/src/ui/form-renderer.js b/lib/src/ui/form-renderer.js index c604007..3c67891 100644 --- a/lib/src/ui/form-renderer.js +++ b/lib/src/ui/form-renderer.js @@ -1,3 +1,5 @@ +import { markdownConverter } from '../utils/markdown.js'; + /** * LivePreviewManager - Handles debounced live preview updates */ @@ -27,6 +29,22 @@ class LivePreviewManager { this.previewTimeouts.set(elementId, timeoutId); } + scheduleGroupPreview(groupElement, children, markdown) { + const elementId = this.getElementId(groupElement); + + // Clear existing timeout + if (this.previewTimeouts.has(elementId)) { + clearTimeout(this.previewTimeouts.get(elementId)); + } + + // Schedule new group preview update with 500ms debounce + const timeoutId = setTimeout(() => { + this.updateGroupPreview(groupElement, children, markdown); + }, 500); + + this.previewTimeouts.set(elementId, timeoutId); + } + updatePreview(element, newValue, elementType) { // Store original content if first preview if (!this.originalContent && this.activeElement === element) { @@ -39,6 +57,24 @@ class LivePreviewManager { // ResizeObserver will automatically detect height changes } + updateGroupPreview(groupElement, children, markdown) { + // Store original HTML content if first preview + if (!this.originalContent && this.activeElement === groupElement) { + this.originalContent = children.map(child => child.innerHTML); + } + + // Apply preview styling to group + groupElement.classList.add('insertr-preview-active'); + + // Update elements with rendered HTML from markdown + markdownConverter.updateGroupElements(children, markdown); + + // Add preview styling to all children + children.forEach(child => { + child.classList.add('insertr-preview-active'); + }); + } + extractOriginalContent(element, elementType) { switch (elementType) { case 'link': @@ -131,11 +167,11 @@ class LivePreviewManager { if (!this.originalContent) return; if (Array.isArray(this.originalContent)) { - // Group element - restore children content + // Group element - restore children HTML content const children = Array.from(element.children); children.forEach((child, index) => { - if (this.originalContent[index]) { - child.textContent = this.originalContent[index]; + if (this.originalContent[index] !== undefined) { + child.innerHTML = this.originalContent[index]; } }); } else if (typeof this.originalContent === 'object') { @@ -328,37 +364,16 @@ export class InsertrFormRenderer { * Combine content from multiple child elements into markdown */ combineChildContent(children) { - const parts = []; - - children.forEach(child => { - const content = child.textContent.trim(); - if (content) { - parts.push(content); - } - }); - - // Join with double newlines to create paragraph separation in markdown - return parts.join('\n\n'); + // Use markdown converter to extract HTML and convert to markdown + return markdownConverter.extractGroupMarkdown(children); } /** - * Split markdown content back into individual element content + * Update elements with markdown content using proper HTML rendering */ - splitMarkdownContent(markdown, children) { - // Split on double newlines to get paragraphs - const paragraphs = markdown.split(/\n\s*\n/).filter(p => p.trim()); - const results = []; - - // Map paragraphs back to children - children.forEach((child, index) => { - const content = paragraphs[index] || ''; - results.push({ - element: child, - content: content.trim() - }); - }); - - return results; + updateElementsFromMarkdown(children, markdown) { + // Use markdown converter to render HTML and update elements + markdownConverter.updateGroupElements(children, markdown); } /** @@ -368,30 +383,33 @@ export class InsertrFormRenderer { const saveBtn = form.querySelector('.insertr-btn-save'); const cancelBtn = form.querySelector('.insertr-btn-cancel'); - // Setup live preview for markdown content + // Setup live preview for markdown content with debouncing const textarea = form.querySelector('textarea'); if (textarea) { textarea.addEventListener('input', () => { const markdown = textarea.value; - this.previewGroupContent(groupElement, children, markdown); + // Use the preview manager's debounced system for groups + this.previewManager.scheduleGroupPreview(groupElement, children, markdown); }); } if (saveBtn) { saveBtn.addEventListener('click', () => { const markdown = textarea.value; - const splitContent = this.splitMarkdownContent(markdown, children); - // Clear preview before saving - this.previewManager.clearPreview(groupElement); + // Update elements with final HTML rendering (don't clear preview first!) + this.updateElementsFromMarkdown(children, markdown); - // Update each child element - splitContent.forEach(({ element, content }) => { - if (content) { - element.textContent = content; - } + // Remove preview styling from group and children + groupElement.classList.remove('insertr-preview-active'); + children.forEach(child => { + child.classList.remove('insertr-preview-active'); }); + // Clear preview manager state but don't restore content + this.previewManager.activeElement = null; + this.previewManager.originalContent = null; + onSave({ text: markdown }); this.closeForm(); }); @@ -426,27 +444,7 @@ export class InsertrFormRenderer { }); } - /** - * Preview group content changes - */ - previewGroupContent(groupElement, children, markdown) { - // Store original content if first preview - if (!this.previewManager.originalContent && this.previewManager.activeElement === groupElement) { - this.previewManager.originalContent = children.map(child => child.textContent); - } - // Apply preview styling to group - groupElement.classList.add('insertr-preview-active'); - - // Split and preview content - const splitContent = this.splitMarkdownContent(markdown, children); - splitContent.forEach(({ element, content }) => { - if (content) { - element.textContent = content; - element.classList.add('insertr-preview-active'); - } - }); - } /** * Close current form diff --git a/lib/src/utils/markdown.js b/lib/src/utils/markdown.js new file mode 100644 index 0000000..c319b20 --- /dev/null +++ b/lib/src/utils/markdown.js @@ -0,0 +1,207 @@ +/** + * Markdown conversion utilities using Marked and Turndown + */ +import { marked } from 'marked'; +import TurndownService from 'turndown'; + +/** + * MarkdownConverter - Handles bidirectional HTML ↔ Markdown conversion + */ +export class MarkdownConverter { + constructor() { + this.initializeMarked(); + this.initializeTurndown(); + } + + /** + * Configure marked for HTML output + */ + initializeMarked() { + marked.setOptions({ + gfm: true, // GitHub Flavored Markdown + breaks: true, // Convert \n to
+ pedantic: false, // Don't be overly strict + sanitize: false, // Allow HTML (we control the input) + smartLists: true, // Smarter list behavior + smartypants: false // Don't convert quotes/dashes + }); + } + + /** + * Configure turndown for markdown output + */ + initializeTurndown() { + this.turndown = new TurndownService({ + headingStyle: 'atx', // # headers instead of underlines + hr: '---', // horizontal rule style + bulletListMarker: '-', // bullet list marker + codeBlockStyle: 'fenced', // ``` code blocks + fence: '```', // fence marker + emDelimiter: '*', // emphasis delimiter + strongDelimiter: '**', // strong delimiter + linkStyle: 'inlined', // [text](url) instead of reference style + linkReferenceStyle: 'full' // full reference links + }); + + // Add custom rules for better conversion + this.addTurndownRules(); + } + + /** + * Add custom turndown rules for better HTML → Markdown conversion + */ + addTurndownRules() { + // Handle paragraph spacing properly - ensure double newlines between paragraphs + this.turndown.addRule('paragraph', { + filter: 'p', + replacement: function (content) { + if (!content.trim()) return ''; + return content.trim() + '\n\n'; + } + }); + + // Handle bold text in markdown + this.turndown.addRule('bold', { + filter: ['strong', 'b'], + replacement: function (content) { + if (!content.trim()) return ''; + return '**' + content + '**'; + } + }); + + // Handle italic text in markdown + this.turndown.addRule('italic', { + filter: ['em', 'i'], + replacement: function (content) { + if (!content.trim()) return ''; + return '*' + content + '*'; + } + }); + } + + /** + * Convert HTML to Markdown + * @param {string} html - HTML string to convert + * @returns {string} - Markdown string + */ + htmlToMarkdown(html) { + if (!html || html.trim() === '') { + return ''; + } + + try { + const markdown = this.turndown.turndown(html); + // Clean up and normalize newlines for proper paragraph separation + return markdown + .replace(/\n{3,}/g, '\n\n') // Replace 3+ newlines with 2 + .replace(/^\n+|\n+$/g, '') // Remove leading/trailing newlines + .trim(); // Remove other whitespace + } catch (error) { + console.warn('HTML to Markdown conversion failed:', error); + // Fallback: extract text content + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + return tempDiv.textContent || tempDiv.innerText || ''; + } + } + + /** + * Convert Markdown to HTML + * @param {string} markdown - Markdown string to convert + * @returns {string} - HTML string + */ + markdownToHtml(markdown) { + if (!markdown || markdown.trim() === '') { + return ''; + } + + try { + const html = marked(markdown); + return html; + } catch (error) { + console.warn('Markdown to HTML conversion failed:', error); + // Fallback: convert line breaks to paragraphs + return markdown + .split(/\n\s*\n/) + .filter(p => p.trim()) + .map(p => `

${p.trim()}

`) + .join(''); + } + } + + /** + * Extract HTML content from a group of elements + * @param {HTMLElement[]} elements - Array of DOM elements + * @returns {string} - Combined HTML content + */ + extractGroupHTML(elements) { + const htmlParts = []; + + elements.forEach(element => { + // Wrap inner content in paragraph tags to preserve structure + const html = element.innerHTML.trim(); + if (html) { + // If element is already a paragraph, use its outer HTML + if (element.tagName.toLowerCase() === 'p') { + htmlParts.push(element.outerHTML); + } else { + // Wrap in paragraph tags + htmlParts.push(`

${html}

`); + } + } + }); + + return htmlParts.join('\n'); + } + + /** + * Convert HTML content from group elements to markdown + * @param {HTMLElement[]} elements - Array of DOM elements + * @returns {string} - Markdown representation + */ + extractGroupMarkdown(elements) { + const html = this.extractGroupHTML(elements); + const markdown = this.htmlToMarkdown(html); + return markdown; + } + + /** + * Update group elements with markdown content + * @param {HTMLElement[]} elements - Array of DOM elements to update + * @param {string} markdown - Markdown content to render + */ + updateGroupElements(elements, markdown) { + const html = this.markdownToHtml(markdown); + + // Split HTML into paragraphs + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + const paragraphs = Array.from(tempDiv.querySelectorAll('p, div, h1, h2, h3, h4, h5, h6')); + + // Handle case where we have more/fewer paragraphs than elements + const maxCount = Math.max(elements.length, paragraphs.length); + + for (let i = 0; i < maxCount; i++) { + if (i < elements.length && i < paragraphs.length) { + // Update existing element with corresponding paragraph + elements[i].innerHTML = paragraphs[i].innerHTML; + } else if (i < elements.length) { + // More elements than paragraphs - clear extra elements + elements[i].innerHTML = ''; + } else if (i < paragraphs.length) { + // More paragraphs than elements - create new element + const newElement = document.createElement('p'); + newElement.innerHTML = paragraphs[i].innerHTML; + + // Insert after the last existing element + const lastElement = elements[elements.length - 1]; + lastElement.parentNode.insertBefore(newElement, lastElement.nextSibling); + elements.push(newElement); // Add to our elements array for future updates + } + } + } +} + +// Export singleton instance +export const markdownConverter = new MarkdownConverter(); \ No newline at end of file