feat: Implement HTML-first style preservation system
- Add StyleContext class for extracting and applying HTML attributes/styles - Enhance MarkdownConverter with style-aware conversion methods - Switch backend storage from markdown to HTML with 'html' content type - Update editor workflow to preserve CSS classes, IDs, and attributes - Maintain markdown editing UX while storing HTML for style preservation - Support complex attributes like rel, data-*, aria-*, etc. This enables editing styled content like <a class="fancy" rel="me">text</a> while preserving all styling attributes through the markdown editing process.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Markdown conversion utilities using Marked and Turndown
|
||||
* Markdown conversion utilities using Marked and Turndown with Style Preservation
|
||||
*/
|
||||
import { marked } from 'marked';
|
||||
import TurndownService from 'turndown';
|
||||
import { StyleContext } from './style-context.js';
|
||||
|
||||
/**
|
||||
* MarkdownConverter - Handles bidirectional HTML ↔ Markdown conversion
|
||||
@@ -11,6 +12,7 @@ export class MarkdownConverter {
|
||||
constructor() {
|
||||
this.initializeMarked();
|
||||
this.initializeTurndown();
|
||||
this.styleContext = new StyleContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +188,37 @@ export class MarkdownConverter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HTML to Markdown with style context preservation
|
||||
* @param {string} html - HTML string to convert
|
||||
* @param {HTMLElement} originalElement - Original DOM element for context
|
||||
* @returns {Object} - Object containing markdown and style context
|
||||
*/
|
||||
htmlToMarkdownWithContext(html, originalElement = null) {
|
||||
if (!html || html.trim() === '') {
|
||||
return { markdown: '', styleContext: null };
|
||||
}
|
||||
|
||||
let styleContext = null;
|
||||
|
||||
// Extract style context if original element provided
|
||||
if (originalElement) {
|
||||
styleContext = this.styleContext.extractStyleContext(originalElement);
|
||||
} else {
|
||||
// Create temporary element to analyze
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
styleContext = this.styleContext.extractStyleContext(tempDiv);
|
||||
}
|
||||
|
||||
const markdown = this.htmlToMarkdown(html);
|
||||
|
||||
return {
|
||||
markdown,
|
||||
styleContext: styleContext.hasPreservableContent ? styleContext : null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Markdown to HTML
|
||||
* @param {string} markdown - Markdown string to convert
|
||||
@@ -210,6 +243,51 @@ export class MarkdownConverter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Markdown to HTML with style context restoration
|
||||
* @param {string} markdown - Markdown string to convert
|
||||
* @param {Object} styleContext - Style context to restore
|
||||
* @returns {string} - HTML string with styles restored
|
||||
*/
|
||||
markdownToHtmlWithStyles(markdown, styleContext) {
|
||||
if (!markdown || markdown.trim() === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert markdown to basic HTML first
|
||||
const basicHtml = this.markdownToHtml(markdown);
|
||||
|
||||
// If no style context, return basic HTML
|
||||
if (!styleContext || !this.styleContext.validateContext(styleContext)) {
|
||||
return basicHtml;
|
||||
}
|
||||
|
||||
// Apply style context to the converted HTML
|
||||
return this.applyStyleContextToHtml(basicHtml, styleContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply style context to HTML string
|
||||
* @param {string} html - HTML string to enhance
|
||||
* @param {Object} styleContext - Style context to apply
|
||||
* @returns {string} - Enhanced HTML with styles applied
|
||||
*/
|
||||
applyStyleContextToHtml(html, styleContext) {
|
||||
try {
|
||||
// Create temporary container
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
// Apply style context
|
||||
this.styleContext.applyStyleContext(tempDiv, styleContext);
|
||||
|
||||
return tempDiv.innerHTML;
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply style context:', error);
|
||||
return html; // Return original HTML on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract HTML content from a group of elements
|
||||
* @param {HTMLElement[]} elements - Array of DOM elements
|
||||
@@ -235,6 +313,73 @@ export class MarkdownConverter {
|
||||
return htmlParts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract HTML content with style context from a group of elements
|
||||
* @param {HTMLElement[]} elements - Array of DOM elements
|
||||
* @returns {Object} - Object with HTML content and combined style context
|
||||
*/
|
||||
extractGroupHTMLWithContext(elements) {
|
||||
const htmlParts = [];
|
||||
const allStyleContexts = [];
|
||||
|
||||
elements.forEach((element, index) => {
|
||||
// Extract style context for this element
|
||||
const elementContext = this.styleContext.extractStyleContext(element);
|
||||
if (elementContext.hasPreservableContent) {
|
||||
allStyleContexts.push({
|
||||
index,
|
||||
context: elementContext
|
||||
});
|
||||
}
|
||||
|
||||
// Extract HTML content
|
||||
const html = element.innerHTML.trim();
|
||||
if (html) {
|
||||
if (element.tagName.toLowerCase() === 'p') {
|
||||
htmlParts.push(element.outerHTML);
|
||||
} else {
|
||||
htmlParts.push(`<p>${html}</p>`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Combine all style contexts
|
||||
const combinedContext = this.combineStyleContexts(allStyleContexts);
|
||||
|
||||
return {
|
||||
html: htmlParts.join('\n'),
|
||||
styleContext: combinedContext
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine multiple style contexts into a single context
|
||||
* @param {Array} styleContexts - Array of style contexts with index info
|
||||
* @returns {Object} - Combined style context
|
||||
*/
|
||||
combineStyleContexts(styleContexts) {
|
||||
if (styleContexts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const combinedMap = new Map();
|
||||
let hasContent = false;
|
||||
|
||||
styleContexts.forEach(({ index, context }) => {
|
||||
// Adjust paths to include element index
|
||||
for (const [path, elementInfo] of context.elementMap) {
|
||||
const adjustedPath = `${index}.${path}`;
|
||||
combinedMap.set(adjustedPath, elementInfo);
|
||||
hasContent = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasContent ? {
|
||||
elementMap: combinedMap,
|
||||
hasPreservableContent: true
|
||||
} : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HTML content from group elements to markdown
|
||||
* @param {HTMLElement[]} elements - Array of DOM elements
|
||||
@@ -246,6 +391,21 @@ export class MarkdownConverter {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HTML content from group elements to markdown with style context
|
||||
* @param {HTMLElement[]} elements - Array of DOM elements
|
||||
* @returns {Object} - Object with markdown and style context
|
||||
*/
|
||||
extractGroupMarkdownWithContext(elements) {
|
||||
const { html, styleContext } = this.extractGroupHTMLWithContext(elements);
|
||||
const markdown = this.htmlToMarkdown(html);
|
||||
|
||||
return {
|
||||
markdown,
|
||||
styleContext
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group elements with markdown content
|
||||
* @param {HTMLElement[]} elements - Array of DOM elements to update
|
||||
@@ -282,6 +442,47 @@ export class MarkdownConverter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group elements with markdown content and style context
|
||||
* @param {HTMLElement[]} elements - Array of DOM elements to update
|
||||
* @param {string} markdown - Markdown content to render
|
||||
* @param {Object} styleContext - Style context to apply
|
||||
*/
|
||||
updateGroupElementsWithStyles(elements, markdown, styleContext) {
|
||||
// Convert markdown to HTML with styles
|
||||
const html = styleContext ?
|
||||
this.markdownToHtmlWithStyles(markdown, styleContext) :
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user