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

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

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

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

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

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

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

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

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

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

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

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

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

View 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';

View 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

View 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

View 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

View 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