MarkdownToImage.jsx 11.8 KB
Newer Older
fisherdaddy's avatar
fisherdaddy committed
1
import React, { useState, useRef, useEffect } from 'react';
fisherdaddy's avatar
fisherdaddy committed
2
import styled from 'styled-components';
3
import { marked } from 'marked';
4
import { useTranslation } from '../js/i18n';
5
import SEO from './SEO';
6
import DOMPurify from 'dompurify';
fisherdaddy's avatar
fisherdaddy committed
7 8
import { usePageLoading } from '../hooks/usePageLoading';
import LoadingOverlay from './LoadingOverlay';
9 10 11 12 13

// 更新预设模板
const templates = [
  { 
    name: 'simple',
14
    bgColor: 'linear-gradient(135deg, #ffffff 0%, #f5f7ff 100%)',
15
    fallbackColor: '#ffffff',
16
    textColor: '#2d3748',
17
    font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
18
    padding: '40px 45px'
19 20 21
  },
  {
    name: 'ai-style',
22
    bgColor: 'linear-gradient(120deg, #0A2463 0%, #3E92CC 100%)',
23
    fallbackColor: '#0A2463',
24 25
    textColor: '#ffffff',
    font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
26
    padding: '40px 45px'
27 28 29
  },
  {
    name: 'dark',
30
    bgColor: 'linear-gradient(135deg, #1a202c 0%, #2d3748 100%)',
31
    fallbackColor: '#1a202c',
32
    textColor: '#f7fafc',
33
    font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
34
    padding: '40px 45px'
35 36 37
  },
  {
    name: 'paper',
38
    bgColor: 'linear-gradient(135deg, #fdf6e3 0%, #f9f3db 100%)',
39
    fallbackColor: '#fdf6e3',
40
    textColor: '#433422',
41
    font: 'Georgia, "Nimbus Roman No9 L", "Songti SC", serif',
42
    padding: '40px 45px'
43 44 45
  },
  {
    name: 'minimal',
46
    bgColor: 'linear-gradient(135deg, #f8f9fa 0%, #edf2f7 100%)',
47
    fallbackColor: '#f8f9fa',
48
    textColor: '#1a202c',
49
    font: '-apple-system, "SF Pro Text", sans-serif',
50
    padding: '40px 45px'
51 52 53
  },
  {
    name: 'tech',
54
    bgColor: 'linear-gradient(135deg, #0f1b3d 0%, #1e293b 100%)',
55
    fallbackColor: '#0f1b3d',
56 57
    textColor: '#e2e8f0',
    font: '"SF Mono", SFMono-Regular, Consolas, monospace',
58
    padding: '40px 45px'
59 60 61 62 63 64
  }
];

const Container = styled.div`
  min-height: 100vh;
  background: linear-gradient(135deg, #f5f7ff 0%, #ffffff 100%);
65
  padding: 4rem 2rem 2rem;
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
  position: relative;
  
  &::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: 
      linear-gradient(90deg, rgba(99, 102, 241, 0.05) 1px, transparent 1px),
      linear-gradient(rgba(99, 102, 241, 0.05) 1px, transparent 1px);
    background-size: 20px 20px;
    pointer-events: none;
  }
`;

const ContentWrapper = styled.div`
  display: flex;
  gap: 2rem;
  max-width: 1400px;
  margin: 0 auto;
  position: relative;
  z-index: 1;

  @media (max-width: 768px) {
    flex-direction: column;
  }
`;

const InputContainer = styled.div`
  flex: 1;
  background: rgba(255, 255, 255, 0.8);
  backdrop-filter: blur(10px);
  border-radius: 16px;
  padding: 1.5rem;
  box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.2);
  display: flex;
  flex-direction: column;
  gap: 1rem;
`;

const TitleLabel = styled.h2`
  font-size: 1.8rem;
  margin-bottom: 1.5rem;
  background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  font-weight: 700;
  letter-spacing: -0.02em;
`;

const Section = styled.div`
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
`;

125 126 127 128 129 130 131 132 133 134
const TemplateSection = styled(Section)`
  margin-bottom: 1rem;
`;

const EditorSection = styled(Section)`
  flex: 1;
  display: flex;
  flex-direction: column;
`;

135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
const Label = styled.label`
  font-size: 1rem;
  color: #333333;
`;

const TemplateGrid = styled.div`
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
`;

const TemplateItem = styled.button`
  padding: 0.5rem 1rem;
  background: ${props => props.selected ? 
    'linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)' : 
    'rgba(255, 255, 255, 0.8)'
  };
  color: ${props => props.selected ? '#ffffff' : '#333333'};
  border: 2px solid ${props => props.selected ? '#4F46E5' : 'rgba(99, 102, 241, 0.1)'};
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s ease;
  position: relative;
  overflow: hidden;

  &:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 12px rgba(99, 102, 241, 0.15);
    border-color: rgba(99, 102, 241, 0.3);
  }

  ${props => props.selected && `
    &::after {
      content: '✓';
      position: absolute;
      top: 4px;
      right: 4px;
      font-size: 12px;
      color: #ffffff;
    }
  `}
`;

179
const Editor = styled.textarea`
180
  width: 100%;
181 182 183 184 185 186 187
  height: 500px;
  padding: 1rem;
  border: none;
  background: transparent;
  font-family: 'SF Mono', monospace;
  font-size: 14px;
  line-height: 1.5;
188
  resize: vertical;
189 190 191 192 193 194 195 196 197 198
  color: #1a1a1a;
  overflow-y: auto;

  &:focus {
    outline: none;
  }

  &::placeholder {
    color: #64748b;
  }
199
`;
fisherdaddy's avatar
fisherdaddy committed
200 201

const DownloadButton = styled.button`
202
  background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
fisherdaddy's avatar
fisherdaddy committed
203
  color: white;
204
  padding: 0.5rem 1rem;
fisherdaddy's avatar
fisherdaddy committed
205
  border: none;
206
  border-radius: 6px;
fisherdaddy's avatar
fisherdaddy committed
207
  cursor: pointer;
208 209 210 211 212 213 214 215
  font-weight: 600;
  transition: opacity 0.2s;
  font-size: 0.9rem;
  position: absolute;
  top: 1.5rem;
  right: 1.5rem;
  opacity: ${props => props.visible ? 1 : 0};
  pointer-events: ${props => props.visible ? 'auto' : 'none'};
fisherdaddy's avatar
fisherdaddy committed
216 217

  &:hover {
218
    opacity: 0.9;
fisherdaddy's avatar
fisherdaddy committed
219 220 221
  }
`;

222 223 224 225 226
const PreviewContainer = styled(InputContainer)`
  overflow: auto;
  position: relative;
  min-height: 400px;
  display: block;
227
  
228 229 230 231 232
  img {
    max-width: 100%;
    height: auto;
    display: block;
    margin: 1em auto;
233
  }
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248

  h1, h2, h3, h4, h5, h6 {
    margin-top: 1.5em;
    margin-bottom: 0.5em;
    font-weight: 600;
    line-height: 1.3;
  }

  p {
    margin: 1em 0;
    line-height: 1.6;
  }

  ul, ol {
    margin: 1em 0;
249 250
    padding-left: 1em;
    list-style-type: none;
251 252
  }

253 254 255 256 257 258
  li {
    margin: 0.5em 0;
    line-height: 1.6;
    list-style-type: none;
    position: relative;
    padding-left: 1.2em;
259
  }
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274
  
  /* 为无序列表项添加自定义标记 */
  ul li::before {
    content: "•";
    position: absolute;
    left: 0;
    top: -0.25em; /* 使用更大的负值,进一步向上移动圆点 */
    color: #4F46E5; /* 使用主题色 */
    font-weight: bold;
    font-size: 1.2em;
    line-height: 1.6;
    display: inline-block; /* 更好的对齐控制 */
  }
  
  /* 为有序列表项添加自定义标记 */
275
  ol {
276
    counter-reset: item;
277
  }
278 279 280 281 282 283 284 285
  
  ol li::before {
    content: counter(item) ".";
    counter-increment: item;
    position: absolute;
    left: 0;
    color: #4F46E5; /* 使用主题色 */
    font-weight: bold;
286
    line-height: 1.6;
287
    display: inline-block; /* 更好的对齐控制 */
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
  }

  pre, code {
    background: rgba(0, 0, 0, 0.05);
    border-radius: 4px;
    padding: 0.2em 0.4em;
    font-family: 'SF Mono', monospace;
  }

  pre code {
    background: none;
    padding: 0;
  }

  blockquote {
    border-left: 4px solid #e2e8f0;
    margin: 1em 0;
    padding-left: 1em;
    color: #64748b;
  }

  table {
    border-collapse: collapse;
311
    width: 100%;
312 313 314 315 316 317 318 319 320 321 322
    margin: 1em 0;
  }

  th, td {
    border: 1px solid #e2e8f0;
    padding: 0.5em;
    text-align: left;
  }

  th {
    background: rgba(0, 0, 0, 0.05);
323 324 325 326
  }
`;


fisherdaddy's avatar
fisherdaddy committed
327
function MarkdownToImage() {
328
  const { t } = useTranslation();
fisherdaddy's avatar
fisherdaddy committed
329
  const [text, setText] = useState('');
330
  const [selectedTemplate, setSelectedTemplate] = useState(templates[0]);
fisherdaddy's avatar
fisherdaddy committed
331
  const previewRef = useRef(null);
fisherdaddy's avatar
fisherdaddy committed
332
  const isLoading = usePageLoading();
fisherdaddy's avatar
fisherdaddy committed
333 334

  const handleDownload = async () => {
335
    const previewElement = previewRef.current;
336 337

    console.log('previewElement', previewElement);
338 339
    if (!previewElement) return;

fisherdaddy's avatar
fisherdaddy committed
340
    try {
341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363
      // 等待图片加载
      const waitForImages = () => {
        const images = previewElement.getElementsByTagName('img');
        const promises = Array.from(images).map(img => {
          if (img.complete) return Promise.resolve();
          return new Promise((resolve, reject) => {
            img.onload = resolve;
            img.onerror = reject;
            // 确保图片使用完整的 URL
            if (img.src.startsWith('/')) {
              img.src = window.location.origin + img.src;
            }
            // 添加跨域属性
            img.crossOrigin = 'anonymous';
          });
        });
        return Promise.all(promises);
      };

      await waitForImages();
      // 等待渲染完成
      await new Promise(resolve => setTimeout(resolve, 500));

fisherdaddy's avatar
fisherdaddy committed
364
      const html2canvas = (await import('html2canvas')).default;
365
      const canvas = await html2canvas(previewElement, {
366
        backgroundColor: selectedTemplate.fallbackColor,
fisherdaddy's avatar
fisherdaddy committed
367
        scale: 2,
368 369 370 371 372
        useCORS: true,
        allowTaint: false,
        logging: false,
        onclone: (clonedDoc) => {
          const clonedElement = clonedDoc.querySelector('.markdown-content');
373
          console.log('clonedElement', clonedElement);
374 375 376 377 378 379 380 381
          if (clonedElement) {
            clonedElement.style.width = '100%';
            clonedElement.style.position = 'relative';
            clonedElement.style.transform = 'none';
            clonedElement.style.transformOrigin = '0 0';
            clonedElement.style.overflow = 'visible';
          }
        }
fisherdaddy's avatar
fisherdaddy committed
382 383 384
      });

      const link = document.createElement('a');
385
      link.download = 'markdown-preview.png';
fisherdaddy's avatar
fisherdaddy committed
386 387 388
      link.href = canvas.toDataURL('image/png');
      link.click();
    } catch (error) {
389
      console.error('导出图片失败:', error);
fisherdaddy's avatar
fisherdaddy committed
390 391 392
    }
  };

393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425
  const renderPreview = () => {
    // 配置 marked 选项
    marked.setOptions({
      gfm: true, // 启用 GitHub 风格的 Markdown
      breaks: true, // 启用换行符转换为 <br>
      headerIds: true,
      mangle: false,
      pedantic: false,
      smartLists: true, // 优化列表输出
      smartypants: true, // 优化标点符号
    });

    // 使用 DOMPurify 清理 HTML
    const cleanHtml = DOMPurify.sanitize(marked(text), {
      ADD_TAGS: ['img'],
      ADD_ATTR: ['src', 'alt'],
    });

    return (
      <div
        ref={previewRef}
        dangerouslySetInnerHTML={{ __html: cleanHtml }}
        style={{
          fontFamily: selectedTemplate.font,
          color: selectedTemplate.textColor,
          background: selectedTemplate.bgColor,
          padding: selectedTemplate.padding,
          minHeight: '100%',
        }}
      />
    );
  };

fisherdaddy's avatar
fisherdaddy committed
426
  return (
fisherdaddy's avatar
fisherdaddy committed
427
    <>
fisherdaddy's avatar
fisherdaddy committed
428
      {isLoading && <LoadingOverlay />}
fisherdaddy's avatar
fisherdaddy committed
429
      <SEO
430 431
        title={t('tools.markdown2image.title')}
        description={t('tools.markdown2image.description')}
fisherdaddy's avatar
fisherdaddy committed
432
      />
433 434 435 436
      <Container>
        
        <ContentWrapper>
          <InputContainer>
437
            <TitleLabel>{t('tools.markdown2image.title')}</TitleLabel>
438 439
            
            {/* 模板选择 */}
440
            <TemplateSection>
441
              <Label>{t('tools.markdown2image.selectTemplate')}</Label>
442 443 444 445 446 447 448 449 450
              <TemplateGrid>
                {templates.map(template => (
                  <TemplateItem
                    key={template.name}
                    selected={template === selectedTemplate}
                    onClick={() => setSelectedTemplate(template)}
                    background={template.bgColor}
                    color={template.textColor}
                  >
451
                    {t(`tools.markdown2image.templates.${template.name}`)}
452 453 454
                  </TemplateItem>
                ))}
              </TemplateGrid>
455
            </TemplateSection>
456 457

            {/* Markdown 编辑器 */}
458
            <EditorSection>
459
              <Label>{t('tools.markdown2image.inputLabel')}</Label>
460
              <Editor
461 462
                value={text}
                onChange={(e) => setText(e.target.value)}
463
                placeholder={t('tools.markdown2image.placeholder')}
464
              />
465
            </EditorSection>
466 467
          </InputContainer>

468 469 470 471
          <PreviewContainer>
            <DownloadButton 
              onClick={handleDownload}
              visible={text.length > 0}
472
            >
473 474 475
              {t('tools.markdown2image.downloadButton')}
            </DownloadButton>
            {renderPreview()}
fisherdaddy's avatar
fisherdaddy committed
476
          </PreviewContainer>
477 478
        </ContentWrapper>
      </Container>
fisherdaddy's avatar
fisherdaddy committed
479
    </>
fisherdaddy's avatar
fisherdaddy committed
480 481 482
  );
}

fisherdaddy's avatar
fisherdaddy committed
483
export default MarkdownToImage;