feat: unify link editing interfaces with comprehensive polish

Multi-Property Editor Polish:
- Add comprehensive form styling (.insertr-form-group, .insertr-form-input, etc.)
- Professional layout with titles, validation, auto-focus, and help text
- Enhanced link/button/image editors with real-time validation
- Consistent spacing, colors, and visual hierarchy

Smart Default Formatting:
- Add Bold, Italic, Link options when not detected in content
- Intelligent detection respects existing developer styles
- Visual distinction for default vs detected styles with info-colored borders
- Content-aware: only adds to elements that benefit from text formatting

Link Interface Unification:
- Create shared createLinkConfigurationForm() component
- Eliminate code duplication between direct editing and popup creation
- Update createLinkEditor() and showLinkConfigPopup() to use shared component
- Fix link button styling to match other style buttons with preview content

Benefits:
- Consistent professional editing experience across all interfaces
- Reduced maintenance burden through code unification
- Enhanced UX with validation, keyboard shortcuts, and visual feedback
- Maintains CLASSES.md philosophy while improving out-of-box experience
This commit is contained in:
2025-09-21 20:47:22 +02:00
parent b75eda2a87
commit d44bdd41b4
3 changed files with 760 additions and 279 deletions

View File

@@ -442,6 +442,46 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* Default formatting style buttons */
.insertr-style-btn.insertr-default-style {
border-color: var(--insertr-info);
background: rgba(23, 162, 184, 0.1);
position: relative;
}
.insertr-style-btn.insertr-default-style:hover {
border-color: var(--insertr-info);
background: rgba(23, 162, 184, 0.2);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(23, 162, 184, 0.3);
}
.insertr-style-btn.insertr-default-style:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(23, 162, 184, 0.2);
}
/* Default preview content styling */
.insertr-default-preview {
font-size: var(--insertr-font-size-sm);
font-weight: 500;
padding: var(--insertr-spacing-xs) var(--insertr-spacing-sm);
display: inline-block;
}
/* Small indicator for default styles */
.insertr-style-btn.insertr-default-style::after {
content: '';
position: absolute;
top: 2px;
right: 2px;
width: 6px;
height: 6px;
background: var(--insertr-info);
border-radius: 50%;
opacity: 0.7;
}
/* Editor components */
.insertr-simple-editor,
.insertr-rich-editor,
@@ -479,7 +519,126 @@ body:not(.insertr-edit-mode) .insertr-editing-hover::after {
overflow-y: auto;
}
/* =================================================================
MULTI-PROPERTY FORM COMPONENTS
Professional form styling for direct editors (links, buttons, images)
================================================================= */
/* Direct editor container */
.insertr-direct-editor {
background: var(--insertr-bg-primary);
border: 1px solid var(--insertr-border-color);
border-radius: var(--insertr-border-radius);
padding: var(--insertr-spacing-lg);
min-width: 400px;
max-width: 600px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
font-family: var(--insertr-font-family);
color: var(--insertr-text-primary);
}
/* Form groups */
.insertr-form-group {
margin-bottom: var(--insertr-spacing-md);
}
.insertr-form-group:last-child {
margin-bottom: 0;
}
/* Form labels */
.insertr-form-label {
display: block;
margin-bottom: var(--insertr-spacing-xs);
font-weight: 500;
font-size: var(--insertr-font-size-sm);
color: var(--insertr-text-primary);
line-height: 1.4;
}
/* Form inputs and selects */
.insertr-form-input,
.insertr-form-select {
width: 100%;
padding: var(--insertr-spacing-sm) var(--insertr-spacing-md);
border: 1px solid var(--insertr-border-color);
border-radius: var(--insertr-border-radius);
font-size: var(--insertr-font-size-base);
font-family: var(--insertr-font-family);
line-height: 1.4;
color: var(--insertr-text-primary);
background: var(--insertr-bg-primary);
transition: var(--insertr-transition);
box-sizing: border-box;
}
/* Focus states */
.insertr-form-input:focus,
.insertr-form-select:focus {
outline: none;
border-color: var(--insertr-primary);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
/* Hover states for selects */
.insertr-form-select:hover {
border-color: var(--insertr-text-secondary);
}
/* Disabled states */
.insertr-form-input:disabled,
.insertr-form-select:disabled {
background: var(--insertr-bg-secondary);
color: var(--insertr-text-muted);
cursor: not-allowed;
opacity: 0.6;
}
/* Error states */
.insertr-form-input.insertr-error,
.insertr-form-select.insertr-error {
border-color: var(--insertr-danger);
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25);
}
/* Success states */
.insertr-form-input.insertr-success,
.insertr-form-select.insertr-success {
border-color: var(--insertr-success);
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.25);
}
/* Form validation messages */
.insertr-form-message {
margin-top: var(--insertr-spacing-xs);
font-size: var(--insertr-font-size-sm);
line-height: 1.3;
}
.insertr-form-message.insertr-error {
color: var(--insertr-danger);
}
.insertr-form-message.insertr-success {
color: var(--insertr-success);
}
.insertr-form-message.insertr-info {
color: var(--insertr-info);
}
/* Specific editor variants */
.insertr-link-editor {
/* Link-specific styling if needed */
}
.insertr-button-editor {
/* Button-specific styling if needed */
}
.insertr-image-editor {
/* Image-specific styling if needed */
}
/* Form actions */
.insertr-form-actions {

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,6 @@
/**
* StyleDetectionEngine - Analyzes elements for nested styled children with position preservation
*
* Implements the "one layer deep" analysis described in CLASSES.md line 27:
* "Only direct child elements are analyzed and preserved"
*
* Purpose: Extract styled nested elements as formatting options AND preserve their positions
*/
@@ -21,6 +18,7 @@ export class StyleDetectionEngine {
/**
* Analyze element for nested styled elements AND their positions (CLASSES.md line 26-29)
* Returns both detected styles and structured content that preserves positions
* Enhanced with smart default formatting options when not present
*
* @param {HTMLElement} element - The .insertr element to analyze
* @returns {Object} - {styles: Map, structure: Array}
@@ -32,23 +30,15 @@ export class StyleDetectionEngine {
// Parse the element's content while preserving structure
this.parseContentStructure(element, styleMap, contentStructure);
// Add smart default formatting options if not already present
this.addSmartDefaults(styleMap, element);
return {
styles: styleMap,
structure: contentStructure
};
}
/**
* Legacy method for backward compatibility - only returns styles
*
* @param {HTMLElement} element - The .insertr element to analyze
* @returns {Map} - Map of styleId -> styleInfo objects
*/
detectStyles(element) {
const result = this.detectStylesAndStructure(element);
return result.styles;
}
/**
* Parse content structure while collecting style information
* Creates a structure array that preserves text and styled element positions
@@ -71,14 +61,14 @@ export class StyleDetectionEngine {
} else if (node.nodeType === (typeof Node !== 'undefined' ? Node.ELEMENT_NODE : NODE_TYPES.ELEMENT_NODE)) {
// Element node - analyze for styling and extract editable properties
const styleInfo = this.analyzeElement(node);
if (styleInfo) {
// Styled element - add to both style map and structure
styleMap.set(styleInfo.id, styleInfo);
// Extract all editable properties for this element
const editableProperties = this.extractEditableProperties(node);
structure.push({
type: 'styled',
styleId: styleInfo.id,
@@ -88,7 +78,7 @@ export class StyleDetectionEngine {
} else {
// Unstyled element - treat as text
structure.push({
type: 'text',
type: 'text',
content: node.textContent
});
}
@@ -106,10 +96,10 @@ export class StyleDetectionEngine {
extractEditableProperties(element) {
const tagName = element.tagName.toLowerCase();
const properties = {};
// Always include text content as the primary editable property
properties.content = element.textContent;
// Add element-specific editable properties
switch (tagName) {
case 'a':
@@ -119,13 +109,13 @@ export class StyleDetectionEngine {
properties.target = element.getAttribute('target');
}
break;
case 'img':
properties.src = element.src || '';
properties.alt = element.alt || '';
properties.title = element.title || '';
break;
case 'button':
properties.content = element.textContent;
if (element.type) {
@@ -135,7 +125,7 @@ export class StyleDetectionEngine {
properties.disabled = element.disabled;
}
break;
case 'input':
properties.value = element.value || '';
properties.placeholder = element.placeholder || '';
@@ -143,12 +133,12 @@ export class StyleDetectionEngine {
properties.type = element.type;
}
break;
default:
// For other elements, content is the main editable property
break;
}
return properties;
}
@@ -162,16 +152,16 @@ export class StyleDetectionEngine {
const tagName = element.tagName.toLowerCase();
const classes = Array.from(element.classList);
const attributes = this.extractElementAttributes(element);
// Skip elements without styling (no classes or special attributes)
if (classes.length === 0 && !this.hasSignificantAttributes(attributes)) {
return null;
}
// Generate unique style ID and human-readable name
const styleId = this.generateStyleId(tagName, classes, attributes);
const styleName = this.generateStyleName(tagName, classes, attributes);
return {
id: styleId,
name: styleName,
@@ -193,18 +183,18 @@ export class StyleDetectionEngine {
extractElementAttributes(element) {
const attributes = {};
const skipAttributes = new Set(['class', 'id']); // These are handled separately
for (const attr of element.attributes) {
if (!skipAttributes.has(attr.name)) {
attributes[attr.name] = attr.value;
}
}
// Include ID if present (it's significant for styling)
if (element.id) {
attributes.id = element.id;
}
return attributes;
}
@@ -216,8 +206,8 @@ export class StyleDetectionEngine {
*/
hasSignificantAttributes(attributes) {
// Consider data-*, aria-*, href, rel, target, etc. as significant
return Object.keys(attributes).some(key =>
key.startsWith('data-') ||
return Object.keys(attributes).some(key =>
key.startsWith('data-') ||
key.startsWith('aria-') ||
key === 'href' ||
key === 'rel' ||
@@ -255,17 +245,17 @@ export class StyleDetectionEngine {
if (this.styleNameMappings.has(key)) {
return this.styleNameMappings.get(key);
}
// Generate name from class names
if (classes.length > 0) {
return this.classesToDisplayName(classes);
}
// Generate name from tag + attributes
if (attributes.id) {
return this.tagToDisplayName(tagName) + ' (' + attributes.id + ')';
}
// Fallback to tag name
return this.tagToDisplayName(tagName);
}
@@ -276,7 +266,7 @@ export class StyleDetectionEngine {
*/
initializeStyleMappings() {
const mappings = new Map();
// From demo examples in simple/index.html
mappings.set('strong.emph', 'Emphasis');
mappings.set('strong.brand', 'Brand');
@@ -289,14 +279,14 @@ export class StyleDetectionEngine {
mappings.set('blockquote.testimonial', 'Testimonial');
mappings.set('i.icon-home', 'Home Icon');
mappings.set('i.icon-info', 'Info Icon');
// Common patterns
mappings.set('strong.highlight', 'Highlight Bold');
mappings.set('span.brand', 'Brand Style');
mappings.set('a.button', 'Button Link');
mappings.set('span.tag', 'Tag');
mappings.set('span.badge', 'Badge');
return mappings;
}
@@ -324,7 +314,7 @@ export class StyleDetectionEngine {
tagToDisplayName(tagName) {
const tagMappings = {
'strong': 'Bold',
'em': 'Italic',
'em': 'Italic',
'span': 'Style',
'a': 'Link',
'button': 'Button',
@@ -332,7 +322,7 @@ export class StyleDetectionEngine {
'code': 'Code',
'blockquote': 'Quote'
};
return tagMappings[tagName] || tagName.charAt(0).toUpperCase() + tagName.slice(1);
}
@@ -346,19 +336,19 @@ export class StyleDetectionEngine {
extractTemplate(element) {
const tagName = element.tagName.toLowerCase();
const clone = element.cloneNode(false); // Clone without children
// Create template with placeholders for different element types
const template = {
tagName: tagName,
attributes: {},
editableProperties: []
};
// Copy all attributes
for (const attr of element.attributes) {
template.attributes[attr.name] = attr.value;
}
// Define editable properties and their placeholders
switch (tagName) {
case 'a':
@@ -369,7 +359,7 @@ export class StyleDetectionEngine {
}
clone.textContent = '{{CONTENT}}';
break;
case 'img':
template.editableProperties = ['src', 'alt', 'title'];
template.attributes.src = '{{SRC}}';
@@ -378,12 +368,12 @@ export class StyleDetectionEngine {
template.attributes.title = '{{TITLE}}';
}
break;
case 'button':
template.editableProperties = ['content'];
clone.textContent = '{{CONTENT}}';
break;
case 'input':
template.editableProperties = ['value', 'placeholder'];
if (template.attributes.value !== undefined) {
@@ -393,17 +383,17 @@ export class StyleDetectionEngine {
template.attributes.placeholder = '{{PLACEHOLDER}}';
}
break;
default:
// Default: only content is editable
template.editableProperties = ['content'];
clone.textContent = '{{CONTENT}}';
break;
}
// Store both the structured template and the HTML template for backward compatibility
template.html = clone.outerHTML;
return template;
}
@@ -417,10 +407,10 @@ export class StyleDetectionEngine {
*/
createElementFromTemplate(styleInfo, properties) {
const element = document.createElement(styleInfo.tagName);
// Apply classes
styleInfo.classes.forEach(cls => element.classList.add(cls));
// Apply base attributes (non-editable ones)
Object.entries(styleInfo.attributes).forEach(([key, value]) => {
// Skip attributes that will be set from properties
@@ -428,7 +418,7 @@ export class StyleDetectionEngine {
element.setAttribute(key, value);
}
});
// Handle properties - support both object and string (backward compatibility)
if (typeof properties === 'string') {
// Legacy support: treat as text content
@@ -437,7 +427,7 @@ export class StyleDetectionEngine {
// New multi-property support
this.applyPropertiesToElement(element, properties, styleInfo.tagName);
}
return element;
}
@@ -455,7 +445,7 @@ export class StyleDetectionEngine {
'input': ['value', 'placeholder'],
'button': []
};
return (editableAttributes[tagName] || []).includes(attributeName);
}
@@ -482,7 +472,7 @@ export class StyleDetectionEngine {
element.target = properties.target;
}
break;
case 'img':
if (properties.src !== undefined) {
element.src = properties.src;
@@ -494,7 +484,7 @@ export class StyleDetectionEngine {
element.title = properties.title;
}
break;
case 'button':
if (properties.content !== undefined) {
element.textContent = properties.content;
@@ -506,7 +496,7 @@ export class StyleDetectionEngine {
element.disabled = properties.disabled;
}
break;
case 'input':
if (properties.value !== undefined) {
element.value = properties.value;
@@ -518,7 +508,7 @@ export class StyleDetectionEngine {
element.type = properties.type;
}
break;
default:
// Default: set text content
if (properties.content !== undefined) {
@@ -561,7 +551,7 @@ export class StyleDetectionEngine {
*/
reconstructHTML(structure, styles, updatedProperties = {}) {
let html = '';
structure.forEach((piece, index) => {
if (piece.type === 'text') {
// Check if any styles are applied to this text piece
@@ -578,7 +568,7 @@ export class StyleDetectionEngine {
} else if (piece.type === 'styled') {
// Use updated properties or original properties
const properties = updatedProperties[index]?.properties || piece.properties;
if (styles.has(piece.styleId)) {
const styleInfo = styles.get(piece.styleId);
const styledElement = this.createElementFromTemplate(styleInfo, properties);
@@ -590,10 +580,88 @@ export class StyleDetectionEngine {
}
}
});
return html;
}
/**
* Add smart default formatting options when not already present
* Provides essential formatting (bold, italic, link) if developer hasn't defined them
*
* @param {Map} styleMap - Existing detected styles
* @param {HTMLElement} element - The element being analyzed
*/
addSmartDefaults(styleMap, element) {
// Only add defaults for content elements that benefit from text formatting
const contentTags = new Set(['p', 'div', 'section', 'article', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'th', 'blockquote']);
if (!contentTags.has(element.tagName.toLowerCase())) {
return; // Skip for non-content elements
}
// Check what's already available
const hasStrong = this.hasStyleTag(styleMap, 'strong');
const hasEm = this.hasStyleTag(styleMap, 'em');
const hasB = this.hasStyleTag(styleMap, 'b');
const hasI = this.hasStyleTag(styleMap, 'i');
const hasA = this.hasStyleTag(styleMap, 'a');
// Add Bold if not present (prefer <strong> for semantics)
if (!hasStrong && !hasB) {
styleMap.set('default-strong', {
name: 'Bold',
tagName: 'strong',
classes: [],
attributes: {},
element: null, // Virtual element - no DOM reference
isDefault: true,
description: 'Make text bold for emphasis'
});
}
// Add Italic if not present (prefer <em> for semantics)
if (!hasEm && !hasI) {
styleMap.set('default-em', {
name: 'Italic',
tagName: 'em',
classes: [],
attributes: {},
element: null,
isDefault: true,
description: 'Make text italic for emphasis'
});
}
// Add Link if not present
if (!hasA) {
styleMap.set('default-a', {
name: 'Link',
tagName: 'a',
classes: [],
attributes: { href: '' },
element: null,
isDefault: true,
description: 'Create a hyperlink'
});
}
}
/**
* Check if styleMap already contains a specific tag type
*
* @param {Map} styleMap - Style map to check
* @param {string} tagName - Tag name to look for
* @returns {boolean} - True if tag is already present
*/
hasStyleTag(styleMap, tagName) {
for (const [, styleInfo] of styleMap) {
if (styleInfo.tagName === tagName) {
return true;
}
}
return false;
}
/**
* Extract plain text from structure while preserving order
*
@@ -624,12 +692,12 @@ export class StyleDetectionEngine {
// Simple approach: if text length matches, assume same structure
// More sophisticated approaches could use diff algorithms
const originalText = this.extractTextFromStructure(originalStructure);
if (newText === originalText) {
// No changes - return original structure
return originalStructure;
}
// For now, create simple text structure
// TODO: Implement smarter text-to-structure mapping
return [{
@@ -640,4 +708,4 @@ export class StyleDetectionEngine {
}
// Export singleton instance
export const styleDetectionEngine = new StyleDetectionEngine();
export const styleDetectionEngine = new StyleDetectionEngine();