phase 1 and 2 of embedded images

This commit is contained in:
Stefan Hardegger
2025-09-16 14:58:50 +02:00
parent c92308c24a
commit c7b516be31
14 changed files with 686 additions and 54 deletions

View File

@@ -229,6 +229,38 @@ public class StoryController {
return ResponseEntity.ok(convertToDto(story));
}
@PostMapping("/{id}/process-content-images")
public ResponseEntity<Map<String, Object>> processContentImages(@PathVariable UUID id, @RequestBody ProcessContentImagesRequest request) {
logger.info("Processing content images for story {}", id);
try {
// Process the HTML content to download and replace image URLs
ImageService.ContentImageProcessingResult result = imageService.processContentImages(request.getHtmlContent(), id);
// If there are warnings, let the client decide whether to proceed
if (result.hasWarnings()) {
return ResponseEntity.ok(Map.of(
"processedContent", result.getProcessedContent(),
"warnings", result.getWarnings(),
"downloadedImages", result.getDownloadedImages(),
"hasWarnings", true
));
}
// Success - no warnings
return ResponseEntity.ok(Map.of(
"processedContent", result.getProcessedContent(),
"downloadedImages", result.getDownloadedImages(),
"hasWarnings", false
));
} catch (Exception e) {
logger.error("Failed to process content images for story {}", id, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to process content images: " + e.getMessage()));
}
}
@PostMapping("/reindex")
public ResponseEntity<String> manualReindex() {
if (typesenseService == null) {
@@ -458,7 +490,14 @@ public class StoryController {
story.setDescription(updateReq.getDescription());
}
if (updateReq.getContentHtml() != null) {
story.setContentHtml(sanitizationService.sanitize(updateReq.getContentHtml()));
logger.info("Content before sanitization (length: {}): {}",
updateReq.getContentHtml().length(),
updateReq.getContentHtml().substring(0, Math.min(500, updateReq.getContentHtml().length())));
String sanitizedContent = sanitizationService.sanitize(updateReq.getContentHtml());
logger.info("Content after sanitization (length: {}): {}",
sanitizedContent.length(),
sanitizedContent.substring(0, Math.min(500, sanitizedContent.length())));
story.setContentHtml(sanitizedContent);
}
if (updateReq.getSourceUrl() != null) {
story.setSourceUrl(updateReq.getSourceUrl());

View File

@@ -0,0 +1,23 @@
package com.storycove.dto;
import jakarta.validation.constraints.NotBlank;
public class ProcessContentImagesRequest {
@NotBlank(message = "HTML content is required")
private String htmlContent;
public ProcessContentImagesRequest() {}
public ProcessContentImagesRequest(String htmlContent) {
this.htmlContent = htmlContent;
}
public String getHtmlContent() {
return htmlContent;
}
public void setHtmlContent(String htmlContent) {
this.htmlContent = htmlContent;
}
}

View File

@@ -54,7 +54,7 @@ public class HtmlSanitizationService {
"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"
));
@@ -65,7 +65,7 @@ public class HtmlSanitizationService {
}
private void createSafelist() {
this.allowlist = new Safelist();
this.allowlist = Safelist.relaxed();
// Add allowed tags
if (config.getAllowedTags() != null) {
@@ -83,7 +83,12 @@ public class HtmlSanitizationService {
}
}
// Configure allowed protocols for specific attributes (e.g., href)
// Special handling for img tags - allow all src attributes and validate later
allowlist.removeProtocols("img", "src", "http", "https");
// This is the key: preserve relative URLs by not restricting them
allowlist.preserveRelativeLinks(true);
// Configure allowed protocols for other attributes
if (config.getAllowedProtocols() != null) {
for (Map.Entry<String, Map<String, List<String>>> tagEntry : config.getAllowedProtocols().entrySet()) {
String tag = tagEntry.getKey();
@@ -94,7 +99,8 @@ public class HtmlSanitizationService {
String attribute = attrEntry.getKey();
List<String> protocols = attrEntry.getValue();
if (protocols != null) {
if (protocols != null && !("img".equals(tag) && "src".equals(attribute))) {
// Skip img src since we handled it above
allowlist.addProtocols(tag, attribute, protocols.toArray(new String[0]));
}
}
@@ -102,6 +108,8 @@ public class HtmlSanitizationService {
}
}
logger.info("Configured Jsoup Safelist with preserveRelativeLinks=true for local image URLs");
// Remove specific attributes if needed (deprecated in favor of protocol control)
if (config.getRemovedAttributes() != null) {
for (Map.Entry<String, List<String>> entry : config.getRemovedAttributes().entrySet()) {
@@ -133,8 +141,10 @@ public class HtmlSanitizationService {
if (html == null || html.trim().isEmpty()) {
return "";
}
return Jsoup.clean(html, allowlist);
logger.info("Content before sanitization: "+html);
String saniztedHtml = Jsoup.clean(html, allowlist.preserveRelativeLinks(true));
logger.info("Content after sanitization: "+saniztedHtml);
return saniztedHtml;
}
public String extractPlainText(String html) {

View File

@@ -1,5 +1,7 @@
package com.storycove.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@@ -8,18 +10,22 @@ import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
import java.util.UUID;
import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class ImageService {
private static final Logger logger = LoggerFactory.getLogger(ImageService.class);
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg", "image/jpg", "image/png"
);
@@ -53,7 +59,8 @@ public class ImageService {
public enum ImageType {
COVER("covers"),
AVATAR("avatars");
AVATAR("avatars"),
CONTENT("content");
private final String directory;
@@ -182,6 +189,9 @@ public class ImageService {
maxWidth = avatarMaxSize;
maxHeight = avatarMaxSize;
break;
case CONTENT:
// Content images are not resized
return new Dimension(originalWidth, originalHeight);
default:
return new Dimension(originalWidth, originalHeight);
}
@@ -228,4 +238,224 @@ public class ImageService {
String extension = getFileExtension(filename);
return ALLOWED_EXTENSIONS.contains(extension);
}
// Content image processing methods
/**
* Process HTML content and download all referenced images, replacing URLs with local paths
*/
public ContentImageProcessingResult processContentImages(String htmlContent, UUID storyId) {
logger.info("Processing content images for story: {}, content length: {}", storyId,
htmlContent != null ? htmlContent.length() : 0);
List<String> warnings = new ArrayList<>();
List<String> downloadedImages = new ArrayList<>();
if (htmlContent == null || htmlContent.trim().isEmpty()) {
logger.info("No content to process for story: {}", storyId);
return new ContentImageProcessingResult(htmlContent, warnings, downloadedImages);
}
// Find all img tags with src attributes
Pattern imgPattern = Pattern.compile("<img[^>]+src=[\"']([^\"']+)[\"'][^>]*>", Pattern.CASE_INSENSITIVE);
Matcher matcher = imgPattern.matcher(htmlContent);
int imageCount = 0;
int externalImageCount = 0;
StringBuffer processedContent = new StringBuffer();
while (matcher.find()) {
String fullImgTag = matcher.group(0);
String imageUrl = matcher.group(1);
imageCount++;
logger.info("Found image #{}: {} in tag: {}", imageCount, imageUrl, fullImgTag);
try {
// Skip if it's already a local path or data URL
if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) {
logger.info("Skipping local/data URL: {}", imageUrl);
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
continue;
}
externalImageCount++;
logger.info("Processing external image #{}: {}", externalImageCount, imageUrl);
// Download and store the image
String localPath = downloadImageFromUrl(imageUrl, storyId);
downloadedImages.add(localPath);
// Generate local URL
String localUrl = getLocalImageUrl(storyId, localPath);
logger.info("Downloaded image: {} -> {}", imageUrl, localUrl);
// Replace the src attribute with the local path - handle both single and double quotes
String newImgTag = fullImgTag
.replaceFirst("src=\"" + Pattern.quote(imageUrl) + "\"", "src=\"" + localUrl + "\"")
.replaceFirst("src='" + Pattern.quote(imageUrl) + "'", "src=\"" + localUrl + "\"");
// If replacement didn't work, try a more generic approach
if (newImgTag.equals(fullImgTag)) {
logger.warn("Standard replacement failed for image URL: {}, trying generic replacement", imageUrl);
newImgTag = fullImgTag.replaceAll("src\\s*=\\s*[\"']?" + Pattern.quote(imageUrl) + "[\"']?", "src=\"" + localUrl + "\"");
}
logger.info("Replaced img tag: {} -> {}", fullImgTag, newImgTag);
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(newImgTag));
} catch (Exception e) {
logger.error("Failed to download image: {} - {}", imageUrl, e.getMessage(), e);
warnings.add("Failed to download image: " + imageUrl + " - " + e.getMessage());
// Keep original URL in case of failure
matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag));
}
}
matcher.appendTail(processedContent);
logger.info("Finished processing images for story: {}. Found {} total images, {} external. Downloaded {} images, {} warnings",
storyId, imageCount, externalImageCount, downloadedImages.size(), warnings.size());
return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages);
}
/**
* Download an image from a URL and store it locally
*/
private String downloadImageFromUrl(String imageUrl, UUID storyId) throws IOException {
URL url = new URL(imageUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
// Set a reasonable user agent to avoid blocks
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (StoryCove Image Processor)");
connection.setConnectTimeout(30000); // 30 seconds
connection.setReadTimeout(30000);
try (InputStream inputStream = connection.getInputStream()) {
// Get content type to determine file extension
String contentType = connection.getContentType();
String extension = getExtensionFromContentType(contentType);
if (extension == null) {
// Try to extract from URL
extension = getExtensionFromUrl(imageUrl);
}
if (extension == null || !ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
throw new IllegalArgumentException("Unsupported image format: " + contentType);
}
// Create directories for content images
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString());
Files.createDirectories(contentDir);
// Generate unique filename
String filename = UUID.randomUUID().toString() + "." + extension.toLowerCase();
Path filePath = contentDir.resolve(filename);
// Read and validate the image
byte[] imageData = inputStream.readAllBytes();
ByteArrayInputStream bais = new ByteArrayInputStream(imageData);
BufferedImage image = ImageIO.read(bais);
if (image == null) {
throw new IOException("Invalid image format");
}
// Save the image
Files.write(filePath, imageData);
// Return relative path
return ImageType.CONTENT.getDirectory() + "/" + storyId.toString() + "/" + filename;
} finally {
connection.disconnect();
}
}
/**
* Generate local image URL for serving
*/
private String getLocalImageUrl(UUID storyId, String imagePath) {
String currentLibraryId = libraryService.getCurrentLibraryId();
if (currentLibraryId == null || currentLibraryId.trim().isEmpty()) {
logger.warn("Current library ID is null or empty when generating local image URL for story: {}", storyId);
return "/api/files/images/default/" + imagePath;
}
String localUrl = "/api/files/images/" + currentLibraryId + "/" + imagePath;
logger.info("Generated local image URL: {} for story: {}", localUrl, storyId);
return localUrl;
}
/**
* Get file extension from content type
*/
private String getExtensionFromContentType(String contentType) {
if (contentType == null) return null;
switch (contentType.toLowerCase()) {
case "image/jpeg":
case "image/jpg":
return "jpg";
case "image/png":
return "png";
default:
return null;
}
}
/**
* Extract file extension from URL
*/
private String getExtensionFromUrl(String url) {
try {
String path = new URL(url).getPath();
int lastDot = path.lastIndexOf('.');
if (lastDot > 0 && lastDot < path.length() - 1) {
return path.substring(lastDot + 1).toLowerCase();
}
} catch (Exception ignored) {
}
return null;
}
/**
* Clean up content images for a story
*/
public void deleteContentImages(UUID storyId) {
try {
Path contentDir = Paths.get(getUploadDir(), ImageType.CONTENT.getDirectory(), storyId.toString());
if (Files.exists(contentDir)) {
Files.walk(contentDir)
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(java.io.File::delete);
}
} catch (IOException e) {
// Log but don't throw - this is cleanup
System.err.println("Failed to clean up content images for story " + storyId + ": " + e.getMessage());
}
}
/**
* Result class for content image processing
*/
public static class ContentImageProcessingResult {
private final String processedContent;
private final List<String> warnings;
private final List<String> downloadedImages;
public ContentImageProcessingResult(String processedContent, List<String> warnings, List<String> downloadedImages) {
this.processedContent = processedContent;
this.warnings = warnings;
this.downloadedImages = downloadedImages;
}
public String getProcessedContent() { return processedContent; }
public List<String> getWarnings() { return warnings; }
public List<String> getDownloadedImages() { return downloadedImages; }
public boolean hasWarnings() { return !warnings.isEmpty(); }
}
}

View File

@@ -4,7 +4,7 @@
"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", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
"a", "img", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
"blockquote", "cite", "q", "hr", "details", "summary"
],
"allowedAttributes": {
@@ -18,6 +18,7 @@
"h5": ["class", "style"],
"h6": ["class", "style"],
"a": ["class", "href", "title"],
"img": ["src", "alt", "width", "height", "class", "style"],
"table": ["class", "style"],
"th": ["class", "style", "colspan", "rowspan"],
"td": ["class", "style", "colspan", "rowspan"],
@@ -41,6 +42,9 @@
"allowedProtocols": {
"a": {
"href": ["http", "https", "#", "/"]
},
"img": {
"src": ["http", "https", "data", "/", "cid"]
}
},
"description": "HTML sanitization configuration for StoryCove story content. This configuration is shared between frontend (DOMPurify) and backend (Jsoup) to ensure consistency."

View File

@@ -52,6 +52,8 @@ services:
postgres:
image: postgres:15-alpine
# No port mapping - only accessible within the Docker network
ports:
- "5432:5432"
environment:
- POSTGRES_DB=storycove
- POSTGRES_USER=storycove

View File

@@ -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,6 +251,25 @@ 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();
@@ -276,6 +296,42 @@ 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>

View File

@@ -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;
}
}

View File

@@ -342,6 +342,8 @@ export default function EditStoryPage() {
onChange={handleContentChange}
placeholder="Edit your story content here..."
error={errors.contentHtml}
storyId={storyId}
enableImageProcessing={true}
/>
</div>

View File

@@ -121,17 +121,29 @@ 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');
// 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;
headings.forEach((heading, index) => {
heading.id = `heading-${index}`;
});
if (headingMatches) {
processedContent = processedContent.replace(/<h([1-6])([^>]*)>/gi, (match, level, attrs) => {
const headingId = `heading-${headingCount++}`;
setSanitizedContent(doc.body.innerHTML);
setHasHeadings(headings.length > 0);
// 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) {

View File

@@ -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,
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
@@ -358,12 +460,37 @@ export default function RichTextEditor({
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"

View File

@@ -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;

View File

@@ -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