初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View 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/ # 图标等静态资源
```

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

View File

@@ -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>
)
}

View File

@@ -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: '删除'
},
}

View File

@@ -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;

View File

@@ -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])
}

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

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

View File

@@ -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])
}

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

View 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: '删除'
},
}

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

View 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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,40 @@
/**
* 公共类型定义:编辑器相关类型
*/
import type { EditorStyleConfig } from './style';
import type { MoveableOptions } from './moveable';
import type { OnElementSelect, OnStyleChange, OnContentChange, OnReady, OnHistoryChange } from './events';
import type { HistoryManagerOptions } from './history';
export interface Position {
top: number;
left: number;
width: number;
height: number;
bottom: number;
right: number;
}
export interface HTMLEditorOptions {
id:string,
container?: HTMLElement | string | null;
enableMoveable?: boolean;
enableHistory?: boolean; // 是否启用历史记录
historyOptions?: HistoryManagerOptions; // 历史记录配置
theme?: string;
autoSave?: boolean;
styleConfig?: EditorStyleConfig;
enableContentEditable?: boolean; // 是否启用contenteditable编辑
enableGlobalContentEditable?: boolean; // 是否启用全局contenteditable模式
onElementSelect?: OnElementSelect | null;
onStyleChange?: OnStyleChange | null;
onContentChange?: OnContentChange | null;
onReady?: OnReady | null;
onHistoryChange?: OnHistoryChange | null;
// Moveable 配置透传
moveableOptions?: MoveableOptions;
helperBox?: boolean;
// 忽略选择的标签
ignoreSelectTags?: string[];
}

View File

@@ -0,0 +1,28 @@
/**
* Core Types: Editor Events
* 集中事件相关类型
*/
import type { HistoryState } from './history';
// 事件名类型(可按需扩展)
export type EditorEventName =
| 'ready'
| 'hover'
| 'elementSelect'
| 'styleChange'
| 'contentChange'
| 'historyChange';
// 自定义事件 detail 结构
export interface EditorEventDetail {
editor: unknown; // 避免循环依赖,保持通用;需要时可在使用点断言为 HTMLEditor
args: any[];
}
// 回调签名统一声明(供 HTMLEditorOptions 参考或复用)
export type OnElementSelect = (element: HTMLElement | null, position?: { top: number; left: number; width: number; height: number; bottom: number; right: number }) => void;
export type OnStyleChange = (element: HTMLElement, styles: Record<string, string>) => void;
export type OnContentChange = () => void;
export type OnReady = () => void;
export type OnHistoryChange = (state: HistoryState) => void;

View File

@@ -0,0 +1,5 @@
/**
* History Manager Types Export
*/
export * from '../../core/historyManager/types';

View File

@@ -0,0 +1,32 @@
/**
* Core Types: Moveable
*/
export interface MoveableOptions {
draggable?: boolean;
scalable?: boolean;
resizable?: boolean;
renderDirections?: Array<'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'w' | 'e'>;
keepRatio?: boolean;
throttleDrag?: number;
throttleResize?: number;
throttleScale?: number;
origin?: boolean;
// 吸附与标尺线相关
snappable?: boolean;
snapCenter?: boolean;
snapThreshold?: number;
snapGridWidth?: number;
snapGridHeight?: number;
snapContainer?: HTMLElement | null;
elementGuidelines?: HTMLElement[];
horizontalGuidelines?: number[];
verticalGuidelines?: number[];
snapDirections?: {
left?: boolean;
top?: boolean;
right?: boolean;
bottom?: boolean;
center?: boolean;
middle?: boolean;
};
}

View File

@@ -0,0 +1,52 @@
/**
* Core Types: Style
*/
export interface StyleChange {
[key: string]: string;
}
export interface ElementStyles {
fontSize: string;
color: string;
fontWeight: string;
backgroundColor: string;
borderWidth: string;
padding: string;
margin: string;
borderRadius: string;
}
export interface EditorStyleConfig {
// Hover 样式配置
hover: {
outline?: string;
outlineOffset?: string;
cursor?: string;
backgroundColor?: string;
};
// Focus/Selected 样式配置
selected: {
outline?: string;
outlineOffset?: string;
cursor?: string;
backgroundColor?: string;
};
// 角标样式配置
badge: {
enabled?: boolean;
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
offset?: {
top?: string;
left?: string;
right?: string;
bottom?: string;
};
background?: string;
color?: string;
padding?: string;
borderRadius?: string;
fontSize?: string;
fontFamily?: string;
zIndex?: number;
};
}

View File

@@ -0,0 +1,9 @@
/**
* Types Barrel
* 统一导出所有类型,便于外部按需引用
*/
export * from './core/editor';
export * from './core/moveable';
export * from './core/style';
export * from './core/events';
export * from './core/history';

View File

@@ -0,0 +1,121 @@
import React, { useEffect, useRef } from 'react'
import { useDebounceFn } from 'ahooks'
import { cleanDom } from '../lib/core/utils'
import { Html } from '../components/html-render/task-html'
import { useIframeMode } from '../hooks/useIframeMode'
import { useDiff } from '../hooks/useDiff'
import { useEditState } from '../hooks/useEditState'
import { FloatingToolbar } from '../../ppt-editor/FloatingToolbar'
import type { ArtifactEditState } from '../types'
import { saveMarkdown } from '../server'
import { TaskArtifact } from '@/components/nova-sdk'
export const HtmlWithEditMode: React.FC<{
taskId: string
taskArtifact: TaskArtifact
onStateChange?: (state: ArtifactEditState) => void
content: string
isDoc?: boolean
}> = props => {
const { taskId, isDoc ,taskArtifact, content, onStateChange } = props
const htmlIframe = useRef<HTMLIFrameElement>(null)
const editState = useEditState()
const saveHandler = async (type: 'auto' | 'manual' = 'auto', callback?: () => void) => {
if (editState.isSaving) return
if (!htmlIframe.current) return
const iframeDoc = htmlIframe.current.contentDocument?.documentElement
if (!iframeDoc) return
editState.setSaveType(type)
editState.setIsSaving(true)
try {
const srcDoc = cleanDom(iframeDoc)
await saveMarkdown({
task_id: taskId,
content: srcDoc,
path: taskArtifact.path,
})
callback?.()
} catch (e) {
console.error(e)
} finally {
editState.setIsSaving(false)
editState.setSaveType(null)
}
}
const handleSave = useDebounceFn(saveHandler, { wait: 1000 })
const manualSave = () => {
handleSave.cancel()
saveHandler('manual')
}
const useIframeReturn = useIframeMode(taskId, htmlIframe, {
enableGlobalContentEditable: isDoc,
onContentChange: content => {
handleSave.run('auto')
},
onHistoryChange: (_state, instance) => {
editState.handleHistoryChangeEvent(instance)
// 同时向上传递状态,保持兼容性
if (onStateChange && instance) {
const canRedo = instance.EditorRegistry.canRedo()
const canUndo = instance.EditorRegistry.canUndo()
onStateChange({
canRedo,
canUndo,
redo: () => instance.EditorRegistry.redo(),
undo: () => instance.EditorRegistry.undo(),
})
}
},
})
useEffect(() => {
const saveHandler = (callback?: () => void) => {
if (!useIframeReturn.loadSuccess) {
callback?.()
return
}
if (!htmlIframe.current) return
const iframeDoc = htmlIframe.current.contentDocument?.documentElement
if (iframeDoc) {
const srcDoc = cleanDom(iframeDoc)
handleSave.run('auto', callback)
}
}
// eventBus.on('html-edit-save', saveHandler)
return () => {
// eventBus.off('html-edit-save', saveHandler)
}
}, [useIframeReturn.loadSuccess])
// doc编辑与web编辑差异逻辑
useDiff(useIframeReturn, isDoc)
return (
<div className="relative h-full ">
{/* 悬浮工具栏 */}
<Html
className={`opacity-0 ${'opacity-100'} h-full`}
content={content}
ref={htmlIframe}
/>
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 12px', zIndex: 100, position: 'absolute', left: 0, right: 0, top: 80 }}>
<FloatingToolbar
canUndo={editState.canUndo}
canRedo={editState.canRedo}
onUndo={() => editState.undo.current()}
onRedo={() => editState.redo.current()}
onSave={manualSave}
isSaving={editState.isSaving}
saveType={editState.saveType}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import React, { useRef } from 'react'
import { Html } from '../components/html-render/task-html'
import { HtmlWithEditMode } from './baseEdit'
import { PPTEditProvider } from '../context'
import { PPTEditToolBar } from '../components/toolbar-doc'
import type { TaskArtifact } from '@/components/nova-sdk/types'
import { useLoadContent } from '../hooks/useLoadContent'
import type { ArtifactEditState } from '../types'
const HtmlDoc = ({
taskId,
taskArtifact,
onStateChange,
editable = false,
}: {
taskId: string
taskArtifact: TaskArtifact
onStateChange?: (state: ArtifactEditState) => void
editable?: boolean
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const content = useLoadContent(taskArtifact);
if (!editable) {
return <Html className='h-full' content={content} />
}
return (
<PPTEditProvider>
<div className='relative h-full'>
<PPTEditToolBar containerRef={containerRef} />
<div className='h-full' ref={containerRef}>
<HtmlWithEditMode taskId={taskId} taskArtifact={taskArtifact} content={content} isDoc={true} onStateChange={onStateChange}/>
</div>
</div>
</PPTEditProvider>
)
}
export const TaskHtmlDoc = React.memo(HtmlDoc)

View File

@@ -0,0 +1,39 @@
import React, { useRef } from 'react'
import { Html } from '../components/html-render/task-html'
import { PPTEditToolBar } from '../components/toolbar-web'
import { PPTEditProvider } from '../context'
import { HtmlWithEditMode } from './baseEdit'
import type { TaskArtifact } from '@/components/nova-sdk/types'
import { useLoadContent } from '../hooks/useLoadContent'
import type { ArtifactEditState } from '../types'
function HtmlWeb({
taskId,
taskArtifact,
onStateChange,
editable = false,
}: {
taskId: string
taskArtifact: TaskArtifact
onStateChange?: (state: ArtifactEditState) => void
editable?: boolean
}) {
const containerRef = useRef<HTMLDivElement>(null)
const content = useLoadContent(taskArtifact);
if (!editable) {
return <Html className='h-full' content={content} />
}
return (
<PPTEditProvider>
<div className='relative h-full'>
<PPTEditToolBar containerRef={containerRef} />
<div className='h-full' ref={containerRef}>
<HtmlWithEditMode taskId={taskId} taskArtifact={taskArtifact} content={content} isDoc={false} onStateChange={onStateChange}/>
</div>
</div>
</PPTEditProvider>
)
}
export const TaskHtmlWeb = React.memo(HtmlWeb)

View File

@@ -0,0 +1,9 @@
import { request } from '@/http/request';
export function saveMarkdown(content: {
task_id: string
path: string
content: string
}) {
return request.post('/v1/super_agent/chat/write_file', content)
}

View File

@@ -0,0 +1,7 @@
export interface ArtifactEditState {
canUndo: boolean
canRedo: boolean
redo: () => void
undo: () => void
}