Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/editor/EditorApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ function EditorAppInner({ initialCategory }: EditorAppProps) {
<span className="editor-topbar-name">{currentTemplate.name}</span>
</div>
<AITopbarButton />
<ExportBar apiUrl={apiUrl} downloadUrl={downloadUrl} params={state.params} templateId={state.templateId} />
<ExportBar apiUrl={apiUrl} downloadUrl={downloadUrl} params={state.params} templateId={state.templateId} svg={svg} />
</div>

{/* Mobile Tabs */}
Expand Down Expand Up @@ -278,7 +278,7 @@ function EditorAppInner({ initialCategory }: EditorAppProps) {
<Canvas svg={svg} loading={loading} error={error} />
</div>
{/* The topbar ExportBar always mounts and owns the shortcuts, so this second one opts out. */}
<ExportBar apiUrl={apiUrl} downloadUrl={downloadUrl} params={state.params} templateId={state.templateId} shortcuts={false} />
<ExportBar apiUrl={apiUrl} downloadUrl={downloadUrl} params={state.params} templateId={state.templateId} svg={svg} shortcuts={false} />
<PlatformPreviewStrip
svg={svg}
title={String(state.params.title || '')}
Expand Down
33 changes: 25 additions & 8 deletions src/components/editor/ExportBar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { generateMetaTags } from '@/lib/meta-tag-generator';
import { containsLocalImage, svgToPngBlob } from './export-utils';

interface ExportBarProps {
apiUrl: string;
downloadUrl: string;
params: Record<string, any>;
templateId: string;
svg: string | null;
/**
* Whether this bar owns the keyboard shortcuts. Pass false on a second
* ExportBar so Ctrl/Cmd+S and Ctrl/Cmd+Shift+C fire once, not once per bar.
Expand All @@ -24,22 +26,32 @@ export function ExportBar({
downloadUrl,
params,
templateId,
svg,
shortcuts = true,
}: ExportBarProps) {
const [downloading, setDownloading] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const downloadRef = useRef(downloadUrl);
const apiUrlRef = useRef(apiUrl);
const svgRef = useRef(svg);
const hasLocalImage = containsLocalImage(params);

downloadRef.current = downloadUrl;
apiUrlRef.current = apiUrl;
svgRef.current = svg;

const handleDownload = useCallback(async () => {
setDownloading(true);
try {
const res = await fetch(downloadRef.current);
if (!res.ok) throw new Error('Download failed');
const blob = await res.blob();
let blob: Blob;
if (hasLocalImage) {
if (!svgRef.current) throw new Error('Preview is not ready');
blob = await svgToPngBlob(svgRef.current);
} else {
const res = await fetch(downloadRef.current);
if (!res.ok) throw new Error('Download failed');
blob = await res.blob();
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
Expand All @@ -55,7 +67,11 @@ export function ExportBar({
} finally {
setDownloading(false);
}
}, [templateId]);
}, [hasLocalImage, templateId]);

const showLocalImageWarning = useCallback(() => {
window.showToast?.('Local images cannot be included in a shareable URL.', 'error');
}, []);

const copyToClipboard = useCallback(async (text: string, label: string) => {
try {
Expand Down Expand Up @@ -96,13 +112,14 @@ export function ExportBar({
// Ctrl/Cmd+Shift+C → Copy API URL
if (e.key === 'C' && e.shiftKey) {
e.preventDefault();
copyToClipboard(apiUrlRef.current, 'url');
if (hasLocalImage) showLocalImageWarning();
else copyToClipboard(apiUrlRef.current, 'url');
}
}

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleDownload, copyToClipboard, shortcuts]);
}, [handleDownload, copyToClipboard, hasLocalImage, showLocalImageWarning, shortcuts]);

const metaTags = generateMetaTags({
title: params.title ? String(params.title) : undefined,
Expand All @@ -126,15 +143,15 @@ export function ExportBar({
<button
type="button"
className="export-btn export-btn-ghost"
onClick={() => copyToClipboard(apiUrl, 'url')}
onClick={() => hasLocalImage ? showLocalImageWarning() : copyToClipboard(apiUrl, 'url')}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
{copied === 'url' ? 'Copied!' : 'Copy Image URL'}
</button>
<button
type="button"
className="export-btn export-btn-ghost"
onClick={() => copyToClipboard(metaTags, 'meta')}
onClick={() => hasLocalImage ? showLocalImageWarning() : copyToClipboard(metaTags, 'meta')}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
{copied === 'meta' ? 'Copied!' : 'Copy Meta Tags'}
Expand Down
26 changes: 26 additions & 0 deletions src/components/editor/export-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export function containsLocalImage(params: Record<string, unknown>): boolean {
return Object.values(params).some(
(value) => typeof value === 'string' && value.trim().toLowerCase().startsWith('data:image/')
);
}

export function svgToPngBlob(svg: string, width = 1200, height = 630): Promise<Blob> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) return reject(new Error('Canvas is not supported'));
try {
context.drawImage(image, 0, 0, width, height);
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('Could not create PNG'))), 'image/png');
} catch (error) {
reject(error);
}
};
image.onerror = () => reject(new Error('Could not render SVG'));
image.src = `data:image/svg+xml,${encodeURIComponent(svg)}`;
});
}
14 changes: 14 additions & 0 deletions tests/components/export-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { containsLocalImage } from '../../src/components/editor/export-utils';

describe('editor export utilities', () => {
it('detects local image data URLs', () => {
expect(containsLocalImage({ imageUrl: 'data:image/png;base64,abc' })).toBe(true);
expect(containsLocalImage({ imageUrl: 'https://example.com/photo.png' })).toBe(false);
});

it('ignores non-image data URLs and empty values', () => {
expect(containsLocalImage({ imageUrl: 'data:text/plain;base64,abc', title: '' })).toBe(false);
expect(containsLocalImage({ imageUrl: ' ' })).toBe(false);
});
});
2 changes: 1 addition & 1 deletion tests/editor/export-bar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const { ExportBar } = await import('@/components/editor/ExportBar');

function metaTagParams(params: Record<string, unknown>) {
renderToString(
React.createElement(ExportBar, { apiUrl: '/api/og?x=1', downloadUrl: '/api/og?x=1', params, templateId: 'saas-maintenance' }),
React.createElement(ExportBar, { apiUrl: '/api/og?x=1', downloadUrl: '/api/og?x=1', params, templateId: 'saas-maintenance', svg: null }),
);
return generateMetaTags.mock.calls.at(-1)![0];
}
Expand Down
Loading