|
1 | 1 | import { Node, NodeResizer } from "@xyflow/react"; |
2 | 2 | import { ImageNodeData } from "../types"; |
3 | 3 |
|
| 4 | +// Normalize and validate URL to prevent XSS attacks |
| 5 | +function normalizeAndValidateUrl(url: string): string | null { |
| 6 | + // Trim whitespace |
| 7 | + url = url.trim(); |
| 8 | + |
| 9 | + // If URL doesn't start with a protocol, prepend https:// |
| 10 | + if (!url.match(/^[a-z]+:\/\//i)) { |
| 11 | + url = 'https://' + url; |
| 12 | + } |
| 13 | + |
| 14 | + try { |
| 15 | + const parsedUrl = new URL(url); |
| 16 | + // Only allow http, https, and mailto protocols |
| 17 | + if (['http:', 'https:', 'mailto:'].includes(parsedUrl.protocol)) { |
| 18 | + return parsedUrl.href; |
| 19 | + } |
| 20 | + return null; |
| 21 | + } catch { |
| 22 | + return null; |
| 23 | + } |
| 24 | +} |
| 25 | + |
| 26 | +// Simple markdown link parser for captions - supports [text](url) and bare URLs |
| 27 | +function parseMarkdownLinks(text: string): React.ReactNode[] { |
| 28 | + const parts: React.ReactNode[] = []; |
| 29 | + |
| 30 | + // Combined regex for markdown links [text](url) and bare URLs |
| 31 | + // Matches markdown links first, then bare URLs (starting with http:// or https://) |
| 32 | + const combinedRegex = /\[([^\]]+)\]\(([^)]+)\)|(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/g; |
| 33 | + |
| 34 | + const matches = Array.from(text.matchAll(combinedRegex)); |
| 35 | + let lastIndex = 0; |
| 36 | + |
| 37 | + matches.forEach((match, index) => { |
| 38 | + // Add text before the link |
| 39 | + if (match.index !== undefined && match.index > lastIndex) { |
| 40 | + parts.push(text.substring(lastIndex, match.index)); |
| 41 | + } |
| 42 | + |
| 43 | + if (match[1] && match[2]) { |
| 44 | + // Markdown link [text](url) |
| 45 | + const linkText = match[1]; |
| 46 | + const linkUrl = match[2]; |
| 47 | + |
| 48 | + const normalizedUrl = normalizeAndValidateUrl(linkUrl); |
| 49 | + if (normalizedUrl) { |
| 50 | + parts.push( |
| 51 | + <a key={index} href={normalizedUrl} target="_blank" rel="noopener noreferrer" style={{ color: "#3b82f6", textDecoration: "underline" }}> |
| 52 | + {linkText} |
| 53 | + </a> |
| 54 | + ); |
| 55 | + } else { |
| 56 | + // If URL is invalid, just show the text |
| 57 | + parts.push(`[${linkText}](${linkUrl})`); |
| 58 | + } |
| 59 | + } else if (match[3]) { |
| 60 | + // Bare URL |
| 61 | + const url = match[3]; |
| 62 | + const normalizedUrl = normalizeAndValidateUrl(url); |
| 63 | + if (normalizedUrl) { |
| 64 | + parts.push( |
| 65 | + <a key={index} href={normalizedUrl} target="_blank" rel="noopener noreferrer" style={{ color: "#3b82f6", textDecoration: "underline" }}> |
| 66 | + {url} |
| 67 | + </a> |
| 68 | + ); |
| 69 | + } else { |
| 70 | + parts.push(url); |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + if (match.index !== undefined) { |
| 75 | + lastIndex = match.index + match[0].length; |
| 76 | + } |
| 77 | + }); |
| 78 | + |
| 79 | + // Add remaining text |
| 80 | + if (lastIndex < text.length) { |
| 81 | + parts.push(text.substring(lastIndex)); |
| 82 | + } |
| 83 | + |
| 84 | + return parts.length > 0 ? parts : [text]; |
| 85 | +} |
| 86 | + |
4 | 87 | export const ImageNode = ({ data, selected }: Node<ImageNodeData>) => { |
5 | 88 | return ( |
6 | 89 | <> |
7 | 90 | {data.data ? ( |
8 | 91 | <> |
9 | 92 | <NodeResizer isVisible={selected} keepAspectRatio /> |
10 | | - <div style={{ display: "flex", justifyContent: "center", alignItems: "center", width: "100%", height: "100%", overflow: "hidden" }}> |
| 93 | + <div style={{ display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", width: "100%", height: "100%", overflow: "hidden", gap: "4px" }}> |
11 | 94 | <img |
12 | 95 | src={data.data} |
13 | 96 | style={{ |
14 | 97 | maxWidth: "100%", |
15 | | - maxHeight: "100%", |
| 98 | + maxHeight: data.caption ? "calc(100% - 24px)" : "100%", |
| 99 | + objectFit: "contain", |
16 | 100 | }} |
17 | 101 | /> |
| 102 | + {data.caption && ( |
| 103 | + <div style={{ fontSize: "11px", color: "#6b7280", textAlign: "center", padding: "0 4px", lineHeight: "1.3" }}> |
| 104 | + {parseMarkdownLinks(data.caption)} |
| 105 | + </div> |
| 106 | + )} |
18 | 107 | </div> |
19 | 108 | </> |
20 | 109 | ) : ( |
|
0 commit comments