🎉 first commit
This commit is contained in:
877
app/components/webbuilder/DiffView.tsx
Normal file
877
app/components/webbuilder/DiffView.tsx
Normal file
@@ -0,0 +1,877 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { type Change, diffLines } from 'diff';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createHighlighter } from 'shiki';
|
||||
import '~/styles/diff-view.css';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
import { formatCode } from '~/utils/prettier';
|
||||
|
||||
// 高亮结果缓存,使用 Map 存储已高亮的代码行
|
||||
const highlightCache = new Map<string, string>();
|
||||
// 格式化结果缓存,使用 Map 存储已格式化的代码
|
||||
const formatCache = new Map<string, string>();
|
||||
|
||||
// 差异计算结果缓存
|
||||
const diffCache = new Map<string, ReturnType<typeof processChanges>>();
|
||||
|
||||
interface VirtualizedListProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
itemHeight: number;
|
||||
className?: string;
|
||||
overscan?: number;
|
||||
}
|
||||
|
||||
function VirtualizedList<T>({ items, renderItem, itemHeight, className = '', overscan = 20 }: VirtualizedListProps<T>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||
const [renderedItems, setRenderedItems] = useState<React.ReactNode[]>([]);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const totalHeight = items.length * itemHeight;
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, clientHeight } = containerRef.current;
|
||||
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
||||
const end = Math.min(items.length, Math.ceil((scrollTop + clientHeight) / itemHeight) + overscan);
|
||||
|
||||
setVisibleRange({ start, end });
|
||||
}, [items.length, itemHeight, overscan]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
handleScroll();
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
}, [items.length, handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRendering) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRendering(true);
|
||||
|
||||
setTimeout(() => {
|
||||
const visibleItems = items.slice(visibleRange.start, visibleRange.end);
|
||||
const newRenderedItems = visibleItems.map((item, index) => {
|
||||
const actualIndex = visibleRange.start + index;
|
||||
return (
|
||||
<div
|
||||
key={actualIndex}
|
||||
style={{ position: 'absolute', top: actualIndex * itemHeight, height: itemHeight, width: '100%' }}
|
||||
>
|
||||
{renderItem(item, actualIndex)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
setRenderedItems(newRenderedItems);
|
||||
setIsRendering(false);
|
||||
}, 0);
|
||||
}, [items, visibleRange, itemHeight, renderItem, isRendering]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`virtualized-list ${className}`}
|
||||
style={{ position: 'relative', height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{ height: totalHeight, position: 'relative' }}>{renderedItems}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeComparisonProps {
|
||||
beforeCode?: string;
|
||||
afterCode: string;
|
||||
language: string;
|
||||
pageName: string;
|
||||
lightTheme: string;
|
||||
darkTheme: string;
|
||||
}
|
||||
|
||||
interface DiffBlock {
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
correspondingLine?: number;
|
||||
charChanges?: Array<{
|
||||
value: string;
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FullscreenButtonProps {
|
||||
onClick: () => void;
|
||||
isFullscreen: boolean;
|
||||
}
|
||||
|
||||
const FullscreenButton = memo(({ onClick, isFullscreen }: FullscreenButtonProps) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="ml-4 p-1 rounded hover:bg-upage-elements-background-depth-3 text-upage-elements-textTertiary hover:text-upage-elements-textPrimary transition-colors"
|
||||
title={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
|
||||
>
|
||||
<div className={isFullscreen ? 'i-ph:corners-in' : 'i-ph:corners-out'} />
|
||||
</button>
|
||||
));
|
||||
|
||||
const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => {
|
||||
if (!isFullscreen) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] bg-black/50 flex items-center justify-center p-6">
|
||||
<div className="size-full max-w-[90vw] max-h-[90vh] bg-upage-elements-background-depth-2 rounded-lg border border-upage-elements-borderColor shadow-xl overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const processChanges = (beforeCode?: string, afterCode?: string) => {
|
||||
try {
|
||||
const normalizeContent = (content: string): string[] => {
|
||||
return content
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd());
|
||||
};
|
||||
|
||||
if (!beforeCode || !afterCode) {
|
||||
return {
|
||||
beforeLines: [],
|
||||
afterLines: [],
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const beforeLines = normalizeContent(beforeCode);
|
||||
const afterLines = normalizeContent(afterCode);
|
||||
|
||||
if (beforeLines.join('\n') === afterLines.join('\n')) {
|
||||
return {
|
||||
beforeLines,
|
||||
afterLines,
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const lineChanges = {
|
||||
before: new Set<number>(),
|
||||
after: new Set<number>(),
|
||||
};
|
||||
|
||||
const unifiedBlocks: DiffBlock[] = [];
|
||||
|
||||
let i = 0,
|
||||
j = 0;
|
||||
|
||||
while (i < beforeLines.length || j < afterLines.length) {
|
||||
if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLines[j],
|
||||
type: 'unchanged',
|
||||
correspondingLine: i,
|
||||
});
|
||||
i++;
|
||||
j++;
|
||||
} else {
|
||||
let matchFound = false;
|
||||
const lookAhead = 3;
|
||||
|
||||
for (let k = 1; k <= lookAhead && i + k < beforeLines.length && j + k < afterLines.length; k++) {
|
||||
if (beforeLines[i + k] === afterLines[j]) {
|
||||
for (let l = 0; l < k; l++) {
|
||||
lineChanges.before.add(i + l);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i + l,
|
||||
content: beforeLines[i + l],
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [{ value: beforeLines[i + l], type: 'removed' }],
|
||||
});
|
||||
}
|
||||
i += k;
|
||||
matchFound = true;
|
||||
break;
|
||||
} else if (beforeLines[i] === afterLines[j + k]) {
|
||||
for (let l = 0; l < k; l++) {
|
||||
lineChanges.after.add(j + l);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j + l,
|
||||
content: afterLines[j + l],
|
||||
type: 'added',
|
||||
correspondingLine: i,
|
||||
charChanges: [{ value: afterLines[j + l], type: 'added' }],
|
||||
});
|
||||
}
|
||||
j += k;
|
||||
matchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchFound) {
|
||||
if (i < beforeLines.length && j < afterLines.length) {
|
||||
const beforeLine = beforeLines[i];
|
||||
const afterLine = afterLines[j];
|
||||
|
||||
let prefixLength = 0;
|
||||
|
||||
while (
|
||||
prefixLength < beforeLine.length &&
|
||||
prefixLength < afterLine.length &&
|
||||
beforeLine[prefixLength] === afterLine[prefixLength]
|
||||
) {
|
||||
prefixLength++;
|
||||
}
|
||||
|
||||
let suffixLength = 0;
|
||||
|
||||
while (
|
||||
suffixLength < beforeLine.length - prefixLength &&
|
||||
suffixLength < afterLine.length - prefixLength &&
|
||||
beforeLine[beforeLine.length - 1 - suffixLength] === afterLine[afterLine.length - 1 - suffixLength]
|
||||
) {
|
||||
suffixLength++;
|
||||
}
|
||||
|
||||
const prefix = beforeLine.slice(0, prefixLength);
|
||||
const beforeMiddle = beforeLine.slice(prefixLength, beforeLine.length - suffixLength);
|
||||
const afterMiddle = afterLine.slice(prefixLength, afterLine.length - suffixLength);
|
||||
const suffix = beforeLine.slice(beforeLine.length - suffixLength);
|
||||
|
||||
if (beforeMiddle || afterMiddle) {
|
||||
if (beforeMiddle) {
|
||||
lineChanges.before.add(i);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i,
|
||||
content: beforeLine,
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [
|
||||
{ value: prefix, type: 'unchanged' },
|
||||
{ value: beforeMiddle, type: 'removed' },
|
||||
{ value: suffix, type: 'unchanged' },
|
||||
],
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
if (afterMiddle) {
|
||||
lineChanges.after.add(j);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLine,
|
||||
type: 'added',
|
||||
correspondingLine: i - 1,
|
||||
charChanges: [
|
||||
{ value: prefix, type: 'unchanged' },
|
||||
{ value: afterMiddle, type: 'added' },
|
||||
{ value: suffix, type: 'unchanged' },
|
||||
],
|
||||
});
|
||||
j++;
|
||||
}
|
||||
} else {
|
||||
if (i < beforeLines.length) {
|
||||
lineChanges.before.add(i);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i,
|
||||
content: beforeLines[i],
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [{ value: beforeLines[i], type: 'removed' }],
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
if (j < afterLines.length) {
|
||||
lineChanges.after.add(j);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLines[j],
|
||||
type: 'added',
|
||||
correspondingLine: i - 1,
|
||||
charChanges: [{ value: afterLines[j], type: 'added' }],
|
||||
});
|
||||
j++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle remaining lines
|
||||
if (i < beforeLines.length) {
|
||||
lineChanges.before.add(i);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i,
|
||||
content: beforeLines[i],
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [{ value: beforeLines[i], type: 'removed' }],
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
if (j < afterLines.length) {
|
||||
lineChanges.after.add(j);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLines[j],
|
||||
type: 'added',
|
||||
correspondingLine: i - 1,
|
||||
charChanges: [{ value: afterLines[j], type: 'added' }],
|
||||
});
|
||||
j++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort blocks by line number
|
||||
const processedBlocks = unifiedBlocks.sort((a, b) => a.lineNumber - b.lineNumber);
|
||||
|
||||
return {
|
||||
beforeLines,
|
||||
afterLines,
|
||||
hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
|
||||
lineChanges,
|
||||
unifiedBlocks: processedBlocks,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing changes:', error);
|
||||
return {
|
||||
beforeLines: [],
|
||||
afterLines: [],
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const lineNumberStyles =
|
||||
'w-9 shrink-0 pl-2 py-1 text-left font-mono text-upage-elements-textTertiary border-r border-upage-elements-borderColor bg-upage-elements-background-depth-1';
|
||||
const lineContentStyles =
|
||||
'px-1 py-1 font-mono whitespace-pre flex-1 group-hover:bg-upage-elements-background-depth-2 text-upage-elements-textPrimary';
|
||||
const diffPanelStyles = 'h-full overflow-auto diff-panel-content';
|
||||
|
||||
// Updated color styles for better consistency
|
||||
const diffLineStyles = {
|
||||
added: 'bg-green-500/10 dark:bg-green-500/20 border-l-4 border-green-500',
|
||||
removed: 'bg-red-500/10 dark:bg-red-500/20 border-l-4 border-red-500',
|
||||
unchanged: '',
|
||||
};
|
||||
|
||||
const changeColorStyles = {
|
||||
added: 'text-green-700 dark:text-green-500 bg-green-500/10 dark:bg-green-500/20',
|
||||
removed: 'text-red-700 dark:text-red-500 bg-red-500/10 dark:bg-red-500/20',
|
||||
unchanged: 'text-upage-elements-textPrimary',
|
||||
};
|
||||
|
||||
const renderContentWarning = () => (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-upage-elements-textTertiary">
|
||||
<div className="i-ph:warning-circle text-4xl text-red-400 mb-2 mx-auto" />
|
||||
<p className="font-medium text-upage-elements-textPrimary">Error processing page</p>
|
||||
<p className="text-sm mt-1">Could not generate diff preview</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NoChangesView = memo(
|
||||
({
|
||||
beforeCode,
|
||||
language,
|
||||
highlighter,
|
||||
theme,
|
||||
}: {
|
||||
beforeCode?: string;
|
||||
language: string;
|
||||
highlighter: any;
|
||||
theme: string;
|
||||
}) => {
|
||||
const codeBlocks = useMemo(() => {
|
||||
if (!beforeCode) {
|
||||
return [];
|
||||
}
|
||||
return beforeCode.split('\n').map((line, index) => ({
|
||||
lineNumber: index,
|
||||
content: line,
|
||||
type: 'unchanged' as const,
|
||||
correspondingLine: index,
|
||||
}));
|
||||
}, [beforeCode]);
|
||||
|
||||
const renderCodeLine = useCallback(
|
||||
(block: DiffBlock, index: number) => (
|
||||
<CodeLine
|
||||
key={`unchanged-${index}`}
|
||||
lineNumber={block.lineNumber}
|
||||
content={block.content}
|
||||
type={block.type}
|
||||
highlighter={highlighter}
|
||||
language={language}
|
||||
block={block}
|
||||
theme={theme}
|
||||
/>
|
||||
),
|
||||
[highlighter, language, theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center p-4">
|
||||
<div className="text-center text-upage-elements-textTertiary">
|
||||
<div className="i-ph:files text-4xl text-green-400 mb-2 mx-auto" />
|
||||
<p className="font-medium text-upage-elements-textPrimary">页面内容相同</p>
|
||||
<p className="text-sm mt-1">两个版本完全相同</p>
|
||||
</div>
|
||||
<div className="mt-4 w-full max-w-2xl bg-upage-elements-background-depth-1 rounded-lg border border-upage-elements-borderColor overflow-hidden">
|
||||
<div className="p-2 text-xs font-bold text-upage-elements-textTertiary border-b border-upage-elements-borderColor">
|
||||
当前内容
|
||||
</div>
|
||||
<div className="overflow-auto max-h-96">
|
||||
{codeBlocks.length > 0 ? (
|
||||
<VirtualizedList
|
||||
items={codeBlocks}
|
||||
renderItem={renderCodeLine}
|
||||
itemHeight={24}
|
||||
className="overflow-x-auto"
|
||||
overscan={10}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-center text-upage-elements-textTertiary">无内容</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const useProcessChanges = (beforeCode?: string, afterCode?: string) => {
|
||||
return useMemo(() => {
|
||||
if (!beforeCode || !afterCode) {
|
||||
return processChanges(beforeCode, afterCode);
|
||||
}
|
||||
|
||||
const cacheKey = `${beforeCode}:${afterCode}`;
|
||||
|
||||
if (diffCache.has(cacheKey)) {
|
||||
return diffCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const result = processChanges(beforeCode, afterCode);
|
||||
diffCache.set(cacheKey, result);
|
||||
return result;
|
||||
}, [beforeCode, afterCode]);
|
||||
};
|
||||
|
||||
const CodeLine = memo(
|
||||
({
|
||||
lineNumber,
|
||||
content,
|
||||
type,
|
||||
highlighter,
|
||||
language,
|
||||
block,
|
||||
theme,
|
||||
}: {
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
highlighter: any;
|
||||
language: string;
|
||||
block: DiffBlock;
|
||||
theme: string;
|
||||
}) => {
|
||||
const bgColor = diffLineStyles[type];
|
||||
const currentTheme = theme === 'dark' ? 'github-dark' : 'github-light';
|
||||
|
||||
const [isHighlighted, setIsHighlighted] = useState(false);
|
||||
const [highlightedContent, setHighlightedContent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlighter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (type === 'unchanged' || !block.charChanges) {
|
||||
const cacheKey = `${content}:${language}:${currentTheme}`;
|
||||
|
||||
if (highlightCache.has(cacheKey)) {
|
||||
setHighlightedContent(highlightCache.get(cacheKey)!);
|
||||
setIsHighlighted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const highlighted = highlighter
|
||||
.codeToHtml(content, { lang: language, theme: currentTheme })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '');
|
||||
|
||||
highlightCache.set(cacheKey, highlighted);
|
||||
setHighlightedContent(highlighted);
|
||||
setIsHighlighted(true);
|
||||
} else {
|
||||
const fragments: string[] = [];
|
||||
|
||||
for (let i = 0; i < block.charChanges!.length; i++) {
|
||||
const change = block.charChanges![i];
|
||||
const changeClass = changeColorStyles[change.type];
|
||||
|
||||
const cacheKey = `${change.value}:${language}:${currentTheme}:${change.type}`;
|
||||
|
||||
let highlighted;
|
||||
if (highlightCache.has(cacheKey)) {
|
||||
highlighted = highlightCache.get(cacheKey);
|
||||
} else {
|
||||
highlighted = highlighter
|
||||
.codeToHtml(change.value, { lang: language, theme: currentTheme })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '');
|
||||
|
||||
highlightCache.set(cacheKey, highlighted);
|
||||
}
|
||||
|
||||
fragments.push(`<span class="${changeClass}">${highlighted}</span>`);
|
||||
}
|
||||
|
||||
setHighlightedContent(fragments.join(''));
|
||||
setIsHighlighted(true);
|
||||
}
|
||||
}, 10);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [content, language, currentTheme, highlighter, type, block.charChanges]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isHighlighted && highlightedContent) {
|
||||
return <span dangerouslySetInnerHTML={{ __html: highlightedContent }} />;
|
||||
}
|
||||
|
||||
if (type === 'unchanged' || !block.charChanges) {
|
||||
return <span>{content}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{block.charChanges.map((change, index) => {
|
||||
const changeClass = changeColorStyles[change.type];
|
||||
return (
|
||||
<span key={index} className={changeClass}>
|
||||
{change.value}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex group min-w-fit">
|
||||
<div className={lineNumberStyles}>{lineNumber + 1}</div>
|
||||
<div className={`${lineContentStyles} ${bgColor}`}>
|
||||
<span className="mr-2 text-upage-elements-textTertiary">
|
||||
{type === 'added' && <span className="text-green-700 dark:text-green-500">+</span>}
|
||||
{type === 'removed' && <span className="text-red-700 dark:text-red-500">-</span>}
|
||||
{type === 'unchanged' && ' '}
|
||||
</span>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 显示文件信息
|
||||
const PageInfo = memo(
|
||||
({
|
||||
pageName,
|
||||
hasChanges,
|
||||
onToggleFullscreen,
|
||||
isFullscreen,
|
||||
beforeCode,
|
||||
afterCode,
|
||||
}: {
|
||||
pageName: string;
|
||||
hasChanges: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
isFullscreen: boolean;
|
||||
beforeCode?: string;
|
||||
afterCode: string;
|
||||
}) => {
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
if (!hasChanges) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
if (!beforeCode || !afterCode) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(beforeCode, afterCode, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
});
|
||||
|
||||
return changes.reduce(
|
||||
(acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
}, [hasChanges, beforeCode, afterCode]);
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-upage-elements-background-depth-1 p-2 text-sm text-upage-elements-textPrimary shrink-0">
|
||||
<div className="i-ph:file mr-2 size-4 shrink-0" />
|
||||
<span className="truncate">{pageName}</span>
|
||||
<span className="ml-auto shrink-0 flex items-center gap-2">
|
||||
{hasChanges ? (
|
||||
<>
|
||||
{showStats && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{additions > 0 && <span className="text-green-700 dark:text-green-500">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-red-700 dark:text-red-500">-{deletions}</span>}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-yellow-600 dark:text-yellow-400">已修改</span>
|
||||
<span className="text-upage-elements-textTertiary text-xs">{new Date().toLocaleTimeString()}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-green-700 dark:text-green-400">无变化</span>
|
||||
)}
|
||||
<FullscreenButton onClick={onToggleFullscreen} isFullscreen={isFullscreen} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const InlineDiffComparison = memo(({ beforeCode, afterCode, pageName, language }: CodeComparisonProps) => {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [highlighter, setHighlighter] = useState<any>(null);
|
||||
const [isHighlighterLoading, setIsHighlighterLoading] = useState(false);
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const { unifiedBlocks, hasChanges, error } = useProcessChanges(beforeCode, afterCode);
|
||||
|
||||
const loadHighlighter = useCallback(() => {
|
||||
if (!highlighter && !isHighlighterLoading && hasChanges) {
|
||||
setIsHighlighterLoading(true);
|
||||
createHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx', 'plaintext'],
|
||||
})
|
||||
.then(setHighlighter)
|
||||
.finally(() => {
|
||||
setIsHighlighterLoading(false);
|
||||
});
|
||||
}
|
||||
}, [highlighter, isHighlighterLoading, hasChanges]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasChanges) {
|
||||
loadHighlighter();
|
||||
}
|
||||
}, [hasChanges, loadHighlighter]);
|
||||
|
||||
const renderCodeLine = useCallback(
|
||||
(block: DiffBlock, index: number) => (
|
||||
<CodeLine
|
||||
key={`${block.lineNumber}-${index}`}
|
||||
lineNumber={block.lineNumber}
|
||||
content={block.content}
|
||||
type={block.type}
|
||||
highlighter={highlighter}
|
||||
language={language}
|
||||
block={block}
|
||||
theme={theme}
|
||||
/>
|
||||
),
|
||||
[highlighter, language, theme],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return renderContentWarning();
|
||||
}
|
||||
|
||||
return (
|
||||
<FullscreenOverlay isFullscreen={isFullscreen}>
|
||||
<div className="size-full flex flex-col">
|
||||
<PageInfo
|
||||
pageName={pageName}
|
||||
hasChanges={hasChanges}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
isFullscreen={isFullscreen}
|
||||
beforeCode={beforeCode}
|
||||
afterCode={afterCode}
|
||||
/>
|
||||
<div className={diffPanelStyles}>
|
||||
{hasChanges ? (
|
||||
<div className="overflow-x-auto min-w-full">
|
||||
<VirtualizedList
|
||||
items={unifiedBlocks}
|
||||
renderItem={renderCodeLine}
|
||||
itemHeight={24}
|
||||
className="overflow-x-auto"
|
||||
overscan={30}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<NoChangesView beforeCode={beforeCode} language={language} highlighter={highlighter} theme={theme} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenOverlay>
|
||||
);
|
||||
});
|
||||
|
||||
export const DiffView = memo(() => {
|
||||
const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory);
|
||||
const pages = useStore(webBuilderStore.pagesStore.pages);
|
||||
const selectedPage = useStore(webBuilderStore.pagesStore.activePage);
|
||||
const currentPage = useStore(webBuilderStore.pagesStore.currentPage);
|
||||
const currentView = useStore(webBuilderStore.currentView);
|
||||
const [formattedContent, setFormattedContent] = useState<string>('');
|
||||
const [effectiveOriginalContent, setEffectiveOriginalContent] = useState<string>('');
|
||||
const [shouldProcess, setShouldProcess] = useState(false);
|
||||
|
||||
// 存当前页面内容的键,用于检测内容是否变化缓
|
||||
const contentCacheKey = useRef<string | null>(null);
|
||||
|
||||
// 当视图切换到 diff 时,标记需要处理
|
||||
useEffect(() => {
|
||||
if (currentView === 'diff') {
|
||||
setShouldProcess(true);
|
||||
}
|
||||
}, [currentView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPage || !currentPage || currentView !== 'diff' || !shouldProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = pages[selectedPage];
|
||||
const originalContent = page && 'content' in page ? page.content : '';
|
||||
|
||||
const history = pageHistory[selectedPage];
|
||||
const originalContentToFormat = history?.originalContent || originalContent;
|
||||
|
||||
if (currentPage?.content) {
|
||||
const currentContent = currentPage.content as string;
|
||||
|
||||
if (formatCache.has(currentContent)) {
|
||||
setFormattedContent(formatCache.get(currentContent)!);
|
||||
|
||||
contentCacheKey.current = currentContent;
|
||||
} else {
|
||||
formatCode(currentContent, { parser: 'html' })
|
||||
.then((formatted) => {
|
||||
formatCache.set(currentContent, formatted);
|
||||
setFormattedContent(formatted);
|
||||
|
||||
contentCacheKey.current = currentContent;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('格式化当前内容失败:', error);
|
||||
setFormattedContent(currentContent);
|
||||
contentCacheKey.current = currentContent;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (originalContentToFormat) {
|
||||
if (formatCache.has(originalContentToFormat)) {
|
||||
setEffectiveOriginalContent(formatCache.get(originalContentToFormat)!);
|
||||
} else {
|
||||
formatCode(originalContentToFormat, { parser: 'html' })
|
||||
.then((formatted) => {
|
||||
formatCache.set(originalContentToFormat, formatted);
|
||||
setEffectiveOriginalContent(formatted);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('格式化原始内容失败:', error);
|
||||
setEffectiveOriginalContent(originalContentToFormat);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setShouldProcess(false);
|
||||
}, [currentPage?.content, selectedPage, currentView, shouldProcess, pageHistory, pages]);
|
||||
|
||||
if (!selectedPage || !currentPage) {
|
||||
return (
|
||||
<div className="flex size-full justify-center items-center bg-upage-elements-background-depth-1 text-upage-elements-textPrimary">
|
||||
选择一个页面来查看差异
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
<InlineDiffComparison
|
||||
beforeCode={effectiveOriginalContent}
|
||||
afterCode={formattedContent}
|
||||
language={'html'}
|
||||
pageName={selectedPage}
|
||||
lightTheme="github-light"
|
||||
darkTheme="github-dark"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('DiffView render error:', error);
|
||||
return (
|
||||
<div className="flex size-full justify-center items-center bg-upage-elements-background-depth-1 text-red-400">
|
||||
<div className="text-center">
|
||||
<div className="i-ph:warning-circle text-4xl mb-2" />
|
||||
<p>渲染差异视图失败</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
102
app/components/webbuilder/EditorPanel.tsx
Normal file
102
app/components/webbuilder/EditorPanel.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { memo } from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { ErrorBoundary } from '~/components/ErrorBoundary';
|
||||
import type { PageMap } from '~/lib/stores/pages';
|
||||
import type { PageHistory, Section } from '~/types/actions';
|
||||
import type { DocumentProperties } from '~/types/editor';
|
||||
import { logger, renderLogger } from '~/utils/logger';
|
||||
import { isMobile } from '~/utils/mobile';
|
||||
import {
|
||||
EditorStudio,
|
||||
type OnChangeCallback,
|
||||
type OnLoadCallback,
|
||||
type OnReadyCallback,
|
||||
type OnSaveCallback,
|
||||
} from '../editor/Editor';
|
||||
import PageTree from './PageTree';
|
||||
|
||||
interface EditorPanelProps {
|
||||
documents?: Record<string, DocumentProperties>;
|
||||
currentPage?: string;
|
||||
currentSection?: Section;
|
||||
pages?: PageMap;
|
||||
unsavedPages?: Set<string>;
|
||||
pageHistory?: Record<string, PageHistory>;
|
||||
isStreaming?: boolean;
|
||||
onEditorChange?: OnChangeCallback;
|
||||
onPageSave?: OnSaveCallback;
|
||||
onPageSelect?: (pageName: string) => void;
|
||||
onPageReset?: () => void;
|
||||
onLoad?: OnLoadCallback;
|
||||
onReady?: OnReadyCallback;
|
||||
}
|
||||
|
||||
const editorSettings: any = { tabSize: 2 };
|
||||
|
||||
export const EditorPanel = memo(
|
||||
({
|
||||
documents,
|
||||
pages,
|
||||
unsavedPages,
|
||||
currentPage,
|
||||
currentSection,
|
||||
isStreaming,
|
||||
onEditorChange,
|
||||
onPageSave,
|
||||
onPageSelect,
|
||||
onPageReset,
|
||||
onLoad,
|
||||
onReady,
|
||||
}: EditorPanelProps) => {
|
||||
renderLogger.trace('EditorPanel');
|
||||
return (
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={100} minSize={20}>
|
||||
<PanelGroup direction="horizontal">
|
||||
<Panel defaultSize={20} minSize={15} collapsible className="border-r border-upage-elements-borderColor">
|
||||
<div className="h-full">
|
||||
<Tabs.Root defaultValue="pages" className="flex flex-col h-full">
|
||||
<Tabs.Content value="pages" className="flex-grow overflow-auto focus-visible:outline-none">
|
||||
<PageTree
|
||||
className="h-full"
|
||||
pages={pages}
|
||||
unsavedPages={unsavedPages}
|
||||
selectedPage={currentPage}
|
||||
onPageSelect={onPageSelect}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle />
|
||||
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
|
||||
<div className="h-full flex-1 overflow-hidden">
|
||||
<ErrorBoundary
|
||||
onError={(error) => {
|
||||
logger.error('Editor 组件发生错误', { error });
|
||||
}}
|
||||
>
|
||||
<EditorStudio
|
||||
documents={documents}
|
||||
editable={!isStreaming && currentPage !== undefined}
|
||||
settings={editorSettings}
|
||||
currentPage={currentPage}
|
||||
currentSection={currentSection}
|
||||
autoFocusOnDocumentChange={!isMobile()}
|
||||
onChange={onEditorChange}
|
||||
onSave={onPageSave}
|
||||
onReset={onPageReset}
|
||||
onLoad={onLoad}
|
||||
onReady={onReady}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
},
|
||||
);
|
||||
65
app/components/webbuilder/InlineInput.tsx
Normal file
65
app/components/webbuilder/InlineInput.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface InlineInputProps {
|
||||
depth: number;
|
||||
placeholder: string;
|
||||
initialValue?: string;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const NODE_PADDING_LEFT = 8;
|
||||
|
||||
export function InlineInput({ depth, placeholder, initialValue = '', onSubmit, onCancel }: InlineInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
|
||||
if (initialValue) {
|
||||
inputRef.current.value = initialValue;
|
||||
inputRef.current.select();
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
const value = inputRef.current?.value.trim();
|
||||
|
||||
if (value) {
|
||||
onSubmit(value);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center w-full px-2 bg-upage-elements-background-depth-4 border border-upage-elements-item-contentAccent py-0.5 text-upage-elements-textPrimary"
|
||||
style={{ paddingLeft: `${6 + depth * NODE_PADDING_LEFT}px` }}
|
||||
>
|
||||
<div className="scale-120 shrink-0 i-ph:file-plus text-upage-elements-textTertiary" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="ml-2 flex-1 bg-transparent border-none outline-none py-0.5 text-sm text-upage-elements-textPrimary placeholder:text-upage-elements-textTertiary min-w-0"
|
||||
placeholder={placeholder}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement !== inputRef.current) {
|
||||
onCancel();
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
app/components/webbuilder/PageDropdown.tsx
Normal file
86
app/components/webbuilder/PageDropdown.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import type { PreviewInfo } from '~/lib/stores/previews';
|
||||
|
||||
interface PageDropdownProps {
|
||||
activePreviewIndex: number;
|
||||
setActivePreviewIndex: (index: number) => void;
|
||||
isDropdownOpen: boolean;
|
||||
setIsDropdownOpen: (value: boolean) => void;
|
||||
setHasSelectedPreview: (value: boolean) => void;
|
||||
previews: PreviewInfo[];
|
||||
}
|
||||
|
||||
export const PageDropdown = memo(
|
||||
({
|
||||
activePreviewIndex,
|
||||
setActivePreviewIndex,
|
||||
isDropdownOpen,
|
||||
setIsDropdownOpen,
|
||||
setHasSelectedPreview,
|
||||
previews,
|
||||
}: PageDropdownProps) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// sort previews alphabetically by filename, preserving original index
|
||||
const sortedPreviews = previews
|
||||
.map((previewInfo, index) => ({ ...previewInfo, index }))
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
|
||||
// close dropdown if user clicks outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isDropdownOpen) {
|
||||
window.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
window.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative z-max" ref={dropdownRef}>
|
||||
<div ref={buttonRef}>
|
||||
<IconButton icon="i-ph:files" onClick={() => setIsDropdownOpen(!isDropdownOpen)} />
|
||||
</div>
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-upage-elements-background-depth-2 border border-upage-elements-borderColor rounded shadow-sm min-w-[180px] dropdown-animation">
|
||||
<div className="px-4 py-2 border-b border-upage-elements-borderColor text-sm font-semibold text-upage-elements-textPrimary">
|
||||
页面
|
||||
</div>
|
||||
{sortedPreviews.map((preview) => (
|
||||
<div
|
||||
key={preview.filename}
|
||||
className="flex items-center px-4 py-2 cursor-pointer hover:bg-upage-elements-item-backgroundActive"
|
||||
onClick={() => {
|
||||
setActivePreviewIndex(preview.index);
|
||||
setIsDropdownOpen(false);
|
||||
setHasSelectedPreview(true);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
activePreviewIndex === preview.index
|
||||
? 'text-upage-elements-item-contentAccent'
|
||||
: 'text-upage-elements-item-contentDefault group-hover:text-upage-elements-item-contentActive'
|
||||
}
|
||||
>
|
||||
{preview.filename}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
453
app/components/webbuilder/PageTree.tsx
Normal file
453
app/components/webbuilder/PageTree.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
import classNames from 'classnames';
|
||||
import { type Change, diffLines } from 'diff';
|
||||
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { ConfirmationDialog, Dialog, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||
import type { PageMap } from '~/lib/stores/pages';
|
||||
import { webBuilderStore } from '~/lib/stores/web-builder';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('PageTree');
|
||||
|
||||
interface PageNode {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pages?: PageMap;
|
||||
selectedPage?: string;
|
||||
onPageSelect?: (pageName: string) => void;
|
||||
unsavedPages?: Set<string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PageTree = memo(({ pages = {}, onPageSelect, selectedPage, className, unsavedPages }: Props) => {
|
||||
renderLogger.trace('PageTree');
|
||||
|
||||
const pageList = useMemo(() => {
|
||||
return buildPageList(pages);
|
||||
}, [pages]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'text-sm rounded-md border border-upage-elements-borderColor',
|
||||
className,
|
||||
'overflow-y-auto modern-scrollbar',
|
||||
)}
|
||||
>
|
||||
<div className="p-2 border-b border-upage-elements-borderColor bg-upage-elements-background-depth-1">
|
||||
<h3 className="font-medium text-upage-elements-textPrimary">页面列表</h3>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{pageList.map((page) => (
|
||||
<Page
|
||||
key={page.id}
|
||||
selected={selectedPage === page.name}
|
||||
page={page}
|
||||
unsavedChanges={unsavedPages instanceof Set && unsavedPages.has(page.name)}
|
||||
onClick={() => {
|
||||
onPageSelect?.(page.name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PageTree;
|
||||
|
||||
function ContextMenuItem({ onSelect, children }: { onSelect?: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<ContextMenu.Item
|
||||
onSelect={onSelect}
|
||||
className="flex items-center w-full px-3 py-2 outline-0 text-sm cursor-pointer rounded-md transition-colors duration-200 hover:bg-upage-elements-item-backgroundActive hover:text-upage-elements-item-contentActive"
|
||||
>
|
||||
{children}
|
||||
</ContextMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function PageContextMenu({ pageName, children }: { pageName: string; children: ReactNode }) {
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleCreatePage = async (pageName: string, pageTitle: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const success = await webBuilderStore.createPage(pageName, pageTitle);
|
||||
|
||||
if (success) {
|
||||
toast.success('页面创建成功');
|
||||
} else {
|
||||
toast.error('页面创建失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('页面创建失败');
|
||||
logger.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const success = await webBuilderStore.deletePage(pageName);
|
||||
|
||||
if (success) {
|
||||
toast.success(`页面删除成功`);
|
||||
setIsDeleteDialogOpen(false);
|
||||
} else {
|
||||
toast.error(`页面删除失败`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`页面删除失败`);
|
||||
logger.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
<ContextMenu.Trigger>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={classNames('relative', {
|
||||
'bg-upage-elements-background-depth-2 border border-dashed border-upage-elements-item-contentAccent rounded-md':
|
||||
isDragging,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content
|
||||
style={{ zIndex: 998 }}
|
||||
className="min-w-56 p-1 border border-upage-elements-borderColor rounded-md z-context-menu bg-upage-elements-background-depth-1 dark:bg-upage-elements-background-depth-2 data-[state=open]:animate-in animate-duration-100 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-98 shadow-lg"
|
||||
>
|
||||
<ContextMenu.Group className="mb-1">
|
||||
<ContextMenuItem onSelect={() => setIsCreateDialogOpen(true)}>
|
||||
<div className="flex items-center gap-2 text-upage-elements-textPrimary">
|
||||
<div className="i-ph:file-plus text-green-500" />
|
||||
新建页面
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</ContextMenu.Group>
|
||||
|
||||
<ContextMenu.Separator className="h-px bg-upage-elements-borderColor my-1" />
|
||||
|
||||
<ContextMenu.Group className="mt-1">
|
||||
<ContextMenuItem onSelect={() => setIsDeleteDialogOpen(true)}>
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<div className="i-ph:trash" />
|
||||
删除页面
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</ContextMenu.Group>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<CreatePageDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
onConfirm={handleCreatePage}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={() => setIsDeleteDialogOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="删除页面"
|
||||
description={`确定要删除页面 "${pageName}" 吗?此操作不可撤销。`}
|
||||
confirmLabel="删除"
|
||||
cancelLabel="取消"
|
||||
variant="destructive"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
page: PageNode;
|
||||
selected: boolean;
|
||||
unsavedChanges?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function formatSaveTime(timestamp: number): string {
|
||||
const saveDate = new Date(timestamp);
|
||||
return `${saveDate.getHours().toString().padStart(2, '0')}:${saveDate.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function Page({ page, onClick, selected, unsavedChanges = false }: PageProps) {
|
||||
const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory);
|
||||
const { name, title } = page;
|
||||
const pageModifications = pageHistory[name];
|
||||
const lastSavedTimes = useStore(webBuilderStore.editorStore.documentLastSaved);
|
||||
const lastSavedTime = lastSavedTimes[name];
|
||||
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
if (!pageModifications?.originalContent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const normalizedOriginal = pageModifications.originalContent.replace(/\r\n/g, '\n');
|
||||
const normalizedCurrent =
|
||||
pageModifications.versions[pageModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
|
||||
|
||||
if (normalizedOriginal === normalizedCurrent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
});
|
||||
|
||||
return changes.reduce(
|
||||
(acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
}, [pageModifications]);
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
<PageContextMenu pageName={name}>
|
||||
<div
|
||||
className={classNames('rounded-md transition-colors duration-200 my-1 overflow-hidden', {
|
||||
'bg-upage-elements-background-depth-1 hover:bg-upage-elements-item-backgroundActive': !selected,
|
||||
'bg-upage-elements-item-backgroundAccent': selected,
|
||||
})}
|
||||
>
|
||||
<NodeButton
|
||||
className={classNames('group', {
|
||||
'text-upage-elements-item-contentDefault': !selected,
|
||||
'text-upage-elements-item-contentAccent': selected,
|
||||
})}
|
||||
iconClasses={classNames('i-ph:file-duotone scale-98', {
|
||||
'group-hover:text-upage-elements-item-contentActive': !selected,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div
|
||||
className={classNames('flex items-center', {
|
||||
'group-hover:text-upage-elements-item-contentActive': !selected,
|
||||
})}
|
||||
>
|
||||
<div className="flex-1 font-medium truncate pr-2">{title || name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{showStats && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{additions > 0 && <span className="text-green-500">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
|
||||
</div>
|
||||
)}
|
||||
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-xs text-upage-elements-textTertiary mt-0.5">
|
||||
<span className="truncate opacity-80">{name}</span>
|
||||
{lastSavedTime && !unsavedChanges && (
|
||||
<span className="flex items-center text-xs whitespace-nowrap">
|
||||
<span className="i-ph:clock-clockwise size-4 mr-1 scale-90 inline-block" />
|
||||
{formatSaveTime(lastSavedTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NodeButton>
|
||||
</div>
|
||||
</PageContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface ButtonProps {
|
||||
iconClasses: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function NodeButton({ iconClasses, onClick, className, children }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classNames('flex items-start gap-1.5 w-full px-3 py-2 border-2 border-transparent', className)}
|
||||
onClick={() => onClick?.()}
|
||||
>
|
||||
<div className={classNames('scale-120 shrink-0 mt-0.5', iconClasses)}></div>
|
||||
<div className="w-full text-left">{children}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPageList(pages: PageMap): PageNode[] {
|
||||
const nodeList: PageNode[] = [];
|
||||
const pageList = Object.values(pages);
|
||||
for (const page of pageList) {
|
||||
if (!page) {
|
||||
continue;
|
||||
}
|
||||
nodeList.push({
|
||||
id: page.name,
|
||||
name: page.name,
|
||||
title: page.title ?? '未命名页面',
|
||||
});
|
||||
}
|
||||
return nodeList.sort((a, b) => compareNodes(a, b));
|
||||
}
|
||||
|
||||
function compareNodes(a: PageNode, b: PageNode): number {
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
interface CreatePageDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (pageName: string, pageTitle: string) => void;
|
||||
}
|
||||
|
||||
function CreatePageDialog({ isOpen, onClose, onConfirm }: CreatePageDialogProps) {
|
||||
const [pageName, setPageName] = useState('');
|
||||
const [pageTitle, setPageTitle] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setPageName('');
|
||||
setPageTitle('');
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!pageName.trim()) {
|
||||
setError('页面名称不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(pageName)) {
|
||||
setError('页面名称只能包含字母、数字、连字符和下划线');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await onConfirm(pageName.trim(), pageTitle.trim() || '未命名页面');
|
||||
onClose();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogRoot open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog showCloseButton={true} onClose={onClose}>
|
||||
<div className="p-6 bg-white dark:bg-gray-950 relative z-10">
|
||||
<DialogTitle>新建页面</DialogTitle>
|
||||
<DialogDescription className="mb-4">请输入页面文件名和标题</DialogDescription>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="pageName" className="block text-sm font-medium text-upage-elements-textPrimary">
|
||||
页面文件名 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="pageName"
|
||||
type="text"
|
||||
value={pageName}
|
||||
onChange={(e) => setPageName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-upage-elements-borderColor rounded-md bg-upage-elements-background-depth-1 text-upage-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-upage-elements-item-contentAccent"
|
||||
placeholder="例如:about"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-upage-elements-textTertiary">只能包含字母、数字、连字符和下划线</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="pageTitle" className="block text-sm font-medium text-upage-elements-textPrimary">
|
||||
页面标题
|
||||
</label>
|
||||
<input
|
||||
id="pageTitle"
|
||||
type="text"
|
||||
value={pageTitle}
|
||||
onChange={(e) => setPageTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-upage-elements-borderColor rounded-md bg-upage-elements-background-depth-1 text-upage-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-upage-elements-item-contentAccent"
|
||||
placeholder="例如:关于我们"
|
||||
/>
|
||||
<p className="text-xs text-upage-elements-textTertiary">如果不填写,将使用默认标题"未命名页面"</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-500 font-medium">{error}</div>}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<Button variant="outline" onClick={onClose} type="button" disabled={isLoading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !pageName.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="i-ph-spinner-gap-bold animate-spin size-4 mr-2" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
'创建页面'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
);
|
||||
}
|
||||
1020
app/components/webbuilder/Preview.tsx
Normal file
1020
app/components/webbuilder/Preview.tsx
Normal file
File diff suppressed because it is too large
Load Diff
301
app/components/webbuilder/ScreenshotSelector.tsx
Normal file
301
app/components/webbuilder/ScreenshotSelector.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ScreenshotSelectorProps {
|
||||
isSelectionMode: boolean;
|
||||
setIsSelectionMode: (mode: boolean) => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export const ScreenshotSelector = memo(
|
||||
({ isSelectionMode, setIsSelectionMode, containerRef }: ScreenshotSelectorProps) => {
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const hasRequestedPermissionRef = useRef(false);
|
||||
|
||||
// 清理流和视频资源的函数
|
||||
const cleanupResources = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.srcObject = null;
|
||||
videoRef.current.remove();
|
||||
videoRef.current = null;
|
||||
}
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
}
|
||||
|
||||
hasRequestedPermissionRef.current = false;
|
||||
}, []);
|
||||
|
||||
// 当组件卸载时清理资源
|
||||
useEffect(() => {
|
||||
return cleanupResources;
|
||||
}, [cleanupResources]);
|
||||
|
||||
// 当选择模式关闭时清理资源
|
||||
useEffect(() => {
|
||||
if (!isSelectionMode && mediaStreamRef.current) {
|
||||
cleanupResources();
|
||||
}
|
||||
}, [isSelectionMode, cleanupResources]);
|
||||
|
||||
const initializeStream = async () => {
|
||||
// 如果已经有流,直接返回
|
||||
if (mediaStreamRef.current) {
|
||||
return mediaStreamRef.current;
|
||||
}
|
||||
|
||||
// 如果已经请求了权限,等待并返回null(避免重复请求)
|
||||
if (hasRequestedPermissionRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
hasRequestedPermissionRef.current = true;
|
||||
|
||||
try {
|
||||
// 请求显示媒体权限
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
displaySurface: 'window',
|
||||
preferCurrentTab: true,
|
||||
surfaceSwitching: 'include',
|
||||
systemAudio: 'exclude',
|
||||
},
|
||||
} as MediaStreamConstraints);
|
||||
|
||||
// 添加流失效时的处理器
|
||||
stream.addEventListener('inactive', () => {
|
||||
cleanupResources();
|
||||
setIsSelectionMode(false);
|
||||
setSelectionStart(null);
|
||||
setSelectionEnd(null);
|
||||
setIsCapturing(false);
|
||||
});
|
||||
|
||||
mediaStreamRef.current = stream;
|
||||
|
||||
// 初始化视频元素
|
||||
if (!videoRef.current) {
|
||||
const video = document.createElement('video');
|
||||
video.style.opacity = '0';
|
||||
video.style.position = 'fixed';
|
||||
video.style.pointerEvents = 'none';
|
||||
video.style.zIndex = '-1';
|
||||
document.body.appendChild(video);
|
||||
videoRef.current = video;
|
||||
}
|
||||
|
||||
// 设置视频流并播放
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
|
||||
return stream;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize stream:', error);
|
||||
setIsSelectionMode(false);
|
||||
toast.error('初始化屏幕捕获失败');
|
||||
hasRequestedPermissionRef.current = false;
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySelection = useCallback(async () => {
|
||||
if (!isSelectionMode || !selectionStart || !selectionEnd || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCapturing(true);
|
||||
|
||||
try {
|
||||
const stream = await initializeStream();
|
||||
|
||||
if (!stream || !videoRef.current) {
|
||||
setIsCapturing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待视频准备好
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 创建临时画布进行全屏截图
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = videoRef.current.videoWidth;
|
||||
tempCanvas.height = videoRef.current.videoHeight;
|
||||
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
if (!tempCtx) {
|
||||
throw new Error('无法获取临时画布上下文');
|
||||
}
|
||||
|
||||
// 绘制完整视频帧
|
||||
tempCtx.drawImage(videoRef.current, 0, 0);
|
||||
|
||||
// 计算视频和屏幕之间的比例因子
|
||||
const scaleX = videoRef.current.videoWidth / window.innerWidth;
|
||||
const scaleY = videoRef.current.videoHeight / window.innerHeight;
|
||||
|
||||
// 获取窗口滚动位置
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY + 40;
|
||||
|
||||
// 获取容器在页面上的位置
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
// 精确剪裁的偏移调整
|
||||
const leftOffset = -9; // 调整左侧位置
|
||||
const bottomOffset = -14; // 调整底部位置
|
||||
|
||||
// 计算缩放后的坐标(带滚动偏移和调整)
|
||||
const scaledX = Math.round(
|
||||
(containerRect.left + Math.min(selectionStart.x, selectionEnd.x) + scrollX + leftOffset) * scaleX,
|
||||
);
|
||||
const scaledY = Math.round(
|
||||
(containerRect.top + Math.min(selectionStart.y, selectionEnd.y) + scrollY + bottomOffset) * scaleY,
|
||||
);
|
||||
const scaledWidth = Math.round(Math.abs(selectionEnd.x - selectionStart.x) * scaleX);
|
||||
const scaledHeight = Math.round(Math.abs(selectionEnd.y - selectionStart.y) * scaleY);
|
||||
|
||||
// 为裁剪区域创建最终画布
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(Math.abs(selectionEnd.x - selectionStart.x));
|
||||
canvas.height = Math.round(Math.abs(selectionEnd.y - selectionStart.y));
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('无法获取画布上下文');
|
||||
}
|
||||
|
||||
// 绘制裁剪区域
|
||||
ctx.drawImage(tempCanvas, scaledX, scaledY, scaledWidth, scaledHeight, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 转换为 blob
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('无法创建 blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
// 创建 FileReader 将 blob 转换为 base64
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
// 查找 textarea 元素
|
||||
const textarea = document.querySelector('textarea');
|
||||
|
||||
if (textarea) {
|
||||
// 从 BaseChat 组件获取 setters
|
||||
const setUploadedFiles = (window as any).__UPAGE_SET_UPLOADED_FILES__;
|
||||
const uploadedFiles = (window as any).__UPAGE_UPLOADED_FILES__ || [];
|
||||
|
||||
if (setUploadedFiles) {
|
||||
// 更新文件和图像数据
|
||||
const file = new File([blob], 'screenshot.png', { type: 'image/png' });
|
||||
setUploadedFiles([...uploadedFiles, file]);
|
||||
toast.success('截图已添加到聊天中');
|
||||
} else {
|
||||
toast.error('无法将截图添加到聊天中');
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
} catch (error) {
|
||||
console.error('Failed to capture screenshot:', error);
|
||||
toast.error('截图失败');
|
||||
cleanupResources();
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
setSelectionStart(null);
|
||||
setSelectionEnd(null);
|
||||
setIsSelectionMode(false); // 捕获后关闭选择模式
|
||||
}
|
||||
}, [isSelectionMode, selectionStart, selectionEnd, containerRef, setIsSelectionMode, cleanupResources]);
|
||||
|
||||
const handleSelectionStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isSelectionMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
setSelectionStart({ x, y });
|
||||
setSelectionEnd({ x, y });
|
||||
},
|
||||
[isSelectionMode],
|
||||
);
|
||||
|
||||
const handleSelectionMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isSelectionMode || !selectionStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
setSelectionEnd({ x, y });
|
||||
},
|
||||
[isSelectionMode, selectionStart],
|
||||
);
|
||||
|
||||
if (!isSelectionMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 cursor-crosshair"
|
||||
onMouseDown={handleSelectionStart}
|
||||
onMouseMove={handleSelectionMove}
|
||||
onMouseUp={handleCopySelection}
|
||||
onMouseLeave={() => {
|
||||
if (selectionStart) {
|
||||
setSelectionStart(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: isCapturing ? 'transparent' : 'rgba(0, 0, 0, 0.1)',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
pointerEvents: 'all',
|
||||
opacity: isCapturing ? 0 : 1,
|
||||
zIndex: 50,
|
||||
transition: 'opacity 0.1s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{selectionStart && selectionEnd && !isCapturing && (
|
||||
<div
|
||||
className="absolute border-2 border-blue-500 bg-blue-200 bg-opacity-20"
|
||||
style={{
|
||||
left: Math.min(selectionStart.x, selectionEnd.x),
|
||||
top: Math.min(selectionStart.y, selectionEnd.y),
|
||||
width: Math.abs(selectionEnd.x - selectionStart.x),
|
||||
height: Math.abs(selectionEnd.y - selectionStart.y),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
427
app/components/webbuilder/WebBuilder.client.tsx
Normal file
427
app/components/webbuilder/WebBuilder.client.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import classNames from 'classnames';
|
||||
import { type Change, diffLines } from 'diff';
|
||||
import { type HTMLMotionProps, motion, type Variants } from 'framer-motion';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { PushToGitHubDialog } from '~/components/header/connections/components/PushToGitHubDialog';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { useProject } from '~/lib/hooks/useProject';
|
||||
import { useChatHistory } from '~/lib/persistence';
|
||||
import { aiState } from '~/lib/stores/ai-state';
|
||||
import type { PageMap } from '~/lib/stores/pages';
|
||||
import { type WebBuilderViewType, webBuilderStore } from '~/lib/stores/web-builder';
|
||||
import type { Page } from '~/types/actions';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
import type { OnChangeCallback, OnLoadCallback, OnReadyCallback, OnSaveCallback } from '../editor/Editor';
|
||||
import { DiffView } from './DiffView';
|
||||
import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
|
||||
const viewTransition = { ease: cubicEasingFn };
|
||||
|
||||
const sliderOptions: SliderOptions<WebBuilderViewType> = {
|
||||
left: {
|
||||
value: 'code',
|
||||
text: '可视化',
|
||||
},
|
||||
middle: {
|
||||
value: 'diff',
|
||||
text: '差异',
|
||||
},
|
||||
right: {
|
||||
value: 'preview',
|
||||
text: '预览',
|
||||
},
|
||||
};
|
||||
|
||||
const workbenchVariants = {
|
||||
closed: {
|
||||
width: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
open: {
|
||||
width: 'var(--workbench-width)',
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: cubicEasingFn,
|
||||
},
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
const PageModifiedDropdown = memo(({ onSelectPage }: { onSelectPage: (pageName: string) => void }) => {
|
||||
const pageHistory = useStore(webBuilderStore.pagesStore.pageHistory);
|
||||
const modifiedPages = Object.entries(pageHistory);
|
||||
const hasChanges = modifiedPages.length > 0;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredPages = useMemo(() => {
|
||||
return modifiedPages.filter(([pageName]) => pageName.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}, [modifiedPages, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover className="relative">
|
||||
{({ open }: { open: boolean }) => (
|
||||
<>
|
||||
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-2 hover:bg-upage-elements-background-depth-3 transition-colors text-upage-elements-textPrimary border border-upage-elements-borderColor">
|
||||
<span className="font-medium">更改页面</span>
|
||||
{hasChanges && (
|
||||
<span className="size-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
|
||||
{modifiedPages.length}
|
||||
</span>
|
||||
)}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-upage-elements-background-depth-2 shadow-xl border border-upage-elements-borderColor">
|
||||
<div className="p-2">
|
||||
<div className="relative mx-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索页面..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-upage-elements-background-depth-1 border border-upage-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
/>
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-upage-elements-textTertiary">
|
||||
<div className="i-ph:magnifying-glass" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredPages.length > 0 ? (
|
||||
filteredPages.map(([pageName, history]) => {
|
||||
return (
|
||||
<button
|
||||
key={pageName}
|
||||
onClick={() => onSelectPage(pageName)}
|
||||
className="w-full px-3 py-2 text-left rounded-md hover:bg-upage-elements-background-depth-1 transition-colors group bg-transparent"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="shrink-0 size-5 text-upage-elements-textTertiary">
|
||||
<div className="i-ph:file-text" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate text-sm font-medium text-upage-elements-textPrimary">
|
||||
{pageName.split('/').pop()}
|
||||
</span>
|
||||
<span className="truncate text-xs text-upage-elements-textTertiary">
|
||||
{pageName}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
// Calculate diff stats
|
||||
const { additions, deletions } = (() => {
|
||||
if (!history.originalContent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
|
||||
const normalizedCurrent =
|
||||
history.versions[history.versions.length - 1]?.content.replace(/\r\n/g, '\n') ||
|
||||
'';
|
||||
|
||||
if (normalizedOriginal === normalizedCurrent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
});
|
||||
|
||||
return changes.reduce(
|
||||
(acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
})();
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
showStats && (
|
||||
<div className="flex items-center gap-1 text-xs shrink-0">
|
||||
{additions > 0 && <span className="text-green-500">+{additions}</span>}
|
||||
{deletions > 0 && <span className="text-red-500">-{deletions}</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div className="size-12 mb-2 text-upage-elements-textTertiary">
|
||||
<div className="i-ph:file-dashed" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-upage-elements-textPrimary">
|
||||
{searchQuery ? '没有匹配的页面' : '没有修改的页面'}
|
||||
</p>
|
||||
<p className="text-xs text-upage-elements-textTertiary mt-1">
|
||||
{searchQuery ? '尝试其他搜索' : '更改将在此处显示'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const WebBuilder = memo(() => {
|
||||
renderLogger.trace('webBuilder');
|
||||
|
||||
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
|
||||
|
||||
// const hasPreview = useStore(computed(webBuilderStore.previews, (previews) => previews.length > 0));
|
||||
const showWorkbench = useStore(webBuilderStore.showWorkbench);
|
||||
const { showChat, chatStarted, isStreaming } = useStore(aiState);
|
||||
const documents = useStore(webBuilderStore.editorStore.editorDocuments);
|
||||
const currentPage = useStore(webBuilderStore.editorStore.selectedDocument);
|
||||
const currentSection = useStore(webBuilderStore.pagesStore.currentSection);
|
||||
const unsavedPages = useStore(webBuilderStore.editorStore.unsavedDocuments);
|
||||
const pages = useStore(webBuilderStore.pagesStore.pages);
|
||||
const selectedView = useStore(webBuilderStore.currentView);
|
||||
|
||||
const isSmallViewport = useViewport(1024);
|
||||
const { saveProject } = useProject();
|
||||
const { getLoadProject } = useChatHistory();
|
||||
|
||||
const setSelectedView = useCallback(
|
||||
(view: WebBuilderViewType) => {
|
||||
if (isStreaming && view !== 'code') {
|
||||
return;
|
||||
}
|
||||
webBuilderStore.currentView.set(view);
|
||||
},
|
||||
[isStreaming],
|
||||
);
|
||||
|
||||
const exportable = useMemo(() => {
|
||||
return Object.keys(pages).length > 0;
|
||||
}, [pages]);
|
||||
|
||||
const onEditorChange = useCallback<OnChangeCallback>((_, pageName, html) => {
|
||||
webBuilderStore.setDocumentContent(pageName, html);
|
||||
}, []);
|
||||
|
||||
const onPageSelect = useCallback((pageName: string | undefined) => {
|
||||
webBuilderStore.setSelectedPage(pageName);
|
||||
}, []);
|
||||
|
||||
const onAutoPageSave = useCallback<OnSaveCallback>(() => {
|
||||
if (isStreaming) {
|
||||
return;
|
||||
}
|
||||
doPageSave();
|
||||
}, [isStreaming]);
|
||||
|
||||
const doPageSave = useCallback(() => {
|
||||
webBuilderStore.saveAllPages().catch(() => {
|
||||
toast.error('文件内容更新失败');
|
||||
});
|
||||
const currentMessageId = webBuilderStore.chatStore.currentMessageId.get();
|
||||
if (currentMessageId) {
|
||||
saveProject(currentMessageId);
|
||||
}
|
||||
}, [saveProject]);
|
||||
|
||||
const onPageReset = useCallback(() => {
|
||||
webBuilderStore.resetCurrentPage();
|
||||
}, []);
|
||||
|
||||
const onLoad = useCallback<OnLoadCallback>(async () => {
|
||||
const pages = await handleLoadProject();
|
||||
const pageMap = Object.fromEntries(pages.map((page) => [page.name, page])) as PageMap;
|
||||
webBuilderStore.setPages(pageMap);
|
||||
}, []);
|
||||
|
||||
const onReady = useCallback<OnReadyCallback>((editor) => {
|
||||
webBuilderStore.editorStore.setEditorInstance(editor);
|
||||
}, []);
|
||||
|
||||
const handleSelectPage = useCallback((pageName: string) => {
|
||||
webBuilderStore.setSelectedPage(pageName);
|
||||
webBuilderStore.currentView.set('diff');
|
||||
}, []);
|
||||
|
||||
// 处理保存的数据,将其转为编辑器可直接使用的格式
|
||||
const handleLoadProject = useCallback(async (): Promise<Page[]> => {
|
||||
const projectData = await getLoadProject();
|
||||
// 新版本数据
|
||||
if (projectData?.pages) {
|
||||
// html 为 pages 中 index 的 content
|
||||
return projectData.pages;
|
||||
}
|
||||
return [];
|
||||
}, [getLoadProject]);
|
||||
|
||||
return (
|
||||
chatStarted && (
|
||||
<motion.div
|
||||
initial="closed"
|
||||
animate={showWorkbench ? 'open' : 'closed'}
|
||||
variants={workbenchVariants}
|
||||
className="z-workbench"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'fixed top-[calc(var(--header-height)+1.5rem)] bottom-6 w-[var(--workbench-inner-width)] mr-4 z-0 transition-[transform,width] duration-200 upage-ease-cubic-bezier',
|
||||
{
|
||||
'w-full': isSmallViewport,
|
||||
'left-0': !showChat,
|
||||
'transform-none': showWorkbench && isSmallViewport,
|
||||
'translate-x-0': showWorkbench && !isSmallViewport,
|
||||
'translate-x-full': !showWorkbench,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 px-2 lg:px-6">
|
||||
<div className="h-full flex flex-col bg-upage-elements-background-depth-2 border border-upage-elements-borderColor shadow-sm rounded-lg overflow-hidden">
|
||||
<div className="flex items-center px-3 py-2 border-b border-upage-elements-borderColor">
|
||||
<Slider
|
||||
selected={selectedView}
|
||||
options={sliderOptions}
|
||||
setSelected={setSelectedView}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<div className="ml-auto" />
|
||||
{selectedView === 'code' && (
|
||||
<div className="flex overflow-y-auto">
|
||||
{unsavedPages.size > 0 && (
|
||||
<PanelHeaderButton className="mr-1 text-sm" onClick={doPageSave}>
|
||||
<div className="i-mingcute:save-line" />
|
||||
保存
|
||||
</PanelHeaderButton>
|
||||
)}
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
disabled={!exportable}
|
||||
onClick={() => {
|
||||
webBuilderStore.exportToZip();
|
||||
}}
|
||||
>
|
||||
<div className="i-mingcute:download-2-line" />
|
||||
下载代码
|
||||
</PanelHeaderButton>
|
||||
<PanelHeaderButton
|
||||
className="mr-1 text-sm"
|
||||
disabled={!exportable}
|
||||
onClick={() => setIsPushDialogOpen(true)}
|
||||
>
|
||||
<div className="i-ph:git-branch" />
|
||||
推送到 GitHub
|
||||
</PanelHeaderButton>
|
||||
</div>
|
||||
)}
|
||||
{selectedView === 'diff' && <PageModifiedDropdown onSelectPage={handleSelectPage} />}
|
||||
<IconButton
|
||||
icon="i-mingcute:close-circle-line"
|
||||
className="-mr-1"
|
||||
size="xl"
|
||||
onClick={() => {
|
||||
webBuilderStore.showWorkbench.set(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<View initial={{ x: '0%' }} animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}>
|
||||
<EditorPanel
|
||||
documents={documents}
|
||||
currentPage={currentPage}
|
||||
currentSection={currentSection}
|
||||
isStreaming={isStreaming}
|
||||
pages={pages}
|
||||
unsavedPages={unsavedPages}
|
||||
onPageSelect={onPageSelect}
|
||||
onEditorChange={onEditorChange}
|
||||
onPageSave={onAutoPageSave}
|
||||
onPageReset={onPageReset}
|
||||
onLoad={onLoad}
|
||||
onReady={onReady}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
|
||||
>
|
||||
{selectedView === 'diff' && !isStreaming ? <DiffView /> : <div></div>}
|
||||
</View>
|
||||
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
|
||||
{selectedView === 'preview' ? <Preview /> : <div></div>}
|
||||
</View>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PushToGitHubDialog
|
||||
isOpen={isPushDialogOpen}
|
||||
onClose={() => setIsPushDialogOpen(false)}
|
||||
onPush={async (repoName, username, token) => {
|
||||
try {
|
||||
const commitMessage = prompt('请输入提交信息:', 'Initial commit') || 'Initial commit';
|
||||
await webBuilderStore.pushToGitHub(repoName, commitMessage, username, token);
|
||||
|
||||
const repoUrl = `https://github.com/${username}/${repoName}`;
|
||||
return repoUrl;
|
||||
} catch (error) {
|
||||
console.error('Error pushing to GitHub:', error);
|
||||
toast.error('GitHub 推送失败');
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// View component for rendering content with motion transitions
|
||||
interface ViewProps extends HTMLMotionProps<'div'> {
|
||||
children: React.JSX.Element;
|
||||
}
|
||||
|
||||
const View = memo(({ children, ...props }: ViewProps) => {
|
||||
return (
|
||||
<motion.div className="absolute inset-0" transition={viewTransition} {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user