初始化模版工程
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/vibe_next_template
|
||||||
|
LOG_DIR=./vibe-next-template
|
||||||
|
|
||||||
|
NOVA_BASE_URL=https://dev-nova-api.betteryeah.com
|
||||||
|
NOVA_TENANT_ID=tenant_xxx
|
||||||
|
NOVA_ACCESS_KEY=access_key_xxx
|
||||||
|
|
||||||
|
LLM_BASE_URL=https://dev-llm-server-internal.betteryeah.com
|
||||||
|
LLM_API_KEY=sk-skills-default-dev-key
|
||||||
50
.gitignore
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# vscode
|
||||||
|
.vscode/*
|
||||||
|
.idea/*
|
||||||
|
|
||||||
|
# Remote Control 运行时数据
|
||||||
|
/remote-control/data/
|
||||||
|
!/remote-control/data/.gitkeep
|
||||||
|
|
||||||
9
.nova/config.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"agent_id": "d730c266fe6748839d9c93ece8e58b84",
|
||||||
|
"agent_name": "Agent",
|
||||||
|
"agent_description": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
|
COPY .npmrc /root/.npmrc
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile && pnpm run build && (test -d public || mkdir public)
|
||||||
|
|
||||||
|
FROM node:22-slim AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
|
COPY .npmrc /root/.npmrc
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY --from=builder /app/package.json ./
|
||||||
|
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/.nova ./.nova
|
||||||
|
COPY --from=builder /app/.env ./.env
|
||||||
|
COPY --from=builder /app/public* ./public
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
|
EXPOSE 13000
|
||||||
|
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
213
README.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# vibe-next-template
|
||||||
|
|
||||||
|
Nova Agent 示例模板工程。[Nova](https://nova.betteryeah.com) 是一个创建 Agent 的 SaaS 产品,可以通过 Nova 创建能够操作沙箱、浏览器、文件系统的专业 Agent。本项目基于 Next.js 提供完整的 Agent 聊天前端,包含消息流、事件列表、会话管理与文件上传能力。
|
||||||
|
|
||||||
|
项目中 `.nova/config.json` 内置了一个示例 Agent,如需基于本模板开发自己的应用,请在 Nova 平台创建 Agent 并替换配置。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Next.js 16(App Router) / React 19 / TypeScript 5
|
||||||
|
- Tailwind CSS 4 / Radix UI / Lucide React Icons
|
||||||
|
- Zustand(状态管理)
|
||||||
|
- 自定义 WebSocket 客户端(实时通信)
|
||||||
|
- 自定义 HTTPClient(Fetch 封装,支持拦截器)
|
||||||
|
- Drizzle ORM + PostgreSQL
|
||||||
|
- Winston(日志,生产环境按天轮转)
|
||||||
|
- Shiki(代码高亮)/ React Markdown + Remark GFM
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. 安装依赖:`pnpm install`
|
||||||
|
2. `.env`环境变量文件已经初始化完成了,所以设计环境变量的需要在这里修改
|
||||||
|
3. (可选)修改 `.nova/config.json`,替换为你自己的 Agent
|
||||||
|
4. 启动开发服务器:`pnpm dev`(端口 13000)
|
||||||
|
|
||||||
|
## Agent 配置
|
||||||
|
|
||||||
|
Agent 配置存放在 `.nova/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"agent_id": "70da765f2d42490ca574d72dce4d24fe",
|
||||||
|
"agent_name": "Nova示例Agent",
|
||||||
|
"agent_description": "一个Nova的Agent示例,包含Agent具备的通用功能,文件操作、沙箱环境执行、浏览器操作等等。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `agents` 数组中的第一个 Agent 作为默认使用的 Agent
|
||||||
|
- 在 Nova 平台创建新 Agent 后,将 `agent_id`、`agent_name`、`agent_description` 替换为新 Agent 的信息
|
||||||
|
- 配置通过 `app/api/nova-config.ts` 的 `getDefaultAgentId()` 在服务端读取,启动后缓存
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.nova/
|
||||||
|
config.json # Agent 配置(agent_id, agent_name, agent_description)
|
||||||
|
|
||||||
|
app/
|
||||||
|
layout.tsx # 根布局(Geist Sans/Mono 字体)
|
||||||
|
page.tsx # 主入口,初始化聊天连接
|
||||||
|
globals.css # 全局样式
|
||||||
|
api/ # Next.js API 路由(BFF 层,代理 Nova 平台 API)
|
||||||
|
oapi-client.ts # 共享的 Nova OpenAPI HTTPClient 实例
|
||||||
|
nova-config.ts # 读取 .nova/config.json,提供 getDefaultAgentId()
|
||||||
|
info/route.ts # 返回客户端配置:agentId, conversationId, wssUrl, token
|
||||||
|
chat/event/route.ts # 代理获取聊天事件历史
|
||||||
|
chat/stop/route.ts # 停止当前任务
|
||||||
|
conversation/route.ts # 会话列表
|
||||||
|
file/upload/route.ts # 文件上传
|
||||||
|
file/sign/route.ts # 文件签名 URL
|
||||||
|
health/route.ts # 健康检查
|
||||||
|
|
||||||
|
components/
|
||||||
|
nova-sdk/ # 核心聊天 SDK
|
||||||
|
types.ts # 核心类型定义(ApiEvent, PlatformConfig, TaskArtifact 等)
|
||||||
|
websocket.ts # WebSocket 客户端(自动重连、心跳、离线队列)
|
||||||
|
store/
|
||||||
|
useNovaStore.ts # Zustand 全局状态(events, artifacts, ws 状态, loading)
|
||||||
|
context/
|
||||||
|
context.ts # NovaContext 定义(HTTPClient + getArtifactUrl)
|
||||||
|
Provider.tsx # Context Provider
|
||||||
|
useNova.ts # useContext hook
|
||||||
|
hooks/
|
||||||
|
useNovaChatLogic.ts # 主编排 hook,组合所有子 hook
|
||||||
|
useNovaEvents.ts # WebSocket + 历史事件获取
|
||||||
|
useNovaService.ts # 从 platformConfig 创建 HTTPClient
|
||||||
|
useMessageSender.ts # 通过 WebSocket 发送消息
|
||||||
|
useEventProcessor.ts # ApiEvent → UI 消息转换
|
||||||
|
useBuildConversationConnect.ts # 初始化:获取 agentId、conversationId
|
||||||
|
useFileUploader.ts # 文件上传逻辑
|
||||||
|
useAttachmentHandlers.ts # 附件点击处理
|
||||||
|
useMessageScroll.ts # 消息列表自动滚动
|
||||||
|
usePanelState.ts # 产物面板开关
|
||||||
|
useSize.ts # 响应式尺寸
|
||||||
|
nova-chat/ # 聊天主组件
|
||||||
|
index.tsx # NovaChat(包裹 Provider、Header、MessageList、Input、TaskPanel)
|
||||||
|
ChatHeader.tsx # 聊天头部
|
||||||
|
ChatInputArea.tsx # 输入区域
|
||||||
|
message-list/ # 消息列表
|
||||||
|
index.tsx # MessageList
|
||||||
|
MessageItem.tsx # 单条消息
|
||||||
|
AttachmentItem.tsx # 文件附件
|
||||||
|
ImageAttachmentItem.tsx # 图片附件
|
||||||
|
ToolCallAction.tsx # 工具调用展示
|
||||||
|
message-input/ # 消息输入
|
||||||
|
index.tsx # 输入组件
|
||||||
|
FilePreviewList.tsx # 文件预览列表
|
||||||
|
task-panel/ # 产物预览面板
|
||||||
|
index.tsx # TaskPanel(侧边栏,50% 宽度)
|
||||||
|
ArtifactList.tsx # 产物列表
|
||||||
|
ArtifactPreview.tsx # 产物预览
|
||||||
|
Preview/ # 各类预览组件(Markdown、代码、PPT、工具调用)
|
||||||
|
ui/ # 基础 UI 组件(Shadcn 风格)
|
||||||
|
button.tsx, dialog.tsx, scroll-area.tsx, image.tsx, image-preview.tsx
|
||||||
|
|
||||||
|
http/
|
||||||
|
index.ts # HTTPClient 类(Fetch 封装)
|
||||||
|
http.ts # 底层 fetch 包装,支持 onRequest/onResponse/onError 钩子
|
||||||
|
type.ts # HTTP 类型定义
|
||||||
|
|
||||||
|
db/
|
||||||
|
index.ts # Drizzle ORM 连接(server-only,全局单例)
|
||||||
|
|
||||||
|
utils/
|
||||||
|
logger.ts # Winston 日志(开发→终端,生产→按天文件轮转)
|
||||||
|
cn.ts # clsx + tailwind-merge 工具函数
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
app/page.tsx
|
||||||
|
│ useBuildConversationConnect() → GET /api/info
|
||||||
|
│ 获取 agentId(来自 .nova/config.json), conversationId, platformConfig
|
||||||
|
▼
|
||||||
|
NovaChat 组件
|
||||||
|
│ useNovaChatLogic() 编排所有子 hook
|
||||||
|
├── useNovaEvents() → WebSocket 连接 + GET /api/chat/event 加载历史
|
||||||
|
├── useMessageSender() → WebSocket 发送消息
|
||||||
|
└── useEventProcessor()→ 事件转 UI 消息
|
||||||
|
▼
|
||||||
|
Zustand Store (useNovaStore)
|
||||||
|
│ events[], artifacts[](从 events 自动提取), loading, ws 状态
|
||||||
|
▼
|
||||||
|
MessageList / TaskPanel 渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 路由
|
||||||
|
|
||||||
|
| 路由 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/info` | GET | 返回客户端配置:agentId, conversationId, wssUrl, token |
|
||||||
|
| `/api/chat/event` | GET | 代理 Nova `/chat/event_list`,获取事件历史 |
|
||||||
|
| `/api/chat/stop` | POST | 停止当前任务 |
|
||||||
|
| `/api/conversation` | GET | 会话列表 |
|
||||||
|
| `/api/file/upload` | POST | 上传文件 |
|
||||||
|
| `/api/file/sign` | POST | 获取文件签名 URL |
|
||||||
|
| `/api/health` | GET | 健康检查 |
|
||||||
|
|
||||||
|
所有 API 路由通过 `app/api/oapi-client.ts` 中的共享 HTTPClient 代理到 Nova 平台,请求头自动注入 `Tenant-Id` 和 `Authorization`。Agent ID 从 `.nova/config.json` 读取,不再依赖环境变量。
|
||||||
|
|
||||||
|
## WebSocket 消息格式
|
||||||
|
|
||||||
|
- 心跳:`{ message_type: 'ping' }`
|
||||||
|
- 聊天消息:`{ message_type: 'chat', conversation_id, content, ... }`
|
||||||
|
- 切换会话:`{ message_type: 'switch_conversation', conversation_id }`
|
||||||
|
|
||||||
|
WebSocket 客户端内置自动重连(可配置次数/间隔)、心跳检测、网络状态监听、页面可见性恢复。
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # 安装依赖
|
||||||
|
pnpm dev # 启动开发服务器(端口 13000)
|
||||||
|
pnpm build # 生产构建
|
||||||
|
pnpm start # 生产运行(端口 13000)
|
||||||
|
pnpm lint # ESLint 检查并自动修复
|
||||||
|
pnpm db:generate # 生成 Drizzle 迁移
|
||||||
|
pnpm db:migrate # 执行数据库迁移
|
||||||
|
pnpm db:studio # 打开 Drizzle Studio GUI
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
参考 `.env.example`,在项目根目录创建 `.env.local`:
|
||||||
|
|
||||||
|
| 变量名 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| `DATABASE_URL` | PostgreSQL 连接地址(Drizzle 使用) |
|
||||||
|
| `LOG_DIR` | 生产环境日志目录(默认 `./logs`,按天切分) |
|
||||||
|
| `NOVA_BASE_URL` | Nova OpenAPI 服务地址 |
|
||||||
|
| `NOVA_TENANT_ID` | 租户 ID(请求头 `Tenant-Id`) |
|
||||||
|
| `NOVA_ACCESS_KEY` | 鉴权密钥(请求头 `Authorization`) |
|
||||||
|
|
||||||
|
> Agent ID 已从环境变量迁移至 `.nova/config.json`,无需在 `.env.local` 中配置。
|
||||||
|
|
||||||
|
## 代码约定
|
||||||
|
|
||||||
|
- **路径别名**:`@/*` → `./*`(tsconfig paths)
|
||||||
|
- **组件**:PascalCase 文件名,使用 `React.memo()` 优化
|
||||||
|
- **Hooks**:camelCase,`use*` 前缀
|
||||||
|
- **工具函数**:camelCase
|
||||||
|
- **常量枚举**:UPPER_SNAKE_CASE(如 `EventType`, `TaskStatus`)
|
||||||
|
- **样式**:Tailwind CSS 工具类,通过 `cn()` 函数(`utils/cn.ts`)合并类名
|
||||||
|
- **回调稳定性**:使用 ahooks 的 `useMemoizedFn()` 或 `useCallback()`
|
||||||
|
- **异步状态**:`queueMicrotask()` 延迟更新,避免 React 批量更新冲突
|
||||||
|
- **Store 去重**:Zustand store 按 `event_id` 去重事件
|
||||||
|
- **日志**:开发环境输出到终端,生产环境按天写入 `${LOG_DIR}/app-YYYY-MM-DD.log`
|
||||||
|
- **数据库**:所有操作通过 Drizzle ORM,`import { db } from '@/db'`,仅限 server 端
|
||||||
|
- **UI 错误提示**:使用中文(如 "Chat 不可用,请检查项目 .env 配置")
|
||||||
|
- **请求头约定**:`X-Locale=zh`, `X-Region=CN`
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `next.config.ts` 中 `reactStrictMode: false`
|
||||||
|
- `.next/` 目录为构建产物,不要手动修改
|
||||||
|
- 数据库连接使用全局单例模式,`db/index.ts` 标记了 `server-only`
|
||||||
|
- 聊天初始化流程:先请求 `/api/info` 获取配置 → 再建立 WebSocket → 加载历史事件
|
||||||
|
- 产物提取来源:`attachments`, `attachment_files`, `files`, `generated_files`,按文件 key 去重
|
||||||
|
- 文件签名通过 `getArtifactUrl()` 懒加载,POST `/file/sign`,失败时 fallback 到原始路径
|
||||||
20
app/RouteChange.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// components/RouterListener.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RouteChange() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = searchParams.toString()
|
||||||
|
? `${pathname}?${searchParams.toString()}`
|
||||||
|
: pathname;
|
||||||
|
|
||||||
|
window.parent.postMessage({ type: "ROUTE_CHANGE", path: url }, "*");
|
||||||
|
}, [pathname, searchParams]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
18
app/api/chat/event/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { oapiClient, sendResponse } from '../../oapi-client'
|
||||||
|
import { getDefaultAgentId } from '../../nova-config'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const conversationId = req.nextUrl.searchParams.get('conversation_id')
|
||||||
|
const pageNo = req.nextUrl.searchParams.get('page_no')
|
||||||
|
const pageSize = req.nextUrl.searchParams.get('page_size')
|
||||||
|
|
||||||
|
const res = await oapiClient.get('/v1/oapi/super_agent/chat/event_list', {
|
||||||
|
agent_id: getDefaultAgentId(),
|
||||||
|
conversation_id: conversationId,
|
||||||
|
page_no: pageNo,
|
||||||
|
page_size: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
14
app/api/chat/oss_url/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { oapiClient, sendResponse } from '../../oapi-client'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = req.body ? await req.json() : {}
|
||||||
|
const { file_path, task_id } = body
|
||||||
|
|
||||||
|
const res = await oapiClient.post('/v1/super_agent/chat/oss_url', {
|
||||||
|
file_path: file_path,
|
||||||
|
task_id: task_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
11
app/api/chat/stop/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { oapiClient, sendResponse } from '../../oapi-client'
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const conversationId = req.nextUrl.searchParams.get('conversation_id')
|
||||||
|
const res = await oapiClient.post('/v1/oapi/super_agent/stop_chat', {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
7
app/api/conversation/info/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { oapiClient, sendResponse } from '../../oapi-client'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = req.body ? await req.json() : {}
|
||||||
|
const res = await oapiClient.post('/v1/super_agent/chat/get_conversation_info_list', body)
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
12
app/api/conversation/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { oapiClient, sendResponse } from '../oapi-client'
|
||||||
|
import { getDefaultAgentId } from '../nova-config'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const res = await oapiClient.get('/v1/oapi/super_agent/chat/conversation_list', {
|
||||||
|
page_no: 1,
|
||||||
|
page_size: 10,
|
||||||
|
agent_id: getDefaultAgentId(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
7
app/api/file/record/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { oapiClient, sendResponse } from '../../oapi-client'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = req.body ? await req.json() : {}
|
||||||
|
const res = await oapiClient.post('/v1/super_agent/file_upload_record/create', body)
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
12
app/api/file/sign/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { oapiClient, sendResponse } from '../../oapi-client'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = req.body ? await req.json() : {}
|
||||||
|
const key = body.file_path || body.key
|
||||||
|
const res = await oapiClient.post('v1/oss/sign_url', {
|
||||||
|
key,
|
||||||
|
params: body.params,
|
||||||
|
})
|
||||||
|
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
8
app/api/file/upload/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { oapiClient, sendResponse } from '../../oapi-client'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.formData()
|
||||||
|
const res = await oapiClient.post('/v1/oapi/super_agent/chat/file_upload', body)
|
||||||
|
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
24
app/api/health/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET,OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: true, message: 'ok' },
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: corsHeaders,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: corsHeaders,
|
||||||
|
})
|
||||||
|
}
|
||||||
68
app/api/info/route.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { oapiClient } from '../oapi-client'
|
||||||
|
import { getDefaultAgentId, getDefaultAgentName } from '../nova-config'
|
||||||
|
import { getProjectId, getUserId } from '@/utils/getAuth'
|
||||||
|
|
||||||
|
const buildWssUrl = () => {
|
||||||
|
const baseUrl = process.env.NOVA_BASE_URL!
|
||||||
|
const wssBase = baseUrl.replace('https://', 'wss://').replace('http://', 'ws://')
|
||||||
|
const authorization = process.env.NOVA_ACCESS_KEY
|
||||||
|
const tenantId = process.env.NOVA_TENANT_ID
|
||||||
|
return `${wssBase}/v1/super_agent/chat/completions?Authorization=${authorization}&X-Locale=zh&X-Region=CN&Tenant-Id=${tenantId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const agentId = getDefaultAgentId()
|
||||||
|
const agentName = getDefaultAgentName()
|
||||||
|
|
||||||
|
const list = await oapiClient.get('/v1/oapi/super_agent/chat/conversation_list', {
|
||||||
|
page_no: 1,
|
||||||
|
page_size: 10,
|
||||||
|
agent_id: agentId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const conversationId = list.data?.[0]?.conversation_id
|
||||||
|
|
||||||
|
if (!conversationId) {
|
||||||
|
const res = await oapiClient.post('/v1/oapi/super_agent/chat/create_conversation', {
|
||||||
|
agent_id: agentId,
|
||||||
|
title: 'new conversation',
|
||||||
|
conversation_type: 'REACTUS',
|
||||||
|
external_app_id: getProjectId(),
|
||||||
|
external_user_id: getUserId(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
code: 0,
|
||||||
|
message: 'ok',
|
||||||
|
data: {
|
||||||
|
apiBaseUrl: '/api',
|
||||||
|
agent_id: agentId,
|
||||||
|
agent_name: agentName,
|
||||||
|
conversation_id: res.conversation_id,
|
||||||
|
wssUrl: buildWssUrl(),
|
||||||
|
token: process.env.NOVA_ACCESS_KEY,
|
||||||
|
tenantId: process.env.NOVA_TENANT_ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
apiBaseUrl: '/api',
|
||||||
|
agent_id: agentId,
|
||||||
|
agent_name: agentName,
|
||||||
|
conversation_id: conversationId,
|
||||||
|
wssUrl: buildWssUrl(),
|
||||||
|
token: process.env.NOVA_ACCESS_KEY,
|
||||||
|
tenantId: process.env.NOVA_TENANT_ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
6
app/api/llm-client.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { SkillsClient } from '@/llm'
|
||||||
|
|
||||||
|
export const llmClient = new SkillsClient({
|
||||||
|
apiKey: process.env.LLM_API_KEY!,
|
||||||
|
baseUrl: process.env.LLM_BASE_URL!,
|
||||||
|
})
|
||||||
38
app/api/nova-config.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
interface NovaAgent {
|
||||||
|
agent_id: string
|
||||||
|
agent_name: string
|
||||||
|
agent_description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NovaConfig {
|
||||||
|
agents: NovaAgent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let _config: NovaConfig | null = null
|
||||||
|
|
||||||
|
export function getNovaConfig(): NovaConfig {
|
||||||
|
if (!_config) {
|
||||||
|
const configPath = join(process.cwd(), '.nova', 'config.json')
|
||||||
|
_config = JSON.parse(readFileSync(configPath, 'utf-8'))
|
||||||
|
}
|
||||||
|
return _config!
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultAgentId(): string {
|
||||||
|
const config = getNovaConfig()
|
||||||
|
if (!config.agents.length) {
|
||||||
|
throw new Error('No agents configured in .nova/config.json')
|
||||||
|
}
|
||||||
|
return config.agents[0].agent_id
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultAgentName(): string {
|
||||||
|
const config = getNovaConfig()
|
||||||
|
if (!config.agents.length) {
|
||||||
|
throw new Error('No agents configured in .nova/config.json')
|
||||||
|
}
|
||||||
|
return config.agents[0].agent_name
|
||||||
|
}
|
||||||
85
app/api/oapi-client.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { HTTPClient } from '@/http';
|
||||||
|
import { logger } from '@/utils/logger'
|
||||||
|
|
||||||
|
export const oapiClient = new HTTPClient({
|
||||||
|
baseURL: process.env.NOVA_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Tenant-Id': process.env.NOVA_TENANT_ID,
|
||||||
|
'Authorization': process.env.NOVA_ACCESS_KEY,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
onRequest: async (config) => {
|
||||||
|
logger.info('oapi request start', {
|
||||||
|
method: config.method,
|
||||||
|
url: config.url,
|
||||||
|
body: config.body,
|
||||||
|
query: config.query,
|
||||||
|
headers: config.headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
onResponse: async (response, config) => {
|
||||||
|
logger.info('oapi response received', {
|
||||||
|
method: config.method,
|
||||||
|
url: config.url,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (error, config) => {
|
||||||
|
logger.error('oapi request error', {
|
||||||
|
method: config.method,
|
||||||
|
url: config.url,
|
||||||
|
error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export const sendResponse = (res: any) => {
|
||||||
|
// 兼容 HTTP 层 defaultGetResult 的解包结果:
|
||||||
|
// 若上游返回 { success: true, data: ... },此处可能拿到 data 本身(含 null)
|
||||||
|
if (res == null || typeof res !== 'object' || !('success' in res)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
data: res,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success === false) {
|
||||||
|
logger.error('oapi request failed', {
|
||||||
|
code: res.code,
|
||||||
|
message: res.message,
|
||||||
|
request_id: res.request_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
code: res.code,
|
||||||
|
message: res.message,
|
||||||
|
request_id: res.request_id,
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('oapi request success', {
|
||||||
|
code: res.code,
|
||||||
|
request_id: res.request_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
code: res.code,
|
||||||
|
request_id: res.request_id,
|
||||||
|
success: true,
|
||||||
|
data: res,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
55
app/api/oapi-wrapper-client.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { HttpDefine } from '@/http/type'
|
||||||
|
import { oapiClient } from './oapi-client'
|
||||||
|
|
||||||
|
export type DataWrapped<T> = { data: T }
|
||||||
|
|
||||||
|
async function dataInterceptor<T>(promise: Promise<T>): Promise<DataWrapped<T>> {
|
||||||
|
const result = await promise
|
||||||
|
return { data: result }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const oapiDataClient = {
|
||||||
|
request<T = unknown>(config: HttpDefine): Promise<DataWrapped<T>> {
|
||||||
|
return dataInterceptor(oapiClient.request<T>(config))
|
||||||
|
},
|
||||||
|
|
||||||
|
get<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
query?: Record<string, unknown>,
|
||||||
|
config?: HttpDefine,
|
||||||
|
): Promise<DataWrapped<T>> {
|
||||||
|
return dataInterceptor(oapiClient.get<T>(url, query, config))
|
||||||
|
},
|
||||||
|
|
||||||
|
post<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
body?: Record<string, unknown> | FormData,
|
||||||
|
config?: HttpDefine,
|
||||||
|
): Promise<DataWrapped<T>> {
|
||||||
|
return dataInterceptor(oapiClient.post<T>(url, body, config))
|
||||||
|
},
|
||||||
|
|
||||||
|
put<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
body?: Record<string, unknown> | FormData,
|
||||||
|
config?: HttpDefine,
|
||||||
|
): Promise<DataWrapped<T>> {
|
||||||
|
return dataInterceptor(oapiClient.put<T>(url, body, config))
|
||||||
|
},
|
||||||
|
|
||||||
|
patch<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
body?: Record<string, unknown> | FormData,
|
||||||
|
config?: HttpDefine,
|
||||||
|
): Promise<DataWrapped<T>> {
|
||||||
|
return dataInterceptor(oapiClient.patch<T>(url, body, config))
|
||||||
|
},
|
||||||
|
|
||||||
|
delete<T = unknown>(
|
||||||
|
url: string,
|
||||||
|
query?: Record<string, unknown>,
|
||||||
|
config?: HttpDefine,
|
||||||
|
): Promise<DataWrapped<T>> {
|
||||||
|
return dataInterceptor(oapiClient.delete<T>(url, query, config))
|
||||||
|
},
|
||||||
|
}
|
||||||
6
app/api/oss/upload-sts/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { oapiClient, sendResponse } from '../../oapi-client'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const res = await oapiClient.get('/v1/oss/upload_sts')
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
7
app/api/plugins/skill/upload/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { oapiClient, sendResponse } from '../../../oapi-client'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.formData()
|
||||||
|
const res = await oapiClient.post('/v1/plugins/skill/upload', body)
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
41
app/api/remote-control/agent-info/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getDefaultAgentId } from '@/app/api/nova-config'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const baseUrl = process.env.NOVA_BASE_URL || ''
|
||||||
|
const agentId = getDefaultAgentId()
|
||||||
|
const tenantId = process.env.NOVA_TENANT_ID || ''
|
||||||
|
|
||||||
|
let stats = {
|
||||||
|
activeConnections: 0,
|
||||||
|
totalMessages: 0,
|
||||||
|
averageResponseTime: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { getStats } = await import('@/remote-control/shared/nova-bridge')
|
||||||
|
stats = getStats()
|
||||||
|
} catch {
|
||||||
|
// nova-bridge 未加载
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: 'connected' | 'disconnected' = 'disconnected'
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/health`, {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
status = 'connected'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 连接失败
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
baseUrl,
|
||||||
|
agentId,
|
||||||
|
tenantId,
|
||||||
|
status,
|
||||||
|
...stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
109
app/api/remote-control/config/route.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { ConfigManager, reconnectAllPlatforms } from '@/remote-control/config/manager'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const configManager = ConfigManager.getInstance()
|
||||||
|
await configManager.ensureLoaded()
|
||||||
|
const config = configManager.getMasked()
|
||||||
|
return NextResponse.json(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const configManager = ConfigManager.getInstance()
|
||||||
|
await configManager.ensureLoaded()
|
||||||
|
|
||||||
|
// Replace masked values with existing real values so we don't overwrite secrets
|
||||||
|
const merged = stripMaskedValues(body, configManager.get())
|
||||||
|
|
||||||
|
const validationError = validateConfig(merged)
|
||||||
|
if (validationError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: validationError },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipEmit: lifecycle is managed explicitly below, avoid double-triggering
|
||||||
|
await configManager.update(merged, { skipEmit: true })
|
||||||
|
|
||||||
|
// After saving, explicitly manage bot lifecycle:
|
||||||
|
// - Stop all disabled bots
|
||||||
|
// - Reconnect (stop + start) all enabled bots
|
||||||
|
const config = configManager.get()
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
await reconnectAllPlatforms(config, errors)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: errors.length > 0
|
||||||
|
? `配置已保存,部分渠道连接异常: ${errors.join('; ')}`
|
||||||
|
: '配置已保存并生效',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '保存配置失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASK_PREFIX = '••••'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the frontend sends back a masked value (e.g. "••••pbYI"), replace it
|
||||||
|
* with the real value from the current config so we never overwrite secrets.
|
||||||
|
*/
|
||||||
|
function stripMaskedValues(
|
||||||
|
incoming: Record<string, Record<string, unknown>>,
|
||||||
|
current: Record<string, Record<string, unknown>>,
|
||||||
|
): Record<string, Record<string, unknown>> {
|
||||||
|
const result: Record<string, Record<string, unknown>> = {}
|
||||||
|
for (const platform of Object.keys(incoming)) {
|
||||||
|
const incomingPlatform = incoming[platform]
|
||||||
|
const currentPlatform = current[platform] ?? {}
|
||||||
|
const merged: Record<string, unknown> = {}
|
||||||
|
for (const key of Object.keys(incomingPlatform)) {
|
||||||
|
const val = incomingPlatform[key]
|
||||||
|
if (typeof val === 'string' && val.startsWith(MASK_PREFIX)) {
|
||||||
|
// Keep the real value
|
||||||
|
merged[key] = currentPlatform[key] ?? ''
|
||||||
|
} else {
|
||||||
|
merged[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[platform] = merged
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateConfig(config: Record<string, unknown>): string | null {
|
||||||
|
const discord = config.discord as Record<string, unknown> | undefined
|
||||||
|
const dingtalk = config.dingtalk as Record<string, unknown> | undefined
|
||||||
|
const lark = config.lark as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
if (discord?.enabled && !discord?.botToken) {
|
||||||
|
return 'Discord Bot Token 不能为空'
|
||||||
|
}
|
||||||
|
if (dingtalk?.enabled && (!dingtalk?.clientId || !dingtalk?.clientSecret)) {
|
||||||
|
return '钉钉 Client ID 和 Client Secret 不能为空'
|
||||||
|
}
|
||||||
|
if (lark?.enabled) {
|
||||||
|
if (!lark?.appId || !lark?.appSecret) {
|
||||||
|
return '飞书 App ID 和 App Secret 不能为空'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegram = config.telegram as Record<string, unknown> | undefined
|
||||||
|
const slack = config.slack as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
if (telegram?.enabled && !telegram?.botToken) {
|
||||||
|
return 'Telegram Bot Token 不能为空'
|
||||||
|
}
|
||||||
|
if (slack?.enabled && (!slack?.botToken || !slack?.appToken)) {
|
||||||
|
return 'Slack Bot Token 和 App Token 不能为空'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
33
app/api/remote-control/logs/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { BotLogger } from '@/remote-control/shared/logger'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '100')
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0')
|
||||||
|
const platform = searchParams.get('platform') as 'discord' | 'dingtalk' | null
|
||||||
|
const eventType = searchParams.get('eventType') || null
|
||||||
|
const severity = searchParams.get('severity') as 'info' | 'warning' | 'error' | null
|
||||||
|
|
||||||
|
const { logs, total } = BotLogger.getLogs({
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
platform: platform || undefined,
|
||||||
|
eventType: eventType || undefined,
|
||||||
|
severity: severity || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
logs,
|
||||||
|
total,
|
||||||
|
hasMore: offset + logs.length < total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
BotLogger.clear()
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '日志已清空',
|
||||||
|
})
|
||||||
|
}
|
||||||
71
app/api/remote-control/status/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { ConfigManager } from '@/remote-control/config/manager'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const mgr = ConfigManager.getInstance()
|
||||||
|
await mgr.ensureLoaded()
|
||||||
|
const config = mgr.get()
|
||||||
|
const statuses: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
// Discord Bot 状态
|
||||||
|
if (config.discord.enabled) {
|
||||||
|
try {
|
||||||
|
const discordBot = await import('@/remote-control/bots/discord')
|
||||||
|
statuses.discord = discordBot.getStatus()
|
||||||
|
} catch {
|
||||||
|
statuses.discord = { platform: 'discord', status: 'disconnected', error: 'Bot 模块未加载' }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statuses.discord = { platform: 'discord', status: 'disconnected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 钉钉 Bot 状态
|
||||||
|
if (config.dingtalk.enabled) {
|
||||||
|
try {
|
||||||
|
const dingtalkBot = await import('@/remote-control/bots/dingtalk')
|
||||||
|
statuses.dingtalk = dingtalkBot.getStatus()
|
||||||
|
} catch {
|
||||||
|
statuses.dingtalk = { platform: 'dingtalk', status: 'disconnected', error: 'Bot 模块未加载' }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statuses.dingtalk = { platform: 'dingtalk', status: 'disconnected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 飞书 Bot 状态
|
||||||
|
if (config.lark.enabled) {
|
||||||
|
try {
|
||||||
|
const larkBot = await import('@/remote-control/bots/lark')
|
||||||
|
statuses.lark = larkBot.getStatus()
|
||||||
|
} catch {
|
||||||
|
statuses.lark = { platform: 'lark', status: 'disconnected', error: 'Bot 模块未加载' }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statuses.lark = { platform: 'lark', status: 'disconnected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram Bot 状态
|
||||||
|
if (config.telegram.enabled) {
|
||||||
|
try {
|
||||||
|
const telegramBot = await import('@/remote-control/bots/telegram')
|
||||||
|
statuses.telegram = telegramBot.getStatus()
|
||||||
|
} catch {
|
||||||
|
statuses.telegram = { platform: 'telegram', status: 'disconnected', error: 'Bot 模块未加载' }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statuses.telegram = { platform: 'telegram', status: 'disconnected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slack Bot 状态
|
||||||
|
if (config.slack.enabled) {
|
||||||
|
try {
|
||||||
|
const slackBot = await import('@/remote-control/bots/slack')
|
||||||
|
statuses.slack = slackBot.getStatus()
|
||||||
|
} catch {
|
||||||
|
statuses.slack = { platform: 'slack', status: 'disconnected', error: 'Bot 模块未加载' }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
statuses.slack = { platform: 'slack', status: 'disconnected' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(statuses)
|
||||||
|
}
|
||||||
147
app/api/remote-control/test/route.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { ConfigManager } from '@/remote-control/config/manager'
|
||||||
|
|
||||||
|
function sleep(ms: number) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll a bot's getStatus() until it leaves 'connecting' state,
|
||||||
|
* or until timeout (default 10s). Returns the final status.
|
||||||
|
*/
|
||||||
|
async function waitForConnection(
|
||||||
|
getStatus: () => { status: string; error?: string },
|
||||||
|
timeoutMs = 10000,
|
||||||
|
intervalMs = 500,
|
||||||
|
): Promise<{ status: string; error?: string }> {
|
||||||
|
const deadline = Date.now() + timeoutMs
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const s = getStatus()
|
||||||
|
// 'connected' or 'disconnected' (with error) means we have a definitive answer
|
||||||
|
if (s.status !== 'connecting') return s
|
||||||
|
await sleep(intervalMs)
|
||||||
|
}
|
||||||
|
return getStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { platform } = await request.json()
|
||||||
|
|
||||||
|
if (platform !== 'discord' && platform !== 'dingtalk' && platform !== 'lark' && platform !== 'telegram' && platform !== 'slack') {
|
||||||
|
return NextResponse.json({ success: false, error: '无效的平台' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const mgr = ConfigManager.getInstance()
|
||||||
|
await mgr.ensureLoaded()
|
||||||
|
const config = mgr.get()
|
||||||
|
|
||||||
|
// Reject test for disabled platforms
|
||||||
|
const platformConfig = config[platform as keyof typeof config] as { enabled: boolean }
|
||||||
|
if (!platformConfig?.enabled) {
|
||||||
|
return NextResponse.json({ success: false, error: '该渠道已禁用,请先启用后再测试' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'discord') {
|
||||||
|
if (!config.discord.botToken) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Discord Bot Token 未配置' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bot = await import('@/remote-control/bots/discord')
|
||||||
|
await bot.stopBot()
|
||||||
|
await bot.startBot(config.discord.botToken)
|
||||||
|
const status = await waitForConnection(() => bot.getStatus())
|
||||||
|
return NextResponse.json({
|
||||||
|
success: status.status === 'connected',
|
||||||
|
status,
|
||||||
|
error: status.status !== 'connected' ? (status.error || '连接超时,请检查 Token 是否正确') : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return NextResponse.json({ success: false, error: `Discord 连接失败: ${msg}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'dingtalk') {
|
||||||
|
if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) {
|
||||||
|
return NextResponse.json({ success: false, error: '钉钉 Client ID 或 Client Secret 未配置' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bot = await import('@/remote-control/bots/dingtalk')
|
||||||
|
await bot.stopBot()
|
||||||
|
await bot.startBot(config.dingtalk.clientId, config.dingtalk.clientSecret)
|
||||||
|
const status = await waitForConnection(() => bot.getStatus())
|
||||||
|
return NextResponse.json({
|
||||||
|
success: status.status === 'connected',
|
||||||
|
status,
|
||||||
|
error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return NextResponse.json({ success: false, error: `钉钉连接失败: ${msg}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'lark') {
|
||||||
|
if (!config.lark.appId || !config.lark.appSecret) {
|
||||||
|
return NextResponse.json({ success: false, error: '飞书 App ID 或 App Secret 未配置' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bot = await import('@/remote-control/bots/lark')
|
||||||
|
await bot.stopBot()
|
||||||
|
await bot.startBot(config.lark.appId, config.lark.appSecret)
|
||||||
|
const status = await waitForConnection(() => bot.getStatus())
|
||||||
|
return NextResponse.json({
|
||||||
|
success: status.status === 'connected',
|
||||||
|
status,
|
||||||
|
error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return NextResponse.json({ success: false, error: `飞书连接失败: ${msg}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'telegram') {
|
||||||
|
if (!config.telegram.botToken) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Telegram Bot Token 未配置' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bot = await import('@/remote-control/bots/telegram')
|
||||||
|
await bot.stopBot()
|
||||||
|
await bot.startBot(config.telegram.botToken)
|
||||||
|
const status = await waitForConnection(() => bot.getStatus())
|
||||||
|
return NextResponse.json({
|
||||||
|
success: status.status === 'connected',
|
||||||
|
status,
|
||||||
|
error: status.status !== 'connected' ? (status.error || '连接超时,请检查 Token 是否正确') : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return NextResponse.json({ success: false, error: `Telegram 连接失败: ${msg}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'slack') {
|
||||||
|
if (!config.slack.botToken || !config.slack.appToken) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Slack Bot Token 或 App Token 未配置' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const bot = await import('@/remote-control/bots/slack')
|
||||||
|
await bot.stopBot()
|
||||||
|
await bot.startBot(config.slack.botToken, config.slack.appToken)
|
||||||
|
const status = await waitForConnection(() => bot.getStatus())
|
||||||
|
return NextResponse.json({
|
||||||
|
success: status.status === 'connected',
|
||||||
|
status,
|
||||||
|
error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return NextResponse.json({ success: false, error: `Slack 连接失败: ${msg}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ success: false, error: '请求解析失败' }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/api/team/[teamId]/plugins/route.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { oapiClient, sendResponse } from '@/app/api/oapi-client'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ teamId: string }> },
|
||||||
|
) {
|
||||||
|
const body = req.body ? await req.json() : {}
|
||||||
|
const { teamId } = await params
|
||||||
|
const res = await oapiClient.post(`/v1/team/${teamId}/plugins`, body)
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
133
app/api/v1/[...path]/route.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { oapiClient, sendResponse } from '../../oapi-client'
|
||||||
|
|
||||||
|
function buildUrl(path: string[]) {
|
||||||
|
return `/v1/${path.join('/')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFullForwardUrl(path: string[], query?: Record<string, string>) {
|
||||||
|
const base = process.env.NOVA_BASE_URL || ''
|
||||||
|
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base
|
||||||
|
const pathname = buildUrl(path)
|
||||||
|
const url = new URL(`${normalizedBase}${pathname}`)
|
||||||
|
Object.entries(query || {}).forEach(([key, value]) => {
|
||||||
|
url.searchParams.set(key, value)
|
||||||
|
})
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function logForwardRequest(args: {
|
||||||
|
method: string
|
||||||
|
path: string[]
|
||||||
|
query?: Record<string, string>
|
||||||
|
body?: unknown
|
||||||
|
}) {
|
||||||
|
const { method, path, query, body } = args
|
||||||
|
console.log('[api/v1 proxy] forward', {
|
||||||
|
method,
|
||||||
|
url: buildFullForwardUrl(path, query),
|
||||||
|
query: query || {},
|
||||||
|
body: body ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function logForwardResponse(method: string, path: string[], res: unknown) {
|
||||||
|
const url = buildUrl(path)
|
||||||
|
try {
|
||||||
|
console.log('[api/v1 proxy] response', {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
raw: JSON.stringify(res, null, 2),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.log('[api/v1 proxy] response', {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
raw: res,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuery(searchParams: URLSearchParams) {
|
||||||
|
const query: Record<string, string> = {}
|
||||||
|
|
||||||
|
searchParams.forEach((value, key) => {
|
||||||
|
// params[task_id] -> task_id
|
||||||
|
if (key.startsWith('params[') && key.endsWith(']')) {
|
||||||
|
const realKey = key.slice(7, -1)
|
||||||
|
if (realKey) query[realKey] = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query[key] = value
|
||||||
|
})
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
const query = normalizeQuery(req.nextUrl.searchParams)
|
||||||
|
logForwardRequest({ method: 'GET', path, query })
|
||||||
|
const res = await oapiClient.get(buildUrl(path), query)
|
||||||
|
logForwardResponse('GET', path, res)
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
const body = req.body ? await req.json() : {}
|
||||||
|
const { path } = await params
|
||||||
|
logForwardRequest({ method: 'POST', path, body })
|
||||||
|
const res = await oapiClient.post(buildUrl(path), body)
|
||||||
|
logForwardResponse('POST', path, res)
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
const body = req.body ? await req.json() : {}
|
||||||
|
const { path } = await params
|
||||||
|
logForwardRequest({ method: 'PUT', path, body })
|
||||||
|
const res = await oapiClient.request({
|
||||||
|
url: buildUrl(path),
|
||||||
|
method: 'put',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
logForwardResponse('PUT', path, res)
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
const body = req.body ? await req.json() : {}
|
||||||
|
const { path } = await params
|
||||||
|
logForwardRequest({ method: 'PATCH', path, body })
|
||||||
|
const res = await oapiClient.request({
|
||||||
|
url: buildUrl(path),
|
||||||
|
method: 'patch',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
logForwardResponse('PATCH', path, res)
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> },
|
||||||
|
) {
|
||||||
|
const { path } = await params
|
||||||
|
const query = normalizeQuery(req.nextUrl.searchParams)
|
||||||
|
logForwardRequest({ method: 'DELETE', path, query })
|
||||||
|
const res = await oapiClient.delete(buildUrl(path), query)
|
||||||
|
logForwardResponse('DELETE', path, res)
|
||||||
|
return sendResponse(res)
|
||||||
|
}
|
||||||
570
app/api/websocket/index.ts
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket 客户端封装
|
||||||
|
*
|
||||||
|
* 提供自动重连、心跳检测、网络状态监听等功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
function latest<T>(value: T) {
|
||||||
|
const ref = { current: value }
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadyState = {
|
||||||
|
Connecting: 0,
|
||||||
|
Open: 1,
|
||||||
|
Closing: 2,
|
||||||
|
Closed: 3,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ReadyState = (typeof ReadyState)[keyof typeof ReadyState]
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
sendMessage: WebSocket['send']
|
||||||
|
disconnect: () => void
|
||||||
|
connect: () => void
|
||||||
|
readyState: ReadyState
|
||||||
|
webSocketIns?: WebSocket
|
||||||
|
clearHeartbeat: () => void
|
||||||
|
switchConversation: (conversationId: string) => void
|
||||||
|
cleanup: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatOptions {
|
||||||
|
/** 心跳间隔,默认 20000ms */
|
||||||
|
heartbeatInterval?: number
|
||||||
|
/** 心跳超时,默认 22000ms */
|
||||||
|
heartbeatTimeout?: number
|
||||||
|
/** 心跳消息,默认 { message_type: 'ping' } */
|
||||||
|
heartbeatMessage?: string | object | (() => string | object)
|
||||||
|
/** 心跳响应类型,默认 'pong' */
|
||||||
|
heartbeatResponseType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiEvent {
|
||||||
|
event_id: string
|
||||||
|
event_type?: string
|
||||||
|
role?: 'user' | 'assistant' | 'system'
|
||||||
|
content?: {
|
||||||
|
text?: string
|
||||||
|
content?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
created_at?: string
|
||||||
|
task_id?: string
|
||||||
|
is_display?: boolean
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
stream?: boolean
|
||||||
|
event_status?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket 事件类型定义
|
||||||
|
type WSOpenEvent = Event
|
||||||
|
interface WSCloseEvent {
|
||||||
|
code?: number
|
||||||
|
reason?: string
|
||||||
|
wasClean?: boolean
|
||||||
|
}
|
||||||
|
interface WSMessageEvent {
|
||||||
|
data: string | ArrayBuffer | Blob
|
||||||
|
}
|
||||||
|
type WSErrorEvent = Event
|
||||||
|
|
||||||
|
export interface Options {
|
||||||
|
/** 重连次数限制,默认 3 */
|
||||||
|
reconnectLimit?: number
|
||||||
|
/** 重连间隔,默认 3000ms */
|
||||||
|
reconnectInterval?: number
|
||||||
|
/** 是否手动连接,默认 false */
|
||||||
|
manual?: boolean
|
||||||
|
/** 连接成功回调 */
|
||||||
|
onOpen?: (event: WSOpenEvent, instance: WebSocket) => void
|
||||||
|
/** 连接关闭回调 */
|
||||||
|
onClose?: (event: WSCloseEvent, instance: WebSocket) => void
|
||||||
|
/** 收到消息回调 */
|
||||||
|
onMessage?: (message: ApiEvent, instance: WebSocket) => void
|
||||||
|
/** 连接错误回调 */
|
||||||
|
onError?: (event: WSErrorEvent, instance: WebSocket) => void
|
||||||
|
/** WebSocket 协议 */
|
||||||
|
protocols?: string | string[]
|
||||||
|
/** 心跳配置,传 false 禁用心跳 */
|
||||||
|
heartbeat?: HeartbeatOptions | boolean
|
||||||
|
/** 是否监听网络状态变化,默认 true */
|
||||||
|
enableNetworkListener?: boolean
|
||||||
|
/** 是否监听文档可见性变化,默认 true */
|
||||||
|
enableVisibilityListener?: boolean
|
||||||
|
/** 重连延迟,默认 300ms */
|
||||||
|
reconnectDelay?: number
|
||||||
|
/** 重连防抖时间,默认 1000ms */
|
||||||
|
reconnectDebounce?: number
|
||||||
|
/** 获取认证 Token */
|
||||||
|
getToken?: () => string | undefined
|
||||||
|
/** 获取租户 ID */
|
||||||
|
getTenantId?: () => string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 WebSocket 客户端
|
||||||
|
*/
|
||||||
|
export function createWebSocketClient(
|
||||||
|
socketUrl: string,
|
||||||
|
options: Options = {},
|
||||||
|
): Result {
|
||||||
|
const {
|
||||||
|
reconnectLimit = 3,
|
||||||
|
reconnectInterval = 3 * 1000,
|
||||||
|
manual = false,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
onMessage,
|
||||||
|
onError,
|
||||||
|
protocols,
|
||||||
|
heartbeat: enableHeartbeat = true,
|
||||||
|
enableNetworkListener = true,
|
||||||
|
enableVisibilityListener = true,
|
||||||
|
reconnectDelay = 300,
|
||||||
|
reconnectDebounce = 1000,
|
||||||
|
getToken,
|
||||||
|
getTenantId,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const heartbeatOptions: HeartbeatOptions =
|
||||||
|
typeof enableHeartbeat === 'object' ? enableHeartbeat : {}
|
||||||
|
const {
|
||||||
|
heartbeatInterval = 20000,
|
||||||
|
heartbeatTimeout = 22000,
|
||||||
|
heartbeatMessage = { message_type: 'ping' },
|
||||||
|
heartbeatResponseType = 'pong',
|
||||||
|
} = heartbeatOptions
|
||||||
|
|
||||||
|
// 提前声明函数,避免使用前未定义
|
||||||
|
let disconnectFn: () => void = () => {
|
||||||
|
throw new Error('disconnectFn not initialized')
|
||||||
|
}
|
||||||
|
let connectWsFn: () => void = () => {
|
||||||
|
throw new Error('connectWsFn not initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOpenRef = latest(onOpen)
|
||||||
|
const onCloseRef = latest(onClose)
|
||||||
|
const onMessageRef = latest(onMessage)
|
||||||
|
const onErrorRef = latest(onError)
|
||||||
|
|
||||||
|
// 确保 ref 始终指向最新的回调
|
||||||
|
if (onMessage) {
|
||||||
|
onMessageRef.current = onMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconnectTimesRef = latest(0)
|
||||||
|
const reconnectTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
const websocketRef = latest<WebSocket | undefined>(undefined)
|
||||||
|
const readyStateRef = latest<ReadyState>(ReadyState.Closed)
|
||||||
|
|
||||||
|
// 心跳相关
|
||||||
|
const heartbeatTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
const heartbeatTimeoutTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
const waitingForPongRef = latest(false)
|
||||||
|
|
||||||
|
// 网络和可见性状态
|
||||||
|
const isOnlineRef = latest(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||||
|
const isVisibleRef = latest(typeof document !== 'undefined' ? !document.hidden : true)
|
||||||
|
|
||||||
|
// 重连防抖定时器
|
||||||
|
const reconnectDebounceTimerRef = latest<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
|
||||||
|
// 更新 readyState 的辅助函数
|
||||||
|
const setReadyState = (state: ReadyState) => {
|
||||||
|
readyStateRef.current = state
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前 readyState
|
||||||
|
const getReadyState = (): ReadyState => {
|
||||||
|
if (websocketRef.current) {
|
||||||
|
const wsState = websocketRef.current.readyState
|
||||||
|
if (wsState === WebSocket.CONNECTING) return ReadyState.Connecting
|
||||||
|
if (wsState === WebSocket.OPEN) return ReadyState.Open
|
||||||
|
if (wsState === WebSocket.CLOSING) return ReadyState.Closing
|
||||||
|
if (wsState === WebSocket.CLOSED) return ReadyState.Closed
|
||||||
|
}
|
||||||
|
return readyStateRef.current
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除心跳相关定时器
|
||||||
|
const clearHeartbeat = () => {
|
||||||
|
if (heartbeatTimerRef.current) {
|
||||||
|
clearTimeout(heartbeatTimerRef.current)
|
||||||
|
heartbeatTimerRef.current = undefined
|
||||||
|
}
|
||||||
|
if (heartbeatTimeoutTimerRef.current) {
|
||||||
|
clearTimeout(heartbeatTimeoutTimerRef.current)
|
||||||
|
heartbeatTimeoutTimerRef.current = undefined
|
||||||
|
}
|
||||||
|
waitingForPongRef.current = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理心跳超时
|
||||||
|
const handlePingTimeout = () => {
|
||||||
|
if (!isOnlineRef.current) {
|
||||||
|
waitingForPongRef.current = false
|
||||||
|
clearHeartbeat()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
waitingForPongRef.current = false
|
||||||
|
clearHeartbeat()
|
||||||
|
disconnectFn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送心跳
|
||||||
|
const sendHeartbeat = () => {
|
||||||
|
if (!isOnlineRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (waitingForPongRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (websocketRef.current && getReadyState() === ReadyState.Open) {
|
||||||
|
try {
|
||||||
|
const message =
|
||||||
|
typeof heartbeatMessage === 'function'
|
||||||
|
? heartbeatMessage()
|
||||||
|
: heartbeatMessage
|
||||||
|
websocketRef.current.send(
|
||||||
|
typeof message === 'string' ? message : JSON.stringify(message),
|
||||||
|
)
|
||||||
|
waitingForPongRef.current = true
|
||||||
|
|
||||||
|
heartbeatTimeoutTimerRef.current = setTimeout(
|
||||||
|
handlePingTimeout,
|
||||||
|
heartbeatTimeout,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
clearHeartbeat()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearHeartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理心跳响应
|
||||||
|
const handlePongReceived = () => {
|
||||||
|
if (!waitingForPongRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
waitingForPongRef.current = false
|
||||||
|
|
||||||
|
if (heartbeatTimeoutTimerRef.current) {
|
||||||
|
clearTimeout(heartbeatTimeoutTimerRef.current)
|
||||||
|
heartbeatTimeoutTimerRef.current = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeatTimerRef.current = setTimeout(sendHeartbeat, heartbeatInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动心跳
|
||||||
|
const startHeartbeat = () => {
|
||||||
|
if (!enableHeartbeat) return
|
||||||
|
clearHeartbeat()
|
||||||
|
heartbeatTimerRef.current = setTimeout(sendHeartbeat, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理原始消息,检查是否是心跳响应
|
||||||
|
const handleRawMessage = (messageData: string): boolean => {
|
||||||
|
try {
|
||||||
|
const rawMessage = JSON.parse(messageData)
|
||||||
|
if (
|
||||||
|
rawMessage.data?.message_type === heartbeatResponseType ||
|
||||||
|
rawMessage.message_type === heartbeatResponseType
|
||||||
|
) {
|
||||||
|
handlePongReceived()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconnect = () => {
|
||||||
|
if (
|
||||||
|
reconnectTimesRef.current < reconnectLimit &&
|
||||||
|
getReadyState() !== ReadyState.Open
|
||||||
|
) {
|
||||||
|
if (reconnectTimerRef.current) {
|
||||||
|
clearTimeout(reconnectTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
connectWsFn()
|
||||||
|
reconnectTimesRef.current++
|
||||||
|
}, reconnectInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWsFn = () => {
|
||||||
|
if (reconnectTimerRef.current) {
|
||||||
|
clearTimeout(reconnectTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websocketRef.current) {
|
||||||
|
websocketRef.current.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建 WebSocket URL
|
||||||
|
const url = new URL(socketUrl)
|
||||||
|
|
||||||
|
// 添加认证信息
|
||||||
|
const token = getToken?.()
|
||||||
|
const tenantId = getTenantId?.()
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
url.searchParams.set('Authorization', token)
|
||||||
|
}
|
||||||
|
if (tenantId) {
|
||||||
|
url.searchParams.set('Tenant-Id', tenantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(url.toString(), protocols)
|
||||||
|
setReadyState(ReadyState.Connecting)
|
||||||
|
|
||||||
|
ws.onerror = event => {
|
||||||
|
if (websocketRef.current !== ws) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reconnect()
|
||||||
|
onErrorRef.current?.(event, ws)
|
||||||
|
setReadyState(ReadyState.Closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = event => {
|
||||||
|
if (websocketRef.current !== ws) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onOpenRef.current?.(event, ws)
|
||||||
|
reconnectTimesRef.current = 0
|
||||||
|
setReadyState(ReadyState.Open)
|
||||||
|
startHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (message: WSMessageEvent) => {
|
||||||
|
if (websocketRef.current !== ws) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageData =
|
||||||
|
typeof message.data === 'string' ? message.data : String(message.data)
|
||||||
|
|
||||||
|
// 先检查是否是心跳响应
|
||||||
|
if (enableHeartbeat && handleRawMessage(messageData)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析消息并触发回调
|
||||||
|
try {
|
||||||
|
const parsedMessage: ApiEvent = JSON.parse(messageData)
|
||||||
|
onMessageRef.current?.(parsedMessage, ws)
|
||||||
|
} catch {
|
||||||
|
// 如果解析失败,尝试作为原始数据传递
|
||||||
|
console.warn('[WebSocket] Failed to parse message:', messageData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = event => {
|
||||||
|
onCloseRef.current?.(event, ws)
|
||||||
|
clearHeartbeat()
|
||||||
|
// closed by server
|
||||||
|
if (websocketRef.current === ws) {
|
||||||
|
reconnect()
|
||||||
|
}
|
||||||
|
// closed by disconnect or closed by server
|
||||||
|
if (!websocketRef.current || websocketRef.current === ws) {
|
||||||
|
setReadyState(ReadyState.Closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
websocketRef.current = ws
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage: WebSocket['send'] = message => {
|
||||||
|
const currentState = getReadyState()
|
||||||
|
if (currentState === ReadyState.Open) {
|
||||||
|
websocketRef.current?.send(message)
|
||||||
|
} else {
|
||||||
|
throw new Error('WebSocket disconnected')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换会话
|
||||||
|
const switchConversation = (conversationId: string) => {
|
||||||
|
// 检查网络状态
|
||||||
|
if (!isOnlineRef.current) {
|
||||||
|
throw new Error('网络连接异常,无法切换会话')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 WebSocket 连接状态
|
||||||
|
const currentState = getReadyState()
|
||||||
|
if (!websocketRef.current || currentState !== ReadyState.Open) {
|
||||||
|
throw new Error('WebSocket 未连接,无法切换会话')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = JSON.stringify({
|
||||||
|
message_type: 'switch_conversation',
|
||||||
|
conversation_id: conversationId,
|
||||||
|
})
|
||||||
|
websocketRef.current.send(message)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`切换会话失败: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
reconnectTimesRef.current = 0
|
||||||
|
connectWsFn()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectFn = () => {
|
||||||
|
if (reconnectTimerRef.current) {
|
||||||
|
clearTimeout(reconnectTimerRef.current)
|
||||||
|
}
|
||||||
|
if (reconnectDebounceTimerRef.current) {
|
||||||
|
clearTimeout(reconnectDebounceTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectTimesRef.current = reconnectLimit
|
||||||
|
clearHeartbeat()
|
||||||
|
websocketRef.current?.close(1000, '手动断开')
|
||||||
|
websocketRef.current = undefined
|
||||||
|
setReadyState(ReadyState.Closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理网络断开
|
||||||
|
const handleNetworkOffline = () => {
|
||||||
|
clearHeartbeat()
|
||||||
|
const currentState = getReadyState()
|
||||||
|
if (
|
||||||
|
currentState === ReadyState.Open ||
|
||||||
|
currentState === ReadyState.Connecting
|
||||||
|
) {
|
||||||
|
disconnectFn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重连函数 - 统一处理重连逻辑
|
||||||
|
const attemptReconnect = () => {
|
||||||
|
// 清除之前的防抖定时器
|
||||||
|
if (reconnectDebounceTimerRef.current) {
|
||||||
|
clearTimeout(reconnectDebounceTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectDebounceTimerRef.current = setTimeout(() => {
|
||||||
|
const currentState = getReadyState()
|
||||||
|
// 已连接或正在连接时跳过
|
||||||
|
if (
|
||||||
|
currentState === ReadyState.Open ||
|
||||||
|
currentState === ReadyState.Connecting
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHeartbeat()
|
||||||
|
|
||||||
|
const isClosed =
|
||||||
|
currentState === ReadyState.Closed ||
|
||||||
|
currentState === ReadyState.Closing
|
||||||
|
|
||||||
|
if (isClosed) {
|
||||||
|
connect()
|
||||||
|
} else {
|
||||||
|
disconnectFn()
|
||||||
|
setTimeout(() => {
|
||||||
|
if (
|
||||||
|
isOnlineRef.current &&
|
||||||
|
getReadyState() !== ReadyState.Open &&
|
||||||
|
getReadyState() !== ReadyState.Connecting
|
||||||
|
) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
}, reconnectDelay)
|
||||||
|
}
|
||||||
|
}, reconnectDebounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网络状态监听
|
||||||
|
let cleanupNetworkListener: (() => void) | undefined
|
||||||
|
|
||||||
|
if (enableNetworkListener && typeof window !== 'undefined') {
|
||||||
|
const handleOnline = () => {
|
||||||
|
isOnlineRef.current = true
|
||||||
|
attemptReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
isOnlineRef.current = false
|
||||||
|
handleNetworkOffline()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
|
||||||
|
cleanupNetworkListener = () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文档可见性监听
|
||||||
|
let cleanupVisibilityListener: (() => void) | undefined
|
||||||
|
|
||||||
|
if (enableVisibilityListener && typeof document !== 'undefined') {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
const isVisible = !document.hidden
|
||||||
|
isVisibleRef.current = isVisible
|
||||||
|
if (isVisible && isOnlineRef.current) {
|
||||||
|
const currentState = getReadyState()
|
||||||
|
if (
|
||||||
|
currentState === ReadyState.Closed ||
|
||||||
|
currentState === ReadyState.Closing
|
||||||
|
) {
|
||||||
|
attemptReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
|
||||||
|
cleanupVisibilityListener = () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动连接
|
||||||
|
if (!manual && socketUrl) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
const cleanup = () => {
|
||||||
|
disconnectFn()
|
||||||
|
cleanupNetworkListener?.()
|
||||||
|
cleanupVisibilityListener?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Result = {
|
||||||
|
sendMessage,
|
||||||
|
connect,
|
||||||
|
disconnect: disconnectFn,
|
||||||
|
get readyState() {
|
||||||
|
return getReadyState()
|
||||||
|
},
|
||||||
|
get webSocketIns() {
|
||||||
|
return websocketRef.current
|
||||||
|
},
|
||||||
|
clearHeartbeat,
|
||||||
|
switchConversation,
|
||||||
|
cleanup,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createWebSocketClient
|
||||||
BIN
app/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
594
app/globals.css
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: "Avenir Next", "SF Pro Rounded", "SF Pro Display", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI Variable", sans-serif;
|
||||||
|
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 10px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 16px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 22px);
|
||||||
|
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-brand: var(--brand);
|
||||||
|
--color-brand-foreground: var(--brand-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.875rem;
|
||||||
|
|
||||||
|
--background: #f5f1ea;
|
||||||
|
--foreground: #171412;
|
||||||
|
|
||||||
|
--card: #fffdf8;
|
||||||
|
--card-foreground: #171412;
|
||||||
|
|
||||||
|
--popover: #fffdf8;
|
||||||
|
--popover-foreground: #171412;
|
||||||
|
|
||||||
|
--primary: #8a6742;
|
||||||
|
--primary-foreground: #fffaf4;
|
||||||
|
|
||||||
|
--secondary: #ece4d7;
|
||||||
|
--secondary-foreground: #2e241c;
|
||||||
|
|
||||||
|
--muted: #efe8dd;
|
||||||
|
--muted-foreground: #7c6f62;
|
||||||
|
|
||||||
|
--accent: #e8dfd1;
|
||||||
|
--accent-foreground: #4b3724;
|
||||||
|
|
||||||
|
--destructive: #cb5f74;
|
||||||
|
|
||||||
|
--border: #ddd2c2;
|
||||||
|
--input: #cfbfaa;
|
||||||
|
--ring: rgba(138, 103, 66, 0.22);
|
||||||
|
|
||||||
|
--brand: #8a6742;
|
||||||
|
--brand-foreground: #fffaf4;
|
||||||
|
--success: #5e856b;
|
||||||
|
--warning: #c98a45;
|
||||||
|
|
||||||
|
--chart-1: #8a6742;
|
||||||
|
--chart-2: #b79267;
|
||||||
|
--chart-3: #6d7788;
|
||||||
|
--chart-4: #5e856b;
|
||||||
|
--chart-5: #c98253;
|
||||||
|
|
||||||
|
--sidebar: #fffdf8;
|
||||||
|
--sidebar-foreground: #171412;
|
||||||
|
--sidebar-primary: #8a6742;
|
||||||
|
--sidebar-primary-foreground: #fffaf4;
|
||||||
|
--sidebar-accent: #e8dfd1;
|
||||||
|
--sidebar-accent-foreground: #2e241c;
|
||||||
|
--sidebar-border: #ddd2c2;
|
||||||
|
--sidebar-ring: rgba(138, 103, 66, 0.22);
|
||||||
|
|
||||||
|
--editor-surface: rgba(255, 251, 245, 0.92);
|
||||||
|
--editor-surface-muted: rgba(239, 232, 221, 0.92);
|
||||||
|
--editor-border: rgba(124, 111, 98, 0.22);
|
||||||
|
--editor-border-strong: rgba(124, 111, 98, 0.36);
|
||||||
|
--editor-shadow: rgba(23, 20, 18, 0.14);
|
||||||
|
--editor-text: #2e241c;
|
||||||
|
--editor-text-muted: rgba(46, 36, 28, 0.54);
|
||||||
|
--editor-accent: #8a6742;
|
||||||
|
--editor-accent-soft: rgba(138, 103, 66, 0.12);
|
||||||
|
--editor-danger: #cb5f74;
|
||||||
|
--editor-danger-soft: rgba(203, 95, 116, 0.12);
|
||||||
|
|
||||||
|
--page-glow-1: rgba(206, 185, 156, 0.32);
|
||||||
|
--page-glow-2: rgba(170, 142, 96, 0.18);
|
||||||
|
--page-gradient-top: #fcfaf6;
|
||||||
|
--page-gradient-bottom: #f5f1ea;
|
||||||
|
--gradient-subtle-1: #fcfaf6;
|
||||||
|
--gradient-subtle-2: #f1e8db;
|
||||||
|
--gradient-subtle-3: #e6dac8;
|
||||||
|
--gradient-brand-1: #3a2b1e;
|
||||||
|
--gradient-brand-2: #8a6742;
|
||||||
|
--gradient-brand-3: #d0b08c;
|
||||||
|
--text-gradient-1: #171412;
|
||||||
|
--text-gradient-2: #8a6742;
|
||||||
|
--text-gradient-3: #c7a173;
|
||||||
|
--glass-bg: rgba(255, 251, 245, 0.84);
|
||||||
|
--glass-border: rgba(221, 210, 194, 0.92);
|
||||||
|
--glow-shadow: rgba(138, 103, 66, 0.2);
|
||||||
|
--brand-shadow: rgba(138, 103, 66, 0.26);
|
||||||
|
--terminal-background: #17171d;
|
||||||
|
--terminal-surface: #121218;
|
||||||
|
--terminal-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--terminal-text: #f3f4f6;
|
||||||
|
--terminal-text-muted: rgba(243, 244, 246, 0.66);
|
||||||
|
--terminal-prompt: #b79267;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #09090d;
|
||||||
|
--foreground: #f5f7ff;
|
||||||
|
|
||||||
|
--card: #12131a;
|
||||||
|
--card-foreground: #f5f7ff;
|
||||||
|
|
||||||
|
--popover: #141624;
|
||||||
|
--popover-foreground: #f5f7ff;
|
||||||
|
|
||||||
|
--primary: #8f7cff;
|
||||||
|
--primary-foreground: #f8f7ff;
|
||||||
|
|
||||||
|
--secondary: #171826;
|
||||||
|
--secondary-foreground: #e8ebff;
|
||||||
|
|
||||||
|
--muted: #131422;
|
||||||
|
--muted-foreground: #9da2bf;
|
||||||
|
|
||||||
|
--accent: #1b1d31;
|
||||||
|
--accent-foreground: #d9ddff;
|
||||||
|
|
||||||
|
--destructive: #ff7ea8;
|
||||||
|
|
||||||
|
--border: rgba(126, 132, 173, 0.2);
|
||||||
|
--input: rgba(126, 132, 173, 0.28);
|
||||||
|
--ring: rgba(143, 124, 255, 0.36);
|
||||||
|
|
||||||
|
--brand: #a18cff;
|
||||||
|
--brand-foreground: #f8f7ff;
|
||||||
|
--success: #48d7c2;
|
||||||
|
--warning: #ffb86b;
|
||||||
|
|
||||||
|
--chart-1: #8f7cff;
|
||||||
|
--chart-2: #45d4ff;
|
||||||
|
--chart-3: #5f7cff;
|
||||||
|
--chart-4: #ff78b8;
|
||||||
|
--chart-5: #ffb86b;
|
||||||
|
|
||||||
|
--sidebar: #0f1018;
|
||||||
|
--sidebar-foreground: #f5f7ff;
|
||||||
|
--sidebar-primary: #8f7cff;
|
||||||
|
--sidebar-primary-foreground: #f8f7ff;
|
||||||
|
--sidebar-accent: #1b1d31;
|
||||||
|
--sidebar-accent-foreground: #f5f7ff;
|
||||||
|
--sidebar-border: rgba(126, 132, 173, 0.2);
|
||||||
|
--sidebar-ring: rgba(143, 124, 255, 0.36);
|
||||||
|
|
||||||
|
--editor-surface: rgba(18, 19, 26, 0.92);
|
||||||
|
--editor-surface-muted: rgba(27, 29, 49, 0.86);
|
||||||
|
--editor-border: rgba(126, 132, 173, 0.2);
|
||||||
|
--editor-border-strong: rgba(126, 132, 173, 0.34);
|
||||||
|
--editor-shadow: rgba(0, 0, 0, 0.32);
|
||||||
|
--editor-text: #f5f7ff;
|
||||||
|
--editor-text-muted: rgba(245, 247, 255, 0.58);
|
||||||
|
--editor-accent: #8f7cff;
|
||||||
|
--editor-accent-soft: rgba(143, 124, 255, 0.16);
|
||||||
|
--editor-danger: #ff7ea8;
|
||||||
|
--editor-danger-soft: rgba(255, 126, 168, 0.16);
|
||||||
|
|
||||||
|
--page-glow-1: rgba(143, 124, 255, 0.2);
|
||||||
|
--page-glow-2: rgba(69, 212, 255, 0.12);
|
||||||
|
--page-gradient-top: #151625;
|
||||||
|
--page-gradient-bottom: #09090d;
|
||||||
|
--gradient-subtle-1: #191b2d;
|
||||||
|
--gradient-subtle-2: #10111b;
|
||||||
|
--gradient-subtle-3: #09090d;
|
||||||
|
--gradient-brand-1: #6156ff;
|
||||||
|
--gradient-brand-2: #9a7cff;
|
||||||
|
--gradient-brand-3: #45d4ff;
|
||||||
|
--text-gradient-1: #f5f7ff;
|
||||||
|
--text-gradient-2: #a18cff;
|
||||||
|
--text-gradient-3: #45d4ff;
|
||||||
|
--glass-bg: rgba(18, 19, 26, 0.84);
|
||||||
|
--glass-border: rgba(126, 132, 173, 0.18);
|
||||||
|
--glow-shadow: rgba(143, 124, 255, 0.22);
|
||||||
|
--brand-shadow: rgba(143, 124, 255, 0.28);
|
||||||
|
--terminal-background: #12131a;
|
||||||
|
--terminal-surface: #0d0f16;
|
||||||
|
--terminal-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--terminal-text: #f5f7ff;
|
||||||
|
--terminal-text-muted: rgba(245, 247, 255, 0.64);
|
||||||
|
--terminal-prompt: #a18cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Display", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI Variable", sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
|
min-height: 100vh;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at top left, var(--page-glow-1), transparent 34%),
|
||||||
|
radial-gradient(circle at 85% 12%, var(--page-glow-2), transparent 22%),
|
||||||
|
linear-gradient(180deg, var(--page-gradient-top) 0%, var(--page-gradient-bottom) 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--ring);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes artifact-shuffle-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px) scale(0.9) rotate(-4deg);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-6px) scale(1.03) rotate(2deg);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translateY(3px) scale(0.98) rotate(-1deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-artifact-shuffle-in {
|
||||||
|
animation-name: artifact-shuffle-in;
|
||||||
|
animation-duration: 800ms;
|
||||||
|
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
animation-fill-mode: backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.card {
|
||||||
|
@apply rounded-xl bg-card;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(16, 38, 52, 0.03),
|
||||||
|
0 14px 34px rgba(16, 38, 52, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(16, 38, 52, 0.04),
|
||||||
|
0 18px 42px rgba(16, 38, 52, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .card {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.28),
|
||||||
|
0 18px 48px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-effect {
|
||||||
|
box-shadow: 0 14px 32px var(--glow-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glow-effect {
|
||||||
|
box-shadow: 0 14px 38px var(--glow-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand {
|
||||||
|
@apply bg-primary text-primary-foreground transition-all duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brand:hover {
|
||||||
|
box-shadow: 0 12px 26px var(--brand-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-panel {
|
||||||
|
background: color-mix(in srgb, var(--card) 92%, white 8%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgba(16, 38, 52, 0.03),
|
||||||
|
0 22px 52px rgba(16, 38, 52, 0.08);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-subtle {
|
||||||
|
background: color-mix(in srgb, var(--card) 84%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
@apply text-foreground leading-relaxed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h1 { @apply mt-6 mb-4 text-2xl font-bold; }
|
||||||
|
.prose h2 { @apply mt-5 mb-3 text-xl font-bold; }
|
||||||
|
.prose h3 { @apply mt-4 mb-2 text-lg font-bold; }
|
||||||
|
.prose p { @apply my-3; }
|
||||||
|
.prose ul { @apply my-3 list-disc pl-6; }
|
||||||
|
.prose ol { @apply my-3 list-decimal pl-6; }
|
||||||
|
.prose li { @apply my-1; }
|
||||||
|
.prose blockquote { @apply my-4 border-l-4 border-muted pl-4 italic; }
|
||||||
|
.prose code { @apply rounded bg-muted px-1.5 py-0.5 font-mono text-sm; }
|
||||||
|
.prose pre { @apply my-4 overflow-x-auto rounded-xl border border-border bg-card p-4; }
|
||||||
|
.prose pre code { @apply bg-transparent p-0; }
|
||||||
|
.prose table { @apply my-6 w-full border-collapse text-sm; }
|
||||||
|
.prose th { @apply border border-border bg-muted/50 px-4 py-2 text-left font-bold; }
|
||||||
|
.prose td { @apply border border-border px-4 py-2; }
|
||||||
|
.prose tr:nth-child(even) { @apply bg-muted/20; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.transition-default {
|
||||||
|
@apply transition-all duration-200 ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(97, 119, 133, 0.36);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(97, 119, 133, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(159, 180, 188, 0.24);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(159, 180, 188, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-subtle {
|
||||||
|
background: linear-gradient(180deg, var(--gradient-subtle-1) 0%, var(--gradient-subtle-2) 52%, var(--gradient-subtle-3) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-gradient-subtle {
|
||||||
|
background: linear-gradient(180deg, var(--gradient-subtle-1) 0%, var(--gradient-subtle-2) 50%, var(--gradient-subtle-3) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-brand {
|
||||||
|
background: linear-gradient(135deg, var(--gradient-brand-1) 0%, var(--gradient-brand-2) 48%, var(--gradient-brand-3) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
@apply backdrop-blur-md;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-lift {
|
||||||
|
@apply transition-all duration-200 ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-lift:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 22px rgba(16, 38, 52, 0.08),
|
||||||
|
0 4px 10px rgba(16, 38, 52, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover-lift:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 8px 16px rgba(0, 0, 0, 0.3),
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.press-effect {
|
||||||
|
@apply transition-transform duration-100 ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.press-effect:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-ring {
|
||||||
|
@apply outline-none ring-2 ring-primary/20 ring-offset-2 ring-offset-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gradient {
|
||||||
|
@apply bg-clip-text text-transparent;
|
||||||
|
background-image: linear-gradient(135deg, var(--text-gradient-1) 0%, var(--text-gradient-2) 48%, var(--text-gradient-3) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gradient {
|
||||||
|
background-image: linear-gradient(135deg, var(--text-gradient-1) 0%, var(--text-gradient-2) 46%, var(--text-gradient-3) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-brand {
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-soft {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 12px rgba(16, 38, 52, 0.04),
|
||||||
|
0 10px 24px rgba(16, 38, 52, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-hover {
|
||||||
|
@apply border border-border transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-hover:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 30%, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================
|
||||||
|
* 全局覆盖 slate/zinc 硬编码颜色 → 语义变量
|
||||||
|
* slate 色系为冷色调(偏蓝),本项目为暖色调,
|
||||||
|
* 统一替换确保亮色/深色模式下颜色风格一致
|
||||||
|
* ================================================ */
|
||||||
|
|
||||||
|
/* 背景 */
|
||||||
|
.bg-white {
|
||||||
|
background-color: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-slate-50,
|
||||||
|
.bg-zinc-50,
|
||||||
|
.bg-gray-50 {
|
||||||
|
background-color: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-slate-100,
|
||||||
|
.bg-zinc-100 {
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文字 */
|
||||||
|
.text-black,
|
||||||
|
.text-slate-900,
|
||||||
|
.text-zinc-900 {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-slate-800,
|
||||||
|
.text-zinc-800,
|
||||||
|
.text-slate-700,
|
||||||
|
.text-zinc-700 {
|
||||||
|
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-slate-600,
|
||||||
|
.text-zinc-600 {
|
||||||
|
color: color-mix(in srgb, var(--muted-foreground) 120%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-slate-500,
|
||||||
|
.text-zinc-500,
|
||||||
|
.text-gray-500,
|
||||||
|
.text-slate-400,
|
||||||
|
.text-zinc-400,
|
||||||
|
.text-gray-400 {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-slate-300,
|
||||||
|
.text-zinc-300 {
|
||||||
|
color: color-mix(in srgb, var(--muted-foreground) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 边框 */
|
||||||
|
.border-slate-100,
|
||||||
|
.border-slate-200,
|
||||||
|
.border-zinc-100,
|
||||||
|
.border-zinc-200 {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-slate-700,
|
||||||
|
.border-slate-800,
|
||||||
|
.border-zinc-700,
|
||||||
|
.border-zinc-800 {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hover 背景 */
|
||||||
|
.hover\:bg-slate-50:hover,
|
||||||
|
.hover\:bg-slate-100:hover,
|
||||||
|
.hover\:bg-zinc-50:hover,
|
||||||
|
.hover\:bg-zinc-100:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-floating-panel {
|
||||||
|
background: var(--editor-surface);
|
||||||
|
border: 1px solid var(--editor-border);
|
||||||
|
box-shadow:
|
||||||
|
0 18px 44px var(--editor-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-floating-panel-soft {
|
||||||
|
background: var(--editor-surface);
|
||||||
|
border: 1px solid var(--editor-border);
|
||||||
|
box-shadow:
|
||||||
|
0 12px 34px var(--editor-shadow),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.14);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-chip {
|
||||||
|
background: var(--editor-surface-muted);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--editor-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-chip:hover {
|
||||||
|
background: color-mix(in srgb, var(--editor-accent-soft) 55%, var(--editor-surface-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-chip-active {
|
||||||
|
background: var(--editor-accent-soft);
|
||||||
|
color: var(--editor-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-divider {
|
||||||
|
background: var(--editor-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-danger {
|
||||||
|
background: var(--editor-danger-soft);
|
||||||
|
color: var(--editor-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色背景(终端/代码块)保留不覆盖:bg-slate-900 bg-slate-950 */
|
||||||
|
}
|
||||||
34
app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { ThemeProvider } from "@/components/provider/Theme/theme-provider";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { AgentationGuard } from "@/components/AgentationGuard";
|
||||||
|
import RouteChange from "./RouteChange";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Nova Chat',
|
||||||
|
description: 'Generated by create Nova Chat',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RootLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout(props: RootLayoutProps) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body className="antialiased">
|
||||||
|
<ThemeProvider>
|
||||||
|
{props.children}
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</ThemeProvider>
|
||||||
|
<AgentationGuard />
|
||||||
|
<Suspense>
|
||||||
|
<RouteChange />
|
||||||
|
</Suspense>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
app/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ImageEditor, ImageEditorHandle } from '@/components/image-editor';
|
||||||
|
import { NovaChat } from '@/components/nova-sdk';
|
||||||
|
import { useBuildConversationConnect } from '@/components/nova-sdk/hooks';
|
||||||
|
import { useImages } from '@/components/nova-sdk/store/useImages';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
const ChatWithImageEditor = () => {
|
||||||
|
const imageEditorRef = useRef<ImageEditorHandle>(null);
|
||||||
|
const { conversationId, platformConfig } = useBuildConversationConnect();
|
||||||
|
useImages(imageEditorRef)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-screen flex">
|
||||||
|
<div className="w-1/2 flex-shrink-0">
|
||||||
|
{conversationId && (
|
||||||
|
<ImageEditor taskId={conversationId} ref={imageEditorRef} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-1/2">
|
||||||
|
<NovaChat
|
||||||
|
platformConfig={platformConfig}
|
||||||
|
conversationId={conversationId}
|
||||||
|
agentId={platformConfig.agentId}
|
||||||
|
panelMode={'dialog'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatWithImageEditor;
|
||||||
797
app/settings/remote-control/page.tsx
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Bot,
|
||||||
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Info,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
// PLATFORM:TYPE_UNION:START
|
||||||
|
type Platform = 'discord' | 'dingtalk' | 'lark' | 'telegram' | 'slack'
|
||||||
|
// PLATFORM:TYPE_UNION:END
|
||||||
|
type ConnectionStatus = 'connected' | 'disconnected' | 'connecting'
|
||||||
|
|
||||||
|
type CSSVarName =
|
||||||
|
| '--background'
|
||||||
|
| '--foreground'
|
||||||
|
| '--card'
|
||||||
|
| '--card-foreground'
|
||||||
|
| '--primary'
|
||||||
|
| '--primary-foreground'
|
||||||
|
| '--muted'
|
||||||
|
| '--muted-foreground'
|
||||||
|
| '--border'
|
||||||
|
| '--input'
|
||||||
|
| '--success'
|
||||||
|
| '--warning'
|
||||||
|
| '--destructive'
|
||||||
|
|
||||||
|
const themeVar = (name: CSSVarName) => `var(${name})`
|
||||||
|
|
||||||
|
interface RemoteControlConfig {
|
||||||
|
// PLATFORM:DINGTALK:CONFIG_INTERFACE:START
|
||||||
|
dingtalk: {
|
||||||
|
enabled: boolean
|
||||||
|
clientId: string
|
||||||
|
clientSecret: string
|
||||||
|
}
|
||||||
|
// PLATFORM:DINGTALK:CONFIG_INTERFACE:END
|
||||||
|
// PLATFORM:DISCORD:CONFIG_INTERFACE:START
|
||||||
|
discord: {
|
||||||
|
enabled: boolean
|
||||||
|
botToken: string
|
||||||
|
}
|
||||||
|
// PLATFORM:DISCORD:CONFIG_INTERFACE:END
|
||||||
|
// PLATFORM:LARK:CONFIG_INTERFACE:START
|
||||||
|
lark: {
|
||||||
|
enabled: boolean
|
||||||
|
appId: string
|
||||||
|
appSecret: string
|
||||||
|
}
|
||||||
|
// PLATFORM:LARK:CONFIG_INTERFACE:END
|
||||||
|
// PLATFORM:TELEGRAM:CONFIG_INTERFACE:START
|
||||||
|
telegram: {
|
||||||
|
enabled: boolean
|
||||||
|
botToken: string
|
||||||
|
}
|
||||||
|
// PLATFORM:TELEGRAM:CONFIG_INTERFACE:END
|
||||||
|
// PLATFORM:SLACK:CONFIG_INTERFACE:START
|
||||||
|
slack: {
|
||||||
|
enabled: boolean
|
||||||
|
botToken: string
|
||||||
|
appToken: string
|
||||||
|
}
|
||||||
|
// PLATFORM:SLACK:CONFIG_INTERFACE:END
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformStatus {
|
||||||
|
platform?: Platform
|
||||||
|
status: ConnectionStatus
|
||||||
|
messagesProcessed?: number
|
||||||
|
activeSessions?: number
|
||||||
|
lastConnectedAt?: string
|
||||||
|
uptime?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogsResponse {
|
||||||
|
logs: LogEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id?: string
|
||||||
|
timestamp: string
|
||||||
|
platform: Platform
|
||||||
|
eventType: string
|
||||||
|
severity: 'info' | 'warning' | 'error'
|
||||||
|
details?: string | Record<string, unknown>
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentInfoResponse {
|
||||||
|
agentId: string
|
||||||
|
baseUrl: string
|
||||||
|
stats?: {
|
||||||
|
totalMessages?: number
|
||||||
|
activeConnections?: number
|
||||||
|
avgResponseTime?: number
|
||||||
|
}
|
||||||
|
totalMessages?: number
|
||||||
|
activeConnections?: number
|
||||||
|
averageResponseTime?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: RemoteControlConfig = {
|
||||||
|
// PLATFORM:DINGTALK:DEFAULT_CONFIG:START
|
||||||
|
dingtalk: { enabled: false, clientId: '', clientSecret: '' },
|
||||||
|
// PLATFORM:DINGTALK:DEFAULT_CONFIG:END
|
||||||
|
// PLATFORM:DISCORD:DEFAULT_CONFIG:START
|
||||||
|
discord: { enabled: false, botToken: '' },
|
||||||
|
// PLATFORM:DISCORD:DEFAULT_CONFIG:END
|
||||||
|
// PLATFORM:LARK:DEFAULT_CONFIG:START
|
||||||
|
lark: { enabled: false, appId: '', appSecret: '' },
|
||||||
|
// PLATFORM:LARK:DEFAULT_CONFIG:END
|
||||||
|
// PLATFORM:TELEGRAM:DEFAULT_CONFIG:START
|
||||||
|
telegram: { enabled: false, botToken: '' },
|
||||||
|
// PLATFORM:TELEGRAM:DEFAULT_CONFIG:END
|
||||||
|
// PLATFORM:SLACK:DEFAULT_CONFIG:START
|
||||||
|
slack: { enabled: false, botToken: '', appToken: '' },
|
||||||
|
// PLATFORM:SLACK:DEFAULT_CONFIG:END
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATUS: Record<Platform, PlatformStatus> = {
|
||||||
|
// PLATFORM:DINGTALK:DEFAULT_STATUS:START
|
||||||
|
dingtalk: { platform: 'dingtalk', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||||
|
// PLATFORM:DINGTALK:DEFAULT_STATUS:END
|
||||||
|
// PLATFORM:DISCORD:DEFAULT_STATUS:START
|
||||||
|
discord: { platform: 'discord', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||||
|
// PLATFORM:DISCORD:DEFAULT_STATUS:END
|
||||||
|
// PLATFORM:LARK:DEFAULT_STATUS:START
|
||||||
|
lark: { platform: 'lark', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||||
|
// PLATFORM:LARK:DEFAULT_STATUS:END
|
||||||
|
// PLATFORM:TELEGRAM:DEFAULT_STATUS:START
|
||||||
|
telegram: { platform: 'telegram', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||||
|
// PLATFORM:TELEGRAM:DEFAULT_STATUS:END
|
||||||
|
// PLATFORM:SLACK:DEFAULT_STATUS:START
|
||||||
|
slack: { platform: 'slack', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 },
|
||||||
|
// PLATFORM:SLACK:DEFAULT_STATUS:END
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMeta(status: ConnectionStatus) {
|
||||||
|
if (status === 'connected') {
|
||||||
|
return { label: '已连接', dot: 'var(--success)' }
|
||||||
|
}
|
||||||
|
if (status === 'connecting') {
|
||||||
|
return { label: '连接中...', dot: 'var(--warning)' }
|
||||||
|
}
|
||||||
|
return { label: '断开连接', dot: 'var(--destructive)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return date.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetails(log: LogEntry) {
|
||||||
|
if (typeof log.details === 'string') return log.details
|
||||||
|
if (log.message) return log.message
|
||||||
|
if (log.details) return JSON.stringify(log.details)
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds?: number) {
|
||||||
|
if (!seconds || seconds <= 0) return '-'
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
if (h > 0) return `${h}小时${m}分`
|
||||||
|
if (m > 0) return `${m}分${s}秒`
|
||||||
|
return `${s}秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RemoteControlPage() {
|
||||||
|
const [config, setConfig] = useState<RemoteControlConfig>(DEFAULT_CONFIG)
|
||||||
|
const [status, setStatus] = useState<Record<Platform, PlatformStatus>>(DEFAULT_STATUS)
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
|
const [agentInfo, setAgentInfo] = useState<AgentInfoResponse | null>(null)
|
||||||
|
// PLATFORM:SHOW_SECRETS:START
|
||||||
|
const [showSecrets, setShowSecrets] = useState({
|
||||||
|
// PLATFORM:DINGTALK:SHOW_SECRETS:START
|
||||||
|
dingtalkSecret: false,
|
||||||
|
// PLATFORM:DINGTALK:SHOW_SECRETS:END
|
||||||
|
// PLATFORM:DISCORD:SHOW_SECRETS:START
|
||||||
|
discordToken: false,
|
||||||
|
// PLATFORM:DISCORD:SHOW_SECRETS:END
|
||||||
|
// PLATFORM:LARK:SHOW_SECRETS:START
|
||||||
|
larkSecret: false,
|
||||||
|
// PLATFORM:LARK:SHOW_SECRETS:END
|
||||||
|
// PLATFORM:TELEGRAM:SHOW_SECRETS:START
|
||||||
|
telegramToken: false,
|
||||||
|
// PLATFORM:TELEGRAM:SHOW_SECRETS:END
|
||||||
|
// PLATFORM:SLACK:SHOW_SECRETS:START
|
||||||
|
slackBotToken: false,
|
||||||
|
slackAppToken: false,
|
||||||
|
// PLATFORM:SLACK:SHOW_SECRETS:END
|
||||||
|
})
|
||||||
|
// PLATFORM:SHOW_SECRETS:END
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
// PLATFORM:TESTING:START
|
||||||
|
const [testing, setTesting] = useState<Record<Platform, boolean>>({
|
||||||
|
// PLATFORM:DINGTALK:TESTING:START
|
||||||
|
dingtalk: false,
|
||||||
|
// PLATFORM:DINGTALK:TESTING:END
|
||||||
|
// PLATFORM:DISCORD:TESTING:START
|
||||||
|
discord: false,
|
||||||
|
// PLATFORM:DISCORD:TESTING:END
|
||||||
|
// PLATFORM:LARK:TESTING:START
|
||||||
|
lark: false,
|
||||||
|
// PLATFORM:LARK:TESTING:END
|
||||||
|
// PLATFORM:TELEGRAM:TESTING:START
|
||||||
|
telegram: false,
|
||||||
|
// PLATFORM:TELEGRAM:TESTING:END
|
||||||
|
// PLATFORM:SLACK:TESTING:START
|
||||||
|
slack: false,
|
||||||
|
// PLATFORM:SLACK:TESTING:END
|
||||||
|
})
|
||||||
|
// PLATFORM:TESTING:END
|
||||||
|
const [refreshingLogs, setRefreshingLogs] = useState(false)
|
||||||
|
const [clearingLogs, setClearingLogs] = useState(false)
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
const response = await fetch('/api/remote-control/status', { cache: 'no-store' })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('状态加载失败')
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as Partial<Record<Platform, PlatformStatus>>
|
||||||
|
setStatus({
|
||||||
|
// PLATFORM:DINGTALK:LOAD_STATUS:START
|
||||||
|
dingtalk: { ...DEFAULT_STATUS.dingtalk, ...(data.dingtalk ?? {}) },
|
||||||
|
// PLATFORM:DINGTALK:LOAD_STATUS:END
|
||||||
|
// PLATFORM:DISCORD:LOAD_STATUS:START
|
||||||
|
discord: { ...DEFAULT_STATUS.discord, ...(data.discord ?? {}) },
|
||||||
|
// PLATFORM:DISCORD:LOAD_STATUS:END
|
||||||
|
// PLATFORM:LARK:LOAD_STATUS:START
|
||||||
|
lark: { ...DEFAULT_STATUS.lark, ...(data.lark ?? {}) },
|
||||||
|
// PLATFORM:LARK:LOAD_STATUS:END
|
||||||
|
// PLATFORM:TELEGRAM:LOAD_STATUS:START
|
||||||
|
telegram: { ...DEFAULT_STATUS.telegram, ...(data.telegram ?? {}) },
|
||||||
|
// PLATFORM:TELEGRAM:LOAD_STATUS:END
|
||||||
|
// PLATFORM:SLACK:LOAD_STATUS:START
|
||||||
|
slack: { ...DEFAULT_STATUS.slack, ...(data.slack ?? {}) },
|
||||||
|
// PLATFORM:SLACK:LOAD_STATUS:END
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadLogs = useCallback(async () => {
|
||||||
|
const response = await fetch('/api/remote-control/logs?limit=50', { cache: 'no-store' })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('日志加载失败')
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as LogsResponse
|
||||||
|
setLogs(Array.isArray(data.logs) ? data.logs : [])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadAgentInfo = useCallback(async () => {
|
||||||
|
const response = await fetch('/api/remote-control/agent-info', { cache: 'no-store' })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Agent 信息加载失败')
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as AgentInfoResponse
|
||||||
|
setAgentInfo(data)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadInitialData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const configResponse = await fetch('/api/remote-control/config', { cache: 'no-store' })
|
||||||
|
if (!configResponse.ok) {
|
||||||
|
throw new Error('配置加载失败')
|
||||||
|
}
|
||||||
|
const configData = (await configResponse.json()) as Partial<RemoteControlConfig>
|
||||||
|
setConfig({
|
||||||
|
// PLATFORM:DINGTALK:LOAD_INITIAL:START
|
||||||
|
dingtalk: { ...DEFAULT_CONFIG.dingtalk, ...(configData.dingtalk ?? {}) },
|
||||||
|
// PLATFORM:DINGTALK:LOAD_INITIAL:END
|
||||||
|
// PLATFORM:DISCORD:LOAD_INITIAL:START
|
||||||
|
discord: { ...DEFAULT_CONFIG.discord, ...(configData.discord ?? {}) },
|
||||||
|
// PLATFORM:DISCORD:LOAD_INITIAL:END
|
||||||
|
// PLATFORM:LARK:LOAD_INITIAL:START
|
||||||
|
lark: { ...DEFAULT_CONFIG.lark, ...(configData.lark ?? {}) },
|
||||||
|
// PLATFORM:LARK:LOAD_INITIAL:END
|
||||||
|
// PLATFORM:TELEGRAM:LOAD_INITIAL:START
|
||||||
|
telegram: { ...DEFAULT_CONFIG.telegram, ...(configData.telegram ?? {}) },
|
||||||
|
// PLATFORM:TELEGRAM:LOAD_INITIAL:END
|
||||||
|
// PLATFORM:SLACK:LOAD_INITIAL:START
|
||||||
|
slack: { ...DEFAULT_CONFIG.slack, ...(configData.slack ?? {}) },
|
||||||
|
// PLATFORM:SLACK:LOAD_INITIAL:END
|
||||||
|
})
|
||||||
|
await Promise.all([loadStatus(), loadLogs(), loadAgentInfo()])
|
||||||
|
} catch {
|
||||||
|
toast.error('加载远程控制配置失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [loadAgentInfo, loadLogs, loadStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadInitialData()
|
||||||
|
}, [loadInitialData])
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/remote-control/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
})
|
||||||
|
const data = (await response.json()) as { success?: boolean; message?: string; error?: string }
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.error || '保存失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(data.message || '配置已保存并生效')
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
void Promise.all([loadStatus(), loadLogs(), loadAgentInfo()])
|
||||||
|
}, 3000)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '保存失败'
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testConnection = async (platform: Platform) => {
|
||||||
|
setTesting(prev => ({ ...prev, [platform]: true }))
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/remote-control/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ platform }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = (await response.json()) as { success?: boolean; message?: string; error?: string }
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
throw new Error(data.error || data.message || '连接测试失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLATFORM:TEST_CONNECTION_SUCCESS:START
|
||||||
|
const platformNames: Record<Platform, string> = {
|
||||||
|
discord: 'Discord',
|
||||||
|
dingtalk: '钉钉',
|
||||||
|
lark: '飞书',
|
||||||
|
telegram: 'Telegram',
|
||||||
|
slack: 'Slack',
|
||||||
|
}
|
||||||
|
toast.success(`${platformNames[platform]}连接测试成功`)
|
||||||
|
// PLATFORM:TEST_CONNECTION_SUCCESS:END
|
||||||
|
await Promise.all([loadStatus(), loadLogs()])
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '连接测试失败'
|
||||||
|
toast.error(message)
|
||||||
|
await Promise.all([loadStatus(), loadLogs()])
|
||||||
|
} finally {
|
||||||
|
setTesting(prev => ({ ...prev, [platform]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshLogs = async () => {
|
||||||
|
setRefreshingLogs(true)
|
||||||
|
try {
|
||||||
|
await loadLogs()
|
||||||
|
toast.success('日志已刷新')
|
||||||
|
} catch {
|
||||||
|
toast.error('刷新日志失败')
|
||||||
|
} finally {
|
||||||
|
setRefreshingLogs(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearLogs = async () => {
|
||||||
|
setClearingLogs(true)
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/remote-control/logs', { method: 'DELETE' })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('清空失败')
|
||||||
|
}
|
||||||
|
setLogs([])
|
||||||
|
toast.success('日志已清空')
|
||||||
|
} catch {
|
||||||
|
toast.error('清空日志失败')
|
||||||
|
} finally {
|
||||||
|
setClearingLogs(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedStats = useMemo(() => {
|
||||||
|
const totalMessages = agentInfo?.stats?.totalMessages ?? agentInfo?.totalMessages ?? 0
|
||||||
|
const activeConnections = agentInfo?.stats?.activeConnections ?? agentInfo?.activeConnections ?? 0
|
||||||
|
const avgResponseTime = agentInfo?.stats?.avgResponseTime ?? agentInfo?.averageResponseTime ?? 0
|
||||||
|
return { totalMessages, activeConnections, avgResponseTime }
|
||||||
|
}, [agentInfo])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="fanling-theme min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="flex items-center gap-2 text-primary">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span>加载远程控制配置中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fanling-theme min-h-screen px-4 py-6 md:px-6" style={{ backgroundColor: themeVar('--background') }}>
|
||||||
|
<div className="mx-auto w-full max-w-5xl space-y-5">
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border bg-card px-4 py-3 shadow-sm" style={{ borderColor: themeVar('--border') }}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm transition-colors"
|
||||||
|
style={{ color: themeVar('--muted-foreground'), border: `1px solid ${themeVar('--border')}` }}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
返回主页
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-lg font-semibold md:text-xl" style={{ color: themeVar('--foreground') }}>
|
||||||
|
远程控制配置
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Bot className="h-5 w-5" style={{ color: themeVar('--primary') }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PLATFORM:DINGTALK:CARD:START */}
|
||||||
|
<PlatformCard
|
||||||
|
title="钉钉 Bot"
|
||||||
|
status={status.dingtalk}
|
||||||
|
enabled={config.dingtalk.enabled}
|
||||||
|
onEnabledChange={enabled => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, enabled } }))}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
label: 'Client ID',
|
||||||
|
type: 'text',
|
||||||
|
value: config.dingtalk.clientId,
|
||||||
|
onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientId: value } })),
|
||||||
|
placeholder: '请输入钉钉 Client ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Client Secret',
|
||||||
|
type: showSecrets.dingtalkSecret ? 'text' : 'password',
|
||||||
|
value: config.dingtalk.clientSecret,
|
||||||
|
onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientSecret: value } })),
|
||||||
|
placeholder: '请输入钉钉 Client Secret',
|
||||||
|
secretVisible: showSecrets.dingtalkSecret,
|
||||||
|
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, dingtalkSecret: !prev.dingtalkSecret })),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onTest={() => void testConnection('dingtalk')}
|
||||||
|
testing={testing.dingtalk}
|
||||||
|
docUrl="https://open.dingtalk.com/document/robots/custom-robot-access"
|
||||||
|
/>
|
||||||
|
{/* PLATFORM:DINGTALK:CARD:END */}
|
||||||
|
|
||||||
|
{/* PLATFORM:DISCORD:CARD:START */}
|
||||||
|
<PlatformCard
|
||||||
|
title="Discord Bot"
|
||||||
|
status={status.discord}
|
||||||
|
enabled={config.discord.enabled}
|
||||||
|
onEnabledChange={enabled => setConfig(prev => ({ ...prev, discord: { ...prev.discord, enabled } }))}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
label: 'Bot Token',
|
||||||
|
type: showSecrets.discordToken ? 'text' : 'password',
|
||||||
|
value: config.discord.botToken,
|
||||||
|
onChange: value => setConfig(prev => ({ ...prev, discord: { ...prev.discord, botToken: value } })),
|
||||||
|
placeholder: '请输入 Discord Bot Token',
|
||||||
|
secretVisible: showSecrets.discordToken,
|
||||||
|
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, discordToken: !prev.discordToken })),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onTest={() => void testConnection('discord')}
|
||||||
|
testing={testing.discord}
|
||||||
|
docUrl="https://docs.discord.com/developers/topics/oauth2#bots"
|
||||||
|
/>
|
||||||
|
{/* PLATFORM:DISCORD:CARD:END */}
|
||||||
|
|
||||||
|
{/* PLATFORM:LARK:CARD:START */}
|
||||||
|
<PlatformCard
|
||||||
|
title="飞书 Bot"
|
||||||
|
status={status.lark}
|
||||||
|
enabled={config.lark.enabled}
|
||||||
|
onEnabledChange={enabled => setConfig(prev => ({ ...prev, lark: { ...prev.lark, enabled } }))}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
label: 'App ID',
|
||||||
|
type: 'text',
|
||||||
|
value: config.lark.appId,
|
||||||
|
onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appId: value } })),
|
||||||
|
placeholder: '请输入飞书 App ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'App Secret',
|
||||||
|
type: showSecrets.larkSecret ? 'text' : 'password',
|
||||||
|
value: config.lark.appSecret,
|
||||||
|
onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appSecret: value } })),
|
||||||
|
placeholder: '请输入飞书 App Secret',
|
||||||
|
secretVisible: showSecrets.larkSecret,
|
||||||
|
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, larkSecret: !prev.larkSecret })),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onTest={() => void testConnection('lark')}
|
||||||
|
testing={testing.lark}
|
||||||
|
docUrl="https://open.feishu.cn/document/home/introduction-to-custom-app-development/self-built-application-development-process"
|
||||||
|
/>
|
||||||
|
{/* PLATFORM:LARK:CARD:END */}
|
||||||
|
|
||||||
|
{/* PLATFORM:TELEGRAM:CARD:START */}
|
||||||
|
<PlatformCard
|
||||||
|
title="Telegram Bot"
|
||||||
|
status={status.telegram}
|
||||||
|
enabled={config.telegram.enabled}
|
||||||
|
onEnabledChange={enabled => setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, enabled } }))}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
label: 'Bot Token',
|
||||||
|
type: showSecrets.telegramToken ? 'text' : 'password',
|
||||||
|
value: config.telegram.botToken,
|
||||||
|
onChange: value => setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, botToken: value } })),
|
||||||
|
placeholder: '请输入 Telegram Bot Token (通过 @BotFather 获取)',
|
||||||
|
secretVisible: showSecrets.telegramToken,
|
||||||
|
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, telegramToken: !prev.telegramToken })),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onTest={() => void testConnection('telegram')}
|
||||||
|
testing={testing.telegram}
|
||||||
|
docUrl="https://core.telegram.org/bots#how-do-i-create-a-bot"
|
||||||
|
/>
|
||||||
|
{/* PLATFORM:TELEGRAM:CARD:END */}
|
||||||
|
|
||||||
|
{/* PLATFORM:SLACK:CARD:START */}
|
||||||
|
<PlatformCard
|
||||||
|
title="Slack Bot"
|
||||||
|
status={status.slack}
|
||||||
|
enabled={config.slack.enabled}
|
||||||
|
onEnabledChange={enabled => setConfig(prev => ({ ...prev, slack: { ...prev.slack, enabled } }))}
|
||||||
|
fields={[
|
||||||
|
{
|
||||||
|
label: 'Bot Token (xoxb-)',
|
||||||
|
type: showSecrets.slackBotToken ? 'text' : 'password',
|
||||||
|
value: config.slack.botToken,
|
||||||
|
onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, botToken: value } })),
|
||||||
|
placeholder: '请输入 Slack Bot Token (xoxb-...)',
|
||||||
|
secretVisible: showSecrets.slackBotToken,
|
||||||
|
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackBotToken: !prev.slackBotToken })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'App-Level Token (xapp-)',
|
||||||
|
type: showSecrets.slackAppToken ? 'text' : 'password',
|
||||||
|
value: config.slack.appToken,
|
||||||
|
onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, appToken: value } })),
|
||||||
|
placeholder: '请输入 Slack App Token (xapp-...)',
|
||||||
|
secretVisible: showSecrets.slackAppToken,
|
||||||
|
onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackAppToken: !prev.slackAppToken })),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onTest={() => void testConnection('slack')}
|
||||||
|
testing={testing.slack}
|
||||||
|
docUrl="https://api.slack.com/start/quickstart"
|
||||||
|
/>
|
||||||
|
{/* PLATFORM:SLACK:CARD:END */}
|
||||||
|
|
||||||
|
<section className="rounded-2xl border bg-card p-4 shadow-sm md:p-5" style={{ borderColor: themeVar('--border') }}>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: themeVar('--foreground') }}>Bot 事件日志(最近 50 条)</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refreshLogs()}
|
||||||
|
disabled={refreshingLogs}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm"
|
||||||
|
style={{ border: `1px solid ${themeVar('--border')}`, color: themeVar('--muted-foreground') }}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${refreshingLogs ? 'animate-spin' : ''}`} />
|
||||||
|
手动刷新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void clearLogs()}
|
||||||
|
disabled={clearingLogs}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-white"
|
||||||
|
style={{ backgroundColor: themeVar('--destructive') }}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
清空日志
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[380px] overflow-auto rounded-xl border" style={{ borderColor: themeVar('--border') }}>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm" style={{ color: themeVar('--muted-foreground') }}>暂无日志数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y" style={{ borderColor: themeVar('--border') }}>
|
||||||
|
{logs.map((log, index) => (
|
||||||
|
<div key={log.id ?? `${log.timestamp}-${index}`} className="grid gap-1 px-4 py-3 text-sm md:grid-cols-[168px_88px_120px_80px_1fr]">
|
||||||
|
<span style={{ color: themeVar('--muted-foreground') }}>{formatDate(log.timestamp)}</span>
|
||||||
|
<span className="font-medium" style={{ color: themeVar('--foreground') }}>{log.platform}</span>
|
||||||
|
<span style={{ color: themeVar('--muted-foreground') }}>{log.eventType}</span>
|
||||||
|
<span>
|
||||||
|
<SeverityTag severity={log.severity} />
|
||||||
|
</span>
|
||||||
|
<span className="break-all" style={{ color: themeVar('--card-foreground') }}>{formatDetails(log)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border bg-card p-4 shadow-sm md:p-5" style={{ borderColor: themeVar('--border') }}>
|
||||||
|
<h2 className="mb-3 text-base font-semibold" style={{ color: themeVar('--foreground') }}>Nova Agent 信息(只读)</h2>
|
||||||
|
<div className="grid gap-3 text-sm md:grid-cols-2" style={{ color: themeVar('--card-foreground') }}>
|
||||||
|
<div className="rounded-xl border p-3" style={{ borderColor: themeVar('--border') }}>
|
||||||
|
<div>Agent ID: <span className="font-medium">{agentInfo?.agentId || '-'}</span></div>
|
||||||
|
<div className="mt-1 break-all">Base URL: <span className="font-medium">{agentInfo?.baseUrl || '-'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border p-3" style={{ borderColor: themeVar('--border') }}>
|
||||||
|
<div>活跃连接数: <span className="font-medium">{mergedStats.activeConnections}</span></div>
|
||||||
|
<div className="mt-1">消息总数: <span className="font-medium">{mergedStats.totalMessages}</span></div>
|
||||||
|
<div className="mt-1">平均响应时间: <span className="font-medium">{mergedStats.avgResponseTime} ms</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="sticky bottom-4 z-10 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void saveConfig()}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors"
|
||||||
|
style={{ backgroundColor: themeVar('--primary') }}
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
保存配置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformField {
|
||||||
|
label: string
|
||||||
|
type: 'text' | 'password'
|
||||||
|
value: string
|
||||||
|
placeholder: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
secretVisible?: boolean
|
||||||
|
onToggleSecret?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlatformCardProps {
|
||||||
|
title: string
|
||||||
|
enabled: boolean
|
||||||
|
onEnabledChange: (enabled: boolean) => void
|
||||||
|
status: PlatformStatus
|
||||||
|
fields: PlatformField[]
|
||||||
|
onTest: () => void
|
||||||
|
testing: boolean
|
||||||
|
docUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformCard({ title, enabled, onEnabledChange, status, fields, onTest, testing, docUrl }: PlatformCardProps) {
|
||||||
|
const meta = statusMeta(status.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border bg-card p-4 shadow-sm md:p-5" style={{ borderColor: themeVar('--border') }}>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: themeVar('--foreground') }}>{title}</h2>
|
||||||
|
<a
|
||||||
|
href={docUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex rounded-full p-1"
|
||||||
|
title="查看官方配置文档"
|
||||||
|
style={{ color: themeVar('--muted-foreground') }}
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={enabled}
|
||||||
|
onClick={() => onEnabledChange(!enabled)}
|
||||||
|
className="inline-flex items-center gap-2 text-sm"
|
||||||
|
style={{ color: themeVar('--card-foreground') }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="relative h-6 w-11 rounded-full transition-colors"
|
||||||
|
style={{ backgroundColor: enabled ? themeVar('--primary') : themeVar('--muted') }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 h-5 w-5 rounded-full transition-all"
|
||||||
|
style={{ backgroundColor: themeVar('--card'), left: enabled ? '22px' : '2px' }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{enabled ? '已启用' : '已禁用'}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 text-sm" style={{ color: themeVar('--card-foreground') }}>
|
||||||
|
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: meta.dot }} />
|
||||||
|
{meta.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{fields.map(field => (
|
||||||
|
<label key={field.label} className="block text-sm">
|
||||||
|
<span className="mb-1.5 block" style={{ color: themeVar('--card-foreground') }}>{field.label}</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={field.type}
|
||||||
|
value={field.value}
|
||||||
|
onChange={event => field.onChange(event.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="h-10 w-full rounded-lg border px-3 pr-10 text-sm outline-none transition-colors focus:border-primary"
|
||||||
|
style={{ borderColor: themeVar('--border'), color: themeVar('--foreground'), backgroundColor: themeVar('--card') }}
|
||||||
|
/>
|
||||||
|
{field.onToggleSecret && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={field.onToggleSecret}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1"
|
||||||
|
style={{ color: themeVar('--muted-foreground') }}
|
||||||
|
aria-label={field.secretVisible ? '隐藏密钥' : '显示密钥'}
|
||||||
|
>
|
||||||
|
{field.secretVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="grid gap-1 text-sm md:grid-cols-2 md:gap-x-6" style={{ color: themeVar('--muted-foreground') }}>
|
||||||
|
<span>最后连接时间: {formatDate(status.lastConnectedAt)}</span>
|
||||||
|
<span>运行时长: {formatDuration(status.uptime)}</span>
|
||||||
|
<span>已处理消息数: {status.messagesProcessed ?? 0}</span>
|
||||||
|
<span>活跃会话数: {status.activeSessions ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={testing || !enabled}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: themeVar('--primary') }}
|
||||||
|
title={!enabled ? '请先启用该渠道' : undefined}
|
||||||
|
>
|
||||||
|
{testing ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
|
测试连接
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{status.error && (
|
||||||
|
<div className="mt-3 inline-flex items-center gap-1 text-sm" style={{ color: themeVar('--destructive') }}>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
{status.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!status.error && status.status === 'connected' && (
|
||||||
|
<div className="mt-3 inline-flex items-center gap-1 text-sm" style={{ color: themeVar('--success') }}>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
连接状态正常
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityTag({ severity }: { severity: LogEntry['severity'] }) {
|
||||||
|
if (severity === 'error') {
|
||||||
|
return <span className="rounded-full px-2 py-0.5 text-xs text-white" style={{ backgroundColor: themeVar('--destructive') }}>error</span>
|
||||||
|
}
|
||||||
|
if (severity === 'warning') {
|
||||||
|
return <span className="rounded-full px-2 py-0.5 text-xs" style={{ backgroundColor: themeVar('--warning'), color: themeVar('--foreground') }}>warning</span>
|
||||||
|
}
|
||||||
|
return <span className="rounded-full px-2 py-0.5 text-xs" style={{ backgroundColor: themeVar('--success'), color: themeVar('--foreground') }}>info</span>
|
||||||
|
}
|
||||||
32
app/share/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { NovaChat } from '@/components/nova-sdk/nova-chat'
|
||||||
|
import { NovaState, useBuildConversationConnect } from '@/components/nova-sdk/hooks/useBuildConversationConnect'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function NovaChatPage() {
|
||||||
|
const { chatEnabled, agentId, conversationId, platformConfig } = useBuildConversationConnect()
|
||||||
|
|
||||||
|
if (chatEnabled === NovaState.Failed) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-screen flex items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">Chat 不可用,请检查项目 .env 配置</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agentId || !conversationId || !platformConfig) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-screen flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 text-primary animate-spin" />
|
||||||
|
<p className="text-primary">正在连接中...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen">
|
||||||
|
<NovaChat mode="share" conversationId={conversationId} platformConfig={platformConfig} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
components/AgentationGuard.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Agentation } from 'agentation'
|
||||||
|
|
||||||
|
export function AgentationGuard() {
|
||||||
|
if (process.env.NODE_ENV !== 'development') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Agentation />
|
||||||
|
}
|
||||||
|
|
||||||
87
components/base/color-picker/index.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { HexColorPicker } from 'react-colorful'
|
||||||
|
import { cn } from '@/utils/cn'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
interface ColorPickerProps {
|
||||||
|
color: string
|
||||||
|
onChange: (color: string) => void
|
||||||
|
presets?: string[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPresets = [
|
||||||
|
'#171412',
|
||||||
|
'#2E241C',
|
||||||
|
'#8A6742',
|
||||||
|
'#B79267',
|
||||||
|
'#D0B08C',
|
||||||
|
'#E8DFD1',
|
||||||
|
'#FFFDF8',
|
||||||
|
'#8F7CFF',
|
||||||
|
'#45D4FF',
|
||||||
|
'#FF78B8',
|
||||||
|
'#48D7C2',
|
||||||
|
'#FFB86B',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ColorPicker({
|
||||||
|
color,
|
||||||
|
onChange,
|
||||||
|
presets = defaultPresets,
|
||||||
|
className,
|
||||||
|
}: ColorPickerProps) {
|
||||||
|
const [inputValue, setInputValue] = React.useState(color)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setInputValue(color)
|
||||||
|
}, [color])
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value
|
||||||
|
setInputValue(val)
|
||||||
|
if (/^#[0-9A-F]{3,6}$/i.test(val)) {
|
||||||
|
onChange(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-3 w-[200px]', className)}>
|
||||||
|
<HexColorPicker
|
||||||
|
color={color}
|
||||||
|
onChange={onChange}
|
||||||
|
className="!w-full !h-[120px]"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<label className="text-[10px] font-medium text-muted-foreground uppercase w-8">
|
||||||
|
Hex
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-7 text-[10px] font-mono px-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{presets.length > 0 && (
|
||||||
|
<div className="grid grid-cols-6 gap-1.5 pt-2 border-t border-border">
|
||||||
|
{presets.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'w-5 h-5 rounded-sm border border-black/5 cursor-pointer transition-all hover:scale-110 active:scale-95',
|
||||||
|
color.toLowerCase() === p.toLowerCase() &&
|
||||||
|
'ring-2 ring-primary ring-offset-1'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: p }}
|
||||||
|
onClick={() => onChange(p)}
|
||||||
|
title={p}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
292
components/html-editor/README.md
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
# TaskArtifactHtml
|
||||||
|
|
||||||
|
HTML 产物渲染与编辑组件,支持 **文档模式(document)** 和 **网页模式(web)** 两种渲染方式。内容通过 iframe 沙箱加载,编辑态下提供所见即所得的工具栏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TaskArtifactHtml } from "@/components/nova-sdk/html-editor";
|
||||||
|
|
||||||
|
<TaskArtifactHtml
|
||||||
|
taskId="task-123"
|
||||||
|
editable={true}
|
||||||
|
type="web"
|
||||||
|
taskArtifact={{
|
||||||
|
path: "/artifacts/page.html",
|
||||||
|
file_name: "page.html",
|
||||||
|
file_type: "text/html",
|
||||||
|
}}
|
||||||
|
onStateChange={(state) => {
|
||||||
|
// state.canUndo / state.canRedo / state.undo() / state.redo()
|
||||||
|
console.log(state);
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
> **前置依赖**:组件内部通过 `useNovaKit()` 获取 API 实例来加载远程内容,因此使用前需确保外层已包裹 `<NovaKitProvider>`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
| 属性 | 类型 | 必填 | 说明 |
|
||||||
|
| --------------- | ------------------------------------ | ---- | ----------------------------------------------------------------- |
|
||||||
|
| `taskId` | `string` | ✅ | 任务 ID,用于关联产物与任务,编辑时也用于保存接口 |
|
||||||
|
| `editable` | `boolean` | ❌ | 是否开启编辑模式。`false` 为只读预览,`true` 显示编辑工具栏 |
|
||||||
|
| `type` | `'document' \| 'web'` | ✅ | 渲染模式。`document` 适合富文本文档编辑,`web` 适合网页类产物编辑 |
|
||||||
|
| `taskArtifact` | `TaskArtifact` | ✅ | 产物数据对象,包含文件路径等信息 |
|
||||||
|
| `onStateChange` | `(state: ArtifactEditState) => void` | ❌ | 编辑状态变化回调,每次 undo/redo 历史变化时触发 |
|
||||||
|
|
||||||
|
### TaskArtifact 类型定义
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TaskArtifact {
|
||||||
|
path: string; // 产物文件路径
|
||||||
|
file_name: string; // 文件名
|
||||||
|
file_type: string; // 文件 MIME 类型
|
||||||
|
last_modified?: number; // 最后修改时间戳
|
||||||
|
url?: string; // 可选的直接访问 URL
|
||||||
|
content?: string; // 可选的内联内容
|
||||||
|
task_id?: string; // 关联的任务 ID
|
||||||
|
event_type?: string; // 工具调用事件类型
|
||||||
|
tool_name?: string; // 工具名称
|
||||||
|
tool_input?: unknown; // 工具输入
|
||||||
|
tool_output?: unknown; // 工具输出
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ArtifactEditState 类型定义
|
||||||
|
|
||||||
|
当传入 `onStateChange` 后,编辑器内部的历史记录发生变化时会通过该回调通知父组件。父组件可据此实现外部的撤销/重做按钮。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface ArtifactEditState {
|
||||||
|
canUndo: boolean; // 是否可以撤销
|
||||||
|
canRedo: boolean; // 是否可以重做
|
||||||
|
undo: () => void; // 执行撤销
|
||||||
|
redo: () => void; // 执行重做
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 两种模式对比
|
||||||
|
|
||||||
|
| 特性 | `document` 模式 | `web` 模式 |
|
||||||
|
| -------- | ------------------------------------------ | ------------------------------------------------------ |
|
||||||
|
| 适用场景 | 富文本文档(文章、报告等) | 网页类产物(落地页、网站等) |
|
||||||
|
| 编辑方式 | 全局 `contentEditable`,支持文本选区工具栏 | 元素级选取与属性编辑 |
|
||||||
|
| 工具栏 | `toolbar-doc` — 基于文本选区定位 | `toolbar-web` — 基于选中元素定位,更丰富的样式编辑能力 |
|
||||||
|
| 内部组件 | `TaskHtmlDoc` | `TaskHtmlWeb` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 只读预览
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 简单预览,不显示任何编辑工具栏
|
||||||
|
<TaskArtifactHtml
|
||||||
|
taskId={task.id}
|
||||||
|
editable={false}
|
||||||
|
type="document"
|
||||||
|
taskArtifact={artifact}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文档编辑模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 开启编辑,用户可以直接在文档中选中文本进行格式编辑
|
||||||
|
<TaskArtifactHtml
|
||||||
|
taskId={task.id}
|
||||||
|
editable={true}
|
||||||
|
type="document"
|
||||||
|
taskArtifact={artifact}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网页编辑模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 开启编辑,用户可以点选网页中的元素进行样式调整
|
||||||
|
<TaskArtifactHtml
|
||||||
|
taskId={task.id}
|
||||||
|
editable={true}
|
||||||
|
type="web"
|
||||||
|
taskArtifact={artifact}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配合外部撤销/重做按钮
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [editState, setEditState] = useState<ArtifactEditState | null>(null);
|
||||||
|
|
||||||
|
<>
|
||||||
|
<div className="toolbar">
|
||||||
|
<button disabled={!editState?.canUndo} onClick={() => editState?.undo()}>
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
<button disabled={!editState?.canRedo} onClick={() => editState?.redo()}>
|
||||||
|
重做
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TaskArtifactHtml
|
||||||
|
taskId={task.id}
|
||||||
|
editable={true}
|
||||||
|
type="web"
|
||||||
|
taskArtifact={artifact}
|
||||||
|
onStateChange={setEditState}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 内部架构
|
||||||
|
|
||||||
|
```
|
||||||
|
TaskArtifactHtml (入口)
|
||||||
|
├── type="document" → TaskHtmlDoc
|
||||||
|
│ ├── editable=false → Html (只读 iframe)
|
||||||
|
│ └── editable=true
|
||||||
|
│ └── PPTEditProvider (编辑状态上下文)
|
||||||
|
│ ├── PPTEditToolBar (toolbar-doc, 文本选区工具栏)
|
||||||
|
│ └── HtmlWithEditMode (基础编辑层)
|
||||||
|
│ ├── useIframeMode → HTMLEditor 实例
|
||||||
|
│ ├── useDiff → 同步编辑状态到 Context
|
||||||
|
│ └── Html (iframe 渲染)
|
||||||
|
│
|
||||||
|
└── type="web" → TaskHtmlWeb
|
||||||
|
├── editable=false → Html (只读 iframe)
|
||||||
|
└── editable=true
|
||||||
|
└── PPTEditProvider
|
||||||
|
├── PPTEditToolBar (toolbar-web, 元素工具栏)
|
||||||
|
└── HtmlWithEditMode (isDoc=false)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心模块说明
|
||||||
|
|
||||||
|
#### `Html`(`components/html-render/task-html.tsx`)
|
||||||
|
|
||||||
|
底层 iframe 渲染组件,接受 HTML 字符串作为 `srcDoc` 注入 iframe。配置了 `sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"`,并自动拦截 iframe 内的 `_blank` 链接和 `window.open` 调用,转发至父窗口打开。
|
||||||
|
|
||||||
|
#### `HtmlWithEditMode`(`mode/baseEdit.tsx`)
|
||||||
|
|
||||||
|
编辑模式的基础封装层,负责:
|
||||||
|
|
||||||
|
- 调用 `useIframeMode` 初始化 `HTMLEditor` 编辑器实例
|
||||||
|
- 调用 `useDiff` 将编辑器状态同步到 `PPTEditContext`
|
||||||
|
- 监听历史记录变化,通过 `onStateChange` 向上传递 `ArtifactEditState`(canUndo/canRedo/undo/redo)
|
||||||
|
- 内容变更时自动防抖保存(1 秒),通过 `saveMarkdown` 接口持久化到服务端
|
||||||
|
|
||||||
|
#### `PPTEditProvider`(`context/index.tsx`)
|
||||||
|
|
||||||
|
编辑状态上下文 Provider,维护:
|
||||||
|
|
||||||
|
- `state` — 当前 `useIframeMode` 的返回值(编辑器实例、选中元素、位置等)
|
||||||
|
- `setState` — 由 `useDiff` hook 调用,将编辑状态同步至上下文
|
||||||
|
- `originalSlide` — 原始幻灯片数据的引用(PPT 场景)
|
||||||
|
|
||||||
|
工具栏组件通过 `usePPTEditContext()` 消费该上下文,获取编辑器实例和选中元素信息来渲染对应的编辑工具。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
### `useLoadContent(taskArtifact: TaskArtifact): string`
|
||||||
|
|
||||||
|
通过 `useNovaKit()` 提供的 API 加载产物的远程 HTML 内容。先调用 `api.getArtifactUrl()` 获取签名 URL,再 `fetch` 获取文本内容。
|
||||||
|
|
||||||
|
### `useIframeMode(id, containerRef, options?, scale?): UseInjectModeReturn`
|
||||||
|
|
||||||
|
核心编辑 hook,负责:
|
||||||
|
|
||||||
|
- 创建 `HTMLEditor` 实例并注入到 iframe 或普通 DOM 容器
|
||||||
|
- 管理元素选中状态和位置计算
|
||||||
|
- 管理 undo/redo 历史记录
|
||||||
|
- 绑定 `Cmd+Z` / `Cmd+Shift+Z` 快捷键
|
||||||
|
|
||||||
|
返回值:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface UseInjectModeReturn {
|
||||||
|
editor: HTMLEditor | null; // 编辑器实例(ref 值)
|
||||||
|
editorIns: HTMLEditor | null; // 编辑器实例(state 值,触发重渲染)
|
||||||
|
selectedElement: HTMLElement | null; // 当前选中的 DOM 元素
|
||||||
|
position: Position | null; // 选中元素在 iframe 内的位置
|
||||||
|
tipPosition: Position | null; // 经过缩放换算后用于定位工具栏的位置
|
||||||
|
injectScript: (target: HTMLElement) => Promise<void>; // 手动注入编辑器
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
clearHistory: () => void;
|
||||||
|
loadSuccess: boolean; // iframe 内容是否加载/注入成功
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `useDiff(useIframeReturn, isDoc?)`
|
||||||
|
|
||||||
|
区分 document / web 两种模式的差异逻辑,将 `useIframeMode` 的状态同步到 `PPTEditContext`:
|
||||||
|
|
||||||
|
- **document 模式**:使用 `useToolPosition` 基于文本选区末尾计算工具栏位置
|
||||||
|
- **web 模式**:基于选中元素的 `getBoundingClientRect` 定位工具栏;失焦后清除状态
|
||||||
|
|
||||||
|
### `useToolPosition(editor, isDoc): Position | null`
|
||||||
|
|
||||||
|
仅在 document 模式下生效。监听编辑器内的 `mousedown` / `mouseup` / `scroll` / `resize` 事件,根据当前文本选区(`Selection`)末尾位置计算工具栏坐标。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 服务端接口
|
||||||
|
|
||||||
|
### `saveMarkdown`(`server/index.ts`)
|
||||||
|
|
||||||
|
编辑模式下内容变更会自动防抖(1 秒)调用此函数保存到服务端:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
POST / v1 / super_agent / chat / write_file;
|
||||||
|
Body: {
|
||||||
|
task_id: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
html-editor/
|
||||||
|
├── index.tsx # 入口组件 TaskArtifactHtml
|
||||||
|
├── README.md # 本文档
|
||||||
|
├── types/
|
||||||
|
│ └── index.ts # ArtifactEditState 等类型定义
|
||||||
|
├── server/
|
||||||
|
│ └── index.ts # saveMarkdown 保存接口
|
||||||
|
├── mode/
|
||||||
|
│ ├── baseEdit.tsx # 编辑模式基础层 HtmlWithEditMode
|
||||||
|
│ ├── html-doc.tsx # 文档模式 TaskHtmlDoc
|
||||||
|
│ └── html-web.tsx # 网页模式 TaskHtmlWeb
|
||||||
|
├── context/
|
||||||
|
│ └── index.tsx # PPTEditProvider / usePPTEditContext
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useDiff.ts # doc/web 差异逻辑
|
||||||
|
│ ├── useIframeMode.ts # 核心编辑器 hook
|
||||||
|
│ ├── useLoadContent.ts # 远程内容加载
|
||||||
|
│ └── useToolPostion.ts # 文本选区工具栏定位
|
||||||
|
├── components/
|
||||||
|
│ ├── html-render/ # iframe 渲染组件
|
||||||
|
│ ├── toolbar-doc/ # 文档模式工具栏
|
||||||
|
│ └── toolbar-web/ # 网页模式工具栏
|
||||||
|
├── lib/ # HTMLEditor 核心库
|
||||||
|
│ ├── core/ # 编辑器引擎、历史记录管理器
|
||||||
|
│ ├── types/ # 内部类型定义
|
||||||
|
│ └── config/ # 编辑器配置
|
||||||
|
└── assets/ # 图标等静态资源
|
||||||
|
```
|
||||||
1
components/html-editor/assets/images/align-center.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121357"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121357)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="10.666748046875" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="9.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 807 B |
1
components/html-editor/assets/images/align-left.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121353"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121353)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="7.333251953125" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="7.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 806 B |
1
components/html-editor/assets/images/align-right.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121361"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121361)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="14" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="11.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 795 B |
1
components/html-editor/assets/images/blod.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139074"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139074)"><g><path d="M12.01479246875,8.98540254375C11.66879276875,8.45240214375,11.15829276875,8.09790274375,10.48379326875,7.92240234375L10.48379326875,7.88840194375C10.84129336875,7.71840194375,11.14179376875,7.52840184375,11.38529296875,7.31840184375C11.62929346875,7.11990214375,11.81629276875,6.90990254375,11.94679356875,6.68890234375C12.20179366875,6.22390224375,12.32379336875,5.73640224375,12.31229396875,5.2259023437500005C12.31229396875,4.227902443750001,12.00329396875,3.4254025437499998,11.38529486875,2.81840241375C10.77279476875,2.21740234375,9.85679486875,1.91140243475,8.63779446875,1.89990234375L3.36279296875,1.89990234375L3.36279296875,14.30190134375L9.054792868749999,14.30190134375C10.03029296875,14.30190134375,10.84379336875,13.97590134375,11.49629306875,13.32340134375C12.15429396875,12.69940234375,12.48879336875,11.85190204375,12.49979306875,10.78040124375C12.49979306875,10.13390164375,12.33829306875,9.53540134375,12.01479246875,8.98540254375ZM5.3797929687499995,3.57540254375L8.42529296875,3.57540254375C9.11129236875,3.5869023437500003,9.61629246875,3.7539025437499998,9.93929246875,4.07740254375C10.268292868749999,4.42340254375,10.43279216875,4.84590264375,10.43279216875,5.34490254375C10.43279216875,5.84390284375,10.268292868749999,6.25790264375,9.93929246875,6.58690264375C9.61629246875,6.94390294375,9.11129236875,7.12290284375,8.425291968749999,7.12290284375L5.3797929687499995,7.12290284375L5.3797929687499995,3.57540254375ZM10.13529296875,11.95440234375C9.81229306875,12.32290134375,9.301793068750001,12.51290134375,8.60429236875,12.52440134375L5.3797929687499995,12.52440134375L5.3797929687499995,8.79890254375L8.60379316875,8.79890254375C9.301293368749999,8.80990214375,9.81179286875,8.994401943749999,10.13479276875,9.35190154375C10.457793268749999,9.714902443749999,10.61979296875,10.15140244375,10.61979296875,10.66190144375C10.61979296875,11.16040134375,10.458292468749999,11.59140114375,10.13529296875,11.95440234375Z" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
21
components/html-editor/assets/images/delete.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none"
|
||||||
|
version="1.1" width="28" height="28" viewBox="0 0 28 28">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="master_svg0_5344_120568">
|
||||||
|
<rect x="6" y="6" width="16" height="16" rx="0" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<g clip-path="url(#master_svg0_5344_120568)">
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M12.000165893750001,7L16.00016589375,7C16.36835579375,7,16.66682629375,7.29847705,16.66682629375,7.6666669800000005C16.66682629375,8.0348599,16.36835579375,8.3333299,16.00016589375,8.3333299L12.000165893750001,8.3333299C11.63197609375,8.3333299,11.33349609375,8.0348599,11.33349609375,7.6666669800000005C11.33349609375,7.29847705,11.63197609375,7,12.000165893750001,7ZM8.00016307375,9.3333099L20.00019609375,9.3333099C20.36839609375,9.3333099,20.66679609375,9.6317899,20.66679609375,9.99998C20.66679609375,10.3681698,20.36839609375,10.6666498,20.00019609375,10.6666498L19.33349609375,10.6666498L19.33349609375,18.333399999999997Q19.33349609375,19.4379,18.55249609375,20.219Q17.77139609375,21,16.66683579375,21L11.33350609375,21Q10.22893619375,21,9.447885993749999,20.219Q8.66683599375,19.4379,8.66683599375,18.333399999999997L8.66683599375,10.6666498L8.00016307375,10.6666498C7.63197314375,10.6666498,7.33349609375,10.3681698,7.33349609375,9.99998C7.33349609375,9.6317899,7.63197314375,9.3333099,8.00016307375,9.3333099ZM10.00017599375,10.6666498L18.00019609375,10.6666498L18.00019609375,18.333399999999997Q18.00019609375,18.8856,17.60969609375,19.2762Q17.219125793750003,19.6667,16.66683579375,19.6667L11.33350609375,19.6667Q10.78122619375,19.6667,10.39069609375,19.2762Q10.00017599375,18.8856,10.00017599375,18.333399999999997L10.00017599375,10.6666498ZM13.33416609375,17.3333L13.33416609375,12.666669800000001C13.33416609375,12.29848,13.03568599375,12,12.66749619375,12C12.299305893749999,12,12.00083589375,12.29848,12.00083589375,12.666669800000001L12.00083589375,17.3333C12.00083589375,17.7015,12.299305893749999,18,12.66749619375,18C13.03568599375,18,13.33416609375,17.7015,13.33416609375,17.3333ZM16.00083639375,17.3333L16.00083639375,12.666669800000001C16.00083639375,12.29848,15.70236589375,12,15.33417609375,12C14.96598629375,12,14.66750619375,12.29848,14.66750619375,12.666669800000001L14.66750619375,17.3333C14.66750619375,17.7015,14.96598629375,18,15.33417609375,18C15.70236589375,18,16.00083639375,17.7015,16.00083639375,17.3333Z"
|
||||||
|
fill-rule="evenodd" fill="currentColor" fill-opacity="1" style="mix-blend-mode:passthrough" />
|
||||||
|
</g>
|
||||||
|
<g style="opacity:0;">
|
||||||
|
<rect x="6" y="6" width="16" height="16" rx="0" fill="#000000" fill-opacity="1"
|
||||||
|
style="mix-blend-mode:passthrough" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
1
components/html-editor/assets/images/dropdown.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><g transform="matrix(-1,0,0,1,32,0)"><g><rect x="16" y="0" width="16" height="16" rx="0" fill="#D8D8D8" fill-opacity="0"/></g><g><path d="M21.21485549,6.20300207C21.47528636,5.956938956,21.88281816,5.934569582,22.1699947,6.13589394L22.2522695,6.20300207L24.4994397,8.3264129L26.7477303,6.20300207C27.0081615,5.956938956,27.4156933,5.934569582,27.7028699,6.13589394L27.7851443,6.20300207C28.0455756,6.44906518,28.0692511,6.83411378,27.8561711,7.1054469000000005L27.7851443,7.1831827L25.0187068,9.796998C24.7582762,10.0430613,24.3507442,10.0654306,24.0635679,9.8641059L23.981293,9.796998L21.21485549,7.1831827C20.928381503,6.91251332,20.928381503,6.4736715,21.21485549,6.20300207Z" fill="#8D8D99" fill-opacity="1"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 879 B |
1
components/html-editor/assets/images/duplicate.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_120572"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_120572)"><g><path d="M18.00017309375,10.000003823437499Q18.00017309375,8.8954539234375,17.21911429375,8.1144230334375Q16.43806359375,7.3333740234375,15.33349419375,7.3333740234375L10.00016399375,7.3333740234375Q8.89559409375,7.3333740234375,8.11454510375,8.1144230334375Q7.33349621295929,8.8954739234375,7.33349609375,10.000043823437501L7.33349609375,15.3333740234375Q7.33349609375,16.4379444234375,8.11454510375,17.2189941234375Q8.89559409375,18.0000740234375,10.00016399375,18.0000740234375Q10.00025419375,19.1045740234375,10.78129389375,19.8855740234375Q11.56234409375,20.6666740234375,12.66691399375,20.6666740234375L18.00027409375,20.6666740234375Q19.104774093750002,20.6666740234375,19.885874093749997,19.8855740234375Q20.66687409375,19.1045740234375,20.66687409375,17.999974023437503L20.66687409375,12.6666641234375Q20.66687409375,11.5620942234375,19.885874093749997,10.7810440234375Q19.104774093750002,10.000003823437499,18.00017309375,10.000003823437499ZM16.666833893750002,10.000003823437499Q16.66681389375,9.4477441234375,16.27630429375,9.0572340234375Q15.88578419375,8.6667039234375,15.33349419375,8.6667039234375L10.00016399375,8.6667039234375Q9.44787409375,8.6667039234375,9.05735399375,9.0572340234375Q8.66683409375,9.4477539234375,8.66683409375,10.000043823437501L8.66683409375,15.3333740234375Q8.66683409375,15.8856640234375,9.05735399375,16.276184123437503Q9.44787409375,16.6667042234375,10.00016399375,16.6667042234375L10.00024409375,12.6666641234375Q10.00024409375,11.5620942234375,10.78129389375,10.7810440234375Q11.56234409375,10.000003823437499,12.66691399375,10.000003823437499L16.666833893750002,10.000003823437499ZM11.33357379375,17.999974023437503Q11.33357379375,18.552274023437498,11.72410389375,18.9427740234375Q12.114623993750001,19.3333740234375,12.66691399375,19.3333740234375L18.00027409375,19.3333740234375Q18.55257409375,19.3333740234375,18.94307409375,18.9427740234375Q19.333574093750002,18.552274023437498,19.333574093750002,17.999974023437503L19.333574093750002,12.6666641234375Q19.333574093750002,12.1143842234375,18.94307409375,11.7238540234375Q18.55257409375,11.3333339234375,18.00027409375,11.3333339234375L12.66691399375,11.3333339234375Q12.114623993750001,11.3333339234375,11.72410389375,11.7238540234375Q11.33357379375,12.1143842234375,11.33357379375,12.6666641234375L11.33357379375,17.999974023437503Z" fill-rule="evenodd" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g><g style="opacity:0;"><rect x="6" y="6" width="16" height="16" rx="0" fill="currentColor" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
1
components/html-editor/assets/images/image-replace.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_124419"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_124419)"><g><path d="M7.33203125,14.8999405234375L7.33203125,18.2000420234375Q7.33203125,19.2217670234375,8.0545013,19.9442380234375Q8.77697205,20.6667080234375,9.798697950000001,20.6667080234375L18.19869925,20.6667080234375Q19.22042325,20.6667080234375,19.94289525,19.9442380234375Q20.66536525,19.2217660234375,20.66536525,18.2000420234375L20.66536525,9.800040723437501Q20.66536525,8.7783148234375,19.94289525,8.0558440734375Q19.22042425,7.3333740234375,18.19869925,7.3333740234375L9.798697950000001,7.3333740234375Q8.77697135,7.3333740234375,8.0545013,8.0558440734375Q7.33203125,8.7783139234375,7.33203125,9.800040723437501L7.33203125,14.8999405234375ZM8.66536475,15.222196623437501L8.66536475,18.2000420234375Q8.66536475,18.669484023437498,8.99731045,19.001429023437503Q9.32925555,19.3333740234375,9.798697950000001,19.3333740234375L10.15507075,19.3333740234375L13.05100445,16.8803472234375L10.88467645,14.150774923437499Q10.74771115,13.9781980234375,10.528880149999999,13.9526033234375Q10.31004905,13.9270081234375,10.13695525,14.0633192234375L8.66536475,15.222196623437501ZM14.06891725,16.018115023437502L11.929062349999999,13.3218975234375Q11.44968315,12.7178797234375,10.683773949999999,12.6282973234375Q9.91786505,12.5387149234375,9.31203745,13.0158043234375L8.66536475,13.5250587234375L8.66536475,9.800040723437501Q8.66536475,9.3305986234375,8.99731045,8.9986532234375Q9.32925605,8.6667075234375,9.798697950000001,8.6667075234375L18.19869925,8.6667075234375Q18.668141249999998,8.6667075234375,19.000086250000003,8.9986532234375Q19.33203125,9.3305986234375,19.33203125,9.800040723437501L19.33203125,17.603058023437498L17.32508465,15.5387697234375Q16.81204125,15.011067423437499,16.076910050000002,14.9754972234375Q15.34177975,14.9399261234375,14.780183749999999,15.4156303234375L14.06891725,16.018115023437502ZM14.24316125,17.6179050234375C14.37814335,17.564095023437503,14.49135205,17.4673410234375,14.565907450000001,17.3445210234375L15.64197735,16.433025323437498Q15.80243305,16.2971096234375,16.01247025,16.3072719234375Q16.222507450000002,16.3174352234375,16.36909105,16.4682073234375L18.910870250000002,19.0826080234375Q18.60672125,19.3333740234375,18.19869925,19.3333740234375L12.21795465,19.3333740234375L14.24316125,17.6179050234375ZM16.09635445,14.0666094234375C17.29297165,14.0666094234375,18.26302125,13.0965605234375,18.26302125,11.899943323437501C18.26302125,10.7033262234375,17.29297165,9.7332763234375,16.09635445,9.7332763234375C14.899737349999999,9.7332763234375,13.92968745,10.7033262234375,13.92968745,11.899943323437501C13.92968745,13.0965605234375,14.899737349999999,14.0666094234375,16.09635445,14.0666094234375ZM16.09635445,11.0666098234375C16.55659105,11.0666098234375,16.92968745,11.4397058234375,16.92968745,11.899943323437501C16.92968745,12.3601804234375,16.55659105,12.7332763234375,16.09635445,12.7332763234375C15.63611695,12.7332763234375,15.26302055,12.3601804234375,15.26302055,11.899943323437501C15.26302055,11.4397058234375,15.63611695,11.0666098234375,16.09635445,11.0666098234375Z" fill-rule="evenodd" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g><g style="opacity:0;"></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
1
components/html-editor/assets/images/italic.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139077"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139077)"><g><path d="M12,1.34033203125L7.5,1.34033203125C7.2239532,1.34046676636,7.0002441,1.56428482125,7.0002441,1.84033188125C7.0002441,2.11637908125,7.2239532,2.34019726125,7.5,2.34033203125L9.0050001,2.34033203125L5.4059997,13.34033203125L4,13.34033203125C3.72385772,13.34033203125,3.5,13.56418903125,3.5,13.84033203125C3.5,14.11647403125,3.72385772,14.34033203125,4,14.34033203125L8.5,14.34033203125C8.7761426,14.34033203125,9,14.11647403125,9,13.84033203125C9,13.56418903125,8.7761426,13.34033203125,8.5,13.34033203125L7.0354998,13.34033203125L10.6374998,2.34033253125L12,2.34033253125C12.2760468,2.34019756125,12.4997559,2.11637938125,12.4997559,1.84033233125C12.4997559,1.56428528125,12.2760468,1.34046722412,12,1.34033203125Z" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
1
components/html-editor/assets/images/underline.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139080"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139080)"><g><path d="M8,12.67640234375C10.6923876,12.67640234375,12.875,10.49379154375,12.875,7.80140254375L12.875,1.39990234375L11.125,1.39990234375L11.125,7.80140254375C11.125,9.52729224375,9.7258897,10.92640204375,8,10.92640204375C6.2741098,10.92640204375,4.8749994999999995,9.52729224375,4.875,7.80140254375L4.875,1.39990234375L3.125,1.39990234375L3.125,7.80140254375C3.1249995999999998,10.49379154375,5.307611,12.67640234375,8,12.67640234375ZM13.5,13.84040234375L2.5,13.84040234375C2.22385757,13.84040234375,2,14.06425934375,2,14.34040234375C2,14.61654434375,2.22385757,14.84040234375,2.5,14.84040234375L13.5,14.84040234375C13.776142,14.84040234375,14,14.61654434375,14,14.34040234375C14,14.06425934375,13.776142,13.84040234375,13.5,13.84040234375Z" fill="#17171D" fill-opacity="1"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
126
components/html-editor/components/html-render/task-html.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
interface HtmlProps {
|
||||||
|
className?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注入脚本,拦截 iframe 中的 _blank 链接和 window.open
|
||||||
|
function injectInterceptScript(iframeWindow: Window) {
|
||||||
|
try {
|
||||||
|
// 检查是否可以访问 iframe 的 document(避免跨域错误)
|
||||||
|
const iframeDocument = iframeWindow.document;
|
||||||
|
if (!iframeDocument) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存父窗口的 window.open 引用
|
||||||
|
const parentWindowOpen = window.open.bind(window);
|
||||||
|
|
||||||
|
// 重写 iframe 内的 window.open
|
||||||
|
iframeWindow.open = function (url, target, features) {
|
||||||
|
return parentWindowOpen(url, target, features);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 拦截所有 <a> 标签的点击事件
|
||||||
|
iframeDocument.addEventListener(
|
||||||
|
'click',
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const anchor = target.closest('a');
|
||||||
|
|
||||||
|
if (anchor && anchor.target === '_blank') {
|
||||||
|
e.preventDefault();
|
||||||
|
const href = anchor.href;
|
||||||
|
if (href) {
|
||||||
|
parentWindowOpen(href, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 监听动态添加的元素
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
// 重新绑定 window.open (防止被覆盖)
|
||||||
|
if (iframeWindow.open !== parentWindowOpen) {
|
||||||
|
iframeWindow.open = function (url, target, features) {
|
||||||
|
return parentWindowOpen(url, target, features);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(iframeDocument.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// 跨域或沙箱限制,无法注入脚本,静默失败
|
||||||
|
// 这种情况下需要依赖 sandbox 属性中的 allow-popups-to-escape-sandbox
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Html = React.forwardRef(
|
||||||
|
(
|
||||||
|
{ className, content }: HtmlProps,
|
||||||
|
_ref: React.ForwardedRef<HTMLIFrameElement>,
|
||||||
|
) => {
|
||||||
|
// 当 iframe 加载完成后注入拦截脚本
|
||||||
|
const handleIframeLoad = React.useCallback(
|
||||||
|
(e: React.SyntheticEvent<HTMLIFrameElement, Event>) => {
|
||||||
|
const iframe = e.currentTarget;
|
||||||
|
if (iframe?.contentWindow) {
|
||||||
|
injectInterceptScript(iframe.contentWindow);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// if (content) {
|
||||||
|
// // 在 content 中添加 base 标签,确保所有链接在 iframe 内部打开
|
||||||
|
// const contentWithBase = content.includes("<head>")
|
||||||
|
// ? content.replace("<head>", '<head><base href="about:srcdoc">')
|
||||||
|
// : content;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <iframe
|
||||||
|
// ref={_ref}
|
||||||
|
// className={cn("w-full border-none", className)}
|
||||||
|
// srcDoc={contentWithBase}
|
||||||
|
// sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
// onLoad={handleIframeLoad}
|
||||||
|
// />
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
ref={_ref}
|
||||||
|
className={cn('w-full border-none', className)}
|
||||||
|
srcDoc={`${content}`}
|
||||||
|
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
onLoad={handleIframeLoad}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// export function copyLink(path: string, taskId: string) {
|
||||||
|
// const encodedPath = path
|
||||||
|
// .split('/')
|
||||||
|
// .map(segment => encodeURIComponent(segment))
|
||||||
|
// .join('/')
|
||||||
|
// const url = `${window.location.origin}/web/${taskId}${encodedPath}`
|
||||||
|
// navigator.clipboard.writeText(url)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function openHtml(path: string, taskId: string) {
|
||||||
|
// const url = `/web/${taskId}${path}`
|
||||||
|
// window.open(url, '_blank')
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export async function zipHtml(path: string, taskId: string) {
|
||||||
|
// const api = `${process.env.API_BASE_URL_SUB}/novakit/api/v1/webpage/zip?file=${encodeURIComponent(path)}&taskId=${taskId}`
|
||||||
|
// download({ url: api, name: `${taskId}.zip` })
|
||||||
|
// }
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import { rgbToHex } from '../../toolbar-web/hooks/useElementStyles'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Bold,
|
||||||
|
Underline,
|
||||||
|
Italic,
|
||||||
|
AlignLeft,
|
||||||
|
AlignCenter,
|
||||||
|
AlignRight,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import type { HTMLEditor } from '../../../lib'
|
||||||
|
import { loca } from './loco'
|
||||||
|
import { ColorPicker } from '@/components/base/color-picker'
|
||||||
|
import { type SelectionFormatting } from '../hooks/useSelectionFormatting'
|
||||||
|
|
||||||
|
interface TextToolbarProps {
|
||||||
|
editor: HTMLEditor
|
||||||
|
formatState: SelectionFormatting
|
||||||
|
}
|
||||||
|
|
||||||
|
const biMap = new Map<number | string, number | string>([
|
||||||
|
['h1', 60], ['h2', 40], ['h3', 32],
|
||||||
|
['h4', 24], ['h5', 18], ['h6', 14],
|
||||||
|
[60, 'h1'], [40, 'h2'], [32, 'h3'],
|
||||||
|
[24, 'h4'], [18, 'h5'], [14, 'h6'],
|
||||||
|
['p', 14], [14, 'p']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TextLevelChangger: React.FC<{
|
||||||
|
editor: HTMLEditor
|
||||||
|
formatState: SelectionFormatting
|
||||||
|
}> = ({ editor, formatState }) => {
|
||||||
|
const [selectedKey, setSelectedKey] = useState<
|
||||||
|
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'
|
||||||
|
>(biMap.get(parseInt(formatState.fontSize || '0')) || 'p' as any)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const key = biMap.get(parseInt(formatState.fontSize || '0'))
|
||||||
|
setSelectedKey(key || 'p' as any);
|
||||||
|
}, [formatState])
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: 'h1', label: 'H1' },
|
||||||
|
{ key: 'h2', label: 'H2' },
|
||||||
|
{ key: 'h3', label: 'H3' },
|
||||||
|
{ key: 'h4', label: 'H4' },
|
||||||
|
{ key: 'h5', label: 'H5' },
|
||||||
|
{ key: 'h6', label: 'H6' },
|
||||||
|
{ key: 'p', label: loca.normal.content },
|
||||||
|
]
|
||||||
|
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
h1: 'H1',
|
||||||
|
h2: 'H2',
|
||||||
|
h3: 'H3',
|
||||||
|
h4: 'H4',
|
||||||
|
h5: 'H5',
|
||||||
|
h6: 'H6',
|
||||||
|
p: loca.normal.content,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<span
|
||||||
|
className='flex items-center justify-between gap-[4px] pl-[8px] pr-[4px] cursor-pointer'
|
||||||
|
>
|
||||||
|
<span>{labelMap[selectedKey]}</span>
|
||||||
|
<span>
|
||||||
|
<ChevronDown className='w-3 h-3' />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className='html-editor-heading-dropdown min-w-[204px]'
|
||||||
|
align='start'
|
||||||
|
>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={selectedKey}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedKey(value as any)
|
||||||
|
if (editor) {
|
||||||
|
editor.globalEditable?.applySelectionFontSize(biMap.get(value) + 'px')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<DropdownMenuRadioItem key={item.key} value={item.key}>
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorColorPicker: React.FC<{
|
||||||
|
editor: HTMLEditor
|
||||||
|
formatState: SelectionFormatting
|
||||||
|
}> = ({ editor, formatState }) => {
|
||||||
|
const [color, setColor] = useState<string>(formatState.color || '#000000')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formatState.color) {
|
||||||
|
setColor(formatState.color)
|
||||||
|
}
|
||||||
|
}, [formatState.color])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='editor-toolbar-color-picker w-[16px] h-[16px]'>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
data-html-editor-ui="true"
|
||||||
|
className='flex flex-row items-center justify-center w-[16px] h-[16px] rounded-[16px] bg-[#eee] cursor-pointer'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='w-[14px] h-[14px] rounded-[14px] overflow-hidden'
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='w-full h-full'
|
||||||
|
style={{
|
||||||
|
backgroundSize: '8px 8px',
|
||||||
|
backgroundImage:
|
||||||
|
'conic-gradient(rgba(98, 105, 153, 0.1) 25%, transparent 25% 50%, rgba(98, 105, 153, 0.1) 50% 75%, transparent 75% 100%)',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className='w-auto p-3'
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ColorPicker
|
||||||
|
color={rgbToHex(color)}
|
||||||
|
onChange={(newColor) => {
|
||||||
|
setColor(newColor)
|
||||||
|
editor.globalEditable?.applySelectionColor(newColor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextToolbar: React.FC<TextToolbarProps> = ({ editor, formatState }) => {
|
||||||
|
const isBold = formatState.isBold
|
||||||
|
const isItalic = formatState.isItalic
|
||||||
|
const isUnderline = formatState.isUnderline
|
||||||
|
const textAlign = formatState.textAlign
|
||||||
|
const toolbarRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const styleManager = editor.styleManager
|
||||||
|
if (!editor || !styleManager) return null
|
||||||
|
|
||||||
|
const handleToggleBold = () => {
|
||||||
|
editor.globalEditable?.applySelectionBold()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleItalic = () => {
|
||||||
|
editor.globalEditable?.applySelectionItalic()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleUnderline = () => {
|
||||||
|
editor.globalEditable?.applySelectionUnderline()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTextAlign = (align: 'left' | 'center' | 'right') => {
|
||||||
|
editor.globalEditable?.applySelectionAlign(align)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const actions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'tools',
|
||||||
|
label: '',
|
||||||
|
icon: TextLevelChangger,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'color-picker',
|
||||||
|
label: '',
|
||||||
|
icon: EditorColorPicker,
|
||||||
|
tooltip: loca.changeTextColor.content,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bold',
|
||||||
|
label: '',
|
||||||
|
icon: Bold,
|
||||||
|
tooltip: loca.textBlod.content,
|
||||||
|
run: handleToggleBold,
|
||||||
|
isActive: isBold,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'italic',
|
||||||
|
label: '',
|
||||||
|
icon: Italic,
|
||||||
|
tooltip: loca.textItalic.content,
|
||||||
|
run: handleToggleItalic,
|
||||||
|
isActive: isItalic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'underline',
|
||||||
|
label: '',
|
||||||
|
icon: Underline,
|
||||||
|
tooltip: loca.textUnderline.content,
|
||||||
|
run: handleToggleUnderline,
|
||||||
|
isActive: isUnderline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'text-align-left',
|
||||||
|
label: '',
|
||||||
|
icon: AlignLeft,
|
||||||
|
tooltip: loca.textAlignLeft.content,
|
||||||
|
run: () => {
|
||||||
|
handleTextAlign('left')
|
||||||
|
},
|
||||||
|
isActive: textAlign === 'left' || textAlign === 'start',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'text-align-center',
|
||||||
|
label: '',
|
||||||
|
icon: AlignCenter,
|
||||||
|
tooltip: loca.textAlignCenter.content,
|
||||||
|
run: () => {
|
||||||
|
handleTextAlign('center')
|
||||||
|
},
|
||||||
|
isActive: textAlign === 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'text-align-right',
|
||||||
|
label: '',
|
||||||
|
icon: AlignRight,
|
||||||
|
tooltip: loca.textAlignRight.content,
|
||||||
|
run: () => {
|
||||||
|
handleTextAlign('right')
|
||||||
|
},
|
||||||
|
isActive: textAlign === 'right' || textAlign === 'end',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
handleToggleBold,
|
||||||
|
handleToggleItalic,
|
||||||
|
handleToggleUnderline,
|
||||||
|
handleTextAlign,
|
||||||
|
textAlign,
|
||||||
|
isBold,
|
||||||
|
isItalic,
|
||||||
|
isUnderline,
|
||||||
|
editor,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div
|
||||||
|
ref={toolbarRef}
|
||||||
|
className='html-editor-toolbar'
|
||||||
|
style={{
|
||||||
|
height: '36px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 100,
|
||||||
|
zIndex: 10000,
|
||||||
|
background: 'var(--editor-surface)',
|
||||||
|
backdropFilter: 'blur(18px)',
|
||||||
|
border: '0.5px solid var(--editor-border-strong)',
|
||||||
|
boxShadow: '0 8px 24px var(--editor-shadow)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actions.map(a => {
|
||||||
|
if (a.key === 'split-line') {
|
||||||
|
return (
|
||||||
|
<div key={a.key} className='h-[50%] w-[1px] bg-border'></div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
min-w-[28px] min-h-[28px] flex flex-row items-center justify-center text-foreground hover:bg-accent cursor-pointer transition-all rounded-[28px] ${a.isActive ? 'bg-accent text-accent-foreground' : ''} ${a.label ? 'px-[8px]' : ''}
|
||||||
|
`}
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
a.run?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center ${a.label ? 'gap-[2px]' : ''}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{a.icon && (
|
||||||
|
<a.icon
|
||||||
|
editor={editor}
|
||||||
|
formatState={formatState}
|
||||||
|
className={`w-4 h-4 ${a.key === 'delete' ? 'text-destructive' : 'text-foreground'}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>{a.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (a.tooltip) {
|
||||||
|
return (
|
||||||
|
<Tooltip key={a.key}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{content}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side='bottom'>
|
||||||
|
<p>{a.tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <React.Fragment key={a.key}>{content}</React.Fragment>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const localize = (key: string, defaultVal: string) => {
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loca = {
|
||||||
|
normal: {
|
||||||
|
content: localize('html-editor-tool-bar-docmode.doc', '正文'),
|
||||||
|
tip: '正文'
|
||||||
|
},
|
||||||
|
changeTextColor: {
|
||||||
|
content: localize('editor-color-picker', '更改颜色'),
|
||||||
|
tip: '更改文本颜色'
|
||||||
|
},
|
||||||
|
changeBgColor: {
|
||||||
|
content: localize('editor-bg-color-picker', '更改背景色'),
|
||||||
|
tip: '更改背景颜色'
|
||||||
|
},
|
||||||
|
imageUpload: {
|
||||||
|
content: localize('editor-image_upload', '替换图片'),
|
||||||
|
tip: '上传图片'
|
||||||
|
},
|
||||||
|
textBlod: {
|
||||||
|
content: localize('text_blod', '文字加粗'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textItalic: {
|
||||||
|
content: localize('text_italic', '文字斜体'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textUnderline: {
|
||||||
|
content: localize('text_underline', '文字下划线'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textAlignLeft: {
|
||||||
|
content: localize('text_align-left', '左对齐'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textAlignCenter: {
|
||||||
|
content: localize('text_align-center', '居中对齐'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textAlignRight: {
|
||||||
|
content: localize('text_align-right', '右对齐'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
content: localize('element_duplicate', '复制'),
|
||||||
|
tip: '复制'
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
content: localize('element_delete', '删除'),
|
||||||
|
tip: '删除'
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { HTMLEditor } from '../../../lib';
|
||||||
|
|
||||||
|
export interface SelectionFormatting {
|
||||||
|
isBold: boolean;
|
||||||
|
isItalic: boolean;
|
||||||
|
isUnderline: boolean;
|
||||||
|
isStrikeThrough: boolean;
|
||||||
|
color: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
fontSize: string;
|
||||||
|
fontFamily: string;
|
||||||
|
textAlign: 'left' | 'center' | 'right' | 'start' | 'end' | 'justify' | string;
|
||||||
|
collapsed: boolean;
|
||||||
|
currentTextLevel: 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectionFormatting(editor: HTMLEditor | null): SelectionFormatting {
|
||||||
|
const [state, setState] = useState<SelectionFormatting>({
|
||||||
|
isBold: false,
|
||||||
|
isItalic: false,
|
||||||
|
isUnderline: false,
|
||||||
|
isStrikeThrough: false,
|
||||||
|
color: '',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
fontSize: '',
|
||||||
|
fontFamily: '',
|
||||||
|
textAlign: 'left',
|
||||||
|
collapsed: true,
|
||||||
|
currentTextLevel: 'p'
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const doc = editor.getDoc().document;
|
||||||
|
const view = editor.getDoc().view;
|
||||||
|
if (!doc || !view) return;
|
||||||
|
|
||||||
|
const readFormatting = () => {
|
||||||
|
const sel = doc.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
||||||
|
const currentTextLevel = editor.globalEditable?.queryHeading() || 'p';
|
||||||
|
setState({
|
||||||
|
isBold: false,
|
||||||
|
isItalic: false,
|
||||||
|
isUnderline: false,
|
||||||
|
isStrikeThrough: false,
|
||||||
|
color: '',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
fontSize: '',
|
||||||
|
fontFamily: '',
|
||||||
|
textAlign: 'left',
|
||||||
|
collapsed: true,
|
||||||
|
currentTextLevel
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
|
||||||
|
// Get all text nodes in the selection
|
||||||
|
const textNodes: Node[] = [];
|
||||||
|
const walker = doc.createTreeWalker(
|
||||||
|
range.commonAncestorContainer.nodeType === Node.TEXT_NODE
|
||||||
|
? range.commonAncestorContainer.parentNode as Node
|
||||||
|
: range.commonAncestorContainer,
|
||||||
|
NodeFilter.SHOW_TEXT,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
let node: Node | null;
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
if (range.intersectsNode(node)) {
|
||||||
|
textNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no text nodes found, fall back to container
|
||||||
|
if (textNodes.length === 0) {
|
||||||
|
const container = range.commonAncestorContainer.nodeType === 1
|
||||||
|
? (range.commonAncestorContainer as HTMLElement)
|
||||||
|
: (range.commonAncestorContainer.parentNode as HTMLElement);
|
||||||
|
textNodes.push(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ALL text nodes have each style
|
||||||
|
let isBold = true;
|
||||||
|
let isItalic = true;
|
||||||
|
let isUnderline = true;
|
||||||
|
let isStrikeThrough = true;
|
||||||
|
let color = '';
|
||||||
|
let backgroundColor = '';
|
||||||
|
let fontSize = '';
|
||||||
|
let fontFamily = '';
|
||||||
|
let textAlign: string = 'left';
|
||||||
|
|
||||||
|
const toHex = (input: string): string => {
|
||||||
|
const s = input.trim().toLowerCase();
|
||||||
|
if (!s || s === 'transparent') return '';
|
||||||
|
const m = s.match(/^rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?\)$/);
|
||||||
|
if (m) {
|
||||||
|
const r = Math.max(0, Math.min(255, parseInt(m[1], 10)));
|
||||||
|
const g = Math.max(0, Math.min(255, parseInt(m[2], 10)));
|
||||||
|
const b = Math.max(0, Math.min(255, parseInt(m[3], 10)));
|
||||||
|
const a = m[4] !== undefined ? parseFloat(m[4]) : 1;
|
||||||
|
if (a === 0) return '';
|
||||||
|
const h = (n: number) => n.toString(16).padStart(2, '0');
|
||||||
|
return `#${h(r)}${h(g)}${h(b)}`;
|
||||||
|
}
|
||||||
|
const hx = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
|
||||||
|
if (hx) {
|
||||||
|
if (hx[1].length === 3) {
|
||||||
|
const r = hx[1][0];
|
||||||
|
const g = hx[1][1];
|
||||||
|
const b = hx[1][2];
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
textNodes.forEach((node, index) => {
|
||||||
|
const el = node.nodeType === Node.TEXT_NODE
|
||||||
|
? (node.parentNode as HTMLElement)
|
||||||
|
: (node as HTMLElement);
|
||||||
|
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const cs = view.getComputedStyle(el);
|
||||||
|
|
||||||
|
// Check bold
|
||||||
|
const fw = cs.fontWeight;
|
||||||
|
const nodeBold = fw === 'bold' || parseInt(fw as any, 10) >= 600;
|
||||||
|
if (!nodeBold) isBold = false;
|
||||||
|
|
||||||
|
// Check italic
|
||||||
|
const nodeItalic = cs.fontStyle === 'italic';
|
||||||
|
if (!nodeItalic) isItalic = false;
|
||||||
|
|
||||||
|
// Check underline and strikethrough
|
||||||
|
// Note: text-decoration is NOT inherited, so we must check for <u>/<s> tags in ancestor chain
|
||||||
|
const deco = cs.textDecorationLine || cs.textDecoration;
|
||||||
|
let nodeUnderline = typeof deco === 'string' && deco.indexOf('underline') >= 0;
|
||||||
|
let nodeStrikeThrough = typeof deco === 'string' && deco.indexOf('line-through') >= 0;
|
||||||
|
|
||||||
|
// Also check for <u> and <s> tags in ancestors (since text-decoration doesn't inherit)
|
||||||
|
if (!nodeUnderline || !nodeStrikeThrough) {
|
||||||
|
let ancestor: HTMLElement | null = el;
|
||||||
|
while (ancestor && ancestor !== doc.body) {
|
||||||
|
const tagName = ancestor.tagName.toUpperCase();
|
||||||
|
if (!nodeUnderline && tagName === 'U') {
|
||||||
|
nodeUnderline = true;
|
||||||
|
}
|
||||||
|
if (!nodeStrikeThrough && (tagName === 'S' || tagName === 'STRIKE' || tagName === 'DEL')) {
|
||||||
|
nodeStrikeThrough = true;
|
||||||
|
}
|
||||||
|
if (nodeUnderline && nodeStrikeThrough) break;
|
||||||
|
ancestor = ancestor.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodeUnderline) isUnderline = false;
|
||||||
|
if (!nodeStrikeThrough) isStrikeThrough = false;
|
||||||
|
|
||||||
|
// For color, fontSize, fontFamily - use first node's value
|
||||||
|
if (index === 0) {
|
||||||
|
color = toHex(cs.color);
|
||||||
|
fontSize = cs.fontSize;
|
||||||
|
fontFamily = cs.fontFamily;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get block ancestor for textAlign and backgroundColor
|
||||||
|
const container = range.commonAncestorContainer.nodeType === 1
|
||||||
|
? (range.commonAncestorContainer as HTMLElement)
|
||||||
|
: (range.commonAncestorContainer.parentNode as HTMLElement);
|
||||||
|
const el = container || doc.body;
|
||||||
|
|
||||||
|
const getBlockAncestor = (el: HTMLElement | null) => {
|
||||||
|
let cur: HTMLElement | null = el;
|
||||||
|
while (cur && cur !== doc.body) {
|
||||||
|
const display = view.getComputedStyle(cur).display;
|
||||||
|
if (display !== 'inline') return cur;
|
||||||
|
cur = cur.parentElement;
|
||||||
|
}
|
||||||
|
return doc.body as HTMLElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const block = getBlockAncestor(el);
|
||||||
|
const bs = view.getComputedStyle(block);
|
||||||
|
const cs = view.getComputedStyle(el);
|
||||||
|
const currentTextLevel = editor.globalEditable?.queryHeading() || 'p';
|
||||||
|
backgroundColor = toHex(bs.backgroundColor || cs.backgroundColor);
|
||||||
|
textAlign = bs.textAlign as any;
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isBold,
|
||||||
|
isItalic,
|
||||||
|
isUnderline,
|
||||||
|
isStrikeThrough,
|
||||||
|
color,
|
||||||
|
backgroundColor,
|
||||||
|
fontSize,
|
||||||
|
fontFamily,
|
||||||
|
textAlign,
|
||||||
|
collapsed: false,
|
||||||
|
currentTextLevel
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler = () => readFormatting();
|
||||||
|
doc.addEventListener('selectionchange', handler);
|
||||||
|
doc.addEventListener('keyup', handler);
|
||||||
|
doc.addEventListener('mouseup', handler);
|
||||||
|
doc.body.addEventListener('htmleditor:historyChange', handler as EventListener);
|
||||||
|
doc.body.addEventListener('htmleditor:contentChange', handler as EventListener);
|
||||||
|
readFormatting();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
doc.removeEventListener('selectionchange', handler);
|
||||||
|
doc.removeEventListener('keyup', handler);
|
||||||
|
doc.removeEventListener('mouseup', handler);
|
||||||
|
doc.body.removeEventListener('htmleditor:historyChange', handler as EventListener);
|
||||||
|
doc.body.removeEventListener('htmleditor:contentChange', handler as EventListener);
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSelectionFormatting;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export const useStopPopEvent = (visible?: boolean) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.ant-color-picker input').forEach(input => {
|
||||||
|
input.addEventListener('mousedown', handler, true);
|
||||||
|
input.addEventListener('click', handler, true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
document.querySelectorAll('.ant-color-picker input').forEach(input => {
|
||||||
|
input.removeEventListener('mousedown', handler, true);
|
||||||
|
input.removeEventListener('click', handler, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
}
|
||||||
22
components/html-editor/components/toolbar-doc/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
import { usePPTEditContext } from '../../context'
|
||||||
|
import { Tooltip } from './toolbar'
|
||||||
|
|
||||||
|
export const PPTEditToolBar: React.FC<{
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
}> = props => {
|
||||||
|
const { containerRef } = props
|
||||||
|
const context = usePPTEditContext()
|
||||||
|
if (!context || !context.state) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const { selectedElement, editor, tipPosition } = context.state
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
editor={editor}
|
||||||
|
element={selectedElement}
|
||||||
|
position={tipPosition}
|
||||||
|
containerRef={containerRef}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
components/html-editor/components/toolbar-doc/toolbar.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { HTMLEditor } from '../../lib'
|
||||||
|
import { TextToolbar } from './components/TextToolbar'
|
||||||
|
import useSelectionFormatting from './hooks/useSelectionFormatting'
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
bottom: number
|
||||||
|
right: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
editor: HTMLEditor | null | undefined
|
||||||
|
element: HTMLElement | null | undefined
|
||||||
|
position: Position | null
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip: React.FC<TooltipProps> = ({
|
||||||
|
editor,
|
||||||
|
element,
|
||||||
|
position,
|
||||||
|
containerRef,
|
||||||
|
}) => {
|
||||||
|
if (!editor || !position) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const contaierRect =
|
||||||
|
containerRef.current?.getBoundingClientRect() || ({} as DOMRect)
|
||||||
|
const { left = 0, top = 0 } = contaierRect
|
||||||
|
|
||||||
|
const toolWidth = 288
|
||||||
|
const toolHeight = 36
|
||||||
|
const marginXPad = 32
|
||||||
|
|
||||||
|
// 右边界限制
|
||||||
|
let releativeLeft = position.left - toolWidth / 2
|
||||||
|
if (position.left + toolWidth + left > window.innerWidth) {
|
||||||
|
releativeLeft = window.innerWidth - toolWidth - left - marginXPad
|
||||||
|
}
|
||||||
|
// 左边界限制
|
||||||
|
if(releativeLeft < 5) {
|
||||||
|
releativeLeft = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
let releativeBottom = position.bottom
|
||||||
|
if (releativeBottom + toolHeight + 25 > window.innerHeight) {
|
||||||
|
releativeBottom = position.top - toolHeight - 15
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${releativeBottom + 10}px`,
|
||||||
|
left: `${releativeLeft}px`,
|
||||||
|
zIndex: 100000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const formatState = useSelectionFormatting(editor)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={tooltipStyle} className='html-editor-floating-toolbar'>
|
||||||
|
<TextToolbar editor={editor} formatState={formatState}/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tooltip
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121357"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121357)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="10.666748046875" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="9.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 807 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121353"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121353)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="7.333251953125" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="7.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 806 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121361"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121361)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="14" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="11.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 795 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139074"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139074)"><g><path d="M12.01479246875,8.98540254375C11.66879276875,8.45240214375,11.15829276875,8.09790274375,10.48379326875,7.92240234375L10.48379326875,7.88840194375C10.84129336875,7.71840194375,11.14179376875,7.52840184375,11.38529296875,7.31840184375C11.62929346875,7.11990214375,11.81629276875,6.90990254375,11.94679356875,6.68890234375C12.20179366875,6.22390224375,12.32379336875,5.73640224375,12.31229396875,5.2259023437500005C12.31229396875,4.227902443750001,12.00329396875,3.4254025437499998,11.38529486875,2.81840241375C10.77279476875,2.21740234375,9.85679486875,1.91140243475,8.63779446875,1.89990234375L3.36279296875,1.89990234375L3.36279296875,14.30190134375L9.054792868749999,14.30190134375C10.03029296875,14.30190134375,10.84379336875,13.97590134375,11.49629306875,13.32340134375C12.15429396875,12.69940234375,12.48879336875,11.85190204375,12.49979306875,10.78040124375C12.49979306875,10.13390164375,12.33829306875,9.53540134375,12.01479246875,8.98540254375ZM5.3797929687499995,3.57540254375L8.42529296875,3.57540254375C9.11129236875,3.5869023437500003,9.61629246875,3.7539025437499998,9.93929246875,4.07740254375C10.268292868749999,4.42340254375,10.43279216875,4.84590264375,10.43279216875,5.34490254375C10.43279216875,5.84390284375,10.268292868749999,6.25790264375,9.93929246875,6.58690264375C9.61629246875,6.94390294375,9.11129236875,7.12290284375,8.425291968749999,7.12290284375L5.3797929687499995,7.12290284375L5.3797929687499995,3.57540254375ZM10.13529296875,11.95440234375C9.81229306875,12.32290134375,9.301793068750001,12.51290134375,8.60429236875,12.52440134375L5.3797929687499995,12.52440134375L5.3797929687499995,8.79890254375L8.60379316875,8.79890254375C9.301293368749999,8.80990214375,9.81179286875,8.994401943749999,10.13479276875,9.35190154375C10.457793268749999,9.714902443749999,10.61979296875,10.15140244375,10.61979296875,10.66190144375C10.61979296875,11.16040134375,10.458292468749999,11.59140114375,10.13529296875,11.95440234375Z" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none"
|
||||||
|
version="1.1" width="28" height="28" viewBox="0 0 28 28">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="master_svg0_5344_120568">
|
||||||
|
<rect x="6" y="6" width="16" height="16" rx="0" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<g clip-path="url(#master_svg0_5344_120568)">
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d="M12.000165893750001,7L16.00016589375,7C16.36835579375,7,16.66682629375,7.29847705,16.66682629375,7.6666669800000005C16.66682629375,8.0348599,16.36835579375,8.3333299,16.00016589375,8.3333299L12.000165893750001,8.3333299C11.63197609375,8.3333299,11.33349609375,8.0348599,11.33349609375,7.6666669800000005C11.33349609375,7.29847705,11.63197609375,7,12.000165893750001,7ZM8.00016307375,9.3333099L20.00019609375,9.3333099C20.36839609375,9.3333099,20.66679609375,9.6317899,20.66679609375,9.99998C20.66679609375,10.3681698,20.36839609375,10.6666498,20.00019609375,10.6666498L19.33349609375,10.6666498L19.33349609375,18.333399999999997Q19.33349609375,19.4379,18.55249609375,20.219Q17.77139609375,21,16.66683579375,21L11.33350609375,21Q10.22893619375,21,9.447885993749999,20.219Q8.66683599375,19.4379,8.66683599375,18.333399999999997L8.66683599375,10.6666498L8.00016307375,10.6666498C7.63197314375,10.6666498,7.33349609375,10.3681698,7.33349609375,9.99998C7.33349609375,9.6317899,7.63197314375,9.3333099,8.00016307375,9.3333099ZM10.00017599375,10.6666498L18.00019609375,10.6666498L18.00019609375,18.333399999999997Q18.00019609375,18.8856,17.60969609375,19.2762Q17.219125793750003,19.6667,16.66683579375,19.6667L11.33350609375,19.6667Q10.78122619375,19.6667,10.39069609375,19.2762Q10.00017599375,18.8856,10.00017599375,18.333399999999997L10.00017599375,10.6666498ZM13.33416609375,17.3333L13.33416609375,12.666669800000001C13.33416609375,12.29848,13.03568599375,12,12.66749619375,12C12.299305893749999,12,12.00083589375,12.29848,12.00083589375,12.666669800000001L12.00083589375,17.3333C12.00083589375,17.7015,12.299305893749999,18,12.66749619375,18C13.03568599375,18,13.33416609375,17.7015,13.33416609375,17.3333ZM16.00083639375,17.3333L16.00083639375,12.666669800000001C16.00083639375,12.29848,15.70236589375,12,15.33417609375,12C14.96598629375,12,14.66750619375,12.29848,14.66750619375,12.666669800000001L14.66750619375,17.3333C14.66750619375,17.7015,14.96598629375,18,15.33417609375,18C15.70236589375,18,16.00083639375,17.7015,16.00083639375,17.3333Z"
|
||||||
|
fill-rule="evenodd" fill="currentColor" fill-opacity="1" style="mix-blend-mode:passthrough" />
|
||||||
|
</g>
|
||||||
|
<g style="opacity:0;">
|
||||||
|
<rect x="6" y="6" width="16" height="16" rx="0" fill="#000000" fill-opacity="1"
|
||||||
|
style="mix-blend-mode:passthrough" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><g transform="matrix(-1,0,0,1,32,0)"><g><rect x="16" y="0" width="16" height="16" rx="0" fill="#D8D8D8" fill-opacity="0"/></g><g><path d="M21.21485549,6.20300207C21.47528636,5.956938956,21.88281816,5.934569582,22.1699947,6.13589394L22.2522695,6.20300207L24.4994397,8.3264129L26.7477303,6.20300207C27.0081615,5.956938956,27.4156933,5.934569582,27.7028699,6.13589394L27.7851443,6.20300207C28.0455756,6.44906518,28.0692511,6.83411378,27.8561711,7.1054469000000005L27.7851443,7.1831827L25.0187068,9.796998C24.7582762,10.0430613,24.3507442,10.0654306,24.0635679,9.8641059L23.981293,9.796998L21.21485549,7.1831827C20.928381503,6.91251332,20.928381503,6.4736715,21.21485549,6.20300207Z" fill="#8D8D99" fill-opacity="1"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 879 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_120572"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_120572)"><g><path d="M18.00017309375,10.000003823437499Q18.00017309375,8.8954539234375,17.21911429375,8.1144230334375Q16.43806359375,7.3333740234375,15.33349419375,7.3333740234375L10.00016399375,7.3333740234375Q8.89559409375,7.3333740234375,8.11454510375,8.1144230334375Q7.33349621295929,8.8954739234375,7.33349609375,10.000043823437501L7.33349609375,15.3333740234375Q7.33349609375,16.4379444234375,8.11454510375,17.2189941234375Q8.89559409375,18.0000740234375,10.00016399375,18.0000740234375Q10.00025419375,19.1045740234375,10.78129389375,19.8855740234375Q11.56234409375,20.6666740234375,12.66691399375,20.6666740234375L18.00027409375,20.6666740234375Q19.104774093750002,20.6666740234375,19.885874093749997,19.8855740234375Q20.66687409375,19.1045740234375,20.66687409375,17.999974023437503L20.66687409375,12.6666641234375Q20.66687409375,11.5620942234375,19.885874093749997,10.7810440234375Q19.104774093750002,10.000003823437499,18.00017309375,10.000003823437499ZM16.666833893750002,10.000003823437499Q16.66681389375,9.4477441234375,16.27630429375,9.0572340234375Q15.88578419375,8.6667039234375,15.33349419375,8.6667039234375L10.00016399375,8.6667039234375Q9.44787409375,8.6667039234375,9.05735399375,9.0572340234375Q8.66683409375,9.4477539234375,8.66683409375,10.000043823437501L8.66683409375,15.3333740234375Q8.66683409375,15.8856640234375,9.05735399375,16.276184123437503Q9.44787409375,16.6667042234375,10.00016399375,16.6667042234375L10.00024409375,12.6666641234375Q10.00024409375,11.5620942234375,10.78129389375,10.7810440234375Q11.56234409375,10.000003823437499,12.66691399375,10.000003823437499L16.666833893750002,10.000003823437499ZM11.33357379375,17.999974023437503Q11.33357379375,18.552274023437498,11.72410389375,18.9427740234375Q12.114623993750001,19.3333740234375,12.66691399375,19.3333740234375L18.00027409375,19.3333740234375Q18.55257409375,19.3333740234375,18.94307409375,18.9427740234375Q19.333574093750002,18.552274023437498,19.333574093750002,17.999974023437503L19.333574093750002,12.6666641234375Q19.333574093750002,12.1143842234375,18.94307409375,11.7238540234375Q18.55257409375,11.3333339234375,18.00027409375,11.3333339234375L12.66691399375,11.3333339234375Q12.114623993750001,11.3333339234375,11.72410389375,11.7238540234375Q11.33357379375,12.1143842234375,11.33357379375,12.6666641234375L11.33357379375,17.999974023437503Z" fill-rule="evenodd" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g><g style="opacity:0;"><rect x="6" y="6" width="16" height="16" rx="0" fill="currentColor" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_124419"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_124419)"><g><path d="M7.33203125,14.8999405234375L7.33203125,18.2000420234375Q7.33203125,19.2217670234375,8.0545013,19.9442380234375Q8.77697205,20.6667080234375,9.798697950000001,20.6667080234375L18.19869925,20.6667080234375Q19.22042325,20.6667080234375,19.94289525,19.9442380234375Q20.66536525,19.2217660234375,20.66536525,18.2000420234375L20.66536525,9.800040723437501Q20.66536525,8.7783148234375,19.94289525,8.0558440734375Q19.22042425,7.3333740234375,18.19869925,7.3333740234375L9.798697950000001,7.3333740234375Q8.77697135,7.3333740234375,8.0545013,8.0558440734375Q7.33203125,8.7783139234375,7.33203125,9.800040723437501L7.33203125,14.8999405234375ZM8.66536475,15.222196623437501L8.66536475,18.2000420234375Q8.66536475,18.669484023437498,8.99731045,19.001429023437503Q9.32925555,19.3333740234375,9.798697950000001,19.3333740234375L10.15507075,19.3333740234375L13.05100445,16.8803472234375L10.88467645,14.150774923437499Q10.74771115,13.9781980234375,10.528880149999999,13.9526033234375Q10.31004905,13.9270081234375,10.13695525,14.0633192234375L8.66536475,15.222196623437501ZM14.06891725,16.018115023437502L11.929062349999999,13.3218975234375Q11.44968315,12.7178797234375,10.683773949999999,12.6282973234375Q9.91786505,12.5387149234375,9.31203745,13.0158043234375L8.66536475,13.5250587234375L8.66536475,9.800040723437501Q8.66536475,9.3305986234375,8.99731045,8.9986532234375Q9.32925605,8.6667075234375,9.798697950000001,8.6667075234375L18.19869925,8.6667075234375Q18.668141249999998,8.6667075234375,19.000086250000003,8.9986532234375Q19.33203125,9.3305986234375,19.33203125,9.800040723437501L19.33203125,17.603058023437498L17.32508465,15.5387697234375Q16.81204125,15.011067423437499,16.076910050000002,14.9754972234375Q15.34177975,14.9399261234375,14.780183749999999,15.4156303234375L14.06891725,16.018115023437502ZM14.24316125,17.6179050234375C14.37814335,17.564095023437503,14.49135205,17.4673410234375,14.565907450000001,17.3445210234375L15.64197735,16.433025323437498Q15.80243305,16.2971096234375,16.01247025,16.3072719234375Q16.222507450000002,16.3174352234375,16.36909105,16.4682073234375L18.910870250000002,19.0826080234375Q18.60672125,19.3333740234375,18.19869925,19.3333740234375L12.21795465,19.3333740234375L14.24316125,17.6179050234375ZM16.09635445,14.0666094234375C17.29297165,14.0666094234375,18.26302125,13.0965605234375,18.26302125,11.899943323437501C18.26302125,10.7033262234375,17.29297165,9.7332763234375,16.09635445,9.7332763234375C14.899737349999999,9.7332763234375,13.92968745,10.7033262234375,13.92968745,11.899943323437501C13.92968745,13.0965605234375,14.899737349999999,14.0666094234375,16.09635445,14.0666094234375ZM16.09635445,11.0666098234375C16.55659105,11.0666098234375,16.92968745,11.4397058234375,16.92968745,11.899943323437501C16.92968745,12.3601804234375,16.55659105,12.7332763234375,16.09635445,12.7332763234375C15.63611695,12.7332763234375,15.26302055,12.3601804234375,15.26302055,11.899943323437501C15.26302055,11.4397058234375,15.63611695,11.0666098234375,16.09635445,11.0666098234375Z" fill-rule="evenodd" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g><g style="opacity:0;"></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139077"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139077)"><g><path d="M12,1.34033203125L7.5,1.34033203125C7.2239532,1.34046676636,7.0002441,1.56428482125,7.0002441,1.84033188125C7.0002441,2.11637908125,7.2239532,2.34019726125,7.5,2.34033203125L9.0050001,2.34033203125L5.4059997,13.34033203125L4,13.34033203125C3.72385772,13.34033203125,3.5,13.56418903125,3.5,13.84033203125C3.5,14.11647403125,3.72385772,14.34033203125,4,14.34033203125L8.5,14.34033203125C8.7761426,14.34033203125,9,14.11647403125,9,13.84033203125C9,13.56418903125,8.7761426,13.34033203125,8.5,13.34033203125L7.0354998,13.34033203125L10.6374998,2.34033253125L12,2.34033253125C12.2760468,2.34019756125,12.4997559,2.11637938125,12.4997559,1.84033233125C12.4997559,1.56428528125,12.2760468,1.34046722412,12,1.34033203125Z" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139080"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139080)"><g><path d="M8,12.67640234375C10.6923876,12.67640234375,12.875,10.49379154375,12.875,7.80140254375L12.875,1.39990234375L11.125,1.39990234375L11.125,7.80140254375C11.125,9.52729224375,9.7258897,10.92640204375,8,10.92640204375C6.2741098,10.92640204375,4.8749994999999995,9.52729224375,4.875,7.80140254375L4.875,1.39990234375L3.125,1.39990234375L3.125,7.80140254375C3.1249995999999998,10.49379154375,5.307611,12.67640234375,8,12.67640234375ZM13.5,13.84040234375L2.5,13.84040234375C2.22385757,13.84040234375,2,14.06425934375,2,14.34040234375C2,14.61654434375,2.22385757,14.84040234375,2.5,14.84040234375L13.5,14.84040234375C13.776142,14.84040234375,14,14.61654434375,14,14.34040234375C14,14.06425934375,13.776142,13.84040234375,13.5,13.84040234375Z" fill="#17171D" fill-opacity="1"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,185 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import { Copy, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { HTMLEditor } from '../../../lib'
|
||||||
|
import { ColorPicker } from '@/components/base/color-picker'
|
||||||
|
import { rgbToHex } from '../hooks/useElementStyles'
|
||||||
|
import { loca } from '../loco'
|
||||||
|
|
||||||
|
interface BockToolbarProps {
|
||||||
|
editor: HTMLEditor
|
||||||
|
element: HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorColorPicker: React.FC<{
|
||||||
|
editor: HTMLEditor
|
||||||
|
element: HTMLElement
|
||||||
|
}> = ({ editor, element }) => {
|
||||||
|
const computedStyle = window.getComputedStyle(element);
|
||||||
|
const currentEleColor = computedStyle.background;
|
||||||
|
const [color, setColor] = useState<string>(currentEleColor)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColor(currentEleColor)
|
||||||
|
}, [currentEleColor])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='editor-toolbar-color-picker w-[16px] h-[16px]'>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
data-html-editor-ui="true"
|
||||||
|
className='flex flex-row items-center justify-center w-[16px] h-[16px] rounded-[16px] bg-[#eee] cursor-pointer'
|
||||||
|
>
|
||||||
|
<div className='w-[14px] h-[14px] rounded-[14px] overflow-hidden' style={{
|
||||||
|
background: color
|
||||||
|
}}>
|
||||||
|
<div className='w-full h-full' style={{
|
||||||
|
backgroundSize:'8px 8px',
|
||||||
|
backgroundImage:'conic-gradient(rgba(98, 105, 153, 0.1) 25%, transparent 25% 50%, rgba(98, 105, 153, 0.1) 50% 75%, transparent 75% 100%)'
|
||||||
|
}}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className='w-auto p-3'
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ColorPicker
|
||||||
|
color={rgbToHex(color)}
|
||||||
|
onChange={(newColor) => {
|
||||||
|
setColor(newColor)
|
||||||
|
editor.styleManager?.changeBackground(element, newColor);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BlockToolbar: React.FC<BockToolbarProps> = ({
|
||||||
|
editor,
|
||||||
|
element,
|
||||||
|
}) => {
|
||||||
|
const toolbarRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const styleManager = editor.styleManager
|
||||||
|
if (!editor || !element || !styleManager) return null
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const actions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'color-picker',
|
||||||
|
label: '',
|
||||||
|
icon: EditorColorPicker,
|
||||||
|
tooltip: loca.changeBgColor.content,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'duplicate',
|
||||||
|
label: '',
|
||||||
|
icon: Copy,
|
||||||
|
tooltip: loca.copy.content,
|
||||||
|
run: () => {
|
||||||
|
editor.copyElement(element)
|
||||||
|
},
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: '',
|
||||||
|
icon: Trash2,
|
||||||
|
tooltip: loca.delete.content,
|
||||||
|
run: () => {
|
||||||
|
editor.deleteElement(element)
|
||||||
|
},
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[editor, element],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div
|
||||||
|
ref={toolbarRef}
|
||||||
|
className='html-editor-toolbar'
|
||||||
|
style={{
|
||||||
|
height: '36px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 100,
|
||||||
|
zIndex: 10000,
|
||||||
|
background: 'var(--editor-surface)',
|
||||||
|
backdropFilter: 'blur(18px)',
|
||||||
|
border: '0.5px solid var(--editor-border-strong)',
|
||||||
|
boxShadow: '0 8px 24px var(--editor-shadow)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actions.map(a => {
|
||||||
|
if (a.key === 'split-line') {
|
||||||
|
return (
|
||||||
|
<div key={a.key} className='h-[50%] w-[1px] bg-border'></div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconComp = a.icon as any;
|
||||||
|
const isDelete = a.key === 'delete';
|
||||||
|
const iconClassName = `w-4 h-4 ${isDelete ? 'text-destructive' : 'text-foreground'}`;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
min-w-[28px] min-h-[28px] flex flex-row items-center justify-center text-foreground hover:bg-accent cursor-pointer transition-all rounded-[28px] ${a.isActive ? 'bg-accent text-accent-foreground' : ''} ${a.label ? 'px-[8px]' : ''}
|
||||||
|
`}
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
a.run?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center ${a.label ? 'gap-[2px]' : ''}`}>
|
||||||
|
<div>
|
||||||
|
{IconComp && (
|
||||||
|
a.key === 'color-picker' ? (
|
||||||
|
<IconComp editor={editor} element={element} className={iconClassName} />
|
||||||
|
) : (
|
||||||
|
<IconComp className={iconClassName} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>{a.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={a.key}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{content}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>{a.tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useMemo, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { Copy, Trash2, ImagePlus } from 'lucide-react'
|
||||||
|
|
||||||
|
|
||||||
|
import { HTMLEditor } from '../../../lib'
|
||||||
|
import { loca } from '../loco'
|
||||||
|
// import { customUpload } from '../utils/upload'
|
||||||
|
|
||||||
|
interface BockToolbarProps {
|
||||||
|
editor: HTMLEditor
|
||||||
|
element: HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const EditorImageUpload: React.FC<{
|
||||||
|
editor: HTMLEditor,
|
||||||
|
element: HTMLElement
|
||||||
|
}> = ({ editor: _editor, element: _element }) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const onFileChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
try{
|
||||||
|
// const res = await customUpload({ file })
|
||||||
|
// if (res) {
|
||||||
|
// editor.replaceImage(element, res.url)
|
||||||
|
// }
|
||||||
|
}catch{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-[28px] h-[28px] relative flex flex-row items-center justify-center cursor-pointer'>
|
||||||
|
<input type="file" accept='.jpeg,.jpg,.png,.gif,.bmp,.webp' multiple={false} ref={inputRef} className='absolute w-full h-full top-0 left-0 border-none outline-none cursor-pointer opacity-0 text-[0px]' onChange={onFileChange}/>
|
||||||
|
<ImagePlus className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageToolbar: React.FC<BockToolbarProps> = ({
|
||||||
|
editor,
|
||||||
|
element
|
||||||
|
}) => {
|
||||||
|
const toolbarRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
if (!editor || !element) return null
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const actions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'image-replace',
|
||||||
|
icon: EditorImageUpload,
|
||||||
|
tooltip: loca.imageUpload.content
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'duplicate',
|
||||||
|
label: '',
|
||||||
|
icon: Copy,
|
||||||
|
tooltip: loca.copy.content,
|
||||||
|
run: () => {
|
||||||
|
editor.copyElement(element)
|
||||||
|
},
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: '',
|
||||||
|
icon: Trash2,
|
||||||
|
tooltip: loca.delete.content,
|
||||||
|
run: () => {
|
||||||
|
editor.deleteElement(element)
|
||||||
|
},
|
||||||
|
isActive: false
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[editor, element],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div
|
||||||
|
ref={toolbarRef}
|
||||||
|
className='html-editor-toolbar'
|
||||||
|
style={{
|
||||||
|
height: '36px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 100,
|
||||||
|
zIndex: 10000,
|
||||||
|
background: 'var(--editor-surface)',
|
||||||
|
backdropFilter: 'blur(18px)',
|
||||||
|
border: '0.5px solid var(--editor-border-strong)',
|
||||||
|
boxShadow: '0 8px 24px var(--editor-shadow)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actions.map(a => {
|
||||||
|
if (a.key === 'split-line') {
|
||||||
|
return (
|
||||||
|
<div key={a.key} className='h-[50%] w-[1px] bg-border'></div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconComp = a.icon as any;
|
||||||
|
const isDelete = a.key === 'delete';
|
||||||
|
const iconClassName = `w-4 h-4 ${isDelete ? 'text-destructive' : 'text-foreground'}`;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
min-w-[28px] min-h-[28px] flex flex-row items-center justify-center text-foreground hover:bg-accent cursor-pointer transition-all rounded-[28px] ${a.isActive ? 'bg-accent text-accent-foreground' : ''} ${a.label ? 'px-[8px]' : ''}
|
||||||
|
`}
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
a.run?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center ${a.label ? 'gap-[2px]' : ''}`}>
|
||||||
|
<div>
|
||||||
|
{IconComp && (
|
||||||
|
a.key === 'image-replace' ? (
|
||||||
|
<IconComp editor={editor} element={element} className={iconClassName} />
|
||||||
|
) : (
|
||||||
|
<IconComp className={iconClassName} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>{a.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={a.key}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{content}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>{a.tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Bold,
|
||||||
|
Underline,
|
||||||
|
Italic,
|
||||||
|
AlignLeft,
|
||||||
|
AlignCenter,
|
||||||
|
AlignRight,
|
||||||
|
Copy,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import type { HTMLEditor } from '../../../lib'
|
||||||
|
import {
|
||||||
|
useFontSize,
|
||||||
|
useTextColor,
|
||||||
|
useFontWeight,
|
||||||
|
useFontStyle,
|
||||||
|
useTextDecoration,
|
||||||
|
useTextAlign,
|
||||||
|
} from '../hooks/useElementStyles'
|
||||||
|
import { ColorPicker } from '@/components/base/color-picker'
|
||||||
|
import { loca } from '../loco'
|
||||||
|
|
||||||
|
interface TextToolbarProps {
|
||||||
|
editor: HTMLEditor
|
||||||
|
element: HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextLevelChangger: React.FC<{
|
||||||
|
editor: HTMLEditor
|
||||||
|
element: HTMLElement
|
||||||
|
}> = ({ editor, element }) => {
|
||||||
|
const [fontSize] = useFontSize(element)
|
||||||
|
const [selectedKey, setSelectedKey] = useState<
|
||||||
|
'H1' | 'H2' | 'H3' | 'H4' | 'H5' | 'H6' | 'doc' | 'ol' | 'ul'
|
||||||
|
>('doc')
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: 'H1', label: 'H1' },
|
||||||
|
{ key: 'H2', label: 'H2' },
|
||||||
|
{ key: 'H3', label: 'H3' },
|
||||||
|
{ key: 'H4', label: 'H4' },
|
||||||
|
{ key: 'H5', label: 'H5' },
|
||||||
|
{ key: 'H6', label: 'H6' },
|
||||||
|
{ key: 'doc', label: '正文' },
|
||||||
|
]
|
||||||
|
const tagName = element.tagName.toLowerCase()
|
||||||
|
|
||||||
|
const activeMap = {
|
||||||
|
H1: { isActive: fontSize === 60, label: 'H1', key: 'H1' },
|
||||||
|
H2: { isActive: fontSize === 40, label: 'H2', key: 'H2' },
|
||||||
|
H3: { isActive: fontSize === 32, label: 'H3', key: 'H3' },
|
||||||
|
H4: { isActive: fontSize === 24, label: 'H4', key: 'H4' },
|
||||||
|
H5: { isActive: fontSize === 18, label: 'H5', key: 'H5' },
|
||||||
|
H6: { isActive: fontSize === 14, label: 'H6', key: 'H6' },
|
||||||
|
doc: {
|
||||||
|
isActive: (tagName === 'p' || tagName === 'span') && fontSize === 18,
|
||||||
|
label: '正文',
|
||||||
|
key: 'doc',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const defaultSelectedItem = Object.values(activeMap).find(i => i.isActive)
|
||||||
|
|
||||||
|
console.log('defaultSelectedItem:',defaultSelectedItem)
|
||||||
|
const textTypes: { [key: string]: string } = {
|
||||||
|
doc: '18px',
|
||||||
|
H1: '60px',
|
||||||
|
H2: '40px',
|
||||||
|
H3: '32px',
|
||||||
|
H4: '24px',
|
||||||
|
H5: '18px',
|
||||||
|
H6: '14px',
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultSelectedItem) {
|
||||||
|
setSelectedKey('doc')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedKey(defaultSelectedItem.key as any)
|
||||||
|
}, [defaultSelectedItem?.key])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<span className='flex html-editor-heading-dropdown items-center justify-between gap-[4px] pl-[8px] pr-[4px] cursor-pointer'>
|
||||||
|
<span>{activeMap[selectedKey as keyof typeof activeMap]?.label}</span>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="min-w-[204px]">
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={selectedKey}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedKey(value as any)
|
||||||
|
const newSize = textTypes[value]
|
||||||
|
if (editor) {
|
||||||
|
editor.styleManager?.changeFontSize(element, newSize)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map(item => (
|
||||||
|
<DropdownMenuRadioItem key={item.key} value={item.key}>
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditorColorPicker: React.FC<{
|
||||||
|
editor: HTMLEditor
|
||||||
|
element: HTMLElement
|
||||||
|
}> = ({ editor, element }) => {
|
||||||
|
const [color, setColor] = useTextColor(element)
|
||||||
|
const bgColor = useMemo<string>(
|
||||||
|
() => (typeof color === 'string' ? color : (color as any)?.toHexString?.() || '#000000'),
|
||||||
|
[color],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='editor-toolbar-color-picker w-[16px] h-[16px]'>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
data-html-editor-ui="true"
|
||||||
|
className='flex flex-row items-center justify-center w-[16px] h-[16px] rounded-[16px] bg-[#eee] cursor-pointer'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='w-[14px] h-[14px] rounded-[14px] overflow-hidden'
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='w-full h-full'
|
||||||
|
style={{
|
||||||
|
backgroundSize: '8px 8px',
|
||||||
|
backgroundImage:
|
||||||
|
'conic-gradient(rgba(98, 105, 153, 0.1) 25%, transparent 25% 50%, rgba(98, 105, 153, 0.1) 50% 75%, transparent 75% 100%)',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className='w-auto p-3'
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ColorPicker
|
||||||
|
color={bgColor.startsWith('#') ? bgColor : '#000000'}
|
||||||
|
onChange={(newColor) => {
|
||||||
|
setColor(newColor)
|
||||||
|
editor.styleManager?.changeColor(element, newColor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextToolbar: React.FC<TextToolbarProps> = ({
|
||||||
|
editor,
|
||||||
|
element,
|
||||||
|
}) => {
|
||||||
|
const isBold = useFontWeight(element)
|
||||||
|
const isItalic = useFontStyle(element)
|
||||||
|
const isUnderline = useTextDecoration(element)
|
||||||
|
const textAlign = useTextAlign(element)
|
||||||
|
const toolbarRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const styleManager = editor.styleManager
|
||||||
|
if (!editor || !element || !styleManager) return null
|
||||||
|
|
||||||
|
const handleToggleBold = () => {
|
||||||
|
styleManager.changeFontWeight(element, isBold ? 'normal' : 'bold')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleItalic = () => {
|
||||||
|
styleManager.changeFontStyle(element, isItalic ? 'normal' : 'italic')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleUnderline = () => {
|
||||||
|
styleManager.changeTextDecoration(element, isUnderline ? 'none' : 'underline')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTextAlign = (align: string) => {
|
||||||
|
styleManager.changeTextAlign(element, align)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const actions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'tools',
|
||||||
|
label: '',
|
||||||
|
icon: TextLevelChangger,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'color-picker',
|
||||||
|
label: '',
|
||||||
|
icon: EditorColorPicker,
|
||||||
|
tooltip: loca.changeTextColor.content,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bold',
|
||||||
|
label: '',
|
||||||
|
icon: Bold,
|
||||||
|
tooltip: loca.textBlod.content,
|
||||||
|
run: handleToggleBold,
|
||||||
|
isActive: isBold,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'italic',
|
||||||
|
label: '',
|
||||||
|
icon: Italic,
|
||||||
|
tooltip: loca.textItalic.content,
|
||||||
|
run: handleToggleItalic,
|
||||||
|
isActive: isItalic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'underline',
|
||||||
|
label: '',
|
||||||
|
icon: Underline,
|
||||||
|
tooltip: loca.textUnderline.content,
|
||||||
|
run: handleToggleUnderline,
|
||||||
|
isActive: isUnderline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'text-align-left',
|
||||||
|
label: '',
|
||||||
|
icon: AlignLeft,
|
||||||
|
tooltip: loca.textAlignLeft.content,
|
||||||
|
run: () => {
|
||||||
|
handleTextAlign('left')
|
||||||
|
},
|
||||||
|
isActive: textAlign === 'left' || textAlign === 'start',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'text-align-center',
|
||||||
|
label: '',
|
||||||
|
icon: AlignCenter,
|
||||||
|
tooltip: loca.textAlignCenter.content,
|
||||||
|
run: () => {
|
||||||
|
handleTextAlign('center')
|
||||||
|
},
|
||||||
|
isActive: textAlign === 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'text-align-right',
|
||||||
|
label: '',
|
||||||
|
icon: AlignRight,
|
||||||
|
tooltip: loca.textAlignRight.content,
|
||||||
|
run: () => {
|
||||||
|
handleTextAlign('right')
|
||||||
|
},
|
||||||
|
isActive: textAlign === 'right' || textAlign === 'end',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'split-line',
|
||||||
|
label: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'duplicate',
|
||||||
|
label: '',
|
||||||
|
icon: Copy,
|
||||||
|
tooltip: loca.copy.content,
|
||||||
|
run: () => {
|
||||||
|
editor.copyElement(element)
|
||||||
|
},
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: '',
|
||||||
|
icon: Trash2,
|
||||||
|
tooltip: loca.delete.content,
|
||||||
|
run: () => {
|
||||||
|
editor.deleteElement(element)
|
||||||
|
},
|
||||||
|
isActive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
handleToggleBold,
|
||||||
|
handleToggleItalic,
|
||||||
|
handleToggleUnderline,
|
||||||
|
handleTextAlign,
|
||||||
|
textAlign,
|
||||||
|
isBold,
|
||||||
|
isItalic,
|
||||||
|
isUnderline,
|
||||||
|
editor,
|
||||||
|
element,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<div
|
||||||
|
ref={toolbarRef}
|
||||||
|
className='html-editor-toolbar'
|
||||||
|
style={{
|
||||||
|
height: '36px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 100,
|
||||||
|
zIndex: 10000,
|
||||||
|
background: 'var(--editor-surface)',
|
||||||
|
backdropFilter: 'blur(18px)',
|
||||||
|
border: '0.5px solid var(--editor-border-strong)',
|
||||||
|
boxShadow: '0 8px 24px var(--editor-shadow)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actions.map((a: any) => {
|
||||||
|
if (a.key === 'split-line') {
|
||||||
|
return (
|
||||||
|
<div key={a.key} className='h-[50%] w-[1px] bg-border'></div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconComp = a.icon as any;
|
||||||
|
const isDelete = a.key === 'delete';
|
||||||
|
const iconClassName = `w-4 h-4 ${isDelete ? 'text-destructive' : 'text-foreground'}`;
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
min-w-[28px] min-h-[28px] flex flex-row items-center justify-center text-foreground hover:bg-accent cursor-pointer transition-all rounded-[28px] ${a.isActive ? 'bg-accent text-accent-foreground' : ''} ${a.label ? 'px-[8px]' : ''}
|
||||||
|
`}
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
a.run && a.run()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center ${a.label ? 'gap-[2px]' : ''}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{IconComp && (
|
||||||
|
['tools', 'color-picker'].includes(a.key) ? (
|
||||||
|
<IconComp editor={editor} element={element} className={iconClassName} />
|
||||||
|
) : (
|
||||||
|
<IconComp className={iconClassName} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>{a.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={a.key}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{content}
|
||||||
|
</TooltipTrigger>
|
||||||
|
{a.tooltip ? <TooltipContent side="bottom">
|
||||||
|
<p>{a.tooltip}</p>
|
||||||
|
</TooltipContent> : null}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
// import { ColorPickerProps, GetProp } from 'antd';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
// 辅助函数:将 rgb/rgba 转换为 hex
|
||||||
|
export const rgbToHex = (rgb: string): string => {
|
||||||
|
if (rgb.startsWith('#')) return rgb;
|
||||||
|
|
||||||
|
const match = rgb.match(/\d+/g);
|
||||||
|
if (!match) return '#000000';
|
||||||
|
|
||||||
|
const [r, g, b, a] = match.map(Number);
|
||||||
|
return '#' + [r, g, b, a].map(x => {
|
||||||
|
const hex = x ? x.toString(16) : 'ff'
|
||||||
|
return hex.length === 1 ? '0' + hex : hex;
|
||||||
|
}).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook: 读取字体大小
|
||||||
|
export const useFontSize = (element: HTMLElement | null) => {
|
||||||
|
const [fontSize, setFontSize] = useState(18);
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateFontSize = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const fontSizeValue = computedStyle.fontSize;
|
||||||
|
const fontSize = parseInt(fontSizeValue);
|
||||||
|
setFontSize(fontSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFontSize();
|
||||||
|
|
||||||
|
// 监听样式变化
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateFontSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('transitionend', updateFontSize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
element.removeEventListener('transitionend', updateFontSize);
|
||||||
|
}
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return [fontSize, setFontSize] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook: 读取背景色
|
||||||
|
export const useBackgroundColor = (element: HTMLElement | null) => {
|
||||||
|
const [bgColor, setBgColor] = useState('#ffffff');
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateBgColor = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const bgColorValue = computedStyle.backgroundColor;
|
||||||
|
|
||||||
|
if (bgColorValue && bgColorValue !== 'rgba(0, 0, 0, 0)' && bgColorValue !== 'transparent') {
|
||||||
|
setBgColor(bgColorValue);
|
||||||
|
} else {
|
||||||
|
setBgColor('#ffffff');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateBgColor();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateBgColor();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('transitionend', updateBgColor);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
element.removeEventListener('transitionend', updateBgColor);
|
||||||
|
}
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return [bgColor, setBgColor] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook: 读取文字颜色
|
||||||
|
type Color = string;
|
||||||
|
export const useTextColor = (element: HTMLElement | null) => {
|
||||||
|
const [textColor, setTextColor] = useState<Color>('#000000');
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateTextColor = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const textColorValue = computedStyle.color;
|
||||||
|
|
||||||
|
if (textColorValue) {
|
||||||
|
setTextColor(textColorValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTextColor();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateTextColor();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('transitionend', updateTextColor);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
element.removeEventListener('transitionend', updateTextColor);
|
||||||
|
}
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return [textColor, setTextColor] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook: 读取圆角
|
||||||
|
export const useBorderRadius = (element: HTMLElement | null) => {
|
||||||
|
const [borderRadius, setBorderRadius] = useState('0');
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateBorderRadius = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const borderRadiusValue = computedStyle.borderRadius;
|
||||||
|
setBorderRadius(parseInt(borderRadiusValue).toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
updateBorderRadius();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateBorderRadius();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('transitionend', updateBorderRadius);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
element.removeEventListener('transitionend', updateBorderRadius);
|
||||||
|
}
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return [borderRadius, setBorderRadius] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook: 读取边框
|
||||||
|
export const useBorder = (element: HTMLElement | null) => {
|
||||||
|
const [border, setBorder] = useState('');
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateBorder = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const borderValue = computedStyle.border;
|
||||||
|
setBorder(borderValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateBorder();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateBorder();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('transitionend', updateBorder);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
element.removeEventListener('transitionend', updateBorder);
|
||||||
|
}
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return [border, setBorder] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook: 读取边距
|
||||||
|
export const useMargin = (element: HTMLElement | null) => {
|
||||||
|
const [margin, setMargin] = useState('');
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateMargin = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const marginValue = computedStyle.margin;
|
||||||
|
setMargin(marginValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMargin();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateMargin();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('transitionend', updateMargin);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
element.removeEventListener('transitionend', updateMargin);
|
||||||
|
}
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return [margin, setMargin] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook: 读取字体粗细
|
||||||
|
export const useFontWeight = (element: HTMLElement | null) => {
|
||||||
|
const [isBold, setIsBold] = useState(false);
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateFontWeight = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const fontWeight = computedStyle.fontWeight;
|
||||||
|
// fontWeight 可能是数字(400, 700)或字符串(normal, bold)
|
||||||
|
setIsBold(fontWeight === 'bold' || fontWeight === '700' || parseInt(fontWeight) >= 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFontWeight();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateFontWeight();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('transitionend', updateFontWeight);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
element.removeEventListener('transitionend', updateFontWeight);
|
||||||
|
}
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return isBold;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook: 读取字体样式(斜体)
|
||||||
|
export const useFontStyle = (element: HTMLElement | null) => {
|
||||||
|
const [isItalic, setIsItalic] = useState(false);
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateFontStyle = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const fontStyle = computedStyle.fontStyle;
|
||||||
|
setIsItalic(fontStyle === 'italic');
|
||||||
|
};
|
||||||
|
|
||||||
|
updateFontStyle();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateFontStyle();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
element.addEventListener('transitionend', updateFontStyle);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
element.removeEventListener('transitionend', updateFontStyle);
|
||||||
|
}
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return isItalic;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook: 读取文字装饰(下划线)
|
||||||
|
export const useTextDecoration = (element: HTMLElement | null) => {
|
||||||
|
const [isUnderline, setIsUnderline] = useState(false);
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateTextDecoration = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const textDecoration = computedStyle.textDecoration;
|
||||||
|
setIsUnderline(textDecoration.includes('underline'));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTextDecoration();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateTextDecoration();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return isUnderline;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTextAlign = (element: HTMLElement | null) => {
|
||||||
|
const [textAlign, setTextAlign] = useState('left');
|
||||||
|
const win = element?.ownerDocument.defaultView || window;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const updateTextAlign = () => {
|
||||||
|
const computedStyle = win.getComputedStyle(element);
|
||||||
|
const align = computedStyle.textAlign;
|
||||||
|
setTextAlign(align);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTextAlign();
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
updateTextAlign();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
|
return textAlign;
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export const useStopPopEvent = (visible?: boolean) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.ant-color-picker input').forEach(input => {
|
||||||
|
input.addEventListener('mousedown', handler, true);
|
||||||
|
input.addEventListener('click', handler, true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
document.querySelectorAll('.ant-color-picker input').forEach(input => {
|
||||||
|
input.removeEventListener('mousedown', handler, true);
|
||||||
|
input.removeEventListener('click', handler, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
}
|
||||||
23
components/html-editor/components/toolbar-web/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { usePPTEditContext } from '../../context'
|
||||||
|
import { Tooltip } from './toolbar'
|
||||||
|
|
||||||
|
export const PPTEditToolBar: React.FC<{
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
className?: string
|
||||||
|
}> = props => {
|
||||||
|
const { containerRef, className } = props
|
||||||
|
const context = usePPTEditContext()
|
||||||
|
if (!context || !context.state) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const { selectedElement, editor, tipPosition } = context.state
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
editor={editor}
|
||||||
|
element={selectedElement}
|
||||||
|
position={tipPosition}
|
||||||
|
containerRef={containerRef}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
components/html-editor/components/toolbar-web/loco.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const localize = (key: string, defaultValue: string) => defaultValue
|
||||||
|
export const loca = {
|
||||||
|
changeTextColor: {
|
||||||
|
content: localize('editor-color-picker', '更改颜色'),
|
||||||
|
tip: '更改文本颜色'
|
||||||
|
},
|
||||||
|
changeBgColor: {
|
||||||
|
content: localize('editor-bg-color-picker', '更改背景色'),
|
||||||
|
tip: '更改背景颜色'
|
||||||
|
},
|
||||||
|
imageUpload: {
|
||||||
|
content: localize('editor-image_upload', '替换图片'),
|
||||||
|
tip: '上传图片'
|
||||||
|
},
|
||||||
|
textBlod: {
|
||||||
|
content: localize('text_blod', '文字加粗'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textItalic: {
|
||||||
|
content: localize('text_italic', '文字斜体'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textUnderline: {
|
||||||
|
content: localize('text_underline', '文字下划线'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textAlignLeft: {
|
||||||
|
content: localize('text_align-left', '左对齐'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textAlignCenter: {
|
||||||
|
content: localize('text_align-center', '居中对齐'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
textAlignRight: {
|
||||||
|
content: localize('text_align-right', '右对齐'),
|
||||||
|
tip: '加粗'
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
content: localize('element_duplicate', '复制'),
|
||||||
|
tip: '复制'
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
content: localize('element_delete', '删除'),
|
||||||
|
tip: '删除'
|
||||||
|
},
|
||||||
|
}
|
||||||
175
components/html-editor/components/toolbar-web/styles.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const styles: { [key: string]: React.CSSProperties } = {
|
||||||
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: 'var(--editor-surface)',
|
||||||
|
border: '1px solid var(--editor-border)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
boxShadow: '0 14px 36px var(--editor-shadow)',
|
||||||
|
zIndex: 10000,
|
||||||
|
display: 'flex',
|
||||||
|
backdropFilter: 'blur(18px)',
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
width: '1px',
|
||||||
|
height: '24px',
|
||||||
|
backgroundColor: 'var(--editor-border)',
|
||||||
|
},
|
||||||
|
buttonGroup: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '2px',
|
||||||
|
backgroundColor: 'var(--editor-surface-muted)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '2px',
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '600',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
color: 'var(--editor-text)',
|
||||||
|
},
|
||||||
|
iconButtonActive: {
|
||||||
|
backgroundColor: 'var(--editor-accent)',
|
||||||
|
color: 'var(--primary-foreground)',
|
||||||
|
},
|
||||||
|
inputGroup: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
backgroundColor: 'var(--editor-surface-muted)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: 'var(--editor-text-muted)',
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
numberInput: {
|
||||||
|
width: '40px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '2px 4px',
|
||||||
|
textAlign: 'center',
|
||||||
|
outline: 'none',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: 'var(--editor-text)',
|
||||||
|
},
|
||||||
|
textInput: {
|
||||||
|
width: '100px',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '2px 4px',
|
||||||
|
outline: 'none',
|
||||||
|
color: 'var(--editor-text)',
|
||||||
|
},
|
||||||
|
colorGroup: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
},
|
||||||
|
colorItem: {
|
||||||
|
position: 'relative',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
},
|
||||||
|
colorInput: {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
colorLabel: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '700',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
color: 'var(--editor-text-muted)',
|
||||||
|
mixBlendMode: 'difference',
|
||||||
|
},
|
||||||
|
bgIcon: {
|
||||||
|
width: '14px',
|
||||||
|
height: '14px',
|
||||||
|
border: '2px solid currentColor',
|
||||||
|
borderRadius: '2px',
|
||||||
|
},
|
||||||
|
sliderGroup: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
backgroundColor: 'var(--editor-surface-muted)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
},
|
||||||
|
sliderLabel: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'var(--editor-text-muted)',
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
rangeInput: {
|
||||||
|
width: '70px',
|
||||||
|
height: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
appearance: 'none',
|
||||||
|
background:
|
||||||
|
'linear-gradient(to right, var(--editor-accent) 0%, var(--editor-accent) var(--value), color-mix(in srgb, var(--editor-border-strong) 72%, white) var(--value), color-mix(in srgb, var(--editor-border-strong) 72%, white) 100%)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
|
sliderValue: {
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: 'var(--editor-text)',
|
||||||
|
minWidth: '24px',
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
backgroundColor: 'var(--editor-danger-soft)',
|
||||||
|
color: 'var(--editor-danger)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--editor-text-muted)',
|
||||||
|
padding: '0 8px',
|
||||||
|
},
|
||||||
|
};
|
||||||
73
components/html-editor/components/toolbar-web/toolbar.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import type { HTMLEditor } from '../../lib'
|
||||||
|
import { isBlockElement, isImageElement, isTextElement } from '../../lib'
|
||||||
|
import { TextToolbar } from './components/TextToolbar'
|
||||||
|
import { BlockToolbar } from './components/BlockToolbar'
|
||||||
|
import { ImageToolbar } from './components/ImageToolbar'
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
bottom: number
|
||||||
|
right: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
editor: HTMLEditor | null | undefined
|
||||||
|
element: HTMLElement | null | undefined
|
||||||
|
position: Position | null
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tooltip: React.FC<TooltipProps> = ({
|
||||||
|
editor,
|
||||||
|
element,
|
||||||
|
position,
|
||||||
|
containerRef,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
if (!editor || !element || !position || !editor.styleManager) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isText = isTextElement(element)
|
||||||
|
const isBlock = isBlockElement(element)
|
||||||
|
const isImage = isImageElement(element)
|
||||||
|
|
||||||
|
const contaierRect =
|
||||||
|
containerRef.current?.getBoundingClientRect() || ({} as DOMRect)
|
||||||
|
const { left = 0, top = 0 } = contaierRect
|
||||||
|
|
||||||
|
const toolWidth = isText ? 365 : 105
|
||||||
|
const toolHeight = 36
|
||||||
|
// 右边界限制
|
||||||
|
let releativeLeft = position.left
|
||||||
|
if (position.left + toolWidth + left > window.innerWidth) {
|
||||||
|
releativeLeft = window.innerWidth - toolWidth - left
|
||||||
|
}
|
||||||
|
|
||||||
|
let releativeBottom = position.bottom
|
||||||
|
if (releativeBottom + toolHeight + 25 > window.innerHeight) {
|
||||||
|
releativeBottom = position.top - toolHeight - 15
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${releativeBottom - top + 10}px`,
|
||||||
|
left: `${releativeLeft}px`,
|
||||||
|
zIndex: 100000,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={tooltipStyle} className={`html-editor-floating-toolbar ${className}`}>
|
||||||
|
{isText && <TextToolbar editor={editor} element={element} />}
|
||||||
|
{isBlock && <BlockToolbar editor={editor} element={element} />}
|
||||||
|
{isImage && <ImageToolbar editor={editor} element={element} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tooltip
|
||||||
153
components/html-editor/components/toolbar-web/utils/upload.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import mime from 'mime'
|
||||||
|
import { createCustomOSSUploader } from '@bty/uploader'
|
||||||
|
import { createFileUploadRecord } from '@apis/mindnote/next-agent-chat'
|
||||||
|
import { getOssSignatureUrl, getSTSToken } from '@apis/mindnote/oss'
|
||||||
|
|
||||||
|
export const ACCEPT_FILE_TYPE_LIST = [
|
||||||
|
'.png',
|
||||||
|
'.jpg',
|
||||||
|
'.jpeg',
|
||||||
|
'.gif',
|
||||||
|
'.bmp',
|
||||||
|
'.webp',
|
||||||
|
]
|
||||||
|
|
||||||
|
function getMimeByAcceptList(filename: string): string | undefined {
|
||||||
|
const ext = `.${(filename.split('.').pop() || '').toLowerCase()}`
|
||||||
|
if (!ext) return undefined
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
}
|
||||||
|
if (ACCEPT_FILE_TYPE_LIST.includes(ext)) {
|
||||||
|
return map[ext]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RcFile extends File {
|
||||||
|
uid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface MessageFile {
|
||||||
|
uid?: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
path?: string
|
||||||
|
url?: string
|
||||||
|
upload_file_id?: string
|
||||||
|
byte_size?: number
|
||||||
|
progress?: number
|
||||||
|
uploadStatus?: 'pending' | 'success' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadFile extends MessageFile {
|
||||||
|
uid?: string
|
||||||
|
}
|
||||||
|
interface UploadResult {
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
byte_size: number,
|
||||||
|
url: string,
|
||||||
|
upload_file_id: string
|
||||||
|
}
|
||||||
|
export const customUpload = async (params: {
|
||||||
|
file: File
|
||||||
|
onProgress?: (args: {
|
||||||
|
progress: number
|
||||||
|
file: RcFile
|
||||||
|
uploadStatus: 'success' | 'error' | 'pending'
|
||||||
|
}) => void,
|
||||||
|
onFail?: (message?: any) => void
|
||||||
|
}): Promise<UploadResult | null> => {
|
||||||
|
const { file, onProgress,onFail } = params
|
||||||
|
const ossUploader = createCustomOSSUploader(getSTSToken)
|
||||||
|
try {
|
||||||
|
// 构建文件路径,参考 util.ts 中的格式
|
||||||
|
const timestamp = new Date().valueOf()
|
||||||
|
const uuid = uuidv4()
|
||||||
|
const filePath = `super_agent/user_upload_file/${uuid}/_${timestamp}_${file.name}`
|
||||||
|
// 上传文件到 OSS
|
||||||
|
await ossUploader.multipartUpload({
|
||||||
|
filePath,
|
||||||
|
file,
|
||||||
|
options: {
|
||||||
|
headers: (() => {
|
||||||
|
const contentType = getMimeByAcceptList(file.name)
|
||||||
|
return {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Disposition': 'inline',
|
||||||
|
} as Record<string, string>
|
||||||
|
})(),
|
||||||
|
progress(
|
||||||
|
progress: number,
|
||||||
|
ossFile: { file: RcFile },
|
||||||
|
{ status }: { status: number } = { status: 0 },
|
||||||
|
) {
|
||||||
|
const uploadStatus =
|
||||||
|
progress >= 1 ? (status === 200 ? 'success' : 'error') : 'pending'
|
||||||
|
|
||||||
|
if (uploadStatus !== 'pending') {
|
||||||
|
setTimeout(() => {
|
||||||
|
onProgress?.({
|
||||||
|
progress,
|
||||||
|
file: ossFile?.file || file,
|
||||||
|
uploadStatus,
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
} else {
|
||||||
|
onProgress?.({
|
||||||
|
progress,
|
||||||
|
file: ossFile?.file || file,
|
||||||
|
uploadStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const signatureUrl = await getOssSignatureUrl(filePath)
|
||||||
|
|
||||||
|
// 获取文件类型
|
||||||
|
const file_type =
|
||||||
|
file.type || mime.getType(file.name) || 'application/octet-stream'
|
||||||
|
|
||||||
|
// 只分割最后一个点,例如 'a.b.c' -> ['a.b', 'c']
|
||||||
|
const lastDotIndex = file.name.lastIndexOf('.')
|
||||||
|
const splitName =
|
||||||
|
lastDotIndex !== -1
|
||||||
|
? [
|
||||||
|
file.name.substring(0, lastDotIndex),
|
||||||
|
file.name.substring(lastDotIndex + 1),
|
||||||
|
]
|
||||||
|
: [file.name]
|
||||||
|
const name = `${splitName[0]}-${Math.random().toString(36).substring(2, 5)}${splitName.length > 1 ? `.${splitName[1]}` : ''}`
|
||||||
|
const res = await createFileUploadRecord({
|
||||||
|
file_url: filePath || '',
|
||||||
|
file_type: file.type || 'application/octet-stream',
|
||||||
|
file_name: name,
|
||||||
|
file_byte_size: file.size || 0,
|
||||||
|
conversation_id: uuid,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构造返回的文件对象
|
||||||
|
const result: UploadFile = {
|
||||||
|
name,
|
||||||
|
type: file_type,
|
||||||
|
byte_size: file.size,
|
||||||
|
url: signatureUrl,
|
||||||
|
upload_file_id: res.file_upload_record_id,
|
||||||
|
}
|
||||||
|
return result as UploadResult
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文件上传失败:', error)
|
||||||
|
onFail && onFail(error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
56
components/html-editor/context/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { UseInjectModeReturn } from '../hooks/useIframeMode';
|
||||||
|
|
||||||
|
|
||||||
|
export interface SlideJson {
|
||||||
|
outline: Array<{
|
||||||
|
id: string;
|
||||||
|
summary: string;
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
project_dir: string;
|
||||||
|
slide_ids: string[];
|
||||||
|
slide_list: SliderListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SliderListItem {
|
||||||
|
content: string
|
||||||
|
file_name: string
|
||||||
|
file_type: string
|
||||||
|
id: string
|
||||||
|
path : string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PPTEditContext = createContext<{
|
||||||
|
state: UseInjectModeReturn | null,
|
||||||
|
setState: React.Dispatch<React.SetStateAction<UseInjectModeReturn | null>>
|
||||||
|
originalSlide: React.MutableRefObject<SliderListItem[]>
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
export const usePPTEditContext = () => {
|
||||||
|
return useContext(PPTEditContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PPTEditProvider: React.FC<{
|
||||||
|
children: React.ReactNode
|
||||||
|
slides?: SliderListItem[]
|
||||||
|
}> = (props) => {
|
||||||
|
const originalSlide = useRef<SliderListItem[]>(JSON.parse(JSON.stringify(props.slides || [])));
|
||||||
|
const [state, setState] = useState<UseInjectModeReturn | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
originalSlide.current = JSON.parse(JSON.stringify(props.slides || []))
|
||||||
|
}, [props.slides])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PPTEditContext.Provider value={{
|
||||||
|
state,
|
||||||
|
setState,
|
||||||
|
originalSlide
|
||||||
|
}}>
|
||||||
|
{props.children}
|
||||||
|
</PPTEditContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
35
components/html-editor/hooks/useDiff.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { usePPTEditContext } from '../context'
|
||||||
|
import { useToolPostion } from './useToolPostion'
|
||||||
|
import type { UseInjectModeReturn } from '../hooks/useIframeMode'
|
||||||
|
|
||||||
|
export const useDiff = (
|
||||||
|
useIframeReturn: UseInjectModeReturn,
|
||||||
|
isDoc?: boolean,
|
||||||
|
) => {
|
||||||
|
const editorContext = usePPTEditContext()
|
||||||
|
const { selectedElement, editor, tipPosition } = useIframeReturn
|
||||||
|
|
||||||
|
const docModeToolPosition = useToolPostion(editor, !!isDoc)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasActive = editor?.EditorRegistry.hasActiveEditor()
|
||||||
|
const reWriteState = { ...useIframeReturn }
|
||||||
|
if (isDoc) {
|
||||||
|
// 重写状态
|
||||||
|
reWriteState.position = docModeToolPosition
|
||||||
|
reWriteState.tipPosition = docModeToolPosition
|
||||||
|
editorContext?.setState(reWriteState)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hasActive && selectedElement) {
|
||||||
|
// 有激活的实例且为当前的实例
|
||||||
|
editorContext?.setState(reWriteState)
|
||||||
|
} else if (!selectedElement && !hasActive) {
|
||||||
|
// 失焦后清空所有状态,关闭tip
|
||||||
|
reWriteState.position = null
|
||||||
|
reWriteState.tipPosition = null
|
||||||
|
editorContext?.setState(reWriteState)
|
||||||
|
}
|
||||||
|
}, [selectedElement, editor, tipPosition, isDoc, docModeToolPosition])
|
||||||
|
}
|
||||||
42
components/html-editor/hooks/useEditState.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import type { HTMLEditor } from '../lib';
|
||||||
|
|
||||||
|
export type SaveType = 'manual' | 'auto' | null;
|
||||||
|
|
||||||
|
export const useEditState = () => {
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [saveType, setSaveType] = useState<SaveType>(null);
|
||||||
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
|
const [canRedo, setCanRedo] = useState(false);
|
||||||
|
|
||||||
|
const redo = useRef(() => { });
|
||||||
|
const undo = useRef(() => { });
|
||||||
|
|
||||||
|
const handleHistoryChangeEvent = (instance: HTMLEditor) => {
|
||||||
|
if (!instance) return null
|
||||||
|
const canRedo = instance.EditorRegistry.canRedo()
|
||||||
|
const canUndo = instance.EditorRegistry.canUndo()
|
||||||
|
setCanRedo(canRedo)
|
||||||
|
setCanUndo(canUndo)
|
||||||
|
redo.current = () => {
|
||||||
|
instance.EditorRegistry.redo()
|
||||||
|
}
|
||||||
|
undo.current = () => {
|
||||||
|
instance.EditorRegistry.undo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSaving,
|
||||||
|
setIsSaving,
|
||||||
|
saveType,
|
||||||
|
setSaveType,
|
||||||
|
handleHistoryChangeEvent,
|
||||||
|
canRedo,
|
||||||
|
setCanRedo,
|
||||||
|
canUndo,
|
||||||
|
setCanUndo,
|
||||||
|
redo,
|
||||||
|
undo,
|
||||||
|
}
|
||||||
|
}
|
||||||
261
components/html-editor/hooks/useIframeMode.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import type { Position, EditorStyleConfig, HistoryState } from '../lib'
|
||||||
|
import { cleanDom, HTMLEditor } from '../lib'
|
||||||
|
|
||||||
|
interface UseInjectModeOptions {
|
||||||
|
styleConfig?: EditorStyleConfig
|
||||||
|
enableGlobalContentEditable?: boolean
|
||||||
|
onContentChange?: (srcDoc: string) => void
|
||||||
|
onHistoryChange?: (state: HistoryState, editor: HTMLEditor) => void
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidDom = (innerText: string) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(innerText)
|
||||||
|
if (data && !data.success) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isIfrmae = (target: HTMLElement) => {
|
||||||
|
return target.tagName === 'IFRAME' || target instanceof HTMLIFrameElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseInjectModeReturn {
|
||||||
|
editor: HTMLEditor | null
|
||||||
|
editorIns: HTMLEditor | null
|
||||||
|
selectedElement: HTMLElement | null
|
||||||
|
position: Position | null
|
||||||
|
tipPosition: Position | null
|
||||||
|
injectScript: (targetContainer: HTMLElement) => Promise<void>
|
||||||
|
// 历史记录相关
|
||||||
|
canUndo: boolean
|
||||||
|
canRedo: boolean
|
||||||
|
clearHistory: () => void
|
||||||
|
loadSuccess: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForIframeReady(
|
||||||
|
iframe: HTMLIFrameElement,
|
||||||
|
timeout = 5000,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const start = performance.now()
|
||||||
|
|
||||||
|
const tryAttach = () => {
|
||||||
|
const doc = iframe.contentDocument
|
||||||
|
if (!doc) {
|
||||||
|
if (performance.now() - start > timeout) return resolve()
|
||||||
|
return requestAnimationFrame(tryAttach)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经 complete,直接 resolve
|
||||||
|
if (doc.readyState === 'complete') {
|
||||||
|
return resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
let stableTimer: any
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
clearTimeout(stableTimer)
|
||||||
|
stableTimer = setTimeout(() => {
|
||||||
|
observer.disconnect()
|
||||||
|
resolve()
|
||||||
|
}, 800)
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(doc.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 超时保护
|
||||||
|
setTimeout(() => {
|
||||||
|
observer.disconnect()
|
||||||
|
clearTimeout(stableTimer)
|
||||||
|
console.error('iframe ready timeout')
|
||||||
|
resolve()
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
// 同时监听 readyState
|
||||||
|
const checkReady = () => {
|
||||||
|
if (doc.readyState === 'complete') {
|
||||||
|
observer.disconnect()
|
||||||
|
clearTimeout(stableTimer)
|
||||||
|
resolve()
|
||||||
|
} else if (performance.now() - start < timeout) {
|
||||||
|
requestAnimationFrame(checkReady)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
tryAttach()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIframeMode(
|
||||||
|
id: string,
|
||||||
|
container: React.RefObject<HTMLIFrameElement | HTMLElement | null>,
|
||||||
|
options?: UseInjectModeOptions,
|
||||||
|
scale = 1,
|
||||||
|
): UseInjectModeReturn {
|
||||||
|
const editorRef = useRef<HTMLEditor | null>(null)
|
||||||
|
const [editorIns, setEditorIns] = useState<HTMLEditor | null>(null)
|
||||||
|
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [position, setPosition] = useState<Position | null>(null)
|
||||||
|
const [canUndo, setCanUndo] = useState(false)
|
||||||
|
const [canRedo, setCanRedo] = useState(false)
|
||||||
|
const [loadSuccess, setLoadSuccess] = useState(false)
|
||||||
|
|
||||||
|
const injectScript = useCallback(async (targetContainer: HTMLElement) => {
|
||||||
|
if (!targetContainer) {
|
||||||
|
console.error('ifrmae is null')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (editorRef.current) {
|
||||||
|
console.error('ifrmae is destroyed')
|
||||||
|
editorRef.current.destroy()
|
||||||
|
}
|
||||||
|
const editor = new HTMLEditor({
|
||||||
|
id,
|
||||||
|
styleConfig: options?.styleConfig,
|
||||||
|
enableGlobalContentEditable: options?.enableGlobalContentEditable,
|
||||||
|
onElementSelect: (element: HTMLElement | null, pos?: Position) => {
|
||||||
|
setSelectedElement(element)
|
||||||
|
if (element && pos) {
|
||||||
|
setPosition(pos)
|
||||||
|
} else {
|
||||||
|
setPosition(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStyleChange: (element: HTMLElement) => {
|
||||||
|
if (element) {
|
||||||
|
const pos = element.getBoundingClientRect()
|
||||||
|
setPosition(pos)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onContentChange: () => {
|
||||||
|
// 触发内容变化回调,数据清洗
|
||||||
|
if (options?.onContentChange) {
|
||||||
|
if (editor.isIframe) {
|
||||||
|
const iframe = container.current as HTMLIFrameElement
|
||||||
|
const iframeDoc = iframe.contentDocument?.documentElement
|
||||||
|
if (iframeDoc) {
|
||||||
|
const srcDoc = cleanDom(iframeDoc)
|
||||||
|
options.onContentChange(srcDoc)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const srcDoc = cleanDom(container.current as HTMLElement)
|
||||||
|
options.onContentChange(srcDoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHistoryChange: (state: HistoryState) => {
|
||||||
|
setCanUndo(state.canUndo)
|
||||||
|
setCanRedo(state.canRedo)
|
||||||
|
options?.onHistoryChange?.(state, editor)
|
||||||
|
},
|
||||||
|
enableMoveable: true,
|
||||||
|
helperBox: true,
|
||||||
|
enableHistory: true,
|
||||||
|
historyOptions: {
|
||||||
|
maxHistorySize: 100,
|
||||||
|
mergeInterval: 1000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
editor.init(targetContainer)
|
||||||
|
editorRef.current = editor
|
||||||
|
setEditorIns(editor)
|
||||||
|
}, [id, options, container])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = container.current
|
||||||
|
if (!element || options?.enabled === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isIframeEle = isIfrmae(element)
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// macos 快捷键 command + z 撤销 / command + shift + z 重做
|
||||||
|
if (e.metaKey && e.key === 'z' && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
editorRef.current?.redo()
|
||||||
|
}
|
||||||
|
if (e.metaKey && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
editorRef.current?.undo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isIframeEle) {
|
||||||
|
// iframe 加载
|
||||||
|
const onLoad = async () => {
|
||||||
|
await waitForIframeReady(element as HTMLIFrameElement)
|
||||||
|
const doc = (element as HTMLIFrameElement).contentDocument!
|
||||||
|
// 判断是否是合法的 iframe 内容
|
||||||
|
const loadSuccess = isValidDom(doc.body.innerText)
|
||||||
|
if (loadSuccess) {
|
||||||
|
injectScript(doc.body)
|
||||||
|
console.log('编辑器加载成功')
|
||||||
|
setLoadSuccess(true)
|
||||||
|
const document = (element as HTMLIFrameElement).contentDocument
|
||||||
|
?.documentElement
|
||||||
|
document?.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document?.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
; (element as HTMLIFrameElement).onload = onLoad
|
||||||
|
} else {
|
||||||
|
injectScript(element)
|
||||||
|
setLoadSuccess(true)
|
||||||
|
document?.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document?.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [injectScript, container, options?.enabled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (editorRef.current && loadSuccess) {
|
||||||
|
console.log('销毁编辑器')
|
||||||
|
editorRef.current.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loadSuccess])
|
||||||
|
|
||||||
|
const tipPosition = useMemo(() => {
|
||||||
|
if (!container.current || !position) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const elementRect = container.current.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
top: position.top * scale + elementRect.top,
|
||||||
|
left: position.left * scale,
|
||||||
|
width: position.width * scale,
|
||||||
|
height: position.height * scale,
|
||||||
|
bottom: position.bottom * scale + elementRect.top,
|
||||||
|
right: position.right * scale + elementRect.left,
|
||||||
|
}
|
||||||
|
}, [position, scale, container])
|
||||||
|
|
||||||
|
return {
|
||||||
|
editor: editorRef.current,
|
||||||
|
editorIns,
|
||||||
|
selectedElement,
|
||||||
|
position,
|
||||||
|
tipPosition,
|
||||||
|
injectScript,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
loadSuccess,
|
||||||
|
clearHistory: () => editorRef.current?.clearHistory(),
|
||||||
|
}
|
||||||
|
}
|
||||||
25
components/html-editor/hooks/useLoadContent.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNovaKit } from '@/components/nova-sdk/context/useNovaKit';
|
||||||
|
import type { TaskArtifact } from '@/components/nova-sdk/types';
|
||||||
|
|
||||||
|
export const useLoadContent = (
|
||||||
|
taskArtifact: TaskArtifact
|
||||||
|
) => {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const { api } = useNovaKit();
|
||||||
|
useEffect(() => {
|
||||||
|
if (taskArtifact.path) {
|
||||||
|
api
|
||||||
|
.getArtifactUrl?.(taskArtifact)
|
||||||
|
.then(async (res) => {
|
||||||
|
const url = res?.data || '';
|
||||||
|
if (url) {
|
||||||
|
const content = await fetch(url).then((res) => res.text());
|
||||||
|
setContent(content);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { });
|
||||||
|
}
|
||||||
|
}, [taskArtifact, api]);
|
||||||
|
return content;
|
||||||
|
};
|
||||||
82
components/html-editor/hooks/useToolPostion.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { HTMLEditor } from '../lib'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export const useToolPostion = (editor: HTMLEditor | null, isDoc: boolean) => {
|
||||||
|
const [position, setPosition] = useState<{ left: number; top: number, bottom: number, right: number, width: number, height: number } | null>(null)
|
||||||
|
// 标识是否从编辑器内开始选择
|
||||||
|
const isSelectingRef = useRef(false)
|
||||||
|
const updatePositionFromSelection = () => {
|
||||||
|
if (!editor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const view = editor.getDoc().view
|
||||||
|
if (!view) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const selection = view.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
// 如果选区为空,不需要更新
|
||||||
|
const hasSelectAnything = selection.rangeCount === 0 || selection.toString().trim() === ''
|
||||||
|
if (selection.isCollapsed || selection.rangeCount === 0 || hasSelectAnything) {
|
||||||
|
setPosition(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取选区的 Range 对象
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
// 获取选区末尾的位置
|
||||||
|
// 创建一个新的 Range,只包含选区的末尾点
|
||||||
|
const endRange = range.cloneRange();
|
||||||
|
endRange.collapse(false); // false 表示折叠到末尾
|
||||||
|
const rect = endRange.getBoundingClientRect();
|
||||||
|
|
||||||
|
const { top, left, bottom, right, width, height } = rect
|
||||||
|
setPosition({ top, left, bottom, right, width, height })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !isDoc) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dom = editor.getDoc().document
|
||||||
|
if (!dom) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseDown = () => {
|
||||||
|
isSelectingRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
// 只有从编辑器内开始选择时才处理
|
||||||
|
if (!isSelectingRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSelectingRef.current = false
|
||||||
|
setTimeout(() => {
|
||||||
|
updatePositionFromSelection()
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScrollOrResize = () => {
|
||||||
|
updatePositionFromSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
dom.addEventListener('mousedown', onMouseDown)
|
||||||
|
dom.addEventListener('mouseup', onMouseUp)
|
||||||
|
dom.addEventListener('scroll', onScrollOrResize)
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScrollOrResize, true)
|
||||||
|
window.addEventListener('resize', onScrollOrResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dom.removeEventListener('mousedown', onMouseDown)
|
||||||
|
dom.removeEventListener('mouseup', onMouseUp)
|
||||||
|
dom.removeEventListener('scroll', onScrollOrResize)
|
||||||
|
window.removeEventListener('scroll', onScrollOrResize, true)
|
||||||
|
window.removeEventListener('resize', onScrollOrResize)
|
||||||
|
}
|
||||||
|
}, [editor, updatePositionFromSelection, isDoc])
|
||||||
|
|
||||||
|
return position
|
||||||
|
}
|
||||||
23
components/html-editor/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { TaskArtifact } from '../types';
|
||||||
|
import { TaskHtmlDoc } from './mode/html-doc';
|
||||||
|
import { TaskHtmlWeb } from './mode/html-web';
|
||||||
|
import type { ArtifactEditState } from './types';
|
||||||
|
|
||||||
|
export const TaskArtifactHtml: React.FC<{
|
||||||
|
taskId: string;
|
||||||
|
editable?: boolean;
|
||||||
|
taskArtifact: TaskArtifact;
|
||||||
|
type: 'document' | 'web';
|
||||||
|
onStateChange?: (state: ArtifactEditState) => void;
|
||||||
|
}> = (props) => {
|
||||||
|
const { taskId, editable, taskArtifact, type, onStateChange } = props;
|
||||||
|
const Component = type === 'document' ? TaskHtmlDoc : TaskHtmlWeb;
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
taskId={taskId}
|
||||||
|
taskArtifact={taskArtifact}
|
||||||
|
editable={editable}
|
||||||
|
onStateChange={onStateChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
178
components/html-editor/lib/config/styles.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Editor Styles Configuration
|
||||||
|
* 编辑器样式配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type EditorStyleConfig } from '../types';
|
||||||
|
|
||||||
|
export const defaultStyleConfig: EditorStyleConfig = {
|
||||||
|
hover: {
|
||||||
|
outline: '1px dashed var(--editor-accent)',
|
||||||
|
outlineOffset: '0px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
outline: '1px solid var(--editor-accent)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
enabled: true,
|
||||||
|
position: 'top-left',
|
||||||
|
offset: {
|
||||||
|
top: '-24px',
|
||||||
|
left: '0',
|
||||||
|
},
|
||||||
|
background: 'var(--editor-accent)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
zIndex: 10000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CSS from style configuration
|
||||||
|
* 从样式配置生成CSS
|
||||||
|
*/
|
||||||
|
export function generateEditorCSS(config: EditorStyleConfig = defaultStyleConfig, enableMoveable: boolean | undefined, helperBox: boolean | undefined): string {
|
||||||
|
const { hover, selected, badge } = config;
|
||||||
|
const scope = '.html-visual-editor';
|
||||||
|
|
||||||
|
let css = `
|
||||||
|
${scope} {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
${scope} .hover-highlight {
|
||||||
|
outline: ${helperBox ? 'none' : hover.outline} !important;
|
||||||
|
outline-offset: ${hover.outlineOffset};
|
||||||
|
// position: relative;
|
||||||
|
cursor: ${hover.cursor};
|
||||||
|
${hover.backgroundColor ? `background-color: ${hover.backgroundColor} !important;` : ''}
|
||||||
|
}
|
||||||
|
|
||||||
|
${scope} .selected-element {
|
||||||
|
outline: ${enableMoveable || helperBox ? 'none' : selected.outline} !important;
|
||||||
|
outline-offset: ${selected.outlineOffset};
|
||||||
|
cursor: ${selected.cursor};
|
||||||
|
// position: relative;
|
||||||
|
${selected.backgroundColor ? `background-color: ${selected.backgroundColor} !important;` : ''}
|
||||||
|
}
|
||||||
|
|
||||||
|
${scope} [contenteditable="true"] {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-font-smoothing: inherit !important;
|
||||||
|
-moz-osx-font-smoothing: inherit !important;
|
||||||
|
text-rendering: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
${scope} [contenteditable="true"]:focus {
|
||||||
|
outline: ${enableMoveable || helperBox ? 'none' : selected.outline} !important;
|
||||||
|
outline-offset: ${selected.outlineOffset};
|
||||||
|
cursor: text !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
${scope} [contenteditable="true"]:empty:not(:focus)::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveable-control-box>.moveable-line{
|
||||||
|
background: var(--editor-accent) !important;
|
||||||
|
height: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveable-control-box>.moveable-control:not(.moveable-e):not(.moveable-w){
|
||||||
|
border: 2px solid var(--editor-accent) !important;
|
||||||
|
background: var(--editor-surface) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moveable-control-box>.moveable-control.moveable-e{
|
||||||
|
width: 10px !important;
|
||||||
|
height: 22px !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
margin-top: -11px !important;
|
||||||
|
margin-left: -5px !important;
|
||||||
|
border: 2px solid var(--editor-accent) !important;
|
||||||
|
background: var(--editor-surface) !important;
|
||||||
|
}
|
||||||
|
.moveable-control-box>.moveable-control.moveable-w{
|
||||||
|
width: 10px !important;
|
||||||
|
height: 22px !important;
|
||||||
|
border-radius: 7px !important;
|
||||||
|
border: 2px solid var(--editor-accent) !important;
|
||||||
|
background: var(--editor-surface) !important;
|
||||||
|
margin-top: -11px !important;
|
||||||
|
margin-left: -5px !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 如果启用角标,添加 ::before 伪元素样式
|
||||||
|
if (badge.enabled) {
|
||||||
|
const badgePosition = getBadgePositionCSS(badge.position || 'top-left', badge.offset);
|
||||||
|
css += `
|
||||||
|
${scope} #html-editor-helper-box::before {
|
||||||
|
content: attr(data-element-type);
|
||||||
|
position: absolute;
|
||||||
|
${badgePosition}
|
||||||
|
background: ${badge.background};
|
||||||
|
color: ${badge.color};
|
||||||
|
padding: ${badge.padding};
|
||||||
|
border-radius: ${badge.borderRadius};
|
||||||
|
font-size: ${badge.fontSize};
|
||||||
|
font-family: ${badge.fontFamily};
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: ${badge.zIndex};
|
||||||
|
}}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get badge position CSS based on position type
|
||||||
|
* 根据位置类型获取角标位置CSS
|
||||||
|
*/
|
||||||
|
function getBadgePositionCSS(
|
||||||
|
position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
|
||||||
|
offset?: { top?: string; left?: string; right?: string; bottom?: string }
|
||||||
|
): string {
|
||||||
|
const defaultOffset = offset || {};
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'top-left':
|
||||||
|
return `
|
||||||
|
top: ${defaultOffset.top || '-24px'};
|
||||||
|
left: ${defaultOffset.left || '0'};
|
||||||
|
`;
|
||||||
|
case 'top-right':
|
||||||
|
return `
|
||||||
|
top: ${defaultOffset.top || '-24px'};
|
||||||
|
right: ${defaultOffset.right || '0'};
|
||||||
|
`;
|
||||||
|
case 'bottom-left':
|
||||||
|
return `
|
||||||
|
bottom: ${defaultOffset.bottom || '-24px'};
|
||||||
|
left: ${defaultOffset.left || '0'};
|
||||||
|
`;
|
||||||
|
case 'bottom-right':
|
||||||
|
return `
|
||||||
|
bottom: ${defaultOffset.bottom || '-24px'};
|
||||||
|
right: ${defaultOffset.right || '0'};
|
||||||
|
`;
|
||||||
|
default:
|
||||||
|
return `
|
||||||
|
top: ${defaultOffset.top || '-24px'};
|
||||||
|
left: ${defaultOffset.left || '0'};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
defaultStyleConfig,
|
||||||
|
generateEditorCSS,
|
||||||
|
};
|
||||||
754
components/html-editor/lib/core/editor/index.ts
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
/**
|
||||||
|
* HTML Visual Editor Core Class
|
||||||
|
* 核心编辑器类,提供基础的编辑功能
|
||||||
|
*/
|
||||||
|
import { EventManager } from '../eventManager'
|
||||||
|
import StyleManager from '../styleManager';
|
||||||
|
import { MoveableManager } from '../moveableManager';
|
||||||
|
import { HistoryManager } from '../historyManager';
|
||||||
|
import {
|
||||||
|
createElementAddCommand,
|
||||||
|
createElementDeleteCommand,
|
||||||
|
createAttributeChangeCommand,
|
||||||
|
createStyleChangeCommand,
|
||||||
|
createContentChangeCommand
|
||||||
|
} from '../historyManager/commands';
|
||||||
|
import { defaultStyleConfig, generateEditorCSS } from '../../config/styles';
|
||||||
|
import type { HTMLEditorOptions, Position, EditorStyleConfig } from '../../types';
|
||||||
|
import { createElement, elementWatcher, getElementType, isImageElement, isInlineElement, isTableElement } from '../utils';
|
||||||
|
import EditorRegistry from '../editorRegistry';
|
||||||
|
import { HelperBoxManager } from '../helperBoxManager';
|
||||||
|
import GlobalEditable from '../globalEditable';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export class HTMLEditor {
|
||||||
|
id: string;
|
||||||
|
options: HTMLEditorOptions;
|
||||||
|
selectedElement: HTMLElement | null;
|
||||||
|
|
||||||
|
eventManager: EventManager | null;
|
||||||
|
styleManager: StyleManager | null;
|
||||||
|
moveableManager: MoveableManager | null;
|
||||||
|
historyManager: HistoryManager | null;
|
||||||
|
helperBoxManager: HelperBoxManager | null;
|
||||||
|
container: HTMLElement | null;
|
||||||
|
EditorRegistry: typeof EditorRegistry;
|
||||||
|
elementWatcher!: ReturnType<typeof elementWatcher>;
|
||||||
|
globalEditable: GlobalEditable | null;
|
||||||
|
|
||||||
|
// 操作状态
|
||||||
|
isDragging: boolean = false;
|
||||||
|
isResizing: boolean = false;
|
||||||
|
isChangingBackground: boolean = false;
|
||||||
|
isInsertMode: boolean = false;
|
||||||
|
isChangingColor: boolean = false;
|
||||||
|
isIframe: boolean;
|
||||||
|
__globalEditHandlers: { handleInput?: (e: Event) => void; handleKeyDown?: (e: KeyboardEvent) => void } | null = null;
|
||||||
|
suppressBodyInputRecord: boolean = false;
|
||||||
|
|
||||||
|
constructor(options: HTMLEditorOptions) {
|
||||||
|
this.options = {
|
||||||
|
container: null,
|
||||||
|
theme: 'default',
|
||||||
|
autoSave: false,
|
||||||
|
styleConfig: defaultStyleConfig,
|
||||||
|
enableContentEditable: true, // 默认启用
|
||||||
|
enableMoveable: false, // 默认启用拖拽与缩放
|
||||||
|
enableHistory: true, // 默认启用历史记录
|
||||||
|
historyOptions: {
|
||||||
|
maxHistorySize: 100,
|
||||||
|
mergeInterval: 1000,
|
||||||
|
},
|
||||||
|
onElementSelect: null,
|
||||||
|
onStyleChange: null,
|
||||||
|
onContentChange: null,
|
||||||
|
onReady: null,
|
||||||
|
onHistoryChange: null,
|
||||||
|
ignoreSelectTags: ['body', 'html'],
|
||||||
|
enableGlobalContentEditable: false,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// 合并用户自定义样式配置
|
||||||
|
if (options.styleConfig) {
|
||||||
|
this.options.styleConfig = {
|
||||||
|
...defaultStyleConfig,
|
||||||
|
hover: { ...defaultStyleConfig.hover, ...options.styleConfig.hover },
|
||||||
|
selected: { ...defaultStyleConfig.selected, ...options.styleConfig.selected },
|
||||||
|
badge: { ...defaultStyleConfig.badge, ...options.styleConfig.badge },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并历史记录配置
|
||||||
|
if (options.historyOptions) {
|
||||||
|
this.options.historyOptions = {
|
||||||
|
...this.options.historyOptions,
|
||||||
|
...options.historyOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.id = this.options.id;
|
||||||
|
this.selectedElement = null;
|
||||||
|
|
||||||
|
this.eventManager = null;
|
||||||
|
this.styleManager = null;
|
||||||
|
this.moveableManager = null;
|
||||||
|
this.historyManager = null;
|
||||||
|
this.helperBoxManager = null;
|
||||||
|
this.container = null;
|
||||||
|
this.EditorRegistry = EditorRegistry;
|
||||||
|
this.isIframe = false;
|
||||||
|
this.__globalEditHandlers = null as any;
|
||||||
|
this.globalEditable = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(container?: HTMLElement | string): void {
|
||||||
|
if (container) {
|
||||||
|
this.options.container = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setupContainer();
|
||||||
|
// 注册到全局编辑器注册表
|
||||||
|
this.EditorRegistry.register(this);
|
||||||
|
this.initializeManagers();
|
||||||
|
this.globalEditable = new GlobalEditable(this);
|
||||||
|
this.setGlobalContentEditableEnabled(!!this.options.enableGlobalContentEditable);
|
||||||
|
this.bindEvents();
|
||||||
|
this.elementWatcher = elementWatcher(this);
|
||||||
|
if (this.options.helperBox) {
|
||||||
|
this.helperBoxManager?.init();
|
||||||
|
}
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupContainer(): void {
|
||||||
|
const container = typeof this.options.container === 'string'
|
||||||
|
? document.querySelector<HTMLElement>(this.options.container)
|
||||||
|
: this.options.container;
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('Container not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container = container;
|
||||||
|
this.container.classList.add('html-visual-editor');
|
||||||
|
// 检测container是否是iframe
|
||||||
|
this.detectIframe();
|
||||||
|
|
||||||
|
// 注入编辑器样式
|
||||||
|
this.injectStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前的window/document
|
||||||
|
getDoc() {
|
||||||
|
const container = this.container;
|
||||||
|
return {
|
||||||
|
view: container ? container.ownerDocument.defaultView : window,
|
||||||
|
document: container ? container.ownerDocument : document
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查container是否是iframe中
|
||||||
|
*/
|
||||||
|
detectIframe(): void {
|
||||||
|
// 检查container是否在iframe中
|
||||||
|
if (this.container && this.getDoc().document !== document) {
|
||||||
|
this.isIframe = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注入编辑器样式到文档中
|
||||||
|
*/
|
||||||
|
injectStyles(): void {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const doc = this.getDoc().document;
|
||||||
|
const styleId = 'html-editor-styles';
|
||||||
|
|
||||||
|
// 检查是否已经注入过样式
|
||||||
|
const oldStyleElement = doc.getElementById(styleId);
|
||||||
|
if (oldStyleElement) {
|
||||||
|
doc.head.removeChild(doc.getElementById(styleId) as Node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styleElement = doc.createElement('style');
|
||||||
|
styleElement.id = styleId;
|
||||||
|
styleElement.textContent = generateEditorCSS(this.options.styleConfig as EditorStyleConfig, this.options.enableMoveable, this.options.helperBox);
|
||||||
|
doc.head.appendChild(styleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeManagers(): void {
|
||||||
|
this.eventManager = new EventManager(this);
|
||||||
|
this.styleManager = new StyleManager(this);
|
||||||
|
this.moveableManager = new MoveableManager(this, (this.options as any).moveableOptions ?? {});
|
||||||
|
this.helperBoxManager = new HelperBoxManager(this);
|
||||||
|
|
||||||
|
// 初始化历史管理器
|
||||||
|
if (this.options.enableHistory !== false) {
|
||||||
|
this.historyManager = new HistoryManager(this, this.options.historyOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents(): void {
|
||||||
|
if (this.eventManager) {
|
||||||
|
this.eventManager.bindAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 元素选择
|
||||||
|
selectElement(element: HTMLElement): void {
|
||||||
|
if (this.isInsertMode) return;
|
||||||
|
const lastSelectedElement = this.selectedElement;
|
||||||
|
// 清除上一个选择
|
||||||
|
this.clearSelection();
|
||||||
|
|
||||||
|
element.classList.add('selected-element');
|
||||||
|
element.setAttribute('data-element-type', getElementType(element));
|
||||||
|
if (isInlineElement(element)) {
|
||||||
|
// 如果元素是内联元素,设置为 inline-block,解决moveable无法拖拽的问题
|
||||||
|
element.style.display = 'inline-block';
|
||||||
|
element.setAttribute('original-display', 'inline');
|
||||||
|
}
|
||||||
|
this.selectedElement = element;
|
||||||
|
const isTable = isTableElement(element);
|
||||||
|
// 启用 moveable
|
||||||
|
if (this.options.enableMoveable && this.moveableManager) {
|
||||||
|
const defaultMoveableOptions = (this.options as any).moveableOptions ?? {};
|
||||||
|
const keepRatio = isImageElement(element) ? true : (defaultMoveableOptions.keepRatio ?? false);
|
||||||
|
if (!isTable) {
|
||||||
|
element.style.cursor = 'move';
|
||||||
|
this.moveableManager.enableFor(element, { keepRatio });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果元素不再是 contenteditable,重新启用编辑
|
||||||
|
if (this.options.enableContentEditable && element.getAttribute('contenteditable') !== 'true') {
|
||||||
|
if (isTable) {
|
||||||
|
this.enableElementEditing(element);
|
||||||
|
}
|
||||||
|
// 如果是同一个元素,检查是否需要重新启用编辑
|
||||||
|
if (element === lastSelectedElement) {
|
||||||
|
this.enableElementEditing(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = this.getBoundPostion(element);
|
||||||
|
this.emit('elementSelect', element, position);
|
||||||
|
this.elementWatcher.start(element, () => {
|
||||||
|
this.emit('styleChange', element);
|
||||||
|
this.moveableManager?.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果启用了 helperBox,则更新其位置
|
||||||
|
if (this.options.helperBox && this.helperBoxManager) {
|
||||||
|
this.helperBoxManager.updatePostion(position);
|
||||||
|
this.helperBoxManager.visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取元素的边界相对位置
|
||||||
|
*/
|
||||||
|
getBoundPostion(target: HTMLElement) {
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const containerRect = this.container!.getBoundingClientRect();
|
||||||
|
const position: Position = {
|
||||||
|
top: this.isIframe ? rect.top : rect.top - containerRect.top,
|
||||||
|
left: this.isIframe ? rect.left : rect.left - containerRect.left,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
bottom: this.isIframe ? rect.bottom : rect.bottom - containerRect.top,
|
||||||
|
right: this.isIframe ? rect.right : rect.right - containerRect.left
|
||||||
|
};
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用元素编辑
|
||||||
|
*/
|
||||||
|
enableElementEditing(element: HTMLElement): void {
|
||||||
|
const existingHandlers = (element as any).__editHandlers;
|
||||||
|
if (existingHandlers) {
|
||||||
|
this.removeEditListeners(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原始的contenteditable状态
|
||||||
|
if (!element.hasAttribute('data-original-contenteditable')) {
|
||||||
|
const originalValue = element.getAttribute('contenteditable') || 'inherit';
|
||||||
|
element.setAttribute('data-original-contenteditable', originalValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.execCommand('defaultParagraphSeparator', false, 'br');
|
||||||
|
// 设置为可编辑
|
||||||
|
element.setAttribute('contenteditable', 'true');
|
||||||
|
|
||||||
|
element.focus();
|
||||||
|
|
||||||
|
const initialContent = element.innerHTML;
|
||||||
|
let lastRecordedContent = initialContent;
|
||||||
|
const handleInput = () => {
|
||||||
|
const newContent = element.innerHTML;
|
||||||
|
if (this.historyManager && newContent !== lastRecordedContent) {
|
||||||
|
const cmd = createContentChangeCommand(element, lastRecordedContent, newContent);
|
||||||
|
this.historyManager.push(cmd);
|
||||||
|
lastRecordedContent = newContent;
|
||||||
|
}
|
||||||
|
this.emit('contentChange');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
// 失去焦点时禁用contenteditable
|
||||||
|
this.disableElementEditing(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const selection = this.isIframe ? this.getDoc().document.getSelection() : window.getSelection()
|
||||||
|
if (!selection || !selection.rangeCount) return;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const br = document.createElement('br');
|
||||||
|
range.insertNode(br);
|
||||||
|
// 光标移动到 <br> 之后
|
||||||
|
range.setStartAfter(br);
|
||||||
|
range.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
const newContent = element.innerHTML;
|
||||||
|
if (this.historyManager && newContent !== lastRecordedContent) {
|
||||||
|
const cmd = createContentChangeCommand(element, lastRecordedContent, newContent);
|
||||||
|
this.historyManager.push(cmd);
|
||||||
|
lastRecordedContent = newContent;
|
||||||
|
}
|
||||||
|
this.emit('contentChange');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
element.addEventListener('input', handleInput);
|
||||||
|
element.addEventListener('blur', handleBlur);
|
||||||
|
element.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// 保存事件处理器引用,便于后续清理
|
||||||
|
(element as any).__editHandlers = { handleInput, handleBlur, handleKeyDown };
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeEditListeners(element: HTMLElement): void {
|
||||||
|
const handlers = (element as any).__editHandlers;
|
||||||
|
if (!handlers) return;
|
||||||
|
const pairs: Array<[string, EventListener | undefined]> = [
|
||||||
|
['input', handlers.handleInput],
|
||||||
|
['blur', handlers.handleBlur],
|
||||||
|
['keydown', handlers.handleKeyDown],
|
||||||
|
];
|
||||||
|
for (const [type, fn] of pairs) {
|
||||||
|
if (fn) element.removeEventListener(type, fn as EventListener);
|
||||||
|
}
|
||||||
|
delete (element as any).__editHandlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用元素编辑
|
||||||
|
*/
|
||||||
|
disableElementEditing(element: HTMLElement): void {
|
||||||
|
// 恢复原始的contenteditable状态
|
||||||
|
const originalValue = element.getAttribute('data-original-contenteditable');
|
||||||
|
if (originalValue) {
|
||||||
|
if (originalValue === 'inherit') {
|
||||||
|
element.removeAttribute('contenteditable');
|
||||||
|
} else {
|
||||||
|
element.setAttribute('contenteditable', originalValue);
|
||||||
|
}
|
||||||
|
element.removeAttribute('data-original-contenteditable');
|
||||||
|
} else {
|
||||||
|
element.removeAttribute('contenteditable');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeEditListeners(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInsertMode(value: boolean): void {
|
||||||
|
this.isInsertMode = value;
|
||||||
|
if (this.container) {
|
||||||
|
if (value) {
|
||||||
|
this.container.style.cursor = this.isIframe ? 'crosshair' : 'text';
|
||||||
|
} else {
|
||||||
|
this.container.style.cursor = '';
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
const doc = this.getDoc().document;
|
||||||
|
doc.querySelectorAll('.hover-highlight').forEach((el: Element) => {
|
||||||
|
(el as HTMLElement).classList.remove('hover-highlight');
|
||||||
|
(el as HTMLElement).removeAttribute('data-element-type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enableInsertMode(): void {
|
||||||
|
this.EditorRegistry.enableInsertMode(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
disableInsertMode(): void {
|
||||||
|
this.EditorRegistry.disableAllInsertMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
insertTextAtPosition(clientX: number, clientY: number): HTMLElement | null {
|
||||||
|
if (!this.container) return null;
|
||||||
|
const rect = this.container.getBoundingClientRect();
|
||||||
|
const x = clientX - rect.left;
|
||||||
|
const y = clientY - rect.top;
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.textContent = '请输入文字';
|
||||||
|
element.style.position = 'absolute';
|
||||||
|
element.style.left = `${Math.max(0, Math.round(x))}px`;
|
||||||
|
element.style.top = `${Math.max(0, Math.round(y))}px`;
|
||||||
|
element.style.fontSize = '32px';
|
||||||
|
element.style.lineHeight = '1.2';
|
||||||
|
element.style.backgroundColor = 'transparent';
|
||||||
|
element.style.border = 'none';
|
||||||
|
element.style.padding = '0';
|
||||||
|
element.style.margin = '0';
|
||||||
|
|
||||||
|
if (this.historyManager) {
|
||||||
|
const command = createElementAddCommand(element, (this.selectedElement ?? this.container!) as HTMLElement, this.container!, null);
|
||||||
|
command.execute();
|
||||||
|
this.historyManager.push(command);
|
||||||
|
} else {
|
||||||
|
this.container.appendChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disableInsertMode();
|
||||||
|
this.selectElement(element);
|
||||||
|
// this.enableElementEditing(element);
|
||||||
|
this.emit('contentChange');
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection(): void {
|
||||||
|
if (this.selectedElement) {
|
||||||
|
this.elementWatcher.stop(this.selectedElement);
|
||||||
|
this.selectedElement.classList.remove('selected-element');
|
||||||
|
this.selectedElement.removeAttribute('data-element-type');
|
||||||
|
if (this.selectedElement.getAttribute('original-display') === 'inline') {
|
||||||
|
// 如果元素是内联元素,设置为 inline-block,解决moveable无法拖拽的问题
|
||||||
|
this.selectedElement.style.display = 'inline';
|
||||||
|
this.selectedElement.removeAttribute('original-display');
|
||||||
|
}
|
||||||
|
// 禁用编辑功能
|
||||||
|
if (this.options.enableContentEditable) {
|
||||||
|
this.disableElementEditing(this.selectedElement);
|
||||||
|
}
|
||||||
|
// 销毁 moveable
|
||||||
|
if (this.moveableManager) {
|
||||||
|
this.moveableManager.destroy();
|
||||||
|
this.selectedElement.style.cursor = '';
|
||||||
|
}
|
||||||
|
this.selectedElement = null;
|
||||||
|
this.emit('elementSelect', null);
|
||||||
|
}
|
||||||
|
// 清除主文档中的hover样式
|
||||||
|
document.querySelectorAll('.hover-highlight').forEach(el => {
|
||||||
|
el.classList.remove('hover-highlight');
|
||||||
|
el.removeAttribute('data-element-type');
|
||||||
|
});
|
||||||
|
// 如果在iframe中,也清除iframe文档中的样式
|
||||||
|
if (this.container) {
|
||||||
|
const ownerDoc = this.getDoc().document
|
||||||
|
if (ownerDoc !== document) {
|
||||||
|
ownerDoc.querySelectorAll('.hover-highlight').forEach(el => {
|
||||||
|
el.classList.remove('hover-highlight');
|
||||||
|
el.removeAttribute('data-element-type');
|
||||||
|
});
|
||||||
|
ownerDoc.querySelectorAll('.selected-element').forEach(el => {
|
||||||
|
el.classList.remove('selected-element');
|
||||||
|
el.removeAttribute('data-element-type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果启用了 helperBox,则隐藏
|
||||||
|
if (this.options.helperBox && this.helperBoxManager) {
|
||||||
|
this.helperBoxManager.visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开启/关闭全局 contenteditable 模式
|
||||||
|
*/
|
||||||
|
setGlobalContentEditableEnabled(enabled: boolean): void {
|
||||||
|
this.globalEditable?.setEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 样式应用
|
||||||
|
applyTextStyle(property: string, value: string): boolean {
|
||||||
|
if (!this.styleManager) return false;
|
||||||
|
return this.styleManager.applyTextStyle(property, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBlockStyle(property: string, value: string): boolean {
|
||||||
|
if (!this.styleManager) return false;
|
||||||
|
return this.styleManager.applyBlockStyle(property, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 元素操作
|
||||||
|
addElement(selectedElement: HTMLElement, type: string, content: string = ''): HTMLElement {
|
||||||
|
const element = createElement(type, content);
|
||||||
|
if (this.container) {
|
||||||
|
// 记录添加操作
|
||||||
|
if (this.historyManager) {
|
||||||
|
const command = createElementAddCommand(element, selectedElement, this.container, null);
|
||||||
|
command.execute();
|
||||||
|
this.historyManager.push(command);
|
||||||
|
} else {
|
||||||
|
this.container.appendChild(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.selectElement(element);
|
||||||
|
this.emit('contentChange');
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteElement(element: HTMLElement | null = this.selectedElement): boolean {
|
||||||
|
if (!element || element === this.container) return false;
|
||||||
|
|
||||||
|
const parent = element.parentElement;
|
||||||
|
const nextSibling = element.nextSibling as HTMLElement | null;
|
||||||
|
|
||||||
|
if (!parent) return false;
|
||||||
|
|
||||||
|
// 记录删除操作
|
||||||
|
if (this.historyManager) {
|
||||||
|
const command = createElementDeleteCommand(element, parent, nextSibling);
|
||||||
|
command.execute();
|
||||||
|
this.historyManager.push(command);
|
||||||
|
} else {
|
||||||
|
element.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearSelection();
|
||||||
|
this.emit('contentChange');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制元素并插入到当前元素的同级下方
|
||||||
|
copyElement(element: HTMLElement | null = this.selectedElement): HTMLElement | null {
|
||||||
|
if (!element || element === this.container) return null;
|
||||||
|
|
||||||
|
const parent = element.parentElement;
|
||||||
|
const nextSibling = element.nextSibling as HTMLElement | null;
|
||||||
|
if (!parent) return null;
|
||||||
|
|
||||||
|
// 深拷贝节点,包括子元素与样式
|
||||||
|
const cloned = element.cloneNode(true) as HTMLElement;
|
||||||
|
|
||||||
|
// 清理编辑器相关状态类与属性
|
||||||
|
cloned.classList.remove('selected-element', 'hover-highlight');
|
||||||
|
cloned.removeAttribute('data-element-type');
|
||||||
|
|
||||||
|
if (this.historyManager) {
|
||||||
|
const command = createElementAddCommand(cloned, element, parent, nextSibling);
|
||||||
|
command.execute();
|
||||||
|
this.historyManager.push(command);
|
||||||
|
} else {
|
||||||
|
parent.insertBefore(cloned, nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中新复制的元素
|
||||||
|
this.selectElement(cloned);
|
||||||
|
this.emit('contentChange');
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 替换图片/背景图为远程 URL
|
||||||
|
*/
|
||||||
|
replaceImage(element: HTMLElement | null = this.selectedElement, url: string): boolean {
|
||||||
|
if (!element || !url) return false;
|
||||||
|
|
||||||
|
const tag = element.tagName.toLowerCase();
|
||||||
|
if (tag === 'img') {
|
||||||
|
const oldSrc = element.getAttribute('src');
|
||||||
|
const newSrc = url;
|
||||||
|
if (this.historyManager) {
|
||||||
|
const cmd = createAttributeChangeCommand(element, 'src', oldSrc, newSrc);
|
||||||
|
cmd.execute();
|
||||||
|
this.historyManager.push(cmd);
|
||||||
|
} else {
|
||||||
|
element.setAttribute('src', newSrc);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const oldBg = element.style.backgroundImage || '';
|
||||||
|
const newBg = `url(${url})`;
|
||||||
|
if (this.historyManager) {
|
||||||
|
const cmd = createStyleChangeCommand(element, 'background-image', oldBg, newBg);
|
||||||
|
cmd.execute();
|
||||||
|
this.historyManager.push(cmd);
|
||||||
|
} else {
|
||||||
|
element.style.backgroundImage = newBg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('contentChange');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件系统
|
||||||
|
emit(eventName: string, ...args: any[]): void {
|
||||||
|
const callbackName = `on${eventName.charAt(0).toUpperCase() + eventName.slice(1)}` as keyof HTMLEditorOptions;
|
||||||
|
const callback = this.options[callbackName];
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||||
|
(callback as Function).apply(this, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发自定义事件
|
||||||
|
const event = new CustomEvent(`htmleditor:${eventName}`, {
|
||||||
|
detail: { editor: this, args }
|
||||||
|
});
|
||||||
|
if (this.container) {
|
||||||
|
this.container.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公共API
|
||||||
|
getContent(): string {
|
||||||
|
return this.container ? this.container.innerHTML : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(html: string): void {
|
||||||
|
if (this.container) {
|
||||||
|
this.container.innerHTML = html;
|
||||||
|
this.emit('contentChange');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedElement(): HTMLElement | null {
|
||||||
|
return this.selectedElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作状态管理
|
||||||
|
setDragging(value: boolean): void {
|
||||||
|
this.isDragging = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResizing(value: boolean): void {
|
||||||
|
this.isResizing = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChangingBackground(value: boolean): void {
|
||||||
|
this.isChangingBackground = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setChangingColor(value: boolean): void {
|
||||||
|
this.isChangingColor = value;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 检查是否有任何操作正在进行
|
||||||
|
*/
|
||||||
|
isOperating(): boolean {
|
||||||
|
return (
|
||||||
|
this.isDragging ||
|
||||||
|
this.isResizing ||
|
||||||
|
this.isChangingBackground ||
|
||||||
|
this.isChangingColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置所有操作状态
|
||||||
|
*/
|
||||||
|
resetOperationStates(): void {
|
||||||
|
this.isDragging = false;
|
||||||
|
this.isResizing = false;
|
||||||
|
this.isChangingBackground = false;
|
||||||
|
this.isChangingColor = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 历史记录相关 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销上一个操作
|
||||||
|
*/
|
||||||
|
undo(): boolean {
|
||||||
|
return this.historyManager?.undo() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重做已撤销的操作
|
||||||
|
*/
|
||||||
|
redo(): boolean {
|
||||||
|
return this.historyManager?.redo() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以撤销
|
||||||
|
*/
|
||||||
|
canUndo(): boolean {
|
||||||
|
return this.historyManager?.canUndo() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以重做
|
||||||
|
*/
|
||||||
|
canRedo(): boolean {
|
||||||
|
return this.historyManager?.canRedo() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始批量操作
|
||||||
|
*/
|
||||||
|
beginBatch(): void {
|
||||||
|
this.historyManager?.beginBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束批量操作
|
||||||
|
*/
|
||||||
|
endBatch(): void {
|
||||||
|
this.historyManager?.endBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消批量操作
|
||||||
|
*/
|
||||||
|
cancelBatch(): void {
|
||||||
|
this.historyManager?.cancelBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空历史记录
|
||||||
|
*/
|
||||||
|
clearHistory(): void {
|
||||||
|
this.historyManager?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取历史状态
|
||||||
|
*/
|
||||||
|
getHistoryState() {
|
||||||
|
return this.historyManager?.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.eventManager) {
|
||||||
|
this.eventManager.unbindAll();
|
||||||
|
}
|
||||||
|
if (this.moveableManager) {
|
||||||
|
this.moveableManager.destroy();
|
||||||
|
}
|
||||||
|
if (this.historyManager) {
|
||||||
|
this.historyManager.destroy();
|
||||||
|
}
|
||||||
|
if (this.container) {
|
||||||
|
this.container.classList.remove('html-visual-editor');
|
||||||
|
}
|
||||||
|
this.container = null;
|
||||||
|
// 从注册表中移除
|
||||||
|
this.EditorRegistry.unregister(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
components/html-editor/lib/core/editorRegistry/index.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import type { HTMLEditor } from '../editor';
|
||||||
|
|
||||||
|
const editors = new Set<HTMLEditor>();
|
||||||
|
let lastActiveEditor: HTMLEditor | null = null;
|
||||||
|
|
||||||
|
export const EditorRegistry = {
|
||||||
|
register(editor: HTMLEditor) {
|
||||||
|
editors.add(editor);
|
||||||
|
if (!lastActiveEditor) {
|
||||||
|
lastActiveEditor = editor;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unregister(editor: HTMLEditor) {
|
||||||
|
editors.delete(editor);
|
||||||
|
if (lastActiveEditor === editor) {
|
||||||
|
lastActiveEditor = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearOthers(current: HTMLEditor) {
|
||||||
|
editors.forEach(ed => {
|
||||||
|
if (ed !== current) {
|
||||||
|
ed.clearSelection();
|
||||||
|
if (ed.isInsertMode) {
|
||||||
|
ed.disableInsertMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lastActiveEditor = current;
|
||||||
|
},
|
||||||
|
getAll() {
|
||||||
|
return Array.from(editors);
|
||||||
|
},
|
||||||
|
|
||||||
|
hasActiveEditor() {
|
||||||
|
return Array.from(editors).some(ed => ed.selectedElement);
|
||||||
|
},
|
||||||
|
getActiveEditor(): HTMLEditor | null {
|
||||||
|
const activeBySelection = Array.from(editors).find(ed => ed.selectedElement);
|
||||||
|
if (activeBySelection) return activeBySelection;
|
||||||
|
return lastActiveEditor;
|
||||||
|
},
|
||||||
|
getGlobalUndoTarget(): HTMLEditor | null {
|
||||||
|
let target: HTMLEditor | null = null;
|
||||||
|
let maxTs = -1;
|
||||||
|
editors.forEach(ed => {
|
||||||
|
const hm = ed.historyManager;
|
||||||
|
const ts = hm?.getTopUndoTimestamp?.();
|
||||||
|
if (typeof ts === 'number' && hm?.canUndo?.()) {
|
||||||
|
if (ts > maxTs) {
|
||||||
|
maxTs = ts;
|
||||||
|
target = ed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
},
|
||||||
|
getGlobalRedoTarget(): HTMLEditor | null {
|
||||||
|
let target: HTMLEditor | null = null;
|
||||||
|
let maxTs = -1;
|
||||||
|
editors.forEach(ed => {
|
||||||
|
const hm = ed.historyManager;
|
||||||
|
const ts = hm?.getTopRedoTimestamp?.();
|
||||||
|
if (typeof ts === 'number' && hm?.canRedo?.()) {
|
||||||
|
if (ts > maxTs) {
|
||||||
|
maxTs = ts;
|
||||||
|
target = ed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return target;
|
||||||
|
},
|
||||||
|
undo(): boolean {
|
||||||
|
const target = this.getGlobalUndoTarget();
|
||||||
|
return target ? target.undo() : false;
|
||||||
|
},
|
||||||
|
redo(): boolean {
|
||||||
|
const target = this.getGlobalRedoTarget();
|
||||||
|
return target ? target.redo() : false;
|
||||||
|
},
|
||||||
|
canUndo(): boolean {
|
||||||
|
return Array.from(editors).some(ed => ed.historyManager?.canUndo());
|
||||||
|
},
|
||||||
|
canRedo(): boolean {
|
||||||
|
return Array.from(editors).some(ed => ed.historyManager?.canRedo());
|
||||||
|
},
|
||||||
|
|
||||||
|
enableInsertMode(target: HTMLEditor): void {
|
||||||
|
editors.forEach(ed => {
|
||||||
|
if (ed === target) {
|
||||||
|
ed.setInsertMode(true);
|
||||||
|
} else if (ed.isInsertMode) {
|
||||||
|
ed.setInsertMode(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lastActiveEditor = target;
|
||||||
|
},
|
||||||
|
|
||||||
|
disableAllInsertMode(): void {
|
||||||
|
editors.forEach(ed => {
|
||||||
|
if (ed.isInsertMode) ed.setInsertMode(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
isAnyInInsertMode(): boolean {
|
||||||
|
return Array.from(editors).some(ed => ed.isInsertMode);
|
||||||
|
},
|
||||||
|
|
||||||
|
getInsertModeEditors(): HTMLEditor[] {
|
||||||
|
return Array.from(editors).filter(ed => ed.isInsertMode);
|
||||||
|
}
|
||||||
|
,
|
||||||
|
setGlobalContentEditable(enabled: boolean): void {
|
||||||
|
editors.forEach(ed => {
|
||||||
|
ed.setGlobalContentEditableEnabled(enabled);
|
||||||
|
if (enabled) {
|
||||||
|
ed.clearSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditorRegistry;
|
||||||
202
components/html-editor/lib/core/eventManager/index.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* Event Manager
|
||||||
|
* 事件管理器,处理所有DOM事件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type HTMLEditor } from '../editor';
|
||||||
|
import { getElementType } from '../utils';
|
||||||
|
|
||||||
|
type EventHandler = (e: Event) => void;
|
||||||
|
|
||||||
|
export class EventManager {
|
||||||
|
private editor: HTMLEditor;
|
||||||
|
private boundHandlers: Map<string, EventHandler>;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(editor: HTMLEditor) {
|
||||||
|
this.editor = editor;
|
||||||
|
this.boundHandlers = new Map<string, EventHandler>();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindAll(): void {
|
||||||
|
this.bindHoverEvents();
|
||||||
|
this.bindClickEvents();
|
||||||
|
this.bindDocumentEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
bindHoverEvents(): void {
|
||||||
|
const highlightTracker = this.editor.helperBoxManager!.createHighlightTracker();
|
||||||
|
const handleMouseOver = (e: Event) => {
|
||||||
|
if (this.editor.globalEditable?.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.editor.isInsertMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果正在进行拖动、缩放等操作,不处理 hover
|
||||||
|
if (this.editor.isOperating()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const targetTagName = target.tagName.toLowerCase();
|
||||||
|
// 如果包含在忽略的标签中,不处理
|
||||||
|
if ((this.editor.options.ignoreSelectTags || []).includes(targetTagName)) return;
|
||||||
|
|
||||||
|
if (target.classList.contains('selected-element') || target.classList.contains('moveable-line')) return;
|
||||||
|
|
||||||
|
// 先清除容器内所有非选中元素的hover样式
|
||||||
|
if (this.editor.container) {
|
||||||
|
const doc = this.editor.getDoc().document;
|
||||||
|
doc.querySelectorAll('.hover-highlight').forEach((el: Element) => {
|
||||||
|
if (!el.classList.contains('selected-element')) {
|
||||||
|
el.classList.remove('hover-highlight');
|
||||||
|
el.removeAttribute('data-element-type');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
target.classList.add('hover-highlight');
|
||||||
|
target.setAttribute('data-element-type', getElementType(target));
|
||||||
|
|
||||||
|
if (this.editor.options.helperBox && this.editor.helperBoxManager && this.editor.container) {
|
||||||
|
highlightTracker.start(target);
|
||||||
|
// const position = this.editor.getBoundPostion(target);
|
||||||
|
// this.editor.helperBoxManager.updatePostion(position);
|
||||||
|
this.editor.helperBoxManager.visible(!(this.editor.selectedElement === target));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor.emit('hover', target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseOut = (e: Event) => {
|
||||||
|
if (this.editor.globalEditable?.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.editor.isInsertMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.classList.contains('selected-element')) {
|
||||||
|
target.classList.remove('hover-highlight');
|
||||||
|
target.removeAttribute('data-element-type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果启用了 helperBox,则隐藏
|
||||||
|
if (this.editor.options.helperBox && this.editor.helperBoxManager) {
|
||||||
|
highlightTracker.stop(target);
|
||||||
|
this.editor.helperBoxManager.visible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (this.editor.container) {
|
||||||
|
this.editor.container.addEventListener('mouseover', handleMouseOver);
|
||||||
|
this.editor.container.addEventListener('mouseout', handleMouseOut);
|
||||||
|
|
||||||
|
this.boundHandlers.set('mouseover', handleMouseOver);
|
||||||
|
this.boundHandlers.set('mouseout', handleMouseOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bindClickEvents(): void {
|
||||||
|
const handleClick = (e: Event) => {
|
||||||
|
if (this.editor.globalEditable?.isEnabled()) {
|
||||||
|
// 在全局模式下不进行元素选择,让浏览器原生选择/caret工作
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (this.editor.isInsertMode) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const me = e as MouseEvent;
|
||||||
|
this.editor.insertTextAtPosition(me.clientX, me.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTagName = target.tagName.toLowerCase();
|
||||||
|
// 如果包含在忽略的标签中,不处理
|
||||||
|
if ((this.editor.options.ignoreSelectTags || []).includes(targetTagName)) return;
|
||||||
|
|
||||||
|
// 如果正在进行拖动或缩放操作,不处理点击
|
||||||
|
if (this.editor.isDragging || this.editor.isResizing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当点击当前容器时,清空其他编辑器的选中样式
|
||||||
|
this.editor.EditorRegistry.clearOthers(this.editor);
|
||||||
|
|
||||||
|
// 如果点击的元素已经被选中且可编辑,不要stopPropagation,让contenteditable正常工作
|
||||||
|
if (target === this.editor.selectedElement && target.getAttribute('contenteditable') === 'true') {
|
||||||
|
// 不阻止事件,让用户可以在元素内部点击定位光标
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中元素
|
||||||
|
e.stopPropagation();
|
||||||
|
this.editor.selectElement(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.editor.container) {
|
||||||
|
this.editor.container.addEventListener('click', handleClick);
|
||||||
|
this.boundHandlers.set('click', handleClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bindDocumentEvents(): void {
|
||||||
|
const handleDocumentMouseDown = (e: Event) => {
|
||||||
|
// 使用 composedPath 处理可能的 DOM 节点在事件处理过程中被移除或在 portal 中的情况
|
||||||
|
const path = e.composedPath?.() || [];
|
||||||
|
const target = (path[0] || e.target) as HTMLElement;
|
||||||
|
|
||||||
|
if (!target || !target.closest) return;
|
||||||
|
|
||||||
|
// 如果正在进行操作,不清除选择
|
||||||
|
if (this.editor.isOperating()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInEditorUI = path.some(node => {
|
||||||
|
if (!(node instanceof HTMLElement)) return false;
|
||||||
|
return (
|
||||||
|
node.classList.contains('html-editor-toolbar') ||
|
||||||
|
node.classList.contains('html-editor-heading-dropdown') ||
|
||||||
|
node.classList.contains('html-editor-popover') ||
|
||||||
|
node.classList.contains('ant-color-picker-inner') ||
|
||||||
|
node.hasAttribute('data-radix-popper-content-wrapper') ||
|
||||||
|
node.hasAttribute('data-radix-portal') ||
|
||||||
|
node.hasAttribute('data-html-editor-ui')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.editor.container &&
|
||||||
|
!this.editor.container.contains(target) &&
|
||||||
|
!isInEditorUI
|
||||||
|
) {
|
||||||
|
this.editor.clearSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 mousedown 且开启 capture: true 确保在任何组件阻止冒泡前进行检查
|
||||||
|
document.addEventListener('mousedown', handleDocumentMouseDown, true);
|
||||||
|
this.boundHandlers.set('documentMouseDown', handleDocumentMouseDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
unbindAll(): void {
|
||||||
|
this.boundHandlers.forEach((handler, event) => {
|
||||||
|
if (event === 'documentMouseDown') {
|
||||||
|
document.removeEventListener('mousedown', handler, true);
|
||||||
|
} else if (event === 'documentClick') {
|
||||||
|
document.removeEventListener('click', handler);
|
||||||
|
} else if (this.editor.container) {
|
||||||
|
this.editor.container.removeEventListener(event, handler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.boundHandlers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventManager;
|
||||||
243
components/html-editor/lib/core/globalEditable/index.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { HTMLEditor } from '../editor';
|
||||||
|
import { createContentChangeCommand } from '../historyManager/commands';
|
||||||
|
import { Editor } from './markEngine'
|
||||||
|
import type { MarkSpec } from './markEngine/type';
|
||||||
|
|
||||||
|
export class GlobalEditable {
|
||||||
|
private editor: HTMLEditor;
|
||||||
|
private enabled: boolean = false;
|
||||||
|
private lastRecorded: string = '';
|
||||||
|
private handlers: { input?: (e: Event) => void; keydown?: (e: KeyboardEvent) => void; selectionchange?: () => void } = {};
|
||||||
|
|
||||||
|
constructor(editor: HTMLEditor) {
|
||||||
|
this.editor = editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(enabled: boolean): void {
|
||||||
|
if (enabled === this.enabled) return;
|
||||||
|
if (enabled) this.enable(); else this.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTarget(): HTMLElement {
|
||||||
|
const doc = this.editor.getDoc();
|
||||||
|
return doc.document.body as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachBodyEditable(): void {
|
||||||
|
const doc = this.editor.getDoc();
|
||||||
|
const body = doc.document.body;
|
||||||
|
if (!body) return;
|
||||||
|
if (!body.hasAttribute('data-original-contenteditable')) {
|
||||||
|
const original = body.getAttribute('contenteditable') || 'inherit';
|
||||||
|
body.setAttribute('data-original-contenteditable', original);
|
||||||
|
}
|
||||||
|
doc.document.execCommand('defaultParagraphSeparator', false, 'br');
|
||||||
|
body.setAttribute('contenteditable', 'true');
|
||||||
|
body.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachBodyEditable(): void {
|
||||||
|
const doc = this.editor.getDoc();
|
||||||
|
const body = doc.document.body;
|
||||||
|
const original = body.getAttribute('data-original-contenteditable');
|
||||||
|
if (original) {
|
||||||
|
if (original === 'inherit') body.removeAttribute('contenteditable');
|
||||||
|
else body.setAttribute('contenteditable', original);
|
||||||
|
body.removeAttribute('data-original-contenteditable');
|
||||||
|
} else {
|
||||||
|
body.removeAttribute('contenteditable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindListeners(): void {
|
||||||
|
const doc = this.editor.getDoc();
|
||||||
|
const target = this.getTarget();
|
||||||
|
this.lastRecorded = target.innerHTML;
|
||||||
|
const onInput = () => {
|
||||||
|
const after = target.innerHTML;
|
||||||
|
if (this.editor.suppressBodyInputRecord) {
|
||||||
|
this.lastRecorded = after;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.editor.historyManager && after !== this.lastRecorded) {
|
||||||
|
const cmd = createContentChangeCommand(target, this.lastRecorded, after);
|
||||||
|
this.editor.historyManager.push(cmd);
|
||||||
|
this.lastRecorded = after;
|
||||||
|
}
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
};
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const selection = doc.document.getSelection();
|
||||||
|
if (!selection || !selection.rangeCount) return;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const br = doc.document.createElement('br');
|
||||||
|
range.insertNode(br);
|
||||||
|
range.setStartAfter(br);
|
||||||
|
range.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
onInput();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
doc.document.body.addEventListener('input', onInput);
|
||||||
|
doc.document.body.addEventListener('keydown', onKeydown);
|
||||||
|
this.handlers = { input: onInput, keydown: onKeydown };
|
||||||
|
}
|
||||||
|
|
||||||
|
private unbindListeners(): void {
|
||||||
|
const doc = this.editor.getDoc();
|
||||||
|
if (this.handlers.input) doc.document.body.removeEventListener('input', this.handlers.input);
|
||||||
|
if (this.handlers.keydown) doc.document.body.removeEventListener('keydown', this.handlers.keydown);
|
||||||
|
if (this.handlers.selectionchange) doc.document.removeEventListener('selectionchange', this.handlers.selectionchange);
|
||||||
|
this.handlers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private enable(): void {
|
||||||
|
this.editor.clearSelection();
|
||||||
|
this.editor.eventManager?.unbindAll();
|
||||||
|
this.editor.moveableManager?.destroy();
|
||||||
|
if (this.editor.options.helperBox && this.editor.helperBoxManager) {
|
||||||
|
this.editor.helperBoxManager.visible(false);
|
||||||
|
}
|
||||||
|
this.attachBodyEditable();
|
||||||
|
this.bindListeners();
|
||||||
|
this.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private disable(): void {
|
||||||
|
this.detachBodyEditable();
|
||||||
|
this.unbindListeners();
|
||||||
|
this.enabled = false;
|
||||||
|
this.editor.eventManager?.bindAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private withContentHistory(fn: () => void): boolean {
|
||||||
|
const target = this.getTarget();
|
||||||
|
const before = target.innerHTML;
|
||||||
|
this.editor.suppressBodyInputRecord = true;
|
||||||
|
fn();
|
||||||
|
const after = target.innerHTML;
|
||||||
|
this.editor.suppressBodyInputRecord = false;
|
||||||
|
if (this.editor.historyManager && before !== after) {
|
||||||
|
const cmd = createContentChangeCommand(target, before, after);
|
||||||
|
this.editor.historyManager.push(cmd);
|
||||||
|
}
|
||||||
|
this.lastRecorded = after;
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleMark(spec: MarkSpec): boolean {
|
||||||
|
const ctx = this.editor.getDoc();
|
||||||
|
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||||
|
const action = () => engine.toggle(spec);
|
||||||
|
return this.withContentHistory(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
applySelectionBold(): boolean {
|
||||||
|
return this.toggleMark({ type: 'bold' });
|
||||||
|
}
|
||||||
|
applySelectionItalic(): boolean {
|
||||||
|
return this.toggleMark({ type: 'italic' });
|
||||||
|
}
|
||||||
|
applySelectionUnderline(): boolean {
|
||||||
|
return this.toggleMark({ type: 'underline' });
|
||||||
|
}
|
||||||
|
applySelectionStrikeThrough(): boolean {
|
||||||
|
return this.toggleMark({ type: 'strike' });
|
||||||
|
}
|
||||||
|
applySelectionFontSize(px: string): boolean {
|
||||||
|
return this.toggleMark({ type: 'fontSize', value: px });
|
||||||
|
}
|
||||||
|
applySelectionFontFamily(name: string): boolean {
|
||||||
|
return this.toggleMark({ type: 'fontFamily', value: name });
|
||||||
|
}
|
||||||
|
applySelectionColor(color: string): boolean {
|
||||||
|
return this.toggleMark({ type: 'color', value: color });
|
||||||
|
}
|
||||||
|
applySelectionBackground(color: string): boolean {
|
||||||
|
return this.toggleMark({ type: 'background', value: color });
|
||||||
|
}
|
||||||
|
applySelectionHighlight(color?: string): boolean {
|
||||||
|
return this.toggleMark({ type: 'highlight', value: color });
|
||||||
|
}
|
||||||
|
applySelectionCode(): boolean {
|
||||||
|
return this.toggleMark({ type: 'code' });
|
||||||
|
}
|
||||||
|
applySelectionLink(href: string): boolean {
|
||||||
|
return this.toggleMark({ type: 'link', attrs: { href } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置文本对齐方式
|
||||||
|
* @param alignment - 'left' | 'center' | 'right'
|
||||||
|
*/
|
||||||
|
applySelectionAlign(alignment: 'left' | 'center' | 'right'): boolean {
|
||||||
|
const ctx = this.editor.getDoc();
|
||||||
|
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||||
|
const action = () => engine.align(alignment);
|
||||||
|
return this.withContentHistory(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 左对齐
|
||||||
|
*/
|
||||||
|
applySelectionAlignLeft(): boolean {
|
||||||
|
return this.applySelectionAlign('left');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 居中对齐
|
||||||
|
*/
|
||||||
|
applySelectionAlignCenter(): boolean {
|
||||||
|
return this.applySelectionAlign('center');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右对齐
|
||||||
|
*/
|
||||||
|
applySelectionAlignRight(): boolean {
|
||||||
|
return this.applySelectionAlign('right');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前段落的对齐方式
|
||||||
|
* @returns 'left' | 'center' | 'right' | null
|
||||||
|
*/
|
||||||
|
queryAlign(): 'left' | 'center' | 'right' | null {
|
||||||
|
const ctx = this.editor.getDoc();
|
||||||
|
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||||
|
return engine.queryAlign();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置标题级别
|
||||||
|
* @param level - 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' (普通段落)
|
||||||
|
*/
|
||||||
|
setHeading(level: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'): boolean {
|
||||||
|
const ctx = this.editor.getDoc();
|
||||||
|
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||||
|
const action = () => engine.setHeading(level);
|
||||||
|
return this.withContentHistory(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前标题级别
|
||||||
|
* @returns 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | null
|
||||||
|
*/
|
||||||
|
queryHeading(): 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | null {
|
||||||
|
const ctx = this.editor.getDoc();
|
||||||
|
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||||
|
return engine.queryHeading();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalEditable;
|
||||||
1378
components/html-editor/lib/core/globalEditable/markEngine/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export type MarkType =
|
||||||
|
| 'bold'
|
||||||
|
| 'italic'
|
||||||
|
| 'underline'
|
||||||
|
| 'strike'
|
||||||
|
| 'color'
|
||||||
|
| 'background'
|
||||||
|
| 'fontSize'
|
||||||
|
| 'fontFamily'
|
||||||
|
| 'highlight'
|
||||||
|
| 'code'
|
||||||
|
| 'link'
|
||||||
|
|
||||||
|
export type MarkSpec = {
|
||||||
|
type: MarkType
|
||||||
|
value?: string
|
||||||
|
attrs?: Record<string, string> // 用于 link 等需要额外属性的标记
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocCtx = {
|
||||||
|
view: Window
|
||||||
|
document: Document
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将元素按 range 拆分为三段 */
|
||||||
|
export interface SplitResult {
|
||||||
|
pre: DocumentFragment
|
||||||
|
mid: DocumentFragment
|
||||||
|
post: DocumentFragment
|
||||||
|
}
|
||||||
82
components/html-editor/lib/core/helperBoxManager/index.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { type Position } from '../../types';
|
||||||
|
import { HTMLEditor } from '../editor';
|
||||||
|
import { elementWatcher } from '../utils';
|
||||||
|
|
||||||
|
export class HelperBoxManager {
|
||||||
|
editor: HTMLEditor;
|
||||||
|
element: HTMLElement | null;
|
||||||
|
|
||||||
|
constructor(editor: HTMLEditor) {
|
||||||
|
this.editor = editor;
|
||||||
|
this.element = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (!this.editor.container) return;
|
||||||
|
const doc = this.editor.getDoc().document;
|
||||||
|
const helperBox = doc.getElementById('html-editor-helper-box') || doc.createElement('div');
|
||||||
|
this.element = helperBox;
|
||||||
|
this.element.id = 'html-editor-helper-box';
|
||||||
|
this.element.style.position = 'absolute';
|
||||||
|
this.element.style.zIndex = '9999';
|
||||||
|
this.element.style.display = 'none';
|
||||||
|
this.element.style.pointerEvents = 'none';
|
||||||
|
this.element.style.border = '1px dashed var(--editor-accent)';
|
||||||
|
this.element.style.backgroundColor = 'color-mix(in srgb, var(--editor-accent) 8%, transparent)';
|
||||||
|
|
||||||
|
if (this.editor.isIframe) {
|
||||||
|
doc.body.appendChild(this.element);
|
||||||
|
} else {
|
||||||
|
this.editor.container.style.position = 'relative';
|
||||||
|
this.editor.container.appendChild(this.element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePostion(position: Position) {
|
||||||
|
if (!this.element) return;
|
||||||
|
const { document, view } = this.editor.getDoc();
|
||||||
|
if (!view || !document) return;
|
||||||
|
const scrollTop = view.scrollY || this.editor.container?.scrollTop || 0;
|
||||||
|
const scrollLeft = view.scrollX || this.editor.container?.scrollLeft || 0;
|
||||||
|
|
||||||
|
const doc = this.editor.container?.ownerDocument || document;
|
||||||
|
let offsetTop = position.top;
|
||||||
|
let offsetLeft = position.left;
|
||||||
|
if (this.editor.isIframe && document?.body) {
|
||||||
|
const cs = view?.getComputedStyle(doc.body);
|
||||||
|
const mt = cs ? parseFloat(cs.marginTop || '0') : 0;
|
||||||
|
const ml = cs ? parseFloat(cs.marginLeft || '0') : 0;
|
||||||
|
offsetTop -= mt;
|
||||||
|
offsetLeft -= ml;
|
||||||
|
}
|
||||||
|
this.element.style.width = `${position.width}px`;
|
||||||
|
this.element.style.height = `${position.height}px`;
|
||||||
|
this.element.style.top = `${offsetTop + scrollTop}px`;
|
||||||
|
this.element.style.left = `${offsetLeft + scrollLeft}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建高亮框,根据渲染帧刷新位置(解决dom有动画的case)
|
||||||
|
createHighlightTracker() {
|
||||||
|
const watcher = elementWatcher(this.editor);
|
||||||
|
return {
|
||||||
|
start: (element: HTMLElement) => {
|
||||||
|
watcher.start(element, (postion) => {
|
||||||
|
this.updatePostion(postion);
|
||||||
|
this.element!.setAttribute('data-element-type', element.getAttribute('data-element-type') || '')
|
||||||
|
});
|
||||||
|
},
|
||||||
|
stop: watcher.stop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
visible(visible: boolean) {
|
||||||
|
if (!this.element) return;
|
||||||
|
this.element.style.display = visible ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
delete() {
|
||||||
|
if (!this.element) return;
|
||||||
|
this.element.remove();
|
||||||
|
this.element = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
293
components/html-editor/lib/core/historyManager/commands.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* Command Implementations
|
||||||
|
* 操作命令的具体实现
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Command,
|
||||||
|
OperationType,
|
||||||
|
type StyleChangeCommand,
|
||||||
|
type ContentChangeCommand,
|
||||||
|
type ElementAddCommand,
|
||||||
|
type ElementDeleteCommand,
|
||||||
|
type ElementTagChangeCommand,
|
||||||
|
type BatchCommand,
|
||||||
|
type AttributeChangeCommand
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建元素标签变更命令
|
||||||
|
*/
|
||||||
|
export function createElementTagChangeCommand(
|
||||||
|
element: HTMLElement,
|
||||||
|
newTag: string
|
||||||
|
): ElementTagChangeCommand {
|
||||||
|
const oldTag = element.tagName;
|
||||||
|
const newElement = document.createElement(newTag);
|
||||||
|
|
||||||
|
// Copy attributes
|
||||||
|
for (const attr of Array.from(element.attributes)) {
|
||||||
|
newElement.setAttribute(attr.name, attr.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingLevels: { [key: string]: string } = {
|
||||||
|
H1: '28px',
|
||||||
|
H2: '26px',
|
||||||
|
H3: '24px',
|
||||||
|
H4: '22px',
|
||||||
|
H5: '20px',
|
||||||
|
H6: '18px',
|
||||||
|
};
|
||||||
|
const upperCaseNewTag = newTag.toUpperCase();
|
||||||
|
if (headingLevels[upperCaseNewTag]) {
|
||||||
|
newElement.style.fontSize = headingLevels[upperCaseNewTag];
|
||||||
|
} else {
|
||||||
|
newElement.style.fontSize = '18px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy content
|
||||||
|
newElement.innerHTML = element.innerHTML;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: OperationType.ELEMENT_TAG_CHANGE,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
element,
|
||||||
|
oldTag,
|
||||||
|
newTag,
|
||||||
|
newElement,
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
if (element.parentNode) {
|
||||||
|
element.parentNode.replaceChild(newElement, element);
|
||||||
|
this.element = newElement;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (newElement.parentNode) {
|
||||||
|
newElement.parentNode.replaceChild(element, newElement);
|
||||||
|
this.element = element;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
merge(): boolean {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建批量操作命令
|
||||||
|
*/
|
||||||
|
export function createStyleChangeCommand(
|
||||||
|
element: HTMLElement,
|
||||||
|
property: string,
|
||||||
|
oldValue: string,
|
||||||
|
newValue: string
|
||||||
|
): StyleChangeCommand {
|
||||||
|
const time = Date.now();
|
||||||
|
return {
|
||||||
|
type: OperationType.STYLE_CHANGE,
|
||||||
|
timestamp: time,
|
||||||
|
element,
|
||||||
|
property,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
element.style.setProperty(property, newValue);
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (oldValue) {
|
||||||
|
element.style.setProperty(property, oldValue);
|
||||||
|
} else {
|
||||||
|
element.style.removeProperty(property);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
merge(command: Command): boolean {
|
||||||
|
if (
|
||||||
|
command.type === OperationType.STYLE_CHANGE &&
|
||||||
|
(command as StyleChangeCommand).element === element &&
|
||||||
|
(command as StyleChangeCommand).property === property &&
|
||||||
|
command.timestamp - time < 1000
|
||||||
|
) {
|
||||||
|
this.newValue = (command as StyleChangeCommand).newValue;
|
||||||
|
this.timestamp = command.timestamp;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建属性变更命令
|
||||||
|
*/
|
||||||
|
export function createAttributeChangeCommand(
|
||||||
|
element: HTMLElement,
|
||||||
|
attrName: string,
|
||||||
|
oldValue: string | null,
|
||||||
|
newValue: string | null
|
||||||
|
): AttributeChangeCommand {
|
||||||
|
const time = Date.now();
|
||||||
|
return {
|
||||||
|
type: OperationType.ATTRIBUTE_CHANGE,
|
||||||
|
timestamp: time,
|
||||||
|
element,
|
||||||
|
attrName,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
if (newValue === null || newValue === undefined) {
|
||||||
|
element.removeAttribute(attrName);
|
||||||
|
} else {
|
||||||
|
element.setAttribute(attrName, newValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
if (oldValue === null || oldValue === undefined) {
|
||||||
|
element.removeAttribute(attrName);
|
||||||
|
} else {
|
||||||
|
element.setAttribute(attrName, oldValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
merge(command: Command): boolean {
|
||||||
|
if (
|
||||||
|
command.type === OperationType.ATTRIBUTE_CHANGE &&
|
||||||
|
(command as AttributeChangeCommand).element === element &&
|
||||||
|
(command as AttributeChangeCommand).attrName === attrName &&
|
||||||
|
command.timestamp - time < 1000
|
||||||
|
) {
|
||||||
|
this.newValue = (command as AttributeChangeCommand).newValue;
|
||||||
|
this.timestamp = command.timestamp;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建内容变更命令
|
||||||
|
*/
|
||||||
|
export function createContentChangeCommand(
|
||||||
|
element: HTMLElement,
|
||||||
|
oldContent: string,
|
||||||
|
newContent: string
|
||||||
|
): ContentChangeCommand {
|
||||||
|
const time = Date.now();
|
||||||
|
return {
|
||||||
|
type: OperationType.CONTENT_CHANGE,
|
||||||
|
timestamp: time,
|
||||||
|
element,
|
||||||
|
oldContent,
|
||||||
|
newContent,
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
element.innerHTML = newContent;
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
element.innerHTML = oldContent;
|
||||||
|
},
|
||||||
|
|
||||||
|
merge(command: Command): boolean {
|
||||||
|
if (
|
||||||
|
command.type === OperationType.CONTENT_CHANGE &&
|
||||||
|
(command as ContentChangeCommand).element === element &&
|
||||||
|
command.timestamp - time < 2000
|
||||||
|
) {
|
||||||
|
this.newContent = (command as ContentChangeCommand).newContent;
|
||||||
|
this.timestamp = command.timestamp;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建元素添加命令
|
||||||
|
*/
|
||||||
|
export function createElementAddCommand(
|
||||||
|
element: HTMLElement,
|
||||||
|
selectedElement: HTMLElement,
|
||||||
|
parent: HTMLElement,
|
||||||
|
nextSibling: HTMLElement | null
|
||||||
|
): ElementAddCommand {
|
||||||
|
return {
|
||||||
|
type: OperationType.ELEMENT_ADD,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
element,
|
||||||
|
parent,
|
||||||
|
nextSibling,
|
||||||
|
selectedElement,
|
||||||
|
execute() {
|
||||||
|
if (nextSibling) {
|
||||||
|
parent.insertBefore(element, nextSibling);
|
||||||
|
} else {
|
||||||
|
parent.appendChild(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
parent.removeChild(element);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建元素删除命令
|
||||||
|
*/
|
||||||
|
export function createElementDeleteCommand(
|
||||||
|
element: HTMLElement,
|
||||||
|
parent: HTMLElement,
|
||||||
|
nextSibling: Node | null
|
||||||
|
): ElementDeleteCommand {
|
||||||
|
return {
|
||||||
|
type: OperationType.ELEMENT_DELETE,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
element,
|
||||||
|
parent,
|
||||||
|
nextSibling,
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
if (element.parentNode) {
|
||||||
|
parent.removeChild(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
// 插回原节点对象
|
||||||
|
if (!element.parentNode) {
|
||||||
|
parent.insertBefore(element, nextSibling);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建批量操作命令
|
||||||
|
*/
|
||||||
|
export function createBatchCommand(commands: Command[]): BatchCommand {
|
||||||
|
return {
|
||||||
|
type: OperationType.BATCH,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
commands,
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
commands.forEach((cmd) => cmd.execute());
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
for (let i = commands.length - 1; i >= 0; i--) {
|
||||||
|
commands[i].undo();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
233
components/html-editor/lib/core/historyManager/index.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* History Manager 历史记录管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Command, type HistoryManagerOptions, type HistoryState, OperationType, type BatchCommand } from './types';
|
||||||
|
import type { HTMLEditor } from '../editor';
|
||||||
|
|
||||||
|
export class HistoryManager {
|
||||||
|
private editor: HTMLEditor;
|
||||||
|
private undoStack: Command[] = [];
|
||||||
|
private redoStack: Command[] = [];
|
||||||
|
private options: Required<HistoryManagerOptions>;
|
||||||
|
private isExecuting: boolean = false;
|
||||||
|
private batchCommands: Command[] | null = null;
|
||||||
|
|
||||||
|
constructor(editor: HTMLEditor, options: HistoryManagerOptions = {}) {
|
||||||
|
this.editor = editor;
|
||||||
|
this.options = {
|
||||||
|
maxHistorySize: options.maxHistorySize ?? 100,
|
||||||
|
mergeInterval: options.mergeInterval ?? 1000,
|
||||||
|
enableAutoSnapshot: options.enableAutoSnapshot ?? false,
|
||||||
|
snapshotInterval: options.snapshotInterval ?? 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一个操作
|
||||||
|
*/
|
||||||
|
push(command: Command): void {
|
||||||
|
if (this.isExecuting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果在批量操作中,暂存命令
|
||||||
|
if (this.batchCommands) {
|
||||||
|
this.batchCommands.push(command);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||||
|
if (lastCommand?.merge && lastCommand.merge(command)) {
|
||||||
|
this.notifyStateChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.undoStack.push(command);
|
||||||
|
|
||||||
|
// 清空重做栈
|
||||||
|
this.redoStack = [];
|
||||||
|
if (this.undoStack.length > this.options.maxHistorySize) {
|
||||||
|
this.undoStack.shift();
|
||||||
|
}
|
||||||
|
this.notifyStateChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销操作
|
||||||
|
*/
|
||||||
|
undo(): boolean {
|
||||||
|
if (!this.canUndo()) return false;
|
||||||
|
|
||||||
|
const command = this.undoStack.pop()!;
|
||||||
|
this.isExecuting = true;
|
||||||
|
try {
|
||||||
|
command.undo();
|
||||||
|
if (command.type === OperationType.ELEMENT_ADD) {
|
||||||
|
// 如果是添加元素操作,撤销时选中添加前的元素
|
||||||
|
this.editor.selectElement(command.selectedElement || null);
|
||||||
|
}
|
||||||
|
this.redoStack.push(command);
|
||||||
|
this.notifyStateChange(true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.undoStack.push(command);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.isExecuting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重做操作
|
||||||
|
*/
|
||||||
|
redo(): boolean {
|
||||||
|
if (!this.canRedo()) return false;
|
||||||
|
|
||||||
|
const command = this.redoStack.pop()!;
|
||||||
|
this.isExecuting = true;
|
||||||
|
try {
|
||||||
|
command.execute();
|
||||||
|
this.undoStack.push(command);
|
||||||
|
this.notifyStateChange(true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.redoStack.push(command);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.isExecuting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以撤销
|
||||||
|
*/
|
||||||
|
canUndo(): boolean {
|
||||||
|
const result = this.undoStack.length > 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否可以重做
|
||||||
|
*/
|
||||||
|
canRedo(): boolean {
|
||||||
|
const result = this.redoStack.length > 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始批量操作
|
||||||
|
*/
|
||||||
|
beginBatch(): void {
|
||||||
|
this.batchCommands = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束批量操作
|
||||||
|
*/
|
||||||
|
endBatch(): void {
|
||||||
|
if (!this.batchCommands || this.batchCommands.length === 0) {
|
||||||
|
this.batchCommands = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有一个命令,直接添加
|
||||||
|
if (this.batchCommands.length === 1) {
|
||||||
|
const command = this.batchCommands[0];
|
||||||
|
this.batchCommands = null;
|
||||||
|
this.push(command);
|
||||||
|
} else {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
// 创建批量命令
|
||||||
|
const batchCommand: BatchCommand = {
|
||||||
|
type: OperationType.BATCH,
|
||||||
|
timestamp,
|
||||||
|
commands: this.batchCommands,
|
||||||
|
|
||||||
|
execute() {
|
||||||
|
this.commands.forEach((cmd) => cmd.execute());
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
// 反向执行撤销
|
||||||
|
for (let i = this.commands.length - 1; i >= 0; i--) {
|
||||||
|
this.commands[i].undo();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.batchCommands = null;
|
||||||
|
this.push(batchCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.batchCommands = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消批量操作
|
||||||
|
*/
|
||||||
|
cancelBatch(): void {
|
||||||
|
this.batchCommands = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空历史记录
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.undoStack = [];
|
||||||
|
this.redoStack = [];
|
||||||
|
this.notifyStateChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取历史状态
|
||||||
|
*/
|
||||||
|
getState(): HistoryState {
|
||||||
|
const state = {
|
||||||
|
canUndo: this.canUndo(),
|
||||||
|
canRedo: this.canRedo(),
|
||||||
|
historySize: this.undoStack.length,
|
||||||
|
currentIndex: this.undoStack.length,
|
||||||
|
};
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取撤销栈大小
|
||||||
|
*/
|
||||||
|
getUndoStackSize(): number {
|
||||||
|
return this.undoStack.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取重做栈大小
|
||||||
|
*/
|
||||||
|
getRedoStackSize(): number {
|
||||||
|
return this.redoStack.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTopUndoTimestamp(): number | null {
|
||||||
|
const cmd = this.undoStack[this.undoStack.length - 1];
|
||||||
|
return cmd ? cmd.timestamp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTopRedoTimestamp(): number | null {
|
||||||
|
const cmd = this.redoStack[this.redoStack.length - 1];
|
||||||
|
return cmd ? cmd.timestamp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知状态变化
|
||||||
|
*/
|
||||||
|
private notifyStateChange(contentChange?: boolean): void {
|
||||||
|
this.editor.emit('historyChange', this.getState());
|
||||||
|
if (contentChange) {
|
||||||
|
// 临时处理,之后所有的操作都需要通知
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
components/html-editor/lib/core/historyManager/types.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* History Manager Types
|
||||||
|
* 历史记录管理相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作类型枚举
|
||||||
|
*/
|
||||||
|
export enum OperationType {
|
||||||
|
STYLE_CHANGE = 'style_change',
|
||||||
|
CONTENT_CHANGE = 'content_change',
|
||||||
|
ELEMENT_ADD = 'element_add',
|
||||||
|
ELEMENT_DELETE = 'element_delete',
|
||||||
|
ELEMENT_MOVE = 'element_move',
|
||||||
|
ELEMENT_TAG_CHANGE = 'element_tag_change',
|
||||||
|
ATTRIBUTE_CHANGE = 'attribute_change',
|
||||||
|
BATCH = 'batch',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作命令基础接口
|
||||||
|
*/
|
||||||
|
export interface Command {
|
||||||
|
type: OperationType;
|
||||||
|
timestamp: number;
|
||||||
|
execute: () => void;
|
||||||
|
undo: () => void;
|
||||||
|
merge?: (command: Command) => boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 样式变更命令
|
||||||
|
*/
|
||||||
|
export interface StyleChangeCommand extends Command {
|
||||||
|
type: OperationType.STYLE_CHANGE;
|
||||||
|
element: HTMLElement;
|
||||||
|
property: string;
|
||||||
|
oldValue: string;
|
||||||
|
newValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内容变更命令
|
||||||
|
*/
|
||||||
|
export interface ContentChangeCommand extends Command {
|
||||||
|
type: OperationType.CONTENT_CHANGE;
|
||||||
|
element: HTMLElement;
|
||||||
|
oldContent: string;
|
||||||
|
newContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性变更命令
|
||||||
|
*/
|
||||||
|
export interface AttributeChangeCommand extends Command {
|
||||||
|
type: OperationType.ATTRIBUTE_CHANGE;
|
||||||
|
element: HTMLElement;
|
||||||
|
attrName: string;
|
||||||
|
oldValue: string | null;
|
||||||
|
newValue: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素添加命令
|
||||||
|
*/
|
||||||
|
export interface ElementAddCommand extends Command {
|
||||||
|
type: OperationType.ELEMENT_ADD;
|
||||||
|
element: HTMLElement;
|
||||||
|
selectedElement: HTMLElement;
|
||||||
|
parent: HTMLElement;
|
||||||
|
nextSibling: HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元素删除命令
|
||||||
|
*/
|
||||||
|
export interface ElementDeleteCommand extends Command {
|
||||||
|
type: OperationType.ELEMENT_DELETE;
|
||||||
|
element: HTMLElement;
|
||||||
|
parent: HTMLElement;
|
||||||
|
nextSibling: Node | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElementTagChangeCommand extends Command {
|
||||||
|
type: OperationType.ELEMENT_TAG_CHANGE;
|
||||||
|
element: HTMLElement;
|
||||||
|
oldTag: string;
|
||||||
|
newTag: string;
|
||||||
|
newElement?: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量操作命令
|
||||||
|
*/
|
||||||
|
export interface BatchCommand extends Command {
|
||||||
|
type: OperationType.BATCH;
|
||||||
|
commands: Command[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录管理器配置
|
||||||
|
*/
|
||||||
|
export interface HistoryManagerOptions {
|
||||||
|
maxHistorySize?: number;
|
||||||
|
mergeInterval?: number;
|
||||||
|
enableAutoSnapshot?: boolean;
|
||||||
|
snapshotInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史状态
|
||||||
|
*/
|
||||||
|
export interface HistoryState {
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
historySize: number;
|
||||||
|
currentIndex: number;
|
||||||
|
}
|
||||||
167
components/html-editor/lib/core/moveableManager/events.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Moveable Events Handler
|
||||||
|
* 拖拽与缩放事件处理逻辑
|
||||||
|
*/
|
||||||
|
import type Moveable from 'moveable';
|
||||||
|
import type { HTMLEditor } from '../editor';
|
||||||
|
import { createStyleChangeCommand } from '../historyManager/commands';
|
||||||
|
|
||||||
|
export class MoveableEventsHandler {
|
||||||
|
private editor: HTMLEditor;
|
||||||
|
|
||||||
|
constructor(editor: HTMLEditor) {
|
||||||
|
this.editor = editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定拖拽事件
|
||||||
|
*/
|
||||||
|
bindDragEvents(instance: Moveable) {
|
||||||
|
let originalTransform: string | null = null;
|
||||||
|
let originTransition : string | null = null;
|
||||||
|
|
||||||
|
instance.on('dragStart', ({ target, inputEvent }) => {
|
||||||
|
const el = target as HTMLElement;
|
||||||
|
originalTransform = el.style.transform || '';
|
||||||
|
originTransition = el.style.transition || '';
|
||||||
|
try {
|
||||||
|
inputEvent?.preventDefault();
|
||||||
|
} catch {}
|
||||||
|
el.style.userSelect = 'none';
|
||||||
|
el.style.transition = 'none';
|
||||||
|
this.editor.setDragging(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.on('drag', ({ target, transform }) => {
|
||||||
|
const el = target as HTMLElement;
|
||||||
|
el.style.transform = transform;
|
||||||
|
this.editor.emit('styleChange', el, { transform });
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.on('dragEnd', ({ target }) => {
|
||||||
|
const el = target as HTMLElement;
|
||||||
|
// 记录历史
|
||||||
|
if (this.editor.historyManager && originalTransform !== null) {
|
||||||
|
const newTransform = el.style.transform || '';
|
||||||
|
|
||||||
|
if (originalTransform !== newTransform) {
|
||||||
|
const command = createStyleChangeCommand(el, 'transform', originalTransform, newTransform);
|
||||||
|
command.execute();
|
||||||
|
this.editor.historyManager.push(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.style.transition = originTransition || '';
|
||||||
|
this.editor.setDragging(false);
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
originalTransform = null;
|
||||||
|
originTransition = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定缩放事件
|
||||||
|
*/
|
||||||
|
bindScaleEvents(instance: Moveable) {
|
||||||
|
let originalTransform: string | null = null;
|
||||||
|
|
||||||
|
instance.on('scaleStart', (e) => {
|
||||||
|
this.editor.setResizing(true);
|
||||||
|
e.target.blur();
|
||||||
|
// 记录初始状态
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
originalTransform = el.style.transform || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.on('scale', ({ target, transform, drag }) => {
|
||||||
|
const el = target as HTMLElement;
|
||||||
|
el.style.transform = drag.transform;
|
||||||
|
this.editor.emit('styleChange', el, {
|
||||||
|
transform: drag && drag.transform ? drag.transform : transform,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.on('scaleEnd', ({ target }) => {
|
||||||
|
const el = target as HTMLElement;
|
||||||
|
// 记录历史
|
||||||
|
if (this.editor.historyManager && originalTransform !== null) {
|
||||||
|
const newTransform = el.style.transform || '';
|
||||||
|
|
||||||
|
if (originalTransform !== newTransform) {
|
||||||
|
const command = createStyleChangeCommand(el, 'transform', originalTransform, newTransform);
|
||||||
|
command.execute();
|
||||||
|
this.editor.historyManager.push(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor.setResizing(false);
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
originalTransform = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定缩放事件
|
||||||
|
*/
|
||||||
|
bindResizeEvents(instance: Moveable) {
|
||||||
|
let originalSize = { width: '0px', height: '0px' };
|
||||||
|
let originalTransform: string | null = '';
|
||||||
|
let originTransition : string | null = null;
|
||||||
|
instance.on('resizeStart', (e) => {
|
||||||
|
const ele = e.target as HTMLElement;
|
||||||
|
const style = window.getComputedStyle(ele);
|
||||||
|
this.editor.setResizing(true);
|
||||||
|
e.target.blur();
|
||||||
|
// 记录初始状态
|
||||||
|
originalTransform = e.target.style.transform || '';
|
||||||
|
originTransition = e.target.style.transition || '';
|
||||||
|
// 记录初始大小
|
||||||
|
originalSize = {
|
||||||
|
width: style.width,
|
||||||
|
height: style.height,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
instance.on('resize', (e) => {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
el.style.width = e.width + 'px'
|
||||||
|
el.style.height = e.height + 'px';
|
||||||
|
el.style.transform = e.transform;
|
||||||
|
this.editor.emit('styleChange', el, {
|
||||||
|
transform: e && e.transform ? e.transform : '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.on('resizeEnd', (e) => {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
this.editor.setResizing(false);
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
const newTransform = el.style.transform
|
||||||
|
if (originalTransform !== newTransform) {
|
||||||
|
//
|
||||||
|
this.editor.historyManager?.beginBatch();
|
||||||
|
const command = createStyleChangeCommand(el, 'transform', originalTransform, newTransform);
|
||||||
|
command.execute();
|
||||||
|
this.editor.historyManager?.push(command);
|
||||||
|
const sizeCommand = createStyleChangeCommand(el, 'width', originalSize.width, el.style.width);
|
||||||
|
sizeCommand.execute();
|
||||||
|
this.editor.historyManager?.push(sizeCommand);
|
||||||
|
const heightCommand = createStyleChangeCommand(el, 'height', originalSize.height, el.style.height);
|
||||||
|
heightCommand.execute();
|
||||||
|
this.editor.historyManager?.push(heightCommand);
|
||||||
|
this.editor.historyManager?.endBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.transition = originTransition || '';
|
||||||
|
originalTransform = null;
|
||||||
|
originTransition = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定所有事件
|
||||||
|
*/
|
||||||
|
bindAllEvents(instance: Moveable) {
|
||||||
|
this.bindDragEvents(instance);
|
||||||
|
this.bindScaleEvents(instance);
|
||||||
|
this.bindResizeEvents(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export class MoveableGuidelinesHandler {
|
||||||
|
/**
|
||||||
|
* 计算自动对齐参考线
|
||||||
|
*/
|
||||||
|
static calculateAutoGuidelines(
|
||||||
|
element: HTMLElement,
|
||||||
|
container: HTMLElement,
|
||||||
|
elementGuidelinesOption: HTMLElement[] | undefined
|
||||||
|
): HTMLElement[] {
|
||||||
|
if (elementGuidelinesOption) {
|
||||||
|
return elementGuidelinesOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(container.querySelectorAll<HTMLElement>('*')).filter((el) => {
|
||||||
|
if (el === element) return false;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const visible =
|
||||||
|
style.display !== 'none' &&
|
||||||
|
style.visibility !== 'hidden' &&
|
||||||
|
rect.width > 0 &&
|
||||||
|
rect.height > 0;
|
||||||
|
return visible;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算水平标尺线
|
||||||
|
*/
|
||||||
|
static calculateHorizontalGuidelines(
|
||||||
|
container: HTMLElement,
|
||||||
|
horizontalGuidelinesOption: number[] | undefined
|
||||||
|
): number[] {
|
||||||
|
if (horizontalGuidelinesOption) {
|
||||||
|
return horizontalGuidelinesOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
0,
|
||||||
|
Math.round(container.clientHeight / 2),
|
||||||
|
container.clientHeight,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算垂直标尺线
|
||||||
|
*/
|
||||||
|
static calculateVerticalGuidelines(
|
||||||
|
container: HTMLElement,
|
||||||
|
verticalGuidelinesOption: number[] | undefined
|
||||||
|
): number[] {
|
||||||
|
if (verticalGuidelinesOption) {
|
||||||
|
return verticalGuidelinesOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
0,
|
||||||
|
Math.round(container.clientWidth / 2),
|
||||||
|
container.clientWidth,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取容器元素
|
||||||
|
*/
|
||||||
|
static getContainer(
|
||||||
|
element: HTMLElement,
|
||||||
|
editorContainer: HTMLElement | null,
|
||||||
|
snapContainerOption: HTMLElement | null
|
||||||
|
): HTMLElement {
|
||||||
|
const root = element.ownerDocument?.body || document.body;
|
||||||
|
return snapContainerOption ?? (editorContainer || element.parentElement || root);
|
||||||
|
}
|
||||||
|
}
|
||||||
193
components/html-editor/lib/core/moveableManager/index.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Moveable Manager
|
||||||
|
* 实现选中元素的拖拽与四角缩放
|
||||||
|
*/
|
||||||
|
import Moveable from 'moveable';
|
||||||
|
import { type HTMLEditor } from '../editor';
|
||||||
|
import type { MoveableOptions } from '../../types';
|
||||||
|
import { MoveableEventsHandler } from './events';
|
||||||
|
import { MoveableGuidelinesHandler } from './guidelines';
|
||||||
|
|
||||||
|
export class MoveableManager {
|
||||||
|
private editor: HTMLEditor;
|
||||||
|
private instance: Moveable | null = null;
|
||||||
|
private options: MoveableOptions;
|
||||||
|
private eventsHandler: MoveableEventsHandler;
|
||||||
|
|
||||||
|
// 记录启用前的属性,便于恢复
|
||||||
|
private originalState: {
|
||||||
|
contenteditable?: string | null;
|
||||||
|
userSelect?: string | null;
|
||||||
|
transformOrigin?: string | null;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
constructor(editor: HTMLEditor, options: MoveableOptions = {}) {
|
||||||
|
this.editor = editor;
|
||||||
|
this.eventsHandler = new MoveableEventsHandler(editor);
|
||||||
|
this.options = {
|
||||||
|
draggable: true,
|
||||||
|
scalable: false,
|
||||||
|
resizable: true,
|
||||||
|
renderDirections: ['nw', 'ne', 'sw', 'se', 'n', 's', 'w', 'e'],
|
||||||
|
keepRatio: false,
|
||||||
|
throttleDrag: 0,
|
||||||
|
throttleResize: 0,
|
||||||
|
throttleScale: 0,
|
||||||
|
// 默认开启吸附与标尺线
|
||||||
|
snappable: true,
|
||||||
|
snapCenter: true,
|
||||||
|
snapThreshold: 5,
|
||||||
|
snapGridWidth: undefined,
|
||||||
|
snapGridHeight: undefined,
|
||||||
|
snapContainer: null,
|
||||||
|
elementGuidelines: undefined,
|
||||||
|
horizontalGuidelines: undefined,
|
||||||
|
verticalGuidelines: undefined,
|
||||||
|
snapDirections: {
|
||||||
|
left: true,
|
||||||
|
top: true,
|
||||||
|
right: true,
|
||||||
|
bottom: true,
|
||||||
|
center: true,
|
||||||
|
middle: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enableFor(element: HTMLElement, options?: Partial<MoveableOptions>): void {
|
||||||
|
this.destroy();
|
||||||
|
|
||||||
|
// 启用前准备:禁用 contenteditable 与选择,避免拖拽被当作文本选择
|
||||||
|
this.prepareElement(element);
|
||||||
|
const mergedOptions: MoveableOptions = { ...this.options, ...(options || {}) };
|
||||||
|
|
||||||
|
// 获取容器元素
|
||||||
|
const container = MoveableGuidelinesHandler.getContainer(
|
||||||
|
element,
|
||||||
|
this.editor.container,
|
||||||
|
mergedOptions.snapContainer ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算自动对齐参考线
|
||||||
|
const autoGuidelines = MoveableGuidelinesHandler.calculateAutoGuidelines(
|
||||||
|
element,
|
||||||
|
container,
|
||||||
|
mergedOptions.elementGuidelines
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算水平标尺线
|
||||||
|
const hGuides = MoveableGuidelinesHandler.calculateHorizontalGuidelines(
|
||||||
|
container,
|
||||||
|
mergedOptions.horizontalGuidelines
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算垂直标尺线
|
||||||
|
const vGuides = MoveableGuidelinesHandler.calculateVerticalGuidelines(
|
||||||
|
container,
|
||||||
|
mergedOptions.verticalGuidelines
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = element.ownerDocument?.body || document.body;
|
||||||
|
|
||||||
|
this.instance = new Moveable(root, {
|
||||||
|
target: element,
|
||||||
|
draggable: mergedOptions.draggable,
|
||||||
|
scalable: mergedOptions.scalable,
|
||||||
|
resizable: mergedOptions.resizable,
|
||||||
|
edgeDraggable: true,
|
||||||
|
checkInput: true,
|
||||||
|
origin: false,
|
||||||
|
|
||||||
|
// 缩放手柄
|
||||||
|
renderDirections: mergedOptions.renderDirections,
|
||||||
|
keepRatio: mergedOptions.keepRatio,
|
||||||
|
|
||||||
|
// 性能相关
|
||||||
|
throttleDrag: mergedOptions.throttleDrag,
|
||||||
|
throttleScale: mergedOptions.throttleScale,
|
||||||
|
|
||||||
|
// 吸附与对齐线
|
||||||
|
snappable: mergedOptions.snappable,
|
||||||
|
snapContainer: container,
|
||||||
|
elementGuidelines: autoGuidelines,
|
||||||
|
horizontalGuidelines: hGuides,
|
||||||
|
verticalGuidelines: vGuides,
|
||||||
|
// 提高阈值,避免吸附过强导致"拖不动"的感觉
|
||||||
|
snapThreshold: mergedOptions.snapThreshold ?? 10,
|
||||||
|
snapGridWidth: mergedOptions.snapGridWidth,
|
||||||
|
snapGridHeight: mergedOptions.snapGridHeight,
|
||||||
|
snapDirections: mergedOptions.snapDirections,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 绑定拖拽和缩放事件
|
||||||
|
this.eventsHandler.bindAllEvents(this.instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.updateRect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.instance) {
|
||||||
|
this.instance.destroy();
|
||||||
|
this.instance = null;
|
||||||
|
}
|
||||||
|
// 恢复元素的原始状态
|
||||||
|
const el = this.editor.selectedElement;
|
||||||
|
if (el) {
|
||||||
|
this.restoreElement(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareElement(element: HTMLElement) {
|
||||||
|
// 保存原始状态
|
||||||
|
this.originalState.contenteditable =
|
||||||
|
element.getAttribute('contenteditable');
|
||||||
|
this.originalState.userSelect = element.style.userSelect || null;
|
||||||
|
this.originalState.transformOrigin = element.style.transformOrigin || null;
|
||||||
|
|
||||||
|
element.setAttribute('contenteditable', 'false');
|
||||||
|
element.style.userSelect = 'none';
|
||||||
|
(element.style as any).touchAction = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreElement(element: HTMLElement) {
|
||||||
|
// 恢复 contenteditable
|
||||||
|
if (this.originalState.contenteditable != null) {
|
||||||
|
if (this.originalState.contenteditable === '') {
|
||||||
|
element.removeAttribute('contenteditable');
|
||||||
|
} else {
|
||||||
|
element.setAttribute(
|
||||||
|
'contenteditable',
|
||||||
|
this.originalState.contenteditable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
element.removeAttribute('contenteditable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复 user-select
|
||||||
|
if (this.originalState.userSelect != null) {
|
||||||
|
element.style.userSelect = this.originalState.userSelect || '';
|
||||||
|
} else {
|
||||||
|
element.style.removeProperty('user-select');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复 transform-origin
|
||||||
|
if (this.originalState.transformOrigin != null) {
|
||||||
|
element.style.transformOrigin = this.originalState.transformOrigin || '';
|
||||||
|
} else {
|
||||||
|
element.style.removeProperty('transform-origin');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理 will-change
|
||||||
|
element.style.removeProperty('will-change');
|
||||||
|
|
||||||
|
// 清空记录
|
||||||
|
this.originalState = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
281
components/html-editor/lib/core/styleManager/index.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* Style Manager
|
||||||
|
* 样式管理器,处理元素样式的应用和获取
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type HTMLEditor } from '../editor';
|
||||||
|
import type { ElementStyles } from '../../types';
|
||||||
|
import { createStyleChangeCommand, createElementTagChangeCommand, createContentChangeCommand } from '../historyManager/commands';
|
||||||
|
|
||||||
|
export class StyleManager {
|
||||||
|
private editor: HTMLEditor;
|
||||||
|
|
||||||
|
constructor(editor: HTMLEditor) {
|
||||||
|
this.editor = editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用样式并记录历史
|
||||||
|
*/
|
||||||
|
private applyStyleWithHistory(element: HTMLElement, property: string, value: string): void {
|
||||||
|
const oldValue = element.style.getPropertyValue(property) || window.getComputedStyle(element).getPropertyValue(property);
|
||||||
|
|
||||||
|
// 创建命令并执行
|
||||||
|
if (this.editor.historyManager) {
|
||||||
|
const command = createStyleChangeCommand(element, property, oldValue, value);
|
||||||
|
command.execute();
|
||||||
|
this.editor.historyManager.push(command);
|
||||||
|
} else {
|
||||||
|
// 如果没有历史管理器,直接应用样式
|
||||||
|
element.style.setProperty(property, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字体相关方法
|
||||||
|
changeFont(element: HTMLElement | null, fontFamily: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'font-family', fontFamily);
|
||||||
|
this.editor.emit('styleChange', element, { fontFamily });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeFontSize(element: HTMLElement | null, fontSize: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'font-size', fontSize);
|
||||||
|
this.editor.emit('styleChange', element, { fontSize });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeFontWeight(element: HTMLElement | null, fontWeight: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'font-weight', fontWeight);
|
||||||
|
this.editor.emit('styleChange', element, { fontWeight });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeFontStyle(element: HTMLElement | null, fontStyle: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'font-style', fontStyle);
|
||||||
|
this.editor.emit('styleChange', element, { fontStyle });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeTextDecoration(element: HTMLElement | null, textDecoration: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'text-decoration', textDecoration);
|
||||||
|
this.editor.emit('styleChange', element, { textDecoration });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeTextAlign(element: HTMLElement | null, textAlign: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'text-align', textAlign);
|
||||||
|
this.editor.emit('styleChange', element, { textAlign });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边距相关方法
|
||||||
|
changeMargin(element: HTMLElement | null, margin: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'margin', margin);
|
||||||
|
this.editor.emit('styleChange', element, { margin });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changePadding(element: HTMLElement | null, padding: string,triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'padding', padding);
|
||||||
|
this.editor.emit('styleChange', element, { padding });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 颜色相关方法
|
||||||
|
changeColor(element: HTMLElement | null, color: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'color', color);
|
||||||
|
this.editor.emit('styleChange', element, { color });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeBackground(element: HTMLElement | null, backgroundColor: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
// 使用批量操作记录 background 和 backgroundColor
|
||||||
|
// this.editor.beginBatch();
|
||||||
|
this.applyStyleWithHistory(element, 'background', backgroundColor);
|
||||||
|
// this.editor.endBatch();
|
||||||
|
this.editor.emit('styleChange', element, { backgroundColor, background: backgroundColor });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边框相关方法
|
||||||
|
changeBorder(element: HTMLElement | null, border: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'border', border);
|
||||||
|
this.editor.emit('styleChange', element, { border });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeBorderRadius(element: HTMLElement | null, borderRadius: string, triggerContentChange = true): boolean {
|
||||||
|
if (!element) element = this.editor.selectedElement;
|
||||||
|
if (!element) return false;
|
||||||
|
this.applyStyleWithHistory(element, 'border-radius', borderRadius);
|
||||||
|
this.editor.emit('styleChange', element, { borderRadius });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTextStyle(property: string, value: string, triggerContentChange = true): boolean {
|
||||||
|
if (!this.editor.selectedElement) return false;
|
||||||
|
this.applyStyleWithHistory(this.editor.selectedElement, property, value);
|
||||||
|
this.editor.emit('styleChange', this.editor.selectedElement, { [property]: value });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBlockStyle(property: string, value: string, triggerContentChange = true): boolean {
|
||||||
|
if (!this.editor.selectedElement) return false;
|
||||||
|
|
||||||
|
const el = this.editor.selectedElement;
|
||||||
|
|
||||||
|
// 兼容传入 'background-color' 字符串
|
||||||
|
const prop = property === 'background-color' ? 'background-color' : property;
|
||||||
|
|
||||||
|
// 当设置背景相关属性时,同时更新 background 与 backgroundColor
|
||||||
|
if (prop === 'background' || prop === 'background-color') {
|
||||||
|
this.editor.beginBatch();
|
||||||
|
this.applyStyleWithHistory(el, 'background-color', value);
|
||||||
|
this.applyStyleWithHistory(el, 'background', value);
|
||||||
|
this.editor.endBatch();
|
||||||
|
this.editor.emit('styleChange', el, { backgroundColor: value, background: value });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyStyleWithHistory(el, prop, value);
|
||||||
|
this.editor.emit('styleChange', el, { [prop]: value });
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getComputedStyle(element: HTMLElement, property: string): string {
|
||||||
|
return window.getComputedStyle(element)[property as any];
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementStyles(element: HTMLElement): ElementStyles {
|
||||||
|
const computedStyle = window.getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
fontSize: computedStyle.fontSize,
|
||||||
|
color: computedStyle.color,
|
||||||
|
fontWeight: computedStyle.fontWeight,
|
||||||
|
backgroundColor: computedStyle.backgroundColor,
|
||||||
|
borderWidth: computedStyle.borderWidth,
|
||||||
|
padding: computedStyle.padding,
|
||||||
|
margin: computedStyle.margin,
|
||||||
|
borderRadius: computedStyle.borderRadius
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
changeElementTag(element: HTMLElement, newTag: string, triggerContentChange = true): HTMLElement | null {
|
||||||
|
if (!element || !element.parentNode || !newTag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = createElementTagChangeCommand(element, newTag);
|
||||||
|
command.execute();
|
||||||
|
|
||||||
|
const newElement = (command as any).newElement as HTMLElement;
|
||||||
|
if (!newElement) return null;
|
||||||
|
|
||||||
|
if (this.editor.historyManager) {
|
||||||
|
this.editor.historyManager.push(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor.selectElement(newElement);
|
||||||
|
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
if (triggerContentChange) {
|
||||||
|
this.editor.emit('contentChange');
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
rgbToHex(rgb: string): string {
|
||||||
|
if (!rgb || rgb === 'rgba(0, 0, 0, 0)' || rgb === 'transparent') {
|
||||||
|
return '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = rgb.match(/\d+/g);
|
||||||
|
if (!result) return '#000000';
|
||||||
|
|
||||||
|
const r = parseInt(result[0]);
|
||||||
|
const g = parseInt(result[1]);
|
||||||
|
const b = parseInt(result[2]);
|
||||||
|
|
||||||
|
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================
|
||||||
|
// 选区样式操作(全局contenteditable模式)
|
||||||
|
// =============================
|
||||||
|
applySelectionBold(): boolean { return this.editor.globalEditable?.applySelectionBold() ?? false; }
|
||||||
|
applySelectionItalic(): boolean { return this.editor.globalEditable?.applySelectionItalic() ?? false; }
|
||||||
|
applySelectionUnderline(): boolean { return this.editor.globalEditable?.applySelectionUnderline() ?? false; }
|
||||||
|
applySelectionStrikeThrough(): boolean { return this.editor.globalEditable?.applySelectionStrikeThrough() ?? false; }
|
||||||
|
applySelectionFontSize(px: string): boolean { return this.editor.globalEditable?.applySelectionFontSize(px) ?? false; }
|
||||||
|
applySelectionFontFamily(name: string): boolean { return this.editor.globalEditable?.applySelectionFontFamily(name) ?? false; }
|
||||||
|
applySelectionColor(color: string): boolean { return this.editor.globalEditable?.applySelectionColor(color) ?? false; }
|
||||||
|
applySelectionBackground(color: string): boolean { return this.editor.globalEditable?.applySelectionBackground(color) ?? false; }
|
||||||
|
applySelectionAlign(align: 'left' | 'center' | 'right'): boolean { return this.editor.globalEditable?.applySelectionAlign(align as any) ?? false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StyleManager;
|
||||||
160
components/html-editor/lib/core/utils.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 通用工具函数集合:与 HTMLEditor 实例 (this) 无关的逻辑
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Position } from '../types';
|
||||||
|
import { type HTMLEditor } from './editor';
|
||||||
|
|
||||||
|
export function getElementType(element: HTMLElement): string {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'h1': '标题1', 'h2': '标题2', 'h3': '标题3',
|
||||||
|
'h4': '标题4', 'h5': '标题5', 'h6': '标题6',
|
||||||
|
'p': '段落', 'div': '区块', 'span': '文本',
|
||||||
|
'a': '链接', 'img': '图片',
|
||||||
|
'ul': '无序列表', 'ol': '有序列表', 'li': '列表项'
|
||||||
|
};
|
||||||
|
return typeMap[tagName] || tagName.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDivWithText(element: HTMLElement): boolean {
|
||||||
|
const elementWithText = !!element.textContent?.trim() && element.children.length === 0;
|
||||||
|
return element.tagName.toLowerCase() === 'div' && elementWithText
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextElement(element: HTMLElement): boolean {
|
||||||
|
const textTags = ['p', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'strong', 'em'];
|
||||||
|
return textTags.includes(element.tagName.toLowerCase()) || isDivWithText(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDivWithImage(element: HTMLElement): boolean {
|
||||||
|
const computedStyle = window.getComputedStyle(element);
|
||||||
|
const { backgroundImage, background } = computedStyle
|
||||||
|
const elementWithBgImage = !!backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('url(')
|
||||||
|
const elementBgWithUrl = !!background && background.includes('url(')
|
||||||
|
const divWithbg = element.tagName.toLowerCase() === 'div' && element.children.length === 0 && (elementWithBgImage || elementBgWithUrl);
|
||||||
|
return divWithbg
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function isBlockElement(element: HTMLElement): boolean {
|
||||||
|
const blockTags = ['div', 'section', 'article', 'header', 'footer', 'main', 'body', 'ol', 'ul', 'li', 'button', 'i'];
|
||||||
|
return blockTags.includes(element.tagName.toLowerCase()) && !isDivWithImage(element) && !isDivWithText(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isInlineElement = (element: HTMLElement): boolean => {
|
||||||
|
const display = getComputedStyle(element).display;
|
||||||
|
return display.startsWith('inline') && display !== 'inline-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isTableElement = (element: HTMLElement): boolean => {
|
||||||
|
const tableTags = ['tr', 'td', 'th', 'tbody', 'thead', 'tfoot', 'caption'];
|
||||||
|
const tagName = element.tagName.toLowerCase().toLowerCase();
|
||||||
|
return tableTags.includes(tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageElement(element: HTMLElement): boolean {
|
||||||
|
return isDivWithImage(element) || element.tagName.toLowerCase() === 'img';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createElement(type: string, content: string = ''): HTMLElement {
|
||||||
|
const element = document.createElement(type);
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
element.textContent = content;
|
||||||
|
} else {
|
||||||
|
element.textContent = type === 'div' ? '新区块' : '新文本';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加基础样式
|
||||||
|
element.style.padding = '10px';
|
||||||
|
element.style.margin = '5px';
|
||||||
|
element.style.backgroundColor = '#f8f9fa';
|
||||||
|
element.style.border = '1px dashed #dee2e6';
|
||||||
|
element.style.borderRadius = '4px';
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const elementWatcher = (editor: HTMLEditor) => {
|
||||||
|
let ele: HTMLElement | null = null;
|
||||||
|
let running = false;
|
||||||
|
let frameId: number | null = null;
|
||||||
|
let lastRect: Position | null = null;
|
||||||
|
const update = (element: HTMLElement, callback?: (postition: Position) => void) => {
|
||||||
|
if (!running || !element.isConnected) return;
|
||||||
|
|
||||||
|
const postition = editor.getBoundPostion(element);
|
||||||
|
|
||||||
|
const hasChanged =
|
||||||
|
!lastRect ||
|
||||||
|
postition.left !== lastRect.left ||
|
||||||
|
postition.top !== lastRect.top ||
|
||||||
|
postition.width !== lastRect.width ||
|
||||||
|
postition.height !== lastRect.height ||
|
||||||
|
postition.right !== lastRect.right ||
|
||||||
|
postition.bottom !== lastRect.bottom;
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
lastRect = postition;
|
||||||
|
callback?.(postition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一帧继续
|
||||||
|
frameId = requestAnimationFrame(() => update(element, callback));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
start: (element: HTMLElement, callback?: (position: Position) => void) => {
|
||||||
|
if (!running) {
|
||||||
|
ele = element;
|
||||||
|
running = true;
|
||||||
|
update(element, callback);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop: (element: HTMLElement) => {
|
||||||
|
if (ele !== element) return;
|
||||||
|
running = false;
|
||||||
|
if (frameId) cancelAnimationFrame(frameId);
|
||||||
|
frameId = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cleanDom = (doc: HTMLElement): string => {
|
||||||
|
// 克隆一份
|
||||||
|
const ele = doc.cloneNode(true) as HTMLElement
|
||||||
|
// 移除所有hover以及selected样式
|
||||||
|
ele.querySelectorAll('*').forEach(node => {
|
||||||
|
const tagName = node.tagName.toLowerCase()
|
||||||
|
const classList = node.classList
|
||||||
|
const nodeStyle = (node as HTMLElement).style
|
||||||
|
if (classList.contains('hover-highlight')) {
|
||||||
|
node.classList.remove('hover-highlight')
|
||||||
|
node.removeAttribute('data-element-type')
|
||||||
|
}
|
||||||
|
if (classList.contains('selected-element')) {
|
||||||
|
node.classList.remove('selected-element')
|
||||||
|
node.removeAttribute('data-element-type')
|
||||||
|
}
|
||||||
|
if (tagName === 'style' && node.hasAttribute('data-styled-id')) {
|
||||||
|
node.parentNode?.removeChild(node)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
node.getAttribute('id') === 'html-editor-helper-box' ||
|
||||||
|
classList.contains('moveable-control-box')
|
||||||
|
) {
|
||||||
|
node.parentNode?.removeChild(node)
|
||||||
|
}
|
||||||
|
if (nodeStyle && nodeStyle.cursor === 'move') {
|
||||||
|
nodeStyle.cursor = 'auto'
|
||||||
|
}
|
||||||
|
if (node.hasAttribute('contenteditable')) {
|
||||||
|
node.removeAttribute('contenteditable')
|
||||||
|
}
|
||||||
|
if (nodeStyle.userSelect === 'none') {
|
||||||
|
nodeStyle.userSelect = 'auto'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return ele.outerHTML
|
||||||
|
}
|
||||||
5
components/html-editor/lib/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { HTMLEditor } from './core/editor';
|
||||||
|
export { HistoryManager } from './core/historyManager';
|
||||||
|
export * from './core/historyManager/commands';
|
||||||
|
export * from './core/utils'
|
||||||
|
export * from './types';
|
||||||