phase 1 and 2 of embedded images
This commit is contained in:
@@ -29,6 +29,7 @@ export default function AddStoryPage() {
|
||||
|
||||
const [coverImage, setCoverImage] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [processingImages, setProcessingImages] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<{
|
||||
show: boolean;
|
||||
@@ -250,9 +251,28 @@ export default function AddStoryPage() {
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Helper function to detect external images in HTML content
|
||||
const hasExternalImages = (htmlContent: string): boolean => {
|
||||
if (!htmlContent) return false;
|
||||
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
|
||||
const images = tempDiv.querySelectorAll('img');
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i];
|
||||
const src = img.getAttribute('src');
|
||||
if (src && (src.startsWith('http://') || src.startsWith('https://'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
@@ -275,7 +295,43 @@ export default function AddStoryPage() {
|
||||
};
|
||||
|
||||
const story = await storyApi.createStory(storyData);
|
||||
|
||||
|
||||
// Process images if there are external images in the content
|
||||
if (hasExternalImages(formData.contentHtml)) {
|
||||
try {
|
||||
setProcessingImages(true);
|
||||
const imageResult = await storyApi.processContentImages(story.id, formData.contentHtml);
|
||||
|
||||
// If images were processed and content was updated, save the updated content
|
||||
if (imageResult.processedContent !== formData.contentHtml) {
|
||||
await storyApi.updateStory(story.id, {
|
||||
title: formData.title,
|
||||
summary: formData.summary || undefined,
|
||||
contentHtml: imageResult.processedContent,
|
||||
sourceUrl: formData.sourceUrl || undefined,
|
||||
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
|
||||
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
|
||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
||||
});
|
||||
|
||||
// Show success message with image processing info
|
||||
if (imageResult.downloadedImages.length > 0) {
|
||||
console.log(`Successfully processed ${imageResult.downloadedImages.length} images`);
|
||||
}
|
||||
if (imageResult.warnings && imageResult.warnings.length > 0) {
|
||||
console.warn('Image processing warnings:', imageResult.warnings);
|
||||
}
|
||||
}
|
||||
} catch (imageError) {
|
||||
console.error('Failed to process images:', imageError);
|
||||
// Don't fail the entire operation if image processing fails
|
||||
// The story was created successfully, just without processed images
|
||||
} finally {
|
||||
setProcessingImages(false);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a cover image, upload it separately
|
||||
if (coverImage) {
|
||||
await storyApi.uploadCover(story.id, coverImage);
|
||||
@@ -404,7 +460,11 @@ export default function AddStoryPage() {
|
||||
onChange={handleContentChange}
|
||||
placeholder="Write or paste your story content here..."
|
||||
error={errors.contentHtml}
|
||||
enableImageProcessing={false}
|
||||
/>
|
||||
<p className="text-sm theme-text mt-2">
|
||||
💡 <strong>Tip:</strong> If you paste content with images, they'll be automatically downloaded and stored locally when you save the story.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
@@ -450,6 +510,18 @@ export default function AddStoryPage() {
|
||||
placeholder="https://example.com/original-story-url"
|
||||
/>
|
||||
|
||||
{/* Image Processing Indicator */}
|
||||
{processingImages && (
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
<p className="text-blue-800 dark:text-blue-200">
|
||||
Processing and downloading images...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Error */}
|
||||
{errors.submit && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
@@ -473,7 +545,7 @@ export default function AddStoryPage() {
|
||||
loading={loading}
|
||||
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
|
||||
>
|
||||
Add Story
|
||||
{processingImages ? 'Processing Images...' : 'Add Story'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -134,6 +134,27 @@
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
/* Image styling for story content */
|
||||
.reading-content img {
|
||||
@apply max-w-full h-auto mx-auto my-6 rounded-lg shadow-sm;
|
||||
max-height: 80vh; /* Prevent images from being too tall */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reading-content img[align="left"] {
|
||||
@apply float-left mr-4 mb-4 ml-0;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.reading-content img[align="right"] {
|
||||
@apply float-right ml-4 mb-4 mr-0;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.reading-content img[align="center"] {
|
||||
@apply block mx-auto;
|
||||
}
|
||||
|
||||
/* Editor content styling - same as reading content but for the rich text editor */
|
||||
.editor-content h1 {
|
||||
@apply text-2xl font-bold mt-8 mb-4 theme-header;
|
||||
@@ -183,4 +204,36 @@
|
||||
.editor-content em {
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
/* Image styling for editor content */
|
||||
.editor-content img {
|
||||
@apply max-w-full h-auto mx-auto my-4 rounded border;
|
||||
max-height: 60vh; /* Slightly smaller for editor */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editor-content img[align="left"] {
|
||||
@apply float-left mr-4 mb-4 ml-0;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.editor-content img[align="right"] {
|
||||
@apply float-right ml-4 mb-4 mr-0;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.editor-content img[align="center"] {
|
||||
@apply block mx-auto;
|
||||
}
|
||||
|
||||
/* Loading placeholder for images being processed */
|
||||
.image-processing-placeholder {
|
||||
@apply bg-gray-100 dark:bg-gray-800 animate-pulse rounded border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.image-processing-placeholder::before {
|
||||
content: "🖼️ Processing image...";
|
||||
@apply text-gray-500 dark:text-gray-400 text-sm;
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,8 @@ export default function EditStoryPage() {
|
||||
onChange={handleContentChange}
|
||||
placeholder="Edit your story content here..."
|
||||
error={errors.contentHtml}
|
||||
storyId={storyId}
|
||||
enableImageProcessing={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -120,18 +120,30 @@ export default function StoryReadingPage() {
|
||||
|
||||
// Sanitize story content and add IDs to headings
|
||||
const sanitized = await sanitizeHtml(storyData.contentHtml || '');
|
||||
|
||||
// Parse and add IDs to headings for TOC functionality
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(sanitized, 'text/html');
|
||||
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
heading.id = `heading-${index}`;
|
||||
});
|
||||
|
||||
setSanitizedContent(doc.body.innerHTML);
|
||||
setHasHeadings(headings.length > 0);
|
||||
|
||||
// Add IDs to headings for TOC functionality using regex instead of DOMParser
|
||||
// This avoids potential browser-specific sanitization that might strip src attributes
|
||||
let processedContent = sanitized;
|
||||
const headingMatches = processedContent.match(/<h[1-6][^>]*>/gi);
|
||||
let headingCount = 0;
|
||||
|
||||
if (headingMatches) {
|
||||
processedContent = processedContent.replace(/<h([1-6])([^>]*)>/gi, (match, level, attrs) => {
|
||||
const headingId = `heading-${headingCount++}`;
|
||||
|
||||
// Check if id attribute already exists
|
||||
if (attrs.includes('id=')) {
|
||||
// Replace existing id
|
||||
return match.replace(/id=['"][^'"]*['"]/, `id="${headingId}"`);
|
||||
} else {
|
||||
// Add id attribute
|
||||
return `<h${level}${attrs} id="${headingId}">`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSanitizedContent(processedContent);
|
||||
setHasHeadings(headingCount > 0);
|
||||
|
||||
// Load series stories if part of a series
|
||||
if (storyData.seriesId) {
|
||||
|
||||
@@ -4,19 +4,24 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Textarea } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
||||
import { storyApi } from '../../lib/api';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
storyId?: string; // Optional - for image processing (undefined for new stories)
|
||||
enableImageProcessing?: boolean; // Enable background image processing
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
export default function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Write your story here...',
|
||||
error
|
||||
error,
|
||||
storyId,
|
||||
enableImageProcessing = false
|
||||
}: RichTextEditorProps) {
|
||||
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
|
||||
const [htmlValue, setHtmlValue] = useState(value);
|
||||
@@ -28,6 +33,12 @@ export default function RichTextEditor({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserTyping, setIsUserTyping] = useState(false);
|
||||
|
||||
// Image processing state
|
||||
const [imageProcessingQueue, setImageProcessingQueue] = useState<string[]>([]);
|
||||
const [processedImages, setProcessedImages] = useState<Set<string>>(new Set());
|
||||
const [imageWarnings, setImageWarnings] = useState<string[]>([]);
|
||||
const imageProcessingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Utility functions for cursor position preservation
|
||||
const saveCursorPosition = () => {
|
||||
const selection = window.getSelection();
|
||||
@@ -63,6 +74,82 @@ export default function RichTextEditor({
|
||||
}
|
||||
};
|
||||
|
||||
// Image processing functionality
|
||||
const findImageUrlsInHtml = (html: string): string[] => {
|
||||
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||
const urls: string[] = [];
|
||||
let match;
|
||||
while ((match = imgRegex.exec(html)) !== null) {
|
||||
const url = match[1];
|
||||
// Skip local URLs and data URLs
|
||||
if (!url.startsWith('/') && !url.startsWith('data:')) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
};
|
||||
|
||||
const processContentImagesDebounced = useCallback(async (content: string) => {
|
||||
if (!enableImageProcessing || !storyId) return;
|
||||
|
||||
const imageUrls = findImageUrlsInHtml(content);
|
||||
if (imageUrls.length === 0) return;
|
||||
|
||||
// Find new URLs that haven't been processed yet
|
||||
const newUrls = imageUrls.filter(url => !processedImages.has(url));
|
||||
if (newUrls.length === 0) return;
|
||||
|
||||
// Add to processing queue
|
||||
setImageProcessingQueue(prev => [...prev, ...newUrls]);
|
||||
|
||||
try {
|
||||
// Call the API to process images
|
||||
const result = await storyApi.processContentImages(storyId, content);
|
||||
|
||||
// Mark URLs as processed
|
||||
setProcessedImages(prev => new Set([...Array.from(prev), ...newUrls]));
|
||||
|
||||
// Remove from processing queue
|
||||
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
|
||||
|
||||
// Update content with processed images
|
||||
if (result.processedContent !== content) {
|
||||
onChange(result.processedContent);
|
||||
setHtmlValue(result.processedContent);
|
||||
}
|
||||
|
||||
// Handle warnings
|
||||
if (result.hasWarnings && result.warnings) {
|
||||
setImageWarnings(prev => [...prev, ...result.warnings!]);
|
||||
// Show brief warning notification - could be enhanced with a toast system
|
||||
console.warn('Image processing warnings:', result.warnings);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to process content images:', error);
|
||||
// Remove failed URLs from queue
|
||||
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
|
||||
|
||||
// Show error message - could be enhanced with user notification
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
setImageWarnings(prev => [...prev, `Failed to process some images: ${errorMessage}`]);
|
||||
}
|
||||
}, [enableImageProcessing, storyId, processedImages, onChange]);
|
||||
|
||||
const triggerImageProcessing = useCallback((content: string) => {
|
||||
if (!enableImageProcessing || !storyId) return;
|
||||
|
||||
// Clear existing timeout
|
||||
if (imageProcessingTimeoutRef.current) {
|
||||
clearTimeout(imageProcessingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout to process after user stops typing
|
||||
imageProcessingTimeoutRef.current = setTimeout(() => {
|
||||
processContentImagesDebounced(content);
|
||||
}, 2000); // Wait 2 seconds after user stops typing
|
||||
}, [enableImageProcessing, storyId, processContentImagesDebounced]);
|
||||
|
||||
// Maximize/minimize functionality
|
||||
const toggleMaximize = () => {
|
||||
if (!isMaximized) {
|
||||
@@ -120,9 +207,13 @@ export default function RichTextEditor({
|
||||
|
||||
// Update the state
|
||||
setIsUserTyping(true);
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
const newContent = visualDiv.innerHTML;
|
||||
onChange(newContent);
|
||||
setHtmlValue(newContent);
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
|
||||
// Trigger image processing if enabled
|
||||
triggerImageProcessing(newContent);
|
||||
}
|
||||
} else {
|
||||
// HTML mode - existing logic with improvements
|
||||
@@ -244,6 +335,15 @@ export default function RichTextEditor({
|
||||
};
|
||||
}, [isMaximized, formatText]);
|
||||
|
||||
// Cleanup image processing timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageProcessingTimeoutRef.current) {
|
||||
clearTimeout(imageProcessingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set initial content when component mounts
|
||||
useEffect(() => {
|
||||
const div = visualDivRef.current;
|
||||
@@ -284,6 +384,8 @@ export default function RichTextEditor({
|
||||
if (newHtml !== value) {
|
||||
onChange(newHtml);
|
||||
setHtmlValue(newHtml);
|
||||
// Trigger image processing if enabled
|
||||
triggerImageProcessing(newHtml);
|
||||
}
|
||||
|
||||
// Reset typing state after a short delay
|
||||
@@ -357,13 +459,38 @@ export default function RichTextEditor({
|
||||
if (htmlContent && htmlContent.trim().length > 0) {
|
||||
console.log('Processing HTML content...');
|
||||
console.log('Raw HTML:', htmlContent.substring(0, 500));
|
||||
|
||||
const sanitizedHtml = sanitizeHtmlSync(htmlContent);
|
||||
|
||||
// Check if we have embedded images and image processing is enabled
|
||||
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(htmlContent);
|
||||
let processedHtml = htmlContent;
|
||||
|
||||
if (hasImages && enableImageProcessing && storyId) {
|
||||
console.log('Found images in pasted content, processing before sanitization...');
|
||||
try {
|
||||
// Process images synchronously before sanitization
|
||||
const result = await storyApi.processContentImages(storyId, htmlContent);
|
||||
processedHtml = result.processedContent;
|
||||
console.log('Image processing completed, processed content length:', processedHtml.length);
|
||||
|
||||
// Update image processing state
|
||||
if (result.downloadedImages && result.downloadedImages.length > 0) {
|
||||
setProcessedImages(prev => new Set([...Array.from(prev), ...result.downloadedImages]));
|
||||
}
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
setImageWarnings(prev => [...prev, ...result.warnings!]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Image processing failed during paste:', error);
|
||||
// Continue with original content if image processing fails
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedHtml = sanitizeHtmlSync(processedHtml);
|
||||
console.log('Sanitized HTML length:', sanitizedHtml.length);
|
||||
console.log('Sanitized HTML preview:', sanitizedHtml.substring(0, 500));
|
||||
|
||||
|
||||
// Check if sanitization removed too much content
|
||||
const ratio = sanitizedHtml.length / htmlContent.length;
|
||||
const ratio = sanitizedHtml.length / processedHtml.length;
|
||||
console.log('Sanitization ratio (kept/original):', ratio.toFixed(3));
|
||||
if (ratio < 0.1) {
|
||||
console.warn('Sanitization removed >90% of content - this might be too aggressive');
|
||||
@@ -404,9 +531,12 @@ export default function RichTextEditor({
|
||||
|
||||
// Update the state
|
||||
setIsUserTyping(true);
|
||||
onChange(visualDiv.innerHTML);
|
||||
setHtmlValue(visualDiv.innerHTML);
|
||||
const newContent = visualDiv.innerHTML;
|
||||
onChange(newContent);
|
||||
setHtmlValue(newContent);
|
||||
setTimeout(() => setIsUserTyping(false), 100);
|
||||
|
||||
// Note: Image processing already completed during paste, no need to trigger again
|
||||
} else if (textarea) {
|
||||
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
|
||||
const start = textarea.selectionStart;
|
||||
@@ -493,6 +623,9 @@ export default function RichTextEditor({
|
||||
const html = e.target.value;
|
||||
setHtmlValue(html);
|
||||
onChange(html);
|
||||
|
||||
// Trigger image processing if enabled
|
||||
triggerImageProcessing(html);
|
||||
};
|
||||
|
||||
const getPlainText = (html: string): string => {
|
||||
@@ -532,6 +665,24 @@ export default function RichTextEditor({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Image processing status indicator */}
|
||||
{enableImageProcessing && (
|
||||
<>
|
||||
{imageProcessingQueue.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 mr-2">
|
||||
<div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span>Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...</span>
|
||||
</div>
|
||||
)}
|
||||
{imageWarnings.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 mr-2" title={imageWarnings.join('\n')}>
|
||||
<span>⚠️</span>
|
||||
<span>{imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -673,6 +824,24 @@ export default function RichTextEditor({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Image processing status indicator */}
|
||||
{enableImageProcessing && (
|
||||
<>
|
||||
{imageProcessingQueue.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 mr-2">
|
||||
<div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span>Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...</span>
|
||||
</div>
|
||||
)}
|
||||
{imageWarnings.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 mr-2" title={imageWarnings.join('\n')}>
|
||||
<span>⚠️</span>
|
||||
<span>{imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -152,6 +152,18 @@ export const storyApi = {
|
||||
await api.delete(`/stories/${id}/cover`);
|
||||
},
|
||||
|
||||
processContentImages: async (id: string, htmlContent: string): Promise<{
|
||||
processedContent: string;
|
||||
warnings?: string[];
|
||||
downloadedImages: string[];
|
||||
hasWarnings: boolean;
|
||||
}> => {
|
||||
const response = await api.post(`/stories/${id}/process-content-images`, {
|
||||
htmlContent
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addTag: async (storyId: string, tagId: string): Promise<Story> => {
|
||||
const response = await api.post(`/stories/${storyId}/tags/${tagId}`);
|
||||
return response.data;
|
||||
|
||||
@@ -72,7 +72,7 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
||||
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
||||
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'img',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
|
||||
'blockquote', 'cite', 'q', 'hr'
|
||||
],
|
||||
@@ -87,6 +87,7 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
||||
'h5': ['class', 'style'],
|
||||
'h6': ['class', 'style'],
|
||||
'a': ['class'],
|
||||
'img': ['src', 'alt', 'width', 'height', 'class', 'style'],
|
||||
'table': ['class'],
|
||||
'td': ['class', 'colspan', 'rowspan'],
|
||||
'th': ['class', 'colspan', 'rowspan']
|
||||
@@ -99,6 +100,9 @@ async function fetchSanitizationConfig(): Promise<SanitizationConfig> {
|
||||
allowedProtocols: {
|
||||
'a': {
|
||||
'href': ['http', 'https', '#', '/']
|
||||
},
|
||||
'img': {
|
||||
'src': ['http', 'https', 'data', '/']
|
||||
}
|
||||
},
|
||||
description: 'Fallback sanitization configuration'
|
||||
@@ -237,12 +241,12 @@ export function sanitizeHtmlSync(html: string): string {
|
||||
'p', 'br', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins',
|
||||
'sup', 'sub', 'small', 'big', 'mark', 'pre', 'code', 'kbd', 'samp', 'var',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'img',
|
||||
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col',
|
||||
'blockquote', 'cite', 'q', 'hr', 'details', 'summary'
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'class', 'style', 'colspan', 'rowspan'
|
||||
'class', 'style', 'colspan', 'rowspan', 'src', 'alt', 'width', 'height'
|
||||
],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
SANITIZE_DOM: true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user