refactor: repartition server-side and client-side code
This commit is contained in:
159
app/.client/components/editor/EditDialog.tsx
Normal file
159
app/.client/components/editor/EditDialog.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { sendChatMessageStore } from '~/.client/stores/chat-message';
|
||||
import { DefaultEditor } from './editors/DefaultEditor';
|
||||
import type { EditorProps } from './editors/EditorProps';
|
||||
import { IconEditor } from './editors/IconEditor';
|
||||
import { ImageEditor } from './editors/ImageEditor';
|
||||
import { LinkEditor } from './editors/LinkEditor';
|
||||
import { TextEditor } from './editors/TextEditor';
|
||||
|
||||
export interface EditDialogProps {
|
||||
element: HTMLElement;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export type ElementType = 'text' | 'image' | 'link' | 'button' | 'input' | 'icon' | 'other';
|
||||
|
||||
export const getElementType = (element: HTMLElement): ElementType => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
if (tagName === 'img') {
|
||||
return 'image';
|
||||
}
|
||||
if (tagName === 'a') {
|
||||
return 'link';
|
||||
}
|
||||
if (
|
||||
tagName === 'button' ||
|
||||
(tagName === 'div' && element.classList.contains('btn')) ||
|
||||
(tagName === 'span' && element.classList.contains('btn'))
|
||||
) {
|
||||
return 'button';
|
||||
}
|
||||
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
||||
return 'input';
|
||||
}
|
||||
if (tagName === 'iconify-icon') {
|
||||
return 'icon';
|
||||
}
|
||||
if (
|
||||
tagName === 'p' ||
|
||||
tagName === 'h1' ||
|
||||
tagName === 'h2' ||
|
||||
tagName === 'h3' ||
|
||||
tagName === 'h4' ||
|
||||
tagName === 'h5' ||
|
||||
tagName === 'h6' ||
|
||||
tagName === 'span'
|
||||
) {
|
||||
return 'text';
|
||||
}
|
||||
return 'other';
|
||||
};
|
||||
|
||||
const getEditorComponent = (elementType: ElementType): [React.FC<EditorProps>, string] => {
|
||||
switch (elementType) {
|
||||
case 'text':
|
||||
return [TextEditor, '编辑文本'];
|
||||
case 'image':
|
||||
return [ImageEditor, '编辑图片'];
|
||||
case 'link':
|
||||
return [LinkEditor, '编辑链接'];
|
||||
case 'icon':
|
||||
return [IconEditor, '更改图标'];
|
||||
default:
|
||||
return [DefaultEditor, '编辑元素'];
|
||||
}
|
||||
};
|
||||
|
||||
export const EditDialog: React.FC<EditDialogProps> = ({ element, onClose }) => {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const elementType = getElementType(element);
|
||||
const [EditorComponent, title] = getEditorComponent(elementType);
|
||||
|
||||
const onSendPrompt = async (prompt: string, element: HTMLElement) => {
|
||||
const sendChatMessage = sendChatMessageStore.get();
|
||||
|
||||
if (!sendChatMessage) {
|
||||
console.error('发送消息函数未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const elementInfo = {
|
||||
tagName: element.tagName,
|
||||
className: element.className,
|
||||
id: element.id,
|
||||
innerHTML: element.innerHTML,
|
||||
outerHTML: element.outerHTML,
|
||||
};
|
||||
|
||||
try {
|
||||
sendChatMessage({
|
||||
messageContent: prompt,
|
||||
files: [],
|
||||
metadata: {
|
||||
elementInfo,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
padding: '0',
|
||||
width: '100%',
|
||||
maxWidth: '420px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: 500 }}>{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#64748b',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px' }}>
|
||||
<EditorComponent
|
||||
element={element}
|
||||
onClose={onClose}
|
||||
elementType={elementType}
|
||||
title={title}
|
||||
onSendPrompt={onSendPrompt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
app/.client/components/editor/Editor.tsx
Normal file
200
app/.client/components/editor/Editor.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useChatHistory } from '~/.client/persistence';
|
||||
import { isValidContent } from '~/.client/utils/html-parse';
|
||||
import { throttleWithTrailing } from '~/.client/utils/throttle';
|
||||
import type { Section } from '~/types/actions';
|
||||
import type { DocumentProperties, Editor } from '~/types/editor';
|
||||
import { logger } from '~/utils/logger';
|
||||
import { EditorComponent } from './EditorComponent';
|
||||
|
||||
export interface ScrollPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface EditorUpdate {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type OnChangeCallback = (editor: Editor, pageName: string, html: string) => void;
|
||||
export type OnSaveCallback = () => void;
|
||||
export type OnLoadCallback = (editor: Editor) => void;
|
||||
export type OnReadyCallback = (editor: Editor) => void;
|
||||
|
||||
interface Props {
|
||||
documents?: Record<string, DocumentProperties>;
|
||||
currentPage?: string;
|
||||
currentSection?: Section;
|
||||
editable?: boolean;
|
||||
debounceChange?: number;
|
||||
debounceScroll?: number;
|
||||
onChange?: OnChangeCallback;
|
||||
onReset?: () => void;
|
||||
onSave?: OnSaveCallback;
|
||||
onLoad?: OnLoadCallback;
|
||||
onReady?: OnReadyCallback;
|
||||
className?: string;
|
||||
settings?: any;
|
||||
}
|
||||
|
||||
export const EditorStudio = memo(
|
||||
({ documents, currentPage, currentSection, onChange, onSave, onLoad, onReady }: Props) => {
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
const pendingSectionRef = useRef<Section | null>(null);
|
||||
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { getLoadProject } = useChatHistory();
|
||||
|
||||
const updateComponents = useCallback((editor: Editor, section: Section) => {
|
||||
if (!editor) {
|
||||
logger.warn('编辑器实例不存在,无法更新组件');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!section.domId) {
|
||||
logger.warn('节点ID不存在,无法更新组件');
|
||||
return;
|
||||
}
|
||||
|
||||
const { domId, action, content, sort, rootDomId } = section;
|
||||
// 验证 content 是否有效
|
||||
if (action !== 'remove' && !isValidContent(content)) {
|
||||
logger.warn('内容无效,无法更新组件', JSON.stringify({ action, domId }));
|
||||
return;
|
||||
}
|
||||
if (rootDomId) {
|
||||
editor.scrollToElement(`#${rootDomId}`);
|
||||
}
|
||||
const id = `#${domId}`;
|
||||
try {
|
||||
switch (action) {
|
||||
case 'add':
|
||||
editor.appendContent(id, content, sort);
|
||||
break;
|
||||
case 'update': {
|
||||
editor.updateContent(id, content, sort);
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
editor.deleteContent(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('执行组件操作时出错', JSON.stringify({ error, action, domId }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const throttledSetComponents = useCallback(updateComponents, []);
|
||||
|
||||
const lastSectionRef = useRef<Section | undefined>(undefined);
|
||||
const throttledSetComponentsRef = useRef(throttleWithTrailing(throttledSetComponents, 150));
|
||||
|
||||
function flushPendingUpdate(editor: Editor) {
|
||||
const lastSection = lastSectionRef.current;
|
||||
if (lastSection && lastSection.content) {
|
||||
updateComponents(editor, lastSection);
|
||||
lastSectionRef.current = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function setEditorDocument(editor: Editor, section?: Section) {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* 使用节流函数来更新组件内容
|
||||
* 这样可以避免频繁的更新导致编辑器卡顿
|
||||
*/
|
||||
if (section) {
|
||||
lastSectionRef.current = section;
|
||||
throttledSetComponentsRef.current(editor, section);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSection.pageName) {
|
||||
logger.warn('page should not be empty');
|
||||
}
|
||||
|
||||
// section变更时,先执行上一个section的待处理更新
|
||||
flushPendingUpdate(editor);
|
||||
|
||||
// 保存最新的页面属性,确保在节流期间如果有新的更新进来,会使用最新的数据
|
||||
pendingSectionRef.current = currentSection;
|
||||
setEditorDocument(editor, currentSection);
|
||||
}, [currentSection]);
|
||||
|
||||
// 确保在组件卸载前应用最后一次更新
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const editor = editorRef.current;
|
||||
const pendingSection = pendingSectionRef.current;
|
||||
|
||||
// 清除保存定时器
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (editor && pendingSection && pendingSection) {
|
||||
// 直接应用最后的更新,不通过节流
|
||||
updateComponents(editor, pendingSection);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEditorReady = useCallback(
|
||||
async (editor: Editor) => {
|
||||
editorRef.current = editor ?? null;
|
||||
if (onReady) {
|
||||
onReady(editor);
|
||||
}
|
||||
},
|
||||
[onSave],
|
||||
);
|
||||
|
||||
const handleAutoSave = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleContentChange = useCallback((pageName: string, html: string) => {
|
||||
if (editorRef.current && onChange) {
|
||||
onChange(editorRef.current, pageName, html);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoad = useCallback(async () => {
|
||||
if (editorRef.current && onLoad) {
|
||||
onLoad(editorRef.current);
|
||||
}
|
||||
}, [getLoadProject]);
|
||||
|
||||
return (
|
||||
<EditorComponent
|
||||
currentPage={currentPage}
|
||||
documents={documents}
|
||||
onLoad={handleLoad}
|
||||
onReady={handleEditorReady}
|
||||
onSave={handleAutoSave}
|
||||
onContentChange={handleContentChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
116
app/.client/components/editor/EditorComponent.tsx
Normal file
116
app/.client/components/editor/EditorComponent.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { createRef, useCallback, useEffect, useRef } from 'react';
|
||||
import { useEditorCommands } from '~/.client/hooks';
|
||||
import type { DocumentProperties, Editor } from '~/types/editor';
|
||||
import { EditorController } from './EditorController';
|
||||
import { EditorRender } from './EditorRender';
|
||||
import { PageRender, type PageRenderRef } from './PageRender';
|
||||
|
||||
export interface EditorComponentProps {
|
||||
documents?: Record<string, DocumentProperties>;
|
||||
currentPage?: string;
|
||||
onReady?: (editor: Editor) => void;
|
||||
onLoad?: () => Promise<void>;
|
||||
onContentChange?: (pageName: string, html: string) => void;
|
||||
onSave?: (pageName: string, html: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function EditorComponent(props: EditorComponentProps) {
|
||||
const { documents = {}, currentPage, onReady, onLoad, onContentChange, onSave } = props;
|
||||
|
||||
const controllerRef = useRef<EditorController | null>(null);
|
||||
const lastContentRef = useRef<Record<string, string>>({});
|
||||
const pageRefsRef = useRef<Record<string, RefObject<PageRenderRef>>>({});
|
||||
const currentPageRef = useRef<string | undefined>(currentPage);
|
||||
|
||||
useEditorCommands(controllerRef);
|
||||
|
||||
useEffect(() => {
|
||||
Object.keys(documents).forEach((docName) => {
|
||||
if (!pageRefsRef.current[docName]) {
|
||||
pageRefsRef.current[docName] = createRef<PageRenderRef>();
|
||||
}
|
||||
});
|
||||
}, [documents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new EditorController({
|
||||
getContentElement,
|
||||
getIframeElement,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (controllerRef.current && onReady) {
|
||||
onReady(controllerRef.current);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
currentPageRef.current = currentPage;
|
||||
}, [currentPage]);
|
||||
|
||||
const getContentElement = useCallback((): HTMLElement | null => {
|
||||
const currentPageName = currentPageRef.current ?? 'index';
|
||||
return pageRefsRef.current[currentPageName]?.current?.element ?? null;
|
||||
}, [pageRefsRef]);
|
||||
|
||||
const getIframeElement = useCallback((): HTMLIFrameElement | null => {
|
||||
const currentPageName = currentPageRef.current ?? 'index';
|
||||
return pageRefsRef.current[currentPageName]?.current?.iframe ?? null;
|
||||
}, [pageRefsRef]);
|
||||
|
||||
/**
|
||||
* 执行保存
|
||||
* @param html 要保存的 HTML 内容
|
||||
*/
|
||||
const handleSave = useCallback(
|
||||
(pageName: string, html: string): void => {
|
||||
if (lastContentRef.current[pageName] === html) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastContentRef.current[pageName] = html;
|
||||
if (onSave) {
|
||||
onSave(pageName, html);
|
||||
}
|
||||
},
|
||||
[onSave],
|
||||
);
|
||||
|
||||
const handleContentUpdate = useCallback(
|
||||
(pageName: string, html: string): void => {
|
||||
if (lastContentRef.current[pageName] === html) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onContentChange) {
|
||||
onContentChange(pageName, html);
|
||||
}
|
||||
},
|
||||
[onContentChange],
|
||||
);
|
||||
|
||||
const handleMount = useCallback(async (): Promise<void> => {
|
||||
if (onLoad) {
|
||||
await onLoad();
|
||||
}
|
||||
}, [onLoad]);
|
||||
|
||||
return (
|
||||
<EditorRender onMount={handleMount}>
|
||||
{Object.values(documents).map((document) => (
|
||||
<PageRender
|
||||
isCurrentPage={document.name === currentPage}
|
||||
ref={pageRefsRef.current[document.name]}
|
||||
key={document.name}
|
||||
document={document}
|
||||
onUpdate={handleContentUpdate}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
))}
|
||||
</EditorRender>
|
||||
);
|
||||
}
|
||||
171
app/.client/components/editor/EditorController.tsx
Normal file
171
app/.client/components/editor/EditorController.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { executeScript } from '~/.client/utils/execute-scripts';
|
||||
import { isScriptContent } from '~/.client/utils/html-parse';
|
||||
import type { Editor, EditorControllerProps } from '~/types/editor';
|
||||
|
||||
export class EditorController implements Editor {
|
||||
private props: EditorControllerProps;
|
||||
|
||||
constructor(props: EditorControllerProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
setContent(newHTML: string) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
pageElement.innerHTML = newHTML;
|
||||
}
|
||||
|
||||
replaceWith(query: string, newHTML: string, sort?: number) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement.outerHTML = newHTML;
|
||||
if (sort === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = targetElement.parentElement;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const children = Array.from(parent.children);
|
||||
const index = children.indexOf(targetElement);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sort !== index) {
|
||||
parent.insertBefore(targetElement, children[sort]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定节点下追加 HTML
|
||||
* @param query 查询条件,待添加元素的父节点 ID。
|
||||
* @param newHTML 新 HTML
|
||||
* @param sort 排序位置,从 0 开始表示应该处于第一位,2 表示应该处于第二位,以此类推。不填写则表示默认或者不变
|
||||
*/
|
||||
append(query: string, newHTML: string, sort?: number) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
const parent = targetElement || pageElement;
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newHTML;
|
||||
const newElement = tempDiv.firstElementChild as HTMLElement;
|
||||
if (!newElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sort === undefined) {
|
||||
parent.appendChild(newElement);
|
||||
} else {
|
||||
parent.insertBefore(newElement, parent.children[sort]);
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(query: string, newHTML: string, sort?: number) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newHTML;
|
||||
const newElement = tempDiv.firstElementChild as HTMLElement;
|
||||
if (!newElement) {
|
||||
return;
|
||||
}
|
||||
const id = newElement.id;
|
||||
const targetElement = pageElement.querySelector(`#${id}`);
|
||||
if (targetElement) {
|
||||
this.replaceWith(`#${id}`, newHTML, sort);
|
||||
if (isScriptContent(newHTML)) {
|
||||
this.refresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.append(query, newHTML, sort);
|
||||
const element = pageElement.querySelector(`#${id}`);
|
||||
if (element instanceof HTMLScriptElement) {
|
||||
executeScript(element);
|
||||
const frameRef = this.props.getIframeElement();
|
||||
const event = new Event('DOMContentLoaded', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
frameRef?.contentDocument?.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
updateContent(query: string, newHTML: string, sort?: number) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
this.replaceWith(query, newHTML, sort);
|
||||
if (isScriptContent(newHTML)) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
deleteContent(query: string) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
if (targetElement) {
|
||||
targetElement.remove();
|
||||
}
|
||||
if (targetElement instanceof HTMLScriptElement) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
getContent(query?: string): string {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
return targetElement ? targetElement.innerHTML : '';
|
||||
}
|
||||
|
||||
return pageElement.innerHTML;
|
||||
}
|
||||
|
||||
scrollToElement(query: string) {
|
||||
const pageElement = this.props.getContentElement();
|
||||
if (!pageElement) {
|
||||
return;
|
||||
}
|
||||
const targetElement = pageElement.querySelector(query);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
const iframeElement = this.props.getIframeElement();
|
||||
if (!iframeElement) {
|
||||
return;
|
||||
}
|
||||
iframeElement.contentWindow?.location.reload();
|
||||
}
|
||||
}
|
||||
330
app/.client/components/editor/EditorOverlay.tsx
Normal file
330
app/.client/components/editor/EditorOverlay.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useFrame } from 'react-frame-component';
|
||||
import { EditDialog } from './EditDialog';
|
||||
|
||||
export interface EditorOverlayProps {
|
||||
selectedElement: HTMLElement | null;
|
||||
hoveredElement: HTMLElement | null;
|
||||
setHoveredElement: (element: HTMLElement | null) => void;
|
||||
setSelectedElement: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
const shadowDomStyles = `
|
||||
.overlay-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 999996;
|
||||
}
|
||||
|
||||
.hover-overlay {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
z-index: 999997;
|
||||
background-color: rgba(0, 102, 255, 0.1);
|
||||
border: 1px dashed rgb(0, 87, 255);
|
||||
}
|
||||
|
||||
.select-overlay {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
z-index: 999997;
|
||||
border: 1px dashed rgb(0, 87, 255);
|
||||
}
|
||||
|
||||
.editor-dialog {
|
||||
position: absolute;
|
||||
z-index: 999998;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
min-width: 320px;
|
||||
pointer-events: auto;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 自定义箭头样式 */
|
||||
.floating-arrow {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transform: rotate(45deg);
|
||||
background: white;
|
||||
z-index: 999997;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 编辑器覆盖层组件,负责在 iframe 内创建和管理覆盖层。
|
||||
* 覆盖层用于操作和修改 HTML 元素。
|
||||
* 为防止样式覆盖,因此使用 Shadow DOM 创建覆盖层。
|
||||
*/
|
||||
export const EditorOverlay: React.FC<EditorOverlayProps> = ({
|
||||
selectedElement,
|
||||
hoveredElement,
|
||||
setHoveredElement,
|
||||
setSelectedElement,
|
||||
}) => {
|
||||
const { document: iframeDocument, window: iframeWindow } = useFrame();
|
||||
const [hoverRect, setHoverRect] = useState<DOMRect | null>(null);
|
||||
const [selectRect, setSelectRect] = useState<DOMRect | null>(null);
|
||||
const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null);
|
||||
|
||||
const { refs: hoverRefs, floatingStyles: hoverFloatingStyles } = useFloating({
|
||||
elements: {
|
||||
reference: hoveredElement ?? undefined,
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(({ rects }) => {
|
||||
return -rects.reference.height / 2 - rects.floating.height / 2;
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { refs: selectRefs, floatingStyles: selectFloatingStyles } = useFloating({
|
||||
elements: {
|
||||
reference: selectedElement ?? undefined,
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offset(({ rects }) => {
|
||||
return -rects.reference.height / 2 - rects.floating.height / 2;
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
elements: {
|
||||
reference: selectedElement ?? undefined,
|
||||
},
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: 'bottom',
|
||||
middleware: [
|
||||
offset(10),
|
||||
flip({
|
||||
fallbackPlacements: ['top'],
|
||||
crossAxis: true,
|
||||
boundary: iframeDocument?.body || undefined,
|
||||
}),
|
||||
shift({
|
||||
padding: 10,
|
||||
limiter: {
|
||||
options: {
|
||||
offset: 100,
|
||||
},
|
||||
fn: (state) => {
|
||||
const { x, y } = state;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { getFloatingProps } = useInteractions([useClick(context), useDismiss(context)]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hoveredElement && hoverRefs.reference.current !== hoveredElement) {
|
||||
hoverRefs.reference.current = hoveredElement;
|
||||
}
|
||||
}, [hoveredElement, hoverRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedElement && selectRefs.reference.current !== selectedElement) {
|
||||
selectRefs.reference.current = selectedElement;
|
||||
}
|
||||
}, [selectedElement, selectRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedElement && refs.reference.current !== selectedElement) {
|
||||
refs.reference.current = selectedElement;
|
||||
}
|
||||
}, [selectedElement, refs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeDocument || !iframeWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = iframeDocument.createElement('div');
|
||||
container.id = 'editor-overlay';
|
||||
|
||||
iframeDocument.body.appendChild(container);
|
||||
const shadow = container.attachShadow({ mode: 'open' });
|
||||
|
||||
const style = iframeDocument.createElement('style');
|
||||
style.textContent = shadowDomStyles;
|
||||
shadow.appendChild(style);
|
||||
|
||||
const contentContainer = iframeDocument.createElement('div');
|
||||
contentContainer.className = 'overlay-container';
|
||||
shadow.appendChild(contentContainer);
|
||||
|
||||
setShadowRoot(shadow);
|
||||
|
||||
return () => {
|
||||
if (container && container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
};
|
||||
}, [iframeDocument, iframeWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeDocument || !iframeWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (
|
||||
target === iframeDocument.body ||
|
||||
target === iframeDocument.documentElement ||
|
||||
target.closest('#editor-overlay')
|
||||
) {
|
||||
if (hoveredElement) {
|
||||
setHoveredElement(null);
|
||||
setHoverRect(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoveredElement !== target) {
|
||||
setHoveredElement(target);
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
setHoverRect({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
toJSON: rect.toJSON,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target === iframeDocument.body || target === iframeDocument.documentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedElement(target);
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
setSelectRect({
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
toJSON: rect.toJSON,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleMouseOut = (e: MouseEvent) => {
|
||||
if (!e.relatedTarget || !iframeDocument.contains(e.relatedTarget as Node)) {
|
||||
setHoveredElement(null);
|
||||
setHoverRect(null);
|
||||
}
|
||||
};
|
||||
|
||||
iframeDocument.body.addEventListener('mousemove', handleMouseMove);
|
||||
iframeDocument.body.addEventListener('click', handleClick);
|
||||
iframeDocument.body.addEventListener('submit', handleSubmit);
|
||||
iframeDocument.addEventListener('mouseout', handleMouseOut);
|
||||
|
||||
return () => {
|
||||
iframeDocument.body.removeEventListener('mousemove', handleMouseMove);
|
||||
iframeDocument.body.removeEventListener('click', handleClick);
|
||||
iframeDocument.body.removeEventListener('submit', handleSubmit);
|
||||
iframeDocument.removeEventListener('mouseout', handleMouseOut);
|
||||
};
|
||||
}, [
|
||||
iframeDocument,
|
||||
iframeWindow,
|
||||
selectedElement,
|
||||
hoveredElement,
|
||||
setHoveredElement,
|
||||
setSelectedElement,
|
||||
setHoverRect,
|
||||
setSelectRect,
|
||||
]);
|
||||
|
||||
if (!iframeDocument || !shadowRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlayContainer = shadowRoot.querySelector('.overlay-container');
|
||||
|
||||
if (!overlayContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<>
|
||||
{hoveredElement && hoverRect && (
|
||||
<div
|
||||
ref={hoverRefs.setFloating}
|
||||
className="hover-overlay"
|
||||
style={{
|
||||
...hoverFloatingStyles,
|
||||
width: `${hoverRect.width}px`,
|
||||
height: `${hoverRect.height}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedElement && selectRect && (
|
||||
<div
|
||||
ref={selectRefs.setFloating}
|
||||
className="select-overlay"
|
||||
style={{
|
||||
...selectFloatingStyles,
|
||||
width: `${selectRect.width}px`,
|
||||
height: `${selectRect.height}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedElement && (
|
||||
<div ref={refs.setFloating} className="editor-dialog" style={floatingStyles} {...getFloatingProps()}>
|
||||
<EditDialog element={selectedElement} onClose={() => setSelectedElement(null)} />
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
overlayContainer as Element,
|
||||
);
|
||||
};
|
||||
94
app/.client/components/editor/EditorRender.tsx
Normal file
94
app/.client/components/editor/EditorRender.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { forwardRef, useRef } from 'react';
|
||||
import Frame from 'react-frame-component';
|
||||
|
||||
export interface EditorRenderProps {
|
||||
onMount: (iframe: HTMLIFrameElement | null) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HTML 来渲染编辑器。并在 HTML 有所变化时,调用更新函数。
|
||||
* 为了保证纯净性,此函数将只考虑渲染 HTML 以及更新,与外部的所有交互无关。
|
||||
*/
|
||||
export const EditorRender = forwardRef<HTMLDivElement, EditorRenderProps>(({ onMount, children }, ref) => {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
// 初始化的 HTML 内容,如果有 HTML 所需的一些外部资源,可以在这里添加。但需要注意的是,导出时,需要将这些资源也导出。
|
||||
const initialContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
iframe {
|
||||
border: none;
|
||||
}
|
||||
.page-iframe {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor-content"></div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return (
|
||||
<div className="editor-render w-full h-full relative">
|
||||
<Frame
|
||||
ref={frameRef}
|
||||
initialContent={initialContent}
|
||||
mountTarget="#editor-content"
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="w-full h-full"
|
||||
style={{ border: 'none', margin: 0, padding: 0 }}
|
||||
contentDidMount={() => {
|
||||
onMount(frameRef.current);
|
||||
}}
|
||||
sandbox="allow-scripts allow-same-origin allow-downloads allow-popups allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols"
|
||||
head={
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#editor-content, .frame-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editor-editing {
|
||||
outline: 2px dashed #3b82f6 !important;
|
||||
outline-offset: -2px;
|
||||
min-height: 1em;
|
||||
position: relative;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div ref={ref} style={{ width: '100%', height: '100%', margin: 0, padding: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
427
app/.client/components/editor/PageRender.tsx
Normal file
427
app/.client/components/editor/PageRender.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import Frame from 'react-frame-component';
|
||||
import { executeScripts } from '~/.client/utils/execute-scripts';
|
||||
import { isMac } from '~/.client/utils/os';
|
||||
import type { DocumentProperties } from '~/types/editor';
|
||||
import { EditorOverlay } from './EditorOverlay';
|
||||
|
||||
export interface PageRenderRef {
|
||||
element: HTMLDivElement | null;
|
||||
iframe: HTMLIFrameElement | null;
|
||||
}
|
||||
|
||||
export interface EditorRenderProps {
|
||||
document: DocumentProperties;
|
||||
onUpdate?: (pageName: string, html: string) => void;
|
||||
onSave?: (pageName: string, html: string) => void;
|
||||
isCurrentPage?: boolean;
|
||||
}
|
||||
|
||||
const pageAnimationVariants: Variants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
zIndex: 1,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
zIndex: 2,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.4, 0, 0.2, 1],
|
||||
},
|
||||
},
|
||||
inactive: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
display: 'none',
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 HTML 来渲染页面。并在当前页面的 HTML 有所变化时,调用更新函数。
|
||||
* 为了保证纯净性,此函数将只考虑渲染 HTML 以及更新,与外部的所有交互无关。
|
||||
*/
|
||||
export const PageRender = forwardRef<PageRenderRef, EditorRenderProps>(
|
||||
({ document, onUpdate, onSave, isCurrentPage }, ref) => {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const observerRef = useRef<MutationObserver | null>(null);
|
||||
const lastContentRef = useRef<string | null>(null);
|
||||
const isMountedRef = useRef<boolean>(false);
|
||||
const documentContentRef = useRef<string>(document.content);
|
||||
const previousSelectedElementRef = useRef<HTMLElement | null>(null);
|
||||
const hasUnsavedChangesRef = useRef<boolean>(false);
|
||||
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
||||
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
|
||||
// 解决 react-frame-component 首次加载时可能无法加载的问题。
|
||||
// https://github.com/ryanseddon/react-frame-component/issues/192
|
||||
const [show, setShow] = useState(false);
|
||||
useEffect(() => {
|
||||
setShow(true);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
element: contentRef.current,
|
||||
iframe: frameRef.current,
|
||||
};
|
||||
}, [frameRef.current, contentRef.current]);
|
||||
|
||||
const setElementEditable = useCallback((element: HTMLElement, isEditable: boolean) => {
|
||||
if (isEditable) {
|
||||
element.contentEditable = 'true';
|
||||
element.focus();
|
||||
return;
|
||||
}
|
||||
element.removeAttribute('contenteditable');
|
||||
element.blur();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
documentContentRef.current = document.content;
|
||||
}, [document.content]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!onSave || !hasUnsavedChangesRef.current || !frameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const iframeDocument = frameRef.current.contentDocument;
|
||||
if (!iframeDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorContent = iframeDocument.getElementById('page-content');
|
||||
if (!editorContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentHTML = editorContent.querySelector(`#page-${document.name}`);
|
||||
if (!contentHTML) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = contentHTML.innerHTML;
|
||||
onSave(document.name, currentContent);
|
||||
hasUnsavedChangesRef.current = false;
|
||||
}, [onSave, document.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedElement) {
|
||||
setElementEditable(selectedElement, true);
|
||||
}
|
||||
|
||||
if (previousSelectedElementRef.current !== selectedElement) {
|
||||
setTimeout(() => {
|
||||
handleSave();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
if (previousSelectedElementRef.current) {
|
||||
setElementEditable(previousSelectedElementRef.current, false);
|
||||
}
|
||||
previousSelectedElementRef.current = selectedElement;
|
||||
}, [selectedElement, setElementEditable, handleSave]);
|
||||
|
||||
const processContentUpdate = useCallback(
|
||||
(contentHTML: Element | null) => {
|
||||
if (!contentHTML) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = contentHTML.innerHTML;
|
||||
if (currentContent !== lastContentRef.current) {
|
||||
lastContentRef.current = currentContent;
|
||||
hasUnsavedChangesRef.current = true;
|
||||
if (onUpdate) {
|
||||
onUpdate(document.name, currentContent);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onUpdate, document.name],
|
||||
);
|
||||
|
||||
const setupMutationObserver = useCallback(() => {
|
||||
if (!frameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const iframeDocument = frameRef.current.contentDocument;
|
||||
if (!iframeDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editorContent = iframeDocument.getElementById('page-content');
|
||||
if (!editorContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
let updateTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRealChanges = mutations.some(
|
||||
(mutation) => !(mutation.type === 'attributes' && mutation.attributeName === 'contenteditable'),
|
||||
);
|
||||
|
||||
if (!hasRealChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer);
|
||||
}
|
||||
|
||||
updateTimer = setTimeout(() => {
|
||||
const contentHTML = editorContent.querySelector(`#page-${document.name}`);
|
||||
if (!contentHTML) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = contentHTML.innerHTML;
|
||||
if (currentContent !== lastContentRef.current) {
|
||||
processContentUpdate(contentHTML);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const config: MutationObserverInit = {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
attributeOldValue: true,
|
||||
characterDataOldValue: true,
|
||||
};
|
||||
|
||||
observer.observe(editorContent, config);
|
||||
observerRef.current = observer;
|
||||
|
||||
return () => {
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer);
|
||||
}
|
||||
observer.disconnect();
|
||||
observerRef.current = null;
|
||||
};
|
||||
}, [processContentUpdate]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if ((isMac ? e.metaKey : e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
},
|
||||
[handleSave],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentPage && frameRef.current) {
|
||||
if (frameRef.current.style.display === 'none') {
|
||||
frameRef.current.style.display = 'block';
|
||||
}
|
||||
if (frameRef.current.style.visibility === 'hidden') {
|
||||
frameRef.current.style.visibility = 'visible';
|
||||
}
|
||||
setupMutationObserver();
|
||||
|
||||
// 添加键盘事件监听器
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// 在 iframe 内也添加键盘事件监听器
|
||||
const iframeDocument = frameRef.current.contentDocument;
|
||||
if (iframeDocument) {
|
||||
iframeDocument.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
} else if (!isCurrentPage && observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
if (frameRef.current) {
|
||||
frameRef.current.style.visibility = 'hidden';
|
||||
frameRef.current.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
if (frameRef.current?.contentDocument) {
|
||||
frameRef.current.contentDocument.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
};
|
||||
}, [isCurrentPage, setupMutationObserver, handleKeyDown]);
|
||||
|
||||
const handleFrameMount = useCallback(() => {
|
||||
isMountedRef.current = true;
|
||||
if (frameRef.current) {
|
||||
if (isCurrentPage || isCurrentPage === undefined) {
|
||||
frameRef.current.style.visibility = 'visible';
|
||||
frameRef.current.style.display = 'block';
|
||||
setupMutationObserver();
|
||||
|
||||
const iframeDocument = frameRef.current.contentDocument;
|
||||
if (iframeDocument) {
|
||||
iframeDocument.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
} else {
|
||||
frameRef.current.style.visibility = 'hidden';
|
||||
frameRef.current.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (documentContentRef.current) {
|
||||
const iframeDocument = frameRef.current?.contentDocument;
|
||||
if (!iframeDocument) {
|
||||
return;
|
||||
}
|
||||
const editorContent = iframeDocument.getElementById('page-content');
|
||||
if (!editorContent) {
|
||||
return;
|
||||
}
|
||||
initialPageContent();
|
||||
}
|
||||
// 如果 document 的 content 不为空,则设置为初始内容。
|
||||
}, [isCurrentPage, setupMutationObserver, handleKeyDown]);
|
||||
|
||||
const initialPageContent = useCallback(() => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasUnsavedChangesRef.current = false;
|
||||
lastContentRef.current = documentContentRef.current;
|
||||
contentRef.current.innerHTML = documentContentRef.current;
|
||||
executeScripts(contentRef.current);
|
||||
const event = new Event('DOMContentLoaded', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
frameRef.current?.contentDocument?.dispatchEvent(event);
|
||||
}, [ref, documentContentRef]);
|
||||
|
||||
// 初始化的 HTML 内容,如果有 HTML 所需的一些外部资源,可以在这里添加。但需要注意的是,导出时,需要将这些资源也导出。
|
||||
const initialContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${document.title}</title>
|
||||
<script src="${import.meta.env.BASE_URL}tailwindcss.js"></script>
|
||||
<script src="${import.meta.env.BASE_URL}iconify-icon.min.js"></script>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
#page-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page-content"></div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="page-render w-full h-full absolute"
|
||||
initial="hidden"
|
||||
animate={isCurrentPage ? 'visible' : 'inactive'}
|
||||
variants={pageAnimationVariants}
|
||||
key={document.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
display: isCurrentPage ? 'block' : 'none',
|
||||
position: 'absolute',
|
||||
}}
|
||||
>
|
||||
{show && (
|
||||
<Frame
|
||||
ref={frameRef}
|
||||
initialContent={initialContent}
|
||||
mountTarget="#page-content"
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="page-iframe"
|
||||
loading="lazy"
|
||||
style={{
|
||||
border: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
visibility: isCurrentPage ? 'visible' : 'hidden',
|
||||
display: isCurrentPage ? 'block' : 'none',
|
||||
}}
|
||||
contentDidMount={handleFrameMount}
|
||||
sandbox="allow-scripts allow-forms allow-popups allow-modals allow-storage-access-by-user-activation allow-same-origin"
|
||||
head={
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.page-editing {
|
||||
outline: 2px dashed #3b82f6 !important;
|
||||
outline-offset: -2px;
|
||||
min-height: 1em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[contenteditable="true"] {
|
||||
cursor: text;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div id={`page-${document.name}`} ref={contentRef}></div>
|
||||
<EditorOverlay
|
||||
selectedElement={selectedElement}
|
||||
hoveredElement={hoveredElement}
|
||||
setHoveredElement={setHoveredElement}
|
||||
setSelectedElement={setSelectedElement}
|
||||
/>
|
||||
</Frame>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
94
app/.client/components/editor/editors/DefaultEditor.tsx
Normal file
94
app/.client/components/editor/editors/DefaultEditor.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import loadingSvg from '../icons/loading.svg?raw';
|
||||
import sendSvg from '../icons/send.svg?raw';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 默认编辑器组件,通用的 HTML 组件。
|
||||
*/
|
||||
export const DefaultEditor: React.FC<EditorProps> = ({ element, onSendPrompt }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSendPrompt = async () => {
|
||||
if (!prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await onSendPrompt(prompt, element);
|
||||
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('AI 请求失败:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendPrompt();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
borderRadius: '8px',
|
||||
minHeight: '80px',
|
||||
resize: 'none',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
padding: '12px',
|
||||
paddingRight: '2px',
|
||||
}}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述想修改的逻辑或样式..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSendPrompt}
|
||||
disabled={isLoading || !prompt.trim()}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: isLoading || !prompt.trim() ? 'default' : 'pointer',
|
||||
color: isLoading || !prompt.trim() ? '#cbd5e1' : '#3b82f6',
|
||||
padding: '4px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
paddingRight: '12px',
|
||||
paddingBottom: '12px',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: loadingSvg }} />
|
||||
) : (
|
||||
<div dangerouslySetInnerHTML={{ __html: sendSvg }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
app/.client/components/editor/editors/EditorProps.ts
Normal file
31
app/.client/components/editor/editors/EditorProps.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 编辑器组件的通用接口
|
||||
*/
|
||||
export interface EditorProps {
|
||||
/**
|
||||
* 被编辑的元素
|
||||
*/
|
||||
element: HTMLElement;
|
||||
|
||||
/**
|
||||
* 发送请求到 AI
|
||||
* @param prompt 提示词
|
||||
* @returns
|
||||
*/
|
||||
onSendPrompt: (prompt: string, element: HTMLElement) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 关闭编辑器的回调函数
|
||||
*/
|
||||
onClose: () => void;
|
||||
|
||||
/**
|
||||
* 元素类型
|
||||
*/
|
||||
elementType: string;
|
||||
|
||||
/**
|
||||
* 对话框标题
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
868
app/.client/components/editor/editors/IconEditor.tsx
Normal file
868
app/.client/components/editor/editors/IconEditor.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 生成 iconify 图标的 HTML
|
||||
*
|
||||
* @param icon 图标名称
|
||||
* @param style 样式对象
|
||||
* @returns 包含 iconify 图标的 HTML 字符串
|
||||
*/
|
||||
const iconifyIcon = (icon: string, style: string = '') => {
|
||||
return `<iconify-icon icon="${icon}" ${style ? `style="${style}"` : ''}></iconify-icon>`;
|
||||
};
|
||||
|
||||
const API_BASE_URLS = ['https://api.iconify.design', 'https://api.simplesvg.com', 'https://api.unisvg.com'];
|
||||
|
||||
const API_ENDPOINTS = {
|
||||
COLLECTIONS: '/collections',
|
||||
COLLECTION: '/collection',
|
||||
SEARCH: '/search',
|
||||
};
|
||||
|
||||
// 每页加载的图标数量
|
||||
const ICONS_PER_PAGE = 20;
|
||||
|
||||
interface IconSetInfo {
|
||||
name: string;
|
||||
total?: number;
|
||||
author?: {
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
license?: {
|
||||
title: string;
|
||||
url?: string;
|
||||
};
|
||||
samples?: string[];
|
||||
height?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个API源获取 iconify 数据
|
||||
*
|
||||
* @param endpoint API端点
|
||||
* @param params URL参数对象
|
||||
* @returns 获取到的数据
|
||||
* @throws 如果所有API源都失败,则抛出错误
|
||||
*/
|
||||
const fetchFromAPI = async (endpoint: string, params: Record<string, string> = {}) => {
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join('&');
|
||||
|
||||
const urlSuffix = queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||
let lastError = null;
|
||||
let lastResponseText = '';
|
||||
|
||||
for (const baseUrl of API_BASE_URLS) {
|
||||
try {
|
||||
const url = `${baseUrl}${urlSuffix}`;
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
lastResponseText = await response.text();
|
||||
console.warn(`API 请求失败: ${url}, 状态码: ${response.status}, 响应: ${lastResponseText.substring(0, 100)}...`);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.warn(`从 ${baseUrl}${urlSuffix} 获取数据失败`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const errorMsg = lastResponseText ? `API 返回错误: ${lastResponseText.substring(0, 200)}` : '无法从任何API源获取数据';
|
||||
console.error(errorMsg, lastError);
|
||||
throw new Error(errorMsg);
|
||||
};
|
||||
|
||||
/**
|
||||
* 图标编辑器组件,用于实现 iconify 图标替换。
|
||||
*/
|
||||
export const IconEditor: React.FC<EditorProps> = ({ element, onClose }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentIcon, setCurrentIcon] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [iconSets, setIconSets] = useState<string[]>([]);
|
||||
const [iconSetsInfo, setIconSetsInfo] = useState<Record<string, IconSetInfo>>({});
|
||||
const [selectedIconSet, setSelectedIconSet] = useState<string>('');
|
||||
const [icons, setIcons] = useState<string[]>([]);
|
||||
const [, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [searchResults, setSearchResults] = useState<string[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const [allIconNames, setAllIconNames] = useState<string[]>([]);
|
||||
const [loadedIconNames, setLoadedIconNames] = useState<string[]>([]);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (element && element.tagName.toLowerCase() === 'iconify-icon') {
|
||||
const iconName = element.getAttribute('icon') || '';
|
||||
setCurrentIcon(iconName);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchIconSets = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchFromAPI(API_ENDPOINTS.COLLECTIONS);
|
||||
const prefixes = Object.keys(data);
|
||||
setIconSets(prefixes);
|
||||
setIconSetsInfo(data);
|
||||
|
||||
if (currentIcon) {
|
||||
const [prefix] = currentIcon.split(':');
|
||||
if (prefixes.includes(prefix)) {
|
||||
setSelectedIconSet(prefix);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedIconSet(prefixes[0]);
|
||||
} catch (err) {
|
||||
console.error('获取图标集失败', err);
|
||||
setError('获取图标集失败,请稍后再试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (iconSets.length === 0) {
|
||||
fetchIconSets();
|
||||
}
|
||||
}, [currentIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
if ('IntersectionObserver' in window && loadMoreTriggerRef.current && !isSearching && hasMore) {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && hasMore && !isLoading && !isLoadingMore) {
|
||||
loadMoreIcons();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '0px 0px 100px 0px' },
|
||||
);
|
||||
|
||||
observerRef.current.observe(loadMoreTriggerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [hasMore, isLoading, isLoadingMore, isSearching, selectedIconSet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIconSet) {
|
||||
setPage(1);
|
||||
setIcons([]);
|
||||
setAllIconNames([]);
|
||||
setLoadedIconNames([]);
|
||||
setHasMore(true);
|
||||
|
||||
fetchAllIconNames(selectedIconSet);
|
||||
}
|
||||
}, [selectedIconSet]);
|
||||
|
||||
const fetchAllIconNames = async (prefix: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchFromAPI(API_ENDPOINTS.COLLECTION, {
|
||||
prefix,
|
||||
chars: 'true',
|
||||
aliases: 'true',
|
||||
});
|
||||
const iconNamesSet = new Set<string>();
|
||||
|
||||
if (data.uncategorized && Array.isArray(data.uncategorized)) {
|
||||
data.uncategorized.forEach((name: string) => iconNamesSet.add(name));
|
||||
}
|
||||
|
||||
if (data.categories && typeof data.categories === 'object') {
|
||||
Object.values(data.categories).forEach((icons: any) => {
|
||||
if (Array.isArray(icons)) {
|
||||
icons.forEach((name: string) => iconNamesSet.add(name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.icons && Array.isArray(data.icons)) {
|
||||
data.icons.forEach((name: string) => iconNamesSet.add(name));
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((name: string) => iconNamesSet.add(name));
|
||||
}
|
||||
|
||||
if (iconNamesSet.size === 0 && typeof data === 'object') {
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (typeof data[key] === 'object' && data[key] !== null) {
|
||||
iconNamesSet.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const iconNames = Array.from(iconNamesSet);
|
||||
setAllIconNames(iconNames);
|
||||
if (iconNames.length > 0) {
|
||||
fetchIconsBatch(prefix, iconNames.slice(0, ICONS_PER_PAGE));
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取图标集失败', err instanceof Error ? err.message : String(err));
|
||||
setError('获取图标集失败,请稍后再试');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchIconsBatch = async (prefix: string, iconBatch: string[]) => {
|
||||
if (iconBatch.length === 0) {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const iconsParam = iconBatch.join(',');
|
||||
await fetchFromAPI(`/${prefix}.json`, { icons: iconsParam });
|
||||
const newIcons = iconBatch.map((name) => `${prefix}:${name}`);
|
||||
setIcons((prev) => {
|
||||
const updated = [...prev, ...newIcons];
|
||||
return updated;
|
||||
});
|
||||
|
||||
setLoadedIconNames((prev) => {
|
||||
const updated = [...prev, ...iconBatch];
|
||||
|
||||
const hasMoreIcons = updated.length < allIconNames.length;
|
||||
setTimeout(() => {
|
||||
setHasMore(hasMoreIcons);
|
||||
}, 0);
|
||||
return updated;
|
||||
});
|
||||
|
||||
setPage((prevPage) => prevPage + 1);
|
||||
} catch (err) {
|
||||
console.error('获取图标失败', err instanceof Error ? err.message : String(err));
|
||||
setError('获取图标失败,请稍后再试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreIcons = useCallback(() => {
|
||||
if (isLoading || isLoadingMore || !hasMore || isSearching || !selectedIconSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingMore(true);
|
||||
|
||||
const startIndex = loadedIconNames.length;
|
||||
const endIndex = Math.min(startIndex + ICONS_PER_PAGE, allIconNames.length);
|
||||
|
||||
if (startIndex < endIndex) {
|
||||
const nextBatch = allIconNames.slice(startIndex, endIndex);
|
||||
fetchIconsBatch(selectedIconSet, nextBatch);
|
||||
} else {
|
||||
setHasMore(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [isLoading, isLoadingMore, hasMore, isSearching, selectedIconSet, loadedIconNames.length, allIconNames]);
|
||||
|
||||
const searchIcons = async () => {
|
||||
if (!searchTerm.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchFromAPI(API_ENDPOINTS.SEARCH, { query: searchTerm });
|
||||
let results: string[] = [];
|
||||
if (data.icons && Array.isArray(data.icons)) {
|
||||
results = data.icons;
|
||||
} else if (Array.isArray(data)) {
|
||||
results = data;
|
||||
} else if (data.results && Array.isArray(data.results)) {
|
||||
results = data.results;
|
||||
} else if (typeof data === 'object') {
|
||||
const possibleResults = Object.keys(data).filter((key) => typeof data[key] === 'object' && data[key] !== null);
|
||||
if (possibleResults.length > 0) {
|
||||
results = possibleResults;
|
||||
}
|
||||
}
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
console.error('搜索图标失败', err instanceof Error ? err.message : String(err));
|
||||
setError('搜索图标失败,请稍后再试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
searchIcons();
|
||||
}, [searchTerm]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!isLoading && hasMore && !isLoadingMore && selectedIconSet) {
|
||||
loadMoreIcons();
|
||||
return;
|
||||
}
|
||||
setHasMore(false);
|
||||
setIsLoadingMore(false);
|
||||
}, [isLoading, hasMore, isLoadingMore, selectedIconSet, loadMoreIcons]);
|
||||
|
||||
const selectIcon = (iconName: string) => {
|
||||
if (element && element.tagName.toLowerCase() === 'iconify-icon') {
|
||||
element.setAttribute('icon', iconName);
|
||||
setCurrentIcon(iconName);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleIconSetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelectedIconSet(e.target.value);
|
||||
setPage(1);
|
||||
setIcons([]);
|
||||
};
|
||||
|
||||
const renderIcons = () => {
|
||||
const iconsToRender = isSearching ? searchResults : icons;
|
||||
|
||||
if (iconsToRender.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: '#64748b',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '180px',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 24px; color: #64748b'),
|
||||
}}
|
||||
style={{ marginBottom: '12px' }}
|
||||
/>
|
||||
<div>加载中...</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:mood-sad', 'font-size: 40px; color: #94a3b8'),
|
||||
}}
|
||||
style={{ marginBottom: '16px' }}
|
||||
/>
|
||||
<div>没有找到图标</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '8px', color: '#94a3b8' }}>
|
||||
{isSearching ? '请尝试其他搜索关键词' : '请选择其他图标集'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(65px, 1fr))',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
{iconsToRender.map((iconName) => (
|
||||
<div
|
||||
key={iconName}
|
||||
onClick={() => selectIcon(iconName)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '8px 4px',
|
||||
border: iconName === currentIcon ? '2px solid #3b82f6' : '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: iconName === currentIcon ? '#eff6ff' : '#ffffff',
|
||||
boxShadow:
|
||||
iconName === currentIcon ? '0 1px 3px rgba(59, 130, 246, 0.1)' : '0 1px 2px rgba(0, 0, 0, 0.02)',
|
||||
transition: 'all 0.2s ease',
|
||||
height: '70px',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
if (iconName !== currentIcon) {
|
||||
e.currentTarget.style.backgroundColor = '#f8fafc';
|
||||
e.currentTarget.style.borderColor = '#cbd5e1';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.05)';
|
||||
}
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
if (iconName !== currentIcon) {
|
||||
e.currentTarget.style.backgroundColor = '#ffffff';
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.02)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `<iconify-icon icon="${iconName}" style="font-size: 26px; color: ${iconName === currentIcon ? '#3b82f6' : '#475569'}"></iconify-icon>`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
marginTop: '6px',
|
||||
textAlign: 'center',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
color: iconName === currentIcon ? '#3b82f6' : '#64748b',
|
||||
}}
|
||||
>
|
||||
{iconName.split(':')[1] || iconName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', width: '400px' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
marginBottom: '16px',
|
||||
color: '#475569',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>当前图标:</span>
|
||||
{currentIcon ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#f0f9ff',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #bae6fd',
|
||||
color: '#0284c7',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `<iconify-icon icon="${currentIcon}" style="font-size: 16px; margin-right: 6px"></iconify-icon>`,
|
||||
}}
|
||||
></span>
|
||||
{currentIcon}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#94a3b8', fontSize: '14px', fontStyle: 'italic' }}>未选择</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
marginBottom: '16px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="搜索图标..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 14px',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
background: isLoading ? '#f1f5f9' : '#f8fafc',
|
||||
border: 'none',
|
||||
borderLeft: '1px solid #e2e8f0',
|
||||
padding: '0 16px',
|
||||
cursor: isLoading ? 'default' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease',
|
||||
color: isLoading ? '#94a3b8' : '#64748b',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.backgroundColor = '#f1f5f9';
|
||||
}
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
if (!isLoading) {
|
||||
e.currentTarget.style.backgroundColor = '#f8fafc';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:search', 'font-size: 18px'),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isSearching && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#475569',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
选择图标集:
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<select
|
||||
value={selectedIconSet}
|
||||
onChange={handleIconSetChange}
|
||||
disabled={isLoading || iconSets.length === 0}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
paddingRight: '32px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '14px',
|
||||
color: '#1e293b',
|
||||
backgroundColor: '#ffffff',
|
||||
appearance: 'none',
|
||||
cursor: isLoading || iconSets.length === 0 ? 'default' : 'pointer',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
transition: 'border-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{iconSets.length === 0 ? (
|
||||
<option value="">加载中...</option>
|
||||
) : (
|
||||
iconSets.map((prefix) => (
|
||||
<option key={prefix} value={prefix}>
|
||||
{iconSetsInfo[prefix]?.name ? `${iconSetsInfo[prefix].name}` : prefix}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
top: '0',
|
||||
bottom: '0',
|
||||
pointerEvents: 'none',
|
||||
color: '#64748b',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:chevron-down', 'font-size: 16px'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
color: '#ef4444',
|
||||
fontSize: '14px',
|
||||
marginBottom: '12px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#fef2f2',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #fee2e2',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:alert-circle', 'font-size: 16px; color: currentColor'),
|
||||
}}
|
||||
/>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
style={{
|
||||
maxHeight: '180px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '10px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#ffffff',
|
||||
boxShadow: 'inset 0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
scrollBehavior: 'smooth',
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
const target = e.currentTarget;
|
||||
const isNearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 100;
|
||||
|
||||
if (isNearBottom && hasMore && !isLoading && !isLoadingMore && !isSearching) {
|
||||
loadMoreIcons();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading && icons.length === 0 && !isSearching ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 40px; color: #64748b; opacity: 0.7'),
|
||||
}}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
color: '#64748b',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
正在加载图标集...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderIcons()
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={loadMoreTriggerRef}
|
||||
style={{
|
||||
height: '20px',
|
||||
margin: '20px 0 10px',
|
||||
visibility: hasMore && !isSearching ? 'visible' : 'hidden',
|
||||
display: hasMore && !isSearching ? 'block' : 'none',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{hasMore && !isSearching && !isLoadingMore && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#94a3b8',
|
||||
padding: '5px',
|
||||
border: '1px dashed #e2e8f0',
|
||||
borderRadius: '4px',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
滚动加载更多...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingMore && !isSearching && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 20px; color: #64748b'),
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '14px', color: '#64748b' }}>加载更多图标...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginTop: '20px',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{!isSearching && hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={isLoading || isLoadingMore}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: isLoading || isLoadingMore ? '#f1f5f9' : '#f8fafc',
|
||||
color: isLoading || isLoadingMore ? '#94a3b8' : '#475569',
|
||||
cursor: isLoading || isLoadingMore ? 'default' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
if (!isLoading && !isLoadingMore) {
|
||||
e.currentTarget.style.backgroundColor = '#f1f5f9';
|
||||
e.currentTarget.style.borderColor = '#cbd5e1';
|
||||
}
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
if (!isLoading && !isLoadingMore) {
|
||||
e.currentTarget.style.backgroundColor = '#f8fafc';
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading || isLoadingMore ? (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('line-md:loading-twotone-loop', 'font-size: 14px; color: #64748b'),
|
||||
}}
|
||||
style={{ marginRight: '4px' }}
|
||||
/>
|
||||
加载中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:download', 'font-size: 16px'),
|
||||
}}
|
||||
/>
|
||||
加载更多
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isSearching && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSearching(false);
|
||||
setSearchTerm('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
color: '#475569',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f1f5f9';
|
||||
e.currentTarget.style.borderColor = '#cbd5e1';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f8fafc';
|
||||
e.currentTarget.style.borderColor = '#e2e8f0';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: iconifyIcon('tabler:arrow-left', 'font-size: 16px'),
|
||||
}}
|
||||
/>
|
||||
返回图标集浏览
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
248
app/.client/components/editor/editors/ImageEditor.tsx
Normal file
248
app/.client/components/editor/editors/ImageEditor.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import loadingSvg from '../icons/loading.svg?raw';
|
||||
import uploadSvg from '../icons/upload.svg?raw';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 图片编辑器组件,用于上传和替换图片。
|
||||
*/
|
||||
export const ImageEditor: React.FC<EditorProps> = ({ element, onClose }) => {
|
||||
const imgElement = element as HTMLImageElement;
|
||||
const [src, setSrc] = useState(imgElement.src);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [step, setStep] = useState<'upload' | 'preview' | 'complete'>('upload');
|
||||
const [previewSrc, setPreviewSrc] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const maxFileSizeMB = window.ENV.MAX_UPLOAD_SIZE_MB || 5;
|
||||
const maxFileSize = maxFileSizeMB * 1024 * 1024;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
|
||||
setError(`文件大小超过限制,最大允许${maxSizeMB}MB`);
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result) {
|
||||
setPreviewSrc(event.target.result as string);
|
||||
setStep('preview');
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('文件读取失败');
|
||||
setError('文件读取失败');
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (previewSrc) {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// 从base64 src 中提取文件数据
|
||||
const base64Data = previewSrc.split(',')[1];
|
||||
const byteCharacters = atob(base64Data);
|
||||
const byteArrays = [];
|
||||
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteArrays.push(byteCharacters.charCodeAt(i));
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteArrays);
|
||||
const blob = new Blob([byteArray], { type: 'image/png' });
|
||||
|
||||
if (blob.size > maxFileSize) {
|
||||
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
|
||||
throw new Error(`文件大小超过限制,最大允许${maxSizeMB}MB`);
|
||||
}
|
||||
|
||||
const fileName = `image_${Date.now()}.png`;
|
||||
const file = new File([blob], fileName, { type: 'image/png' });
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || '上传失败');
|
||||
}
|
||||
|
||||
imgElement.src = result.data.url;
|
||||
setSrc(result.data.url);
|
||||
setStep('complete');
|
||||
onClose();
|
||||
|
||||
setTimeout(() => {
|
||||
setStep('upload');
|
||||
setPreviewSrc(null);
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('文件上传失败', error);
|
||||
setError(error instanceof Error ? error.message : '文件上传失败');
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setStep('upload');
|
||||
setPreviewSrc(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{step === 'upload' && (
|
||||
<div
|
||||
style={{
|
||||
border: '2px dashed #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
padding: '24px 16px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={triggerFileInput}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div style={{ padding: '20px 0' }}>
|
||||
<div dangerouslySetInnerHTML={{ __html: loadingSvg }} style={{ margin: '0 auto', display: 'block' }} />
|
||||
<p style={{ marginTop: '12px', color: '#64748b' }}>正在上传图片...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div dangerouslySetInnerHTML={{ __html: uploadSvg }} style={{ margin: '0 auto', display: 'block' }} />
|
||||
<p style={{ margin: '12px 0 0', color: '#64748b' }}>点击或拖拽图片到此处上传</p>
|
||||
{error && <p style={{ margin: '8px 0 0', color: '#ef4444' }}>{error}</p>}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'preview' && previewSrc && (
|
||||
<div>
|
||||
<div style={{ fontSize: '16px', fontWeight: 500, marginBottom: '12px', color: '#1e293b' }}>预览图片</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: '120px' }}>
|
||||
<p style={{ fontSize: '14px', color: '#64748b', marginBottom: '8px' }}>原图</p>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
height: '150px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt="原图"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: '120px' }}>
|
||||
<p style={{ fontSize: '14px', color: '#64748b', marginBottom: '8px' }}>新图</p>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
height: '150px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={previewSrc}
|
||||
alt="新图"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
color: '#64748b',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
disabled={isUploading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? '上传中...' : '替换图片'}
|
||||
</button>
|
||||
{error && <p style={{ margin: '8px 0 0', color: '#ef4444', fontSize: '12px' }}>{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
93
app/.client/components/editor/editors/LinkEditor.tsx
Normal file
93
app/.client/components/editor/editors/LinkEditor.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { memo, useState } from 'react';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 链接编辑器组件,用于编辑链接元素。
|
||||
*/
|
||||
export const LinkEditor: React.FC<EditorProps> = memo(({ element }) => {
|
||||
const linkElement = element as HTMLAnchorElement;
|
||||
const [href, setHref] = useState(linkElement.getAttribute('href') || '');
|
||||
const [content, setContent] = useState(linkElement.innerHTML);
|
||||
const [target, setTarget] = useState(linkElement.target);
|
||||
|
||||
const handleHrefChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newHref = e.target.value;
|
||||
setHref(newHref);
|
||||
linkElement.href = newHref;
|
||||
};
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newContent = e.target.value;
|
||||
setContent(newContent);
|
||||
linkElement.innerHTML = newContent;
|
||||
};
|
||||
|
||||
const handleTargetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newTarget = e.target.value;
|
||||
setTarget(newTarget);
|
||||
linkElement.target = newTarget;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '4px' }}>链接地址</label>
|
||||
<input
|
||||
type="text"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #cbd5e1',
|
||||
outline: 'none',
|
||||
borderRadius: '4px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
value={href}
|
||||
onChange={handleHrefChange}
|
||||
placeholder="https://upage.ai"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '4px' }}>链接文本</label>
|
||||
<input
|
||||
type="text"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #cbd5e1',
|
||||
outline: 'none',
|
||||
borderRadius: '4px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="链接文本"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '4px' }}>打开方式</label>
|
||||
<select
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #cbd5e1',
|
||||
outline: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
value={target}
|
||||
onChange={handleTargetChange}
|
||||
>
|
||||
<option value="">当前窗口</option>
|
||||
<option value="_blank">新窗口</option>
|
||||
<option value="_self">当前框架</option>
|
||||
<option value="_parent">父框架</option>
|
||||
<option value="_top">整个窗口</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
92
app/.client/components/editor/editors/TextEditor.tsx
Normal file
92
app/.client/components/editor/editors/TextEditor.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import loadingSvg from '../icons/loading.svg?raw';
|
||||
import sendSvg from '../icons/send.svg?raw';
|
||||
import type { EditorProps } from './EditorProps';
|
||||
|
||||
/**
|
||||
* 文本编辑器组件,用于向 AI 发送修改请求
|
||||
*/
|
||||
export const TextEditor: React.FC<EditorProps> = ({ element, onClose, onSendPrompt }) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSendPrompt = async () => {
|
||||
if (!prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onSendPrompt(prompt, element);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('AI 请求失败:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendPrompt();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
alignItems: 'end',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
borderRadius: '8px',
|
||||
minHeight: '80px',
|
||||
resize: 'none',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
padding: '12px',
|
||||
paddingRight: '2px',
|
||||
}}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="描述想修改的逻辑或样式..."
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSendPrompt}
|
||||
disabled={isLoading || !prompt.trim()}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: isLoading || !prompt.trim() ? 'default' : 'pointer',
|
||||
color: isLoading || !prompt.trim() ? '#cbd5e1' : '#3b82f6',
|
||||
padding: '4px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
paddingRight: '12px',
|
||||
paddingBottom: '12px',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: loadingSvg }} />
|
||||
) : (
|
||||
<div dangerouslySetInnerHTML={{ __html: sendSvg }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
5
app/.client/components/editor/editors/index.ts
Normal file
5
app/.client/components/editor/editors/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { DefaultEditor } from './DefaultEditor';
|
||||
export type { EditorProps } from './EditorProps';
|
||||
export { ImageEditor } from './ImageEditor';
|
||||
export { LinkEditor } from './LinkEditor';
|
||||
export { TextEditor } from './TextEditor';
|
||||
3
app/.client/components/editor/icons/close.svg
Normal file
3
app/.client/components/editor/icons/close.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M13.46 12L19 17.54V19h-1.46L12 13.46L6.46 19H5v-1.46L10.54 12L5 6.46V5h1.46L12 10.54L17.54 5H19v1.46z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 227 B |
13
app/.client/components/editor/icons/loading.svg
Normal file
13
app/.client/components/editor/icons/loading.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from="0 12 12"
|
||||
to="360 12 12"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
4
app/.client/components/editor/icons/send.svg
Normal file
4
app/.client/components/editor/icons/send.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<rect width="24" height="24" fill="none" />
|
||||
<path fill="currentColor" d="M12 2a10 10 0 0 1 10 10a10 10 0 0 1-10 10A10 10 0 0 1 2 12A10 10 0 0 1 12 2M8 7.71v3.34l7.14.95l-7.14.95v3.34L18 12z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 287 B |
3
app/.client/components/editor/icons/upload.svg
Normal file
3
app/.client/components/editor/icons/upload.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.5 20q-2.28 0-3.89-1.57Q1 16.85 1 14.58q0-1.95 1.17-3.48q1.18-1.53 3.08-1.95q.63-2.3 2.5-3.72Q9.63 4 12 4q2.93 0 4.96 2.04Q19 8.07 19 11q1.73.2 2.86 1.5q1.14 1.28 1.14 3q0 1.88-1.31 3.19T18.5 20H13q-.82 0-1.41-.59Q11 18.83 11 18v-5.15L9.4 14.4L8 13l4-4l4 4l-1.4 1.4l-1.6-1.55V18h5.5q1.05 0 1.77-.73q.73-.72.73-1.77t-.73-1.77Q19.55 13 18.5 13H17v-2q0-2.07-1.46-3.54Q14.08 6 12 6Q9.93 6 8.46 7.46Q7 8.93 7 11h-.5q-1.45 0-2.47 1.03Q3 13.05 3 14.5T4.03 17q1.02 1 2.47 1H9v2m3-7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 601 B |
Reference in New Issue
Block a user