diff --git a/backend/src/main/java/com/storycove/service/AsyncImageProcessingService.java b/backend/src/main/java/com/storycove/service/AsyncImageProcessingService.java index a9188b4..80771d7 100644 --- a/backend/src/main/java/com/storycove/service/AsyncImageProcessingService.java +++ b/backend/src/main/java/com/storycove/service/AsyncImageProcessingService.java @@ -20,6 +20,9 @@ public class AsyncImageProcessingService { private final StoryService storyService; private final ImageProcessingProgressService progressService; + @org.springframework.beans.factory.annotation.Value("${storycove.app.public-url:http://localhost:6925}") + private String publicUrl; + @Autowired public AsyncImageProcessingService(ImageService imageService, StoryService storyService, @@ -103,10 +106,54 @@ public class AsyncImageProcessingService { return count; } + /** + * Check if a URL is external (not from this application). + * Returns true if the URL should be downloaded, false if it's already local. + */ private boolean isExternalUrl(String url) { - return url != null && - (url.startsWith("http://") || url.startsWith("https://")) && - !url.contains("/api/files/images/"); + if (url == null || url.trim().isEmpty()) { + return false; + } + + // Skip data URLs + if (url.startsWith("data:")) { + return false; + } + + // Skip relative URLs (local paths) + if (url.startsWith("/")) { + return false; + } + + // Skip URLs that are already pointing to our API + if (url.contains("/api/files/images/")) { + return false; + } + + // Check if URL starts with the public URL (our own domain) + if (publicUrl != null && !publicUrl.trim().isEmpty()) { + String normalizedUrl = url.trim().toLowerCase(); + String normalizedPublicUrl = publicUrl.trim().toLowerCase(); + + // Remove trailing slash from public URL for comparison + if (normalizedPublicUrl.endsWith("/")) { + normalizedPublicUrl = normalizedPublicUrl.substring(0, normalizedPublicUrl.length() - 1); + } + + if (normalizedUrl.startsWith(normalizedPublicUrl)) { + logger.debug("URL is from this application (matches publicUrl): {}", url); + return false; + } + } + + // If it's an HTTP(S) URL that didn't match our filters, it's external + if (url.startsWith("http://") || url.startsWith("https://")) { + logger.debug("URL is external: {}", url); + return true; + } + + // For any other format, consider it non-external (safer default) + return false; } private ImageService.ContentImageProcessingResult processImagesWithProgress( diff --git a/backend/src/main/java/com/storycove/service/ImageService.java b/backend/src/main/java/com/storycove/service/ImageService.java index a662327..40bb048 100644 --- a/backend/src/main/java/com/storycove/service/ImageService.java +++ b/backend/src/main/java/com/storycove/service/ImageService.java @@ -69,7 +69,10 @@ public class ImageService { @Value("${storycove.images.max-file-size:5242880}") // 5MB default private long maxFileSize; - + + @Value("${storycove.app.public-url:http://localhost:6925}") + private String publicUrl; + public enum ImageType { COVER("covers"), AVATAR("avatars"), @@ -286,9 +289,9 @@ public class ImageService { logger.debug("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.debug("Skipping local/data URL: {}", imageUrl); + // Skip if it's already a local path, data URL, or from this application + if (!isExternalUrl(imageUrl)) { + logger.debug("Skipping local/internal URL: {}", imageUrl); matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag)); continue; } @@ -366,7 +369,7 @@ public class ImageService { Matcher countMatcher = imgPattern.matcher(htmlContent); while (countMatcher.find()) { String imageUrl = countMatcher.group(1); - if (!imageUrl.startsWith("/") && !imageUrl.startsWith("data:")) { + if (isExternalUrl(imageUrl)) { externalImages.add(imageUrl); } } @@ -384,9 +387,9 @@ public class ImageService { logger.debug("Found image: {} in tag: {}", imageUrl, fullImgTag); try { - // Skip if it's already a local path or data URL - if (imageUrl.startsWith("/") || imageUrl.startsWith("data:")) { - logger.debug("Skipping local/data URL: {}", imageUrl); + // Skip if it's already a local path, data URL, or from this application + if (!isExternalUrl(imageUrl)) { + logger.debug("Skipping local/internal URL: {}", imageUrl); matcher.appendReplacement(processedContent, Matcher.quoteReplacement(fullImgTag)); continue; } @@ -429,6 +432,56 @@ public class ImageService { return new ContentImageProcessingResult(processedContent.toString(), warnings, downloadedImages); } + /** + * Check if a URL is external (not from this application). + * Returns true if the URL should be downloaded, false if it's already local. + */ + private boolean isExternalUrl(String url) { + if (url == null || url.trim().isEmpty()) { + return false; + } + + // Skip data URLs + if (url.startsWith("data:")) { + return false; + } + + // Skip relative URLs (local paths) + if (url.startsWith("/")) { + return false; + } + + // Skip URLs that are already pointing to our API + if (url.contains("/api/files/images/")) { + return false; + } + + // Check if URL starts with the public URL (our own domain) + if (publicUrl != null && !publicUrl.trim().isEmpty()) { + String normalizedUrl = url.trim().toLowerCase(); + String normalizedPublicUrl = publicUrl.trim().toLowerCase(); + + // Remove trailing slash from public URL for comparison + if (normalizedPublicUrl.endsWith("/")) { + normalizedPublicUrl = normalizedPublicUrl.substring(0, normalizedPublicUrl.length() - 1); + } + + if (normalizedUrl.startsWith(normalizedPublicUrl)) { + logger.debug("URL is from this application (matches publicUrl): {}", url); + return false; + } + } + + // If it's an HTTP(S) URL that didn't match our filters, it's external + if (url.startsWith("http://") || url.startsWith("https://")) { + logger.debug("URL is external: {}", url); + return true; + } + + // For any other format, consider it non-external (safer default) + return false; + } + /** * Download an image from a URL and store it locally */ diff --git a/frontend/src/components/stories/AuthorSelector.tsx b/frontend/src/components/stories/AuthorSelector.tsx index 87b9652..83c2388 100644 --- a/frontend/src/components/stories/AuthorSelector.tsx +++ b/frontend/src/components/stories/AuthorSelector.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { authorApi } from '../../lib/api'; import { Author } from '../../types/api'; @@ -25,50 +25,64 @@ export default function AuthorSelector({ }: AuthorSelectorProps) { const [isOpen, setIsOpen] = useState(false); const [authors, setAuthors] = useState([]); - const [filteredAuthors, setFilteredAuthors] = useState([]); const [loading, setLoading] = useState(false); const [inputValue, setInputValue] = useState(value || ''); - + const inputRef = useRef(null); const dropdownRef = useRef(null); + const debounceTimerRef = useRef(null); - // Load authors when component mounts or when dropdown opens - useEffect(() => { - const loadAuthors = async () => { - try { - setLoading(true); - const result = await authorApi.getAuthors({ page: 0, size: 100 }); // Get first 100 authors + // Search authors dynamically based on input + const searchAuthors = useCallback(async (query: string) => { + try { + setLoading(true); + + if (!query.trim()) { + // If empty query, load recent/popular authors + const result = await authorApi.getAuthors({ page: 0, size: 20, sortBy: 'name', sortDir: 'asc' }); setAuthors(result.content); - } catch (error) { - console.error('Failed to load authors:', error); - } finally { - setLoading(false); + } else { + // Search by name + const result = await authorApi.searchAuthorsByName(query, { page: 0, size: 20 }); + setAuthors(result.content); + } + } catch (error) { + console.error('Failed to search authors:', error); + setAuthors([]); + } finally { + setLoading(false); + } + }, []); + + // Debounced search effect + useEffect(() => { + // Clear existing timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Only search when dropdown is open + if (isOpen) { + // Set new timer for debounced search + debounceTimerRef.current = setTimeout(() => { + searchAuthors(inputValue); + }, 300); // 300ms debounce delay + } + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); } }; - - if (isOpen && authors.length === 0) { - loadAuthors(); - } - }, [isOpen, authors.length]); - - // Filter authors based on input value - useEffect(() => { - if (!inputValue.trim()) { - setFilteredAuthors(authors); - } else { - const filtered = authors.filter(author => - author.name.toLowerCase().includes(inputValue.toLowerCase()) - ); - setFilteredAuthors(filtered); - } - }, [inputValue, authors]); + }, [inputValue, isOpen, searchAuthors]); // Update input value when prop value changes useEffect(() => { if (value !== inputValue) { setInputValue(value || ''); } - }, [value]); + }, [value, inputValue]); // Handle clicking outside to close dropdown useEffect(() => { @@ -88,7 +102,7 @@ export default function AuthorSelector({ const newValue = e.target.value; setInputValue(newValue); setIsOpen(true); - + // Call onChange for free-form text entry (new author) onChange(newValue); }; @@ -158,13 +172,13 @@ export default function AuthorSelector({
{loading ? (
- Loading authors... + Searching authors...
- ) : filteredAuthors.length > 0 ? ( + ) : authors.length > 0 ? ( <> - {/* Existing authors */} + {/* Search results */}
- {filteredAuthors.map((author) => ( + {authors.map((author) => (
- + {/* New author option if input doesn't match exactly */} - {inputValue.trim() && !filteredAuthors.find(a => a.name.toLowerCase() === inputValue.toLowerCase()) && ( + {inputValue.trim() && !authors.find(a => a.name.toLowerCase() === inputValue.toLowerCase()) && ( <>
@@ -213,9 +227,9 @@ export default function AuthorSelector({
) : ( - /* No authors loaded or empty input */ + /* Empty state - show prompt */
- {authors.length === 0 ? 'No authors yet' : 'Type to search or create new author'} + Type to search for authors or create a new one
)}
diff --git a/frontend/src/components/stories/SeriesSelector.tsx b/frontend/src/components/stories/SeriesSelector.tsx index b523cbc..890c4e8 100644 --- a/frontend/src/components/stories/SeriesSelector.tsx +++ b/frontend/src/components/stories/SeriesSelector.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; -import { seriesApi, storyApi } from '../../lib/api'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { seriesApi } from '../../lib/api'; import { Series } from '../../types/api'; interface SeriesSelectorProps { @@ -27,97 +27,63 @@ export default function SeriesSelector({ }: SeriesSelectorProps) { const [isOpen, setIsOpen] = useState(false); const [series, setSeries] = useState([]); - const [filteredSeries, setFilteredSeries] = useState([]); const [loading, setLoading] = useState(false); const [inputValue, setInputValue] = useState(value || ''); - const [authorSeriesMap, setAuthorSeriesMap] = useState>({}); - + const inputRef = useRef(null); const dropdownRef = useRef(null); + const debounceTimerRef = useRef(null); - // Load series and author-series mappings when component mounts or when dropdown opens + // Search series dynamically based on input + const searchSeries = useCallback(async (query: string) => { + try { + setLoading(true); + + if (!query.trim()) { + // If empty query, load recent/popular series + const result = await seriesApi.getSeries({ page: 0, size: 20, sortBy: 'name', sortDir: 'asc' }); + setSeries(result.content); + } else { + // Search by name + const result = await seriesApi.searchSeriesByName(query, { page: 0, size: 20 }); + setSeries(result.content); + } + } catch (error) { + console.error('Failed to search series:', error); + setSeries([]); + } finally { + setLoading(false); + } + }, []); + + // Debounced search effect useEffect(() => { - const loadSeriesData = async () => { - try { - setLoading(true); - - // Load all series - const seriesResult = await seriesApi.getSeries({ page: 0, size: 100 }); // Get first 100 series - setSeries(seriesResult.content); - - // Load some recent stories to build author-series mapping - // This gives us a sample of which authors have written in which series - try { - const storiesResult = await storyApi.getStories({ page: 0, size: 200 }); // Get recent stories - const newAuthorSeriesMap: Record = {}; - - storiesResult.content.forEach(story => { - if (story.authorId && story.seriesName) { - if (!newAuthorSeriesMap[story.authorId]) { - newAuthorSeriesMap[story.authorId] = []; - } - if (!newAuthorSeriesMap[story.authorId].includes(story.seriesName)) { - newAuthorSeriesMap[story.authorId].push(story.seriesName); - } - } - }); - - setAuthorSeriesMap(newAuthorSeriesMap); - } catch (error) { - console.error('Failed to load author-series mapping:', error); - // Continue without author prioritization if this fails - } - } catch (error) { - console.error('Failed to load series:', error); - } finally { - setLoading(false); + // Clear existing timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Only search when dropdown is open + if (isOpen) { + // Set new timer for debounced search + debounceTimerRef.current = setTimeout(() => { + searchSeries(inputValue); + }, 300); // 300ms debounce delay + } + + // Cleanup timer on unmount + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); } }; - - if (isOpen && series.length === 0) { - loadSeriesData(); - } - }, [isOpen, series.length]); + }, [inputValue, isOpen, searchSeries]); // Update internal value when prop changes useEffect(() => { setInputValue(value || ''); }, [value]); - // Filter and sort series based on input and author priority - useEffect(() => { - let filtered: Series[]; - - if (!inputValue.trim()) { - filtered = [...series]; - } else { - filtered = series.filter(s => - s.name.toLowerCase().includes(inputValue.toLowerCase()) - ); - } - - // Sort series: prioritize those from the current author if authorId is provided - if (authorId && authorSeriesMap[authorId]) { - const authorSeriesNames = authorSeriesMap[authorId]; - - filtered.sort((a, b) => { - const aIsAuthorSeries = authorSeriesNames.includes(a.name); - const bIsAuthorSeries = authorSeriesNames.includes(b.name); - - if (aIsAuthorSeries && !bIsAuthorSeries) return -1; // a first - if (!aIsAuthorSeries && bIsAuthorSeries) return 1; // b first - - // If both or neither are author series, sort alphabetically - return a.name.localeCompare(b.name); - }); - } else { - // No author prioritization, just sort alphabetically - filtered.sort((a, b) => a.name.localeCompare(b.name)); - } - - setFilteredSeries(filtered); - }, [inputValue, series, authorId, authorSeriesMap]); - // Handle clicks outside to close dropdown useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -138,7 +104,7 @@ export default function SeriesSelector({ const newValue = e.target.value; setInputValue(newValue); setIsOpen(true); - + // If user is typing and it doesn't match any existing series exactly, clear the seriesId onChange(newValue, undefined); }; @@ -178,7 +144,7 @@ export default function SeriesSelector({ {required && *} )} - +
- + {/* Dropdown Arrow */}
{loading ? ( -
Loading series...
+
Searching series...
) : ( <> - {filteredSeries.length > 0 ? ( - filteredSeries.map((s) => { - const isAuthorSeries = authorId && authorSeriesMap[authorId]?.includes(s.name); - - return ( - - ); - }) + {series.length > 0 ? ( + series.map((s) => ( + + )) ) : ( <> - {inputValue.trim() && ( + {inputValue.trim() ? ( - )} - {!inputValue.trim() && ( -
No series found
+ ) : ( +
Type to search for series or create a new one
)} )} - - {inputValue.trim() && !filteredSeries.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && ( + + {inputValue.trim() && !series.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && (
); -} \ No newline at end of file +} diff --git a/frontend/src/components/stories/SlateEditor.tsx b/frontend/src/components/stories/SlateEditor.tsx index 6b328b7..b60c848 100644 --- a/frontend/src/components/stories/SlateEditor.tsx +++ b/frontend/src/components/stories/SlateEditor.tsx @@ -35,12 +35,11 @@ interface SlateEditorProps { // Custom types for our editor type CustomElement = { - type: 'paragraph' | 'heading-one' | 'heading-two' | 'heading-three' | 'blockquote' | 'image' | 'code-block'; + type: 'paragraph' | 'heading-one' | 'heading-two' | 'heading-three' | 'image'; children: CustomText[]; src?: string; // for images alt?: string; // for images caption?: string; // for images - language?: string; // for code blocks }; type CustomText = { @@ -49,7 +48,6 @@ type CustomText = { italic?: boolean; underline?: boolean; strikethrough?: boolean; - code?: boolean; }; declare module 'slate' { @@ -100,12 +98,19 @@ const htmlToSlate = (html: string): Descendant[] => { }); break; case 'blockquote': - results.push({ - type: 'blockquote', - children: [{ text: element.textContent || '' }] - }); + case 'pre': + case 'code': { + // Filter out blockquotes, code blocks, and code - convert to paragraph + const text = element.textContent || ''; + if (text.trim()) { + results.push({ + type: 'paragraph', + children: [{ text: text.trim() }] + }); + } break; - case 'img': + } + case 'img': { const img = element as HTMLImageElement; results.push({ type: 'image', @@ -115,18 +120,9 @@ const htmlToSlate = (html: string): Descendant[] => { children: [{ text: '' }] // Images need children in Slate }); break; - case 'pre': - const codeEl = element.querySelector('code'); - const code = codeEl ? codeEl.textContent || '' : element.textContent || ''; - const language = codeEl?.className?.replace('language-', '') || ''; - results.push({ - type: 'code-block', - language, - children: [{ text: code }] - }); - break; + } case 'p': - case 'div': + case 'div': { // Check if this paragraph contains mixed content (text + images) if (element.querySelector('img')) { // Process mixed content - handle both text and images in order @@ -141,6 +137,7 @@ const htmlToSlate = (html: string): Descendant[] => { } } break; + } case 'br': // Handle line breaks by creating empty paragraphs results.push({ @@ -148,7 +145,7 @@ const htmlToSlate = (html: string): Descendant[] => { children: [{ text: '' }] }); break; - default: + default: { // For other elements, try to extract text or recurse const text = element.textContent || ''; if (text.trim()) { @@ -158,6 +155,7 @@ const htmlToSlate = (html: string): Descendant[] => { }); } break; + } } } else if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent || ''; @@ -210,9 +208,6 @@ const slateToHtml = (nodes: Descendant[]): string => { case 'heading-three': htmlParts.push(`

${text}

`); break; - case 'blockquote': - htmlParts.push(`
${text}
`); - break; case 'image': const attrs: string[] = []; if (element.src) attrs.push(`src="${element.src}"`); @@ -220,16 +215,6 @@ const slateToHtml = (nodes: Descendant[]): string => { if (element.caption) attrs.push(`title="${element.caption}"`); htmlParts.push(``); break; - case 'code-block': - const langClass = element.language ? ` class="language-${element.language}"` : ''; - const escapedText = text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - htmlParts.push(`
${escapedText}
`); - break; case 'paragraph': default: htmlParts.push(text ? `

${text}

` : '

'); @@ -500,8 +485,6 @@ const Element = ({ attributes, children, element }: RenderElementProps) => { return

{children}

; case 'heading-three': return

{children}

; - case 'blockquote': - return
{children}
; case 'image': return ( { children={children} /> ); - case 'code-block': - return ( -
-          {children}
-        
- ); default: return

{children}

; } @@ -541,16 +518,12 @@ const Leaf = ({ attributes, children, leaf }: RenderLeafProps) => { children = {children}; } - if (customLeaf.code) { - children = {children}; - } - return {children}; }; // Toolbar component const Toolbar = ({ editor }: { editor: ReactEditor }) => { - type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code'; + type MarkFormat = 'bold' | 'italic' | 'underline' | 'strikethrough'; const isMarkActive = (format: MarkFormat) => { const marks = Editor.marks(editor); @@ -627,7 +600,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => { variant="ghost" onClick={() => toggleBlock('paragraph')} className={isBlockActive('paragraph') ? 'theme-accent-bg text-white' : ''} - title="Normal paragraph" + title="Normal paragraph (Ctrl+Shift+0)" > P @@ -637,7 +610,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => { variant="ghost" onClick={() => toggleBlock('heading-one')} className={`text-lg font-bold ${isBlockActive('heading-one') ? 'theme-accent-bg text-white' : ''}`} - title="Heading 1" + title="Heading 1 (Ctrl+Shift+1)" > H1 @@ -647,7 +620,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => { variant="ghost" onClick={() => toggleBlock('heading-two')} className={`text-base font-bold ${isBlockActive('heading-two') ? 'theme-accent-bg text-white' : ''}`} - title="Heading 2" + title="Heading 2 (Ctrl+Shift+2)" > H2 @@ -657,7 +630,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => { variant="ghost" onClick={() => toggleBlock('heading-three')} className={`text-sm font-bold ${isBlockActive('heading-three') ? 'theme-accent-bg text-white' : ''}`} - title="Heading 3" + title="Heading 3 (Ctrl+Shift+3)" > H3 @@ -691,7 +664,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => { variant="ghost" onClick={() => toggleMark('underline')} className={`underline ${isMarkActive('underline') ? 'theme-accent-bg text-white' : ''}`} - title="Underline" + title="Underline (Ctrl+U)" > U @@ -701,7 +674,7 @@ const Toolbar = ({ editor }: { editor: ReactEditor }) => { variant="ghost" onClick={() => toggleMark('strikethrough')} className={`line-through ${isMarkActive('strikethrough') ? 'theme-accent-bg text-white' : ''}`} - title="Strike-through" + title="Strikethrough (Ctrl+D)" > S @@ -826,49 +799,126 @@ export default function SlateEditor({ // Handle keyboard shortcuts if (!event.ctrlKey && !event.metaKey) return; + // Helper function to toggle marks + const toggleMarkShortcut = (format: 'bold' | 'italic' | 'underline' | 'strikethrough') => { + event.preventDefault(); + const marks = Editor.marks(editor); + const isActive = marks ? marks[format] === true : false; + if (isActive) { + Editor.removeMark(editor, format); + } else { + Editor.addMark(editor, format, true); + } + }; + + // Helper function to toggle blocks + const toggleBlockShortcut = (format: CustomElement['type']) => { + event.preventDefault(); + const isActive = isBlockActive(format); + Transforms.setNodes( + editor, + { type: isActive ? 'paragraph' : format }, + { match: n => SlateElement.isElement(n) && Editor.isBlock(editor, n) } + ); + }; + + // Check if block is active + const isBlockActive = (format: CustomElement['type']) => { + const { selection } = editor; + if (!selection) return false; + const [match] = Array.from( + Editor.nodes(editor, { + at: Editor.unhangRange(editor, selection), + match: n => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === format, + }) + ); + return !!match; + }; + switch (event.key) { - case 'b': { - event.preventDefault(); - const marks = Editor.marks(editor); - const isActive = marks ? marks.bold === true : false; - if (isActive) { - Editor.removeMark(editor, 'bold'); - } else { - Editor.addMark(editor, 'bold', true); + // Text formatting shortcuts + case 'b': + toggleMarkShortcut('bold'); + break; + case 'i': + toggleMarkShortcut('italic'); + break; + case 'u': + toggleMarkShortcut('underline'); + break; + case 'd': + // Ctrl+D for strikethrough + toggleMarkShortcut('strikethrough'); + break; + + // Block formatting shortcuts + case '1': + if (event.shiftKey) { + // Ctrl+Shift+1 for H1 + toggleBlockShortcut('heading-one'); } break; - } - case 'i': { - event.preventDefault(); - const marks = Editor.marks(editor); - const isActive = marks ? marks.italic === true : false; - if (isActive) { - Editor.removeMark(editor, 'italic'); - } else { - Editor.addMark(editor, 'italic', true); + case '2': + if (event.shiftKey) { + // Ctrl+Shift+2 for H2 + toggleBlockShortcut('heading-two'); } break; - } - case 'a': { - // Handle Ctrl+A / Cmd+A to select all + case '3': + if (event.shiftKey) { + // Ctrl+Shift+3 for H3 + toggleBlockShortcut('heading-three'); + } + break; + case '0': + if (event.shiftKey) { + // Ctrl+Shift+0 for normal paragraph + toggleBlockShortcut('paragraph'); + } + break; + + // Select all + case 'a': event.preventDefault(); Transforms.select(editor, { anchor: Editor.start(editor, []), focus: Editor.end(editor, []), }); break; - } } }} />
-
+

Slate.js Editor: Rich text editor with advanced image paste handling. {isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}

+
+ ⌨️ Keyboard Shortcuts +
+
+

Text Formatting:

+
    +
  • Ctrl+B Bold
  • +
  • Ctrl+I Italic
  • +
  • Ctrl+U Underline
  • +
  • Ctrl+D Strikethrough
  • +
+
+
+

Block Formatting:

+
    +
  • Ctrl+Shift+0 Paragraph
  • +
  • Ctrl+Shift+1 Heading 1
  • +
  • Ctrl+Shift+2 Heading 2
  • +
  • Ctrl+Shift+3 Heading 3
  • +
+
+
+