refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View 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 { webBuilderStore } from '~/.client/stores/web-builder';
import { formatCode } from '~/.client/utils/prettier';
import { themeStore } from '~/stores/theme';
// 高亮结果缓存,使用 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 dark:bg-gray-800"
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>
);
}
});

View File

@@ -0,0 +1,100 @@
import * as Tabs from '@radix-ui/react-tabs';
import { memo } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { ErrorBoundary } from '~/.client/components/ErrorBoundary';
import type { PageHistory, Section } from '~/types/actions';
import type { DocumentProperties } from '~/types/editor';
import type { PageMap } from '~/types/pages';
import { logger, renderLogger } from '~/utils/logger';
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}
onChange={onEditorChange}
onSave={onPageSave}
onReset={onPageReset}
onLoad={onLoad}
onReady={onReady}
/>
</ErrorBoundary>
</div>
</Panel>
</PanelGroup>
</Panel>
</PanelGroup>
);
},
);

View 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>
);
}

View File

@@ -0,0 +1,86 @@
import { memo, useEffect, useRef } from 'react';
import { IconButton } from '~/.client/components/ui/IconButton';
import type { PreviewInfo } from '~/.client/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>
);
},
);

View 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 '~/.client/components/ui/Button';
import { ConfirmationDialog, Dialog, DialogDescription, DialogRoot, DialogTitle } from '~/.client/components/ui/Dialog';
import { webBuilderStore } from '~/.client/stores/web-builder';
import type { PageMap } from '~/types/pages';
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 dark:bg-gray-800': !selected,
'text-upage-elements-item-contentAccent dark:bg-gray-800': 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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
},
);

View File

@@ -0,0 +1,432 @@
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 type {
OnChangeCallback,
OnLoadCallback,
OnReadyCallback,
OnSaveCallback,
} from '~/.client/components/editor/Editor';
import { PushToGitHubDialog } from '~/.client/components/header/connections/components/PushToGitHubDialog';
import { IconButton } from '~/.client/components/ui/IconButton';
import { PanelHeaderButton } from '~/.client/components/ui/PanelHeaderButton';
import { Slider, type SliderOptions } from '~/.client/components/ui/Slider';
import useViewport from '~/.client/hooks';
import { useProject } from '~/.client/hooks/useProject';
import { useChatHistory } from '~/.client/persistence';
import { aiState } from '~/.client/stores/ai-state';
import { type WebBuilderViewType, webBuilderStore } from '~/.client/stores/web-builder';
import { cubicEasingFn } from '~/.client/utils/easings';
import type { Page } from '~/types/actions';
import type { PageMap } from '~/types/pages';
import { renderLogger } from '~/utils/logger';
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>
);
});