初始化模版工程
12
components/AgentationGuard.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Agentation } from 'agentation'
|
||||
|
||||
export function AgentationGuard() {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null
|
||||
}
|
||||
|
||||
return <Agentation />
|
||||
}
|
||||
|
||||
87
components/base/color-picker/index.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { HexColorPicker } from 'react-colorful'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface ColorPickerProps {
|
||||
color: string
|
||||
onChange: (color: string) => void
|
||||
presets?: string[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const defaultPresets = [
|
||||
'#171412',
|
||||
'#2E241C',
|
||||
'#8A6742',
|
||||
'#B79267',
|
||||
'#D0B08C',
|
||||
'#E8DFD1',
|
||||
'#FFFDF8',
|
||||
'#8F7CFF',
|
||||
'#45D4FF',
|
||||
'#FF78B8',
|
||||
'#48D7C2',
|
||||
'#FFB86B',
|
||||
]
|
||||
|
||||
export function ColorPicker({
|
||||
color,
|
||||
onChange,
|
||||
presets = defaultPresets,
|
||||
className,
|
||||
}: ColorPickerProps) {
|
||||
const [inputValue, setInputValue] = React.useState(color)
|
||||
|
||||
React.useEffect(() => {
|
||||
setInputValue(color)
|
||||
}, [color])
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value
|
||||
setInputValue(val)
|
||||
if (/^#[0-9A-F]{3,6}$/i.test(val)) {
|
||||
onChange(val)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3 w-[200px]', className)}>
|
||||
<HexColorPicker
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
className="!w-full !h-[120px]"
|
||||
/>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<label className="text-[10px] font-medium text-muted-foreground uppercase w-8">
|
||||
Hex
|
||||
</label>
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
className="h-7 text-[10px] font-mono px-2"
|
||||
/>
|
||||
</div>
|
||||
{presets.length > 0 && (
|
||||
<div className="grid grid-cols-6 gap-1.5 pt-2 border-t border-border">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-5 h-5 rounded-sm border border-black/5 cursor-pointer transition-all hover:scale-110 active:scale-95',
|
||||
color.toLowerCase() === p.toLowerCase() &&
|
||||
'ring-2 ring-primary ring-offset-1'
|
||||
)}
|
||||
style={{ backgroundColor: p }}
|
||||
onClick={() => onChange(p)}
|
||||
title={p}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
components/html-editor/README.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# TaskArtifactHtml
|
||||
|
||||
HTML 产物渲染与编辑组件,支持 **文档模式(document)** 和 **网页模式(web)** 两种渲染方式。内容通过 iframe 沙箱加载,编辑态下提供所见即所得的工具栏。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
```tsx
|
||||
import { TaskArtifactHtml } from "@/components/nova-sdk/html-editor";
|
||||
|
||||
<TaskArtifactHtml
|
||||
taskId="task-123"
|
||||
editable={true}
|
||||
type="web"
|
||||
taskArtifact={{
|
||||
path: "/artifacts/page.html",
|
||||
file_name: "page.html",
|
||||
file_type: "text/html",
|
||||
}}
|
||||
onStateChange={(state) => {
|
||||
// state.canUndo / state.canRedo / state.undo() / state.redo()
|
||||
console.log(state);
|
||||
}}
|
||||
/>;
|
||||
```
|
||||
|
||||
> **前置依赖**:组件内部通过 `useNovaKit()` 获取 API 实例来加载远程内容,因此使用前需确保外层已包裹 `<NovaKitProvider>`。
|
||||
|
||||
---
|
||||
|
||||
## Props
|
||||
|
||||
| 属性 | 类型 | 必填 | 说明 |
|
||||
| --------------- | ------------------------------------ | ---- | ----------------------------------------------------------------- |
|
||||
| `taskId` | `string` | ✅ | 任务 ID,用于关联产物与任务,编辑时也用于保存接口 |
|
||||
| `editable` | `boolean` | ❌ | 是否开启编辑模式。`false` 为只读预览,`true` 显示编辑工具栏 |
|
||||
| `type` | `'document' \| 'web'` | ✅ | 渲染模式。`document` 适合富文本文档编辑,`web` 适合网页类产物编辑 |
|
||||
| `taskArtifact` | `TaskArtifact` | ✅ | 产物数据对象,包含文件路径等信息 |
|
||||
| `onStateChange` | `(state: ArtifactEditState) => void` | ❌ | 编辑状态变化回调,每次 undo/redo 历史变化时触发 |
|
||||
|
||||
### TaskArtifact 类型定义
|
||||
|
||||
```ts
|
||||
interface TaskArtifact {
|
||||
path: string; // 产物文件路径
|
||||
file_name: string; // 文件名
|
||||
file_type: string; // 文件 MIME 类型
|
||||
last_modified?: number; // 最后修改时间戳
|
||||
url?: string; // 可选的直接访问 URL
|
||||
content?: string; // 可选的内联内容
|
||||
task_id?: string; // 关联的任务 ID
|
||||
event_type?: string; // 工具调用事件类型
|
||||
tool_name?: string; // 工具名称
|
||||
tool_input?: unknown; // 工具输入
|
||||
tool_output?: unknown; // 工具输出
|
||||
}
|
||||
```
|
||||
|
||||
### ArtifactEditState 类型定义
|
||||
|
||||
当传入 `onStateChange` 后,编辑器内部的历史记录发生变化时会通过该回调通知父组件。父组件可据此实现外部的撤销/重做按钮。
|
||||
|
||||
```ts
|
||||
interface ArtifactEditState {
|
||||
canUndo: boolean; // 是否可以撤销
|
||||
canRedo: boolean; // 是否可以重做
|
||||
undo: () => void; // 执行撤销
|
||||
redo: () => void; // 执行重做
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 两种模式对比
|
||||
|
||||
| 特性 | `document` 模式 | `web` 模式 |
|
||||
| -------- | ------------------------------------------ | ------------------------------------------------------ |
|
||||
| 适用场景 | 富文本文档(文章、报告等) | 网页类产物(落地页、网站等) |
|
||||
| 编辑方式 | 全局 `contentEditable`,支持文本选区工具栏 | 元素级选取与属性编辑 |
|
||||
| 工具栏 | `toolbar-doc` — 基于文本选区定位 | `toolbar-web` — 基于选中元素定位,更丰富的样式编辑能力 |
|
||||
| 内部组件 | `TaskHtmlDoc` | `TaskHtmlWeb` |
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 只读预览
|
||||
|
||||
```tsx
|
||||
// 简单预览,不显示任何编辑工具栏
|
||||
<TaskArtifactHtml
|
||||
taskId={task.id}
|
||||
editable={false}
|
||||
type="document"
|
||||
taskArtifact={artifact}
|
||||
/>
|
||||
```
|
||||
|
||||
### 文档编辑模式
|
||||
|
||||
```tsx
|
||||
// 开启编辑,用户可以直接在文档中选中文本进行格式编辑
|
||||
<TaskArtifactHtml
|
||||
taskId={task.id}
|
||||
editable={true}
|
||||
type="document"
|
||||
taskArtifact={artifact}
|
||||
/>
|
||||
```
|
||||
|
||||
### 网页编辑模式
|
||||
|
||||
```tsx
|
||||
// 开启编辑,用户可以点选网页中的元素进行样式调整
|
||||
<TaskArtifactHtml
|
||||
taskId={task.id}
|
||||
editable={true}
|
||||
type="web"
|
||||
taskArtifact={artifact}
|
||||
/>
|
||||
```
|
||||
|
||||
### 配合外部撤销/重做按钮
|
||||
|
||||
```tsx
|
||||
const [editState, setEditState] = useState<ArtifactEditState | null>(null);
|
||||
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<button disabled={!editState?.canUndo} onClick={() => editState?.undo()}>
|
||||
撤销
|
||||
</button>
|
||||
<button disabled={!editState?.canRedo} onClick={() => editState?.redo()}>
|
||||
重做
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TaskArtifactHtml
|
||||
taskId={task.id}
|
||||
editable={true}
|
||||
type="web"
|
||||
taskArtifact={artifact}
|
||||
onStateChange={setEditState}
|
||||
/>
|
||||
</>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 内部架构
|
||||
|
||||
```
|
||||
TaskArtifactHtml (入口)
|
||||
├── type="document" → TaskHtmlDoc
|
||||
│ ├── editable=false → Html (只读 iframe)
|
||||
│ └── editable=true
|
||||
│ └── PPTEditProvider (编辑状态上下文)
|
||||
│ ├── PPTEditToolBar (toolbar-doc, 文本选区工具栏)
|
||||
│ └── HtmlWithEditMode (基础编辑层)
|
||||
│ ├── useIframeMode → HTMLEditor 实例
|
||||
│ ├── useDiff → 同步编辑状态到 Context
|
||||
│ └── Html (iframe 渲染)
|
||||
│
|
||||
└── type="web" → TaskHtmlWeb
|
||||
├── editable=false → Html (只读 iframe)
|
||||
└── editable=true
|
||||
└── PPTEditProvider
|
||||
├── PPTEditToolBar (toolbar-web, 元素工具栏)
|
||||
└── HtmlWithEditMode (isDoc=false)
|
||||
```
|
||||
|
||||
### 核心模块说明
|
||||
|
||||
#### `Html`(`components/html-render/task-html.tsx`)
|
||||
|
||||
底层 iframe 渲染组件,接受 HTML 字符串作为 `srcDoc` 注入 iframe。配置了 `sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"`,并自动拦截 iframe 内的 `_blank` 链接和 `window.open` 调用,转发至父窗口打开。
|
||||
|
||||
#### `HtmlWithEditMode`(`mode/baseEdit.tsx`)
|
||||
|
||||
编辑模式的基础封装层,负责:
|
||||
|
||||
- 调用 `useIframeMode` 初始化 `HTMLEditor` 编辑器实例
|
||||
- 调用 `useDiff` 将编辑器状态同步到 `PPTEditContext`
|
||||
- 监听历史记录变化,通过 `onStateChange` 向上传递 `ArtifactEditState`(canUndo/canRedo/undo/redo)
|
||||
- 内容变更时自动防抖保存(1 秒),通过 `saveMarkdown` 接口持久化到服务端
|
||||
|
||||
#### `PPTEditProvider`(`context/index.tsx`)
|
||||
|
||||
编辑状态上下文 Provider,维护:
|
||||
|
||||
- `state` — 当前 `useIframeMode` 的返回值(编辑器实例、选中元素、位置等)
|
||||
- `setState` — 由 `useDiff` hook 调用,将编辑状态同步至上下文
|
||||
- `originalSlide` — 原始幻灯片数据的引用(PPT 场景)
|
||||
|
||||
工具栏组件通过 `usePPTEditContext()` 消费该上下文,获取编辑器实例和选中元素信息来渲染对应的编辑工具。
|
||||
|
||||
---
|
||||
|
||||
## Hooks
|
||||
|
||||
### `useLoadContent(taskArtifact: TaskArtifact): string`
|
||||
|
||||
通过 `useNovaKit()` 提供的 API 加载产物的远程 HTML 内容。先调用 `api.getArtifactUrl()` 获取签名 URL,再 `fetch` 获取文本内容。
|
||||
|
||||
### `useIframeMode(id, containerRef, options?, scale?): UseInjectModeReturn`
|
||||
|
||||
核心编辑 hook,负责:
|
||||
|
||||
- 创建 `HTMLEditor` 实例并注入到 iframe 或普通 DOM 容器
|
||||
- 管理元素选中状态和位置计算
|
||||
- 管理 undo/redo 历史记录
|
||||
- 绑定 `Cmd+Z` / `Cmd+Shift+Z` 快捷键
|
||||
|
||||
返回值:
|
||||
|
||||
```ts
|
||||
interface UseInjectModeReturn {
|
||||
editor: HTMLEditor | null; // 编辑器实例(ref 值)
|
||||
editorIns: HTMLEditor | null; // 编辑器实例(state 值,触发重渲染)
|
||||
selectedElement: HTMLElement | null; // 当前选中的 DOM 元素
|
||||
position: Position | null; // 选中元素在 iframe 内的位置
|
||||
tipPosition: Position | null; // 经过缩放换算后用于定位工具栏的位置
|
||||
injectScript: (target: HTMLElement) => Promise<void>; // 手动注入编辑器
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
clearHistory: () => void;
|
||||
loadSuccess: boolean; // iframe 内容是否加载/注入成功
|
||||
}
|
||||
```
|
||||
|
||||
### `useDiff(useIframeReturn, isDoc?)`
|
||||
|
||||
区分 document / web 两种模式的差异逻辑,将 `useIframeMode` 的状态同步到 `PPTEditContext`:
|
||||
|
||||
- **document 模式**:使用 `useToolPosition` 基于文本选区末尾计算工具栏位置
|
||||
- **web 模式**:基于选中元素的 `getBoundingClientRect` 定位工具栏;失焦后清除状态
|
||||
|
||||
### `useToolPosition(editor, isDoc): Position | null`
|
||||
|
||||
仅在 document 模式下生效。监听编辑器内的 `mousedown` / `mouseup` / `scroll` / `resize` 事件,根据当前文本选区(`Selection`)末尾位置计算工具栏坐标。
|
||||
|
||||
---
|
||||
|
||||
## 服务端接口
|
||||
|
||||
### `saveMarkdown`(`server/index.ts`)
|
||||
|
||||
编辑模式下内容变更会自动防抖(1 秒)调用此函数保存到服务端:
|
||||
|
||||
```ts
|
||||
POST / v1 / super_agent / chat / write_file;
|
||||
Body: {
|
||||
task_id: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
html-editor/
|
||||
├── index.tsx # 入口组件 TaskArtifactHtml
|
||||
├── README.md # 本文档
|
||||
├── types/
|
||||
│ └── index.ts # ArtifactEditState 等类型定义
|
||||
├── server/
|
||||
│ └── index.ts # saveMarkdown 保存接口
|
||||
├── mode/
|
||||
│ ├── baseEdit.tsx # 编辑模式基础层 HtmlWithEditMode
|
||||
│ ├── html-doc.tsx # 文档模式 TaskHtmlDoc
|
||||
│ └── html-web.tsx # 网页模式 TaskHtmlWeb
|
||||
├── context/
|
||||
│ └── index.tsx # PPTEditProvider / usePPTEditContext
|
||||
├── hooks/
|
||||
│ ├── useDiff.ts # doc/web 差异逻辑
|
||||
│ ├── useIframeMode.ts # 核心编辑器 hook
|
||||
│ ├── useLoadContent.ts # 远程内容加载
|
||||
│ └── useToolPostion.ts # 文本选区工具栏定位
|
||||
├── components/
|
||||
│ ├── html-render/ # iframe 渲染组件
|
||||
│ ├── toolbar-doc/ # 文档模式工具栏
|
||||
│ └── toolbar-web/ # 网页模式工具栏
|
||||
├── lib/ # HTMLEditor 核心库
|
||||
│ ├── core/ # 编辑器引擎、历史记录管理器
|
||||
│ ├── types/ # 内部类型定义
|
||||
│ └── config/ # 编辑器配置
|
||||
└── assets/ # 图标等静态资源
|
||||
```
|
||||
1
components/html-editor/assets/images/align-center.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121357"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121357)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="10.666748046875" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="9.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 807 B |
1
components/html-editor/assets/images/align-left.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121353"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121353)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="7.333251953125" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="7.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 806 B |
1
components/html-editor/assets/images/align-right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121361"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121361)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="14" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="11.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 795 B |
1
components/html-editor/assets/images/blod.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139074"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139074)"><g><path d="M12.01479246875,8.98540254375C11.66879276875,8.45240214375,11.15829276875,8.09790274375,10.48379326875,7.92240234375L10.48379326875,7.88840194375C10.84129336875,7.71840194375,11.14179376875,7.52840184375,11.38529296875,7.31840184375C11.62929346875,7.11990214375,11.81629276875,6.90990254375,11.94679356875,6.68890234375C12.20179366875,6.22390224375,12.32379336875,5.73640224375,12.31229396875,5.2259023437500005C12.31229396875,4.227902443750001,12.00329396875,3.4254025437499998,11.38529486875,2.81840241375C10.77279476875,2.21740234375,9.85679486875,1.91140243475,8.63779446875,1.89990234375L3.36279296875,1.89990234375L3.36279296875,14.30190134375L9.054792868749999,14.30190134375C10.03029296875,14.30190134375,10.84379336875,13.97590134375,11.49629306875,13.32340134375C12.15429396875,12.69940234375,12.48879336875,11.85190204375,12.49979306875,10.78040124375C12.49979306875,10.13390164375,12.33829306875,9.53540134375,12.01479246875,8.98540254375ZM5.3797929687499995,3.57540254375L8.42529296875,3.57540254375C9.11129236875,3.5869023437500003,9.61629246875,3.7539025437499998,9.93929246875,4.07740254375C10.268292868749999,4.42340254375,10.43279216875,4.84590264375,10.43279216875,5.34490254375C10.43279216875,5.84390284375,10.268292868749999,6.25790264375,9.93929246875,6.58690264375C9.61629246875,6.94390294375,9.11129236875,7.12290284375,8.425291968749999,7.12290284375L5.3797929687499995,7.12290284375L5.3797929687499995,3.57540254375ZM10.13529296875,11.95440234375C9.81229306875,12.32290134375,9.301793068750001,12.51290134375,8.60429236875,12.52440134375L5.3797929687499995,12.52440134375L5.3797929687499995,8.79890254375L8.60379316875,8.79890254375C9.301293368749999,8.80990214375,9.81179286875,8.994401943749999,10.13479276875,9.35190154375C10.457793268749999,9.714902443749999,10.61979296875,10.15140244375,10.61979296875,10.66190144375C10.61979296875,11.16040134375,10.458292468749999,11.59140114375,10.13529296875,11.95440234375Z" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
21
components/html-editor/assets/images/delete.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none"
|
||||
version="1.1" width="28" height="28" viewBox="0 0 28 28">
|
||||
<defs>
|
||||
<clipPath id="master_svg0_5344_120568">
|
||||
<rect x="6" y="6" width="16" height="16" rx="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g>
|
||||
<g clip-path="url(#master_svg0_5344_120568)">
|
||||
<g>
|
||||
<path
|
||||
d="M12.000165893750001,7L16.00016589375,7C16.36835579375,7,16.66682629375,7.29847705,16.66682629375,7.6666669800000005C16.66682629375,8.0348599,16.36835579375,8.3333299,16.00016589375,8.3333299L12.000165893750001,8.3333299C11.63197609375,8.3333299,11.33349609375,8.0348599,11.33349609375,7.6666669800000005C11.33349609375,7.29847705,11.63197609375,7,12.000165893750001,7ZM8.00016307375,9.3333099L20.00019609375,9.3333099C20.36839609375,9.3333099,20.66679609375,9.6317899,20.66679609375,9.99998C20.66679609375,10.3681698,20.36839609375,10.6666498,20.00019609375,10.6666498L19.33349609375,10.6666498L19.33349609375,18.333399999999997Q19.33349609375,19.4379,18.55249609375,20.219Q17.77139609375,21,16.66683579375,21L11.33350609375,21Q10.22893619375,21,9.447885993749999,20.219Q8.66683599375,19.4379,8.66683599375,18.333399999999997L8.66683599375,10.6666498L8.00016307375,10.6666498C7.63197314375,10.6666498,7.33349609375,10.3681698,7.33349609375,9.99998C7.33349609375,9.6317899,7.63197314375,9.3333099,8.00016307375,9.3333099ZM10.00017599375,10.6666498L18.00019609375,10.6666498L18.00019609375,18.333399999999997Q18.00019609375,18.8856,17.60969609375,19.2762Q17.219125793750003,19.6667,16.66683579375,19.6667L11.33350609375,19.6667Q10.78122619375,19.6667,10.39069609375,19.2762Q10.00017599375,18.8856,10.00017599375,18.333399999999997L10.00017599375,10.6666498ZM13.33416609375,17.3333L13.33416609375,12.666669800000001C13.33416609375,12.29848,13.03568599375,12,12.66749619375,12C12.299305893749999,12,12.00083589375,12.29848,12.00083589375,12.666669800000001L12.00083589375,17.3333C12.00083589375,17.7015,12.299305893749999,18,12.66749619375,18C13.03568599375,18,13.33416609375,17.7015,13.33416609375,17.3333ZM16.00083639375,17.3333L16.00083639375,12.666669800000001C16.00083639375,12.29848,15.70236589375,12,15.33417609375,12C14.96598629375,12,14.66750619375,12.29848,14.66750619375,12.666669800000001L14.66750619375,17.3333C14.66750619375,17.7015,14.96598629375,18,15.33417609375,18C15.70236589375,18,16.00083639375,17.7015,16.00083639375,17.3333Z"
|
||||
fill-rule="evenodd" fill="currentColor" fill-opacity="1" style="mix-blend-mode:passthrough" />
|
||||
</g>
|
||||
<g style="opacity:0;">
|
||||
<rect x="6" y="6" width="16" height="16" rx="0" fill="#000000" fill-opacity="1"
|
||||
style="mix-blend-mode:passthrough" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
1
components/html-editor/assets/images/dropdown.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><g transform="matrix(-1,0,0,1,32,0)"><g><rect x="16" y="0" width="16" height="16" rx="0" fill="#D8D8D8" fill-opacity="0"/></g><g><path d="M21.21485549,6.20300207C21.47528636,5.956938956,21.88281816,5.934569582,22.1699947,6.13589394L22.2522695,6.20300207L24.4994397,8.3264129L26.7477303,6.20300207C27.0081615,5.956938956,27.4156933,5.934569582,27.7028699,6.13589394L27.7851443,6.20300207C28.0455756,6.44906518,28.0692511,6.83411378,27.8561711,7.1054469000000005L27.7851443,7.1831827L25.0187068,9.796998C24.7582762,10.0430613,24.3507442,10.0654306,24.0635679,9.8641059L23.981293,9.796998L21.21485549,7.1831827C20.928381503,6.91251332,20.928381503,6.4736715,21.21485549,6.20300207Z" fill="#8D8D99" fill-opacity="1"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 879 B |
1
components/html-editor/assets/images/duplicate.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_120572"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_120572)"><g><path d="M18.00017309375,10.000003823437499Q18.00017309375,8.8954539234375,17.21911429375,8.1144230334375Q16.43806359375,7.3333740234375,15.33349419375,7.3333740234375L10.00016399375,7.3333740234375Q8.89559409375,7.3333740234375,8.11454510375,8.1144230334375Q7.33349621295929,8.8954739234375,7.33349609375,10.000043823437501L7.33349609375,15.3333740234375Q7.33349609375,16.4379444234375,8.11454510375,17.2189941234375Q8.89559409375,18.0000740234375,10.00016399375,18.0000740234375Q10.00025419375,19.1045740234375,10.78129389375,19.8855740234375Q11.56234409375,20.6666740234375,12.66691399375,20.6666740234375L18.00027409375,20.6666740234375Q19.104774093750002,20.6666740234375,19.885874093749997,19.8855740234375Q20.66687409375,19.1045740234375,20.66687409375,17.999974023437503L20.66687409375,12.6666641234375Q20.66687409375,11.5620942234375,19.885874093749997,10.7810440234375Q19.104774093750002,10.000003823437499,18.00017309375,10.000003823437499ZM16.666833893750002,10.000003823437499Q16.66681389375,9.4477441234375,16.27630429375,9.0572340234375Q15.88578419375,8.6667039234375,15.33349419375,8.6667039234375L10.00016399375,8.6667039234375Q9.44787409375,8.6667039234375,9.05735399375,9.0572340234375Q8.66683409375,9.4477539234375,8.66683409375,10.000043823437501L8.66683409375,15.3333740234375Q8.66683409375,15.8856640234375,9.05735399375,16.276184123437503Q9.44787409375,16.6667042234375,10.00016399375,16.6667042234375L10.00024409375,12.6666641234375Q10.00024409375,11.5620942234375,10.78129389375,10.7810440234375Q11.56234409375,10.000003823437499,12.66691399375,10.000003823437499L16.666833893750002,10.000003823437499ZM11.33357379375,17.999974023437503Q11.33357379375,18.552274023437498,11.72410389375,18.9427740234375Q12.114623993750001,19.3333740234375,12.66691399375,19.3333740234375L18.00027409375,19.3333740234375Q18.55257409375,19.3333740234375,18.94307409375,18.9427740234375Q19.333574093750002,18.552274023437498,19.333574093750002,17.999974023437503L19.333574093750002,12.6666641234375Q19.333574093750002,12.1143842234375,18.94307409375,11.7238540234375Q18.55257409375,11.3333339234375,18.00027409375,11.3333339234375L12.66691399375,11.3333339234375Q12.114623993750001,11.3333339234375,11.72410389375,11.7238540234375Q11.33357379375,12.1143842234375,11.33357379375,12.6666641234375L11.33357379375,17.999974023437503Z" fill-rule="evenodd" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g><g style="opacity:0;"><rect x="6" y="6" width="16" height="16" rx="0" fill="currentColor" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
components/html-editor/assets/images/image-replace.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_124419"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_124419)"><g><path d="M7.33203125,14.8999405234375L7.33203125,18.2000420234375Q7.33203125,19.2217670234375,8.0545013,19.9442380234375Q8.77697205,20.6667080234375,9.798697950000001,20.6667080234375L18.19869925,20.6667080234375Q19.22042325,20.6667080234375,19.94289525,19.9442380234375Q20.66536525,19.2217660234375,20.66536525,18.2000420234375L20.66536525,9.800040723437501Q20.66536525,8.7783148234375,19.94289525,8.0558440734375Q19.22042425,7.3333740234375,18.19869925,7.3333740234375L9.798697950000001,7.3333740234375Q8.77697135,7.3333740234375,8.0545013,8.0558440734375Q7.33203125,8.7783139234375,7.33203125,9.800040723437501L7.33203125,14.8999405234375ZM8.66536475,15.222196623437501L8.66536475,18.2000420234375Q8.66536475,18.669484023437498,8.99731045,19.001429023437503Q9.32925555,19.3333740234375,9.798697950000001,19.3333740234375L10.15507075,19.3333740234375L13.05100445,16.8803472234375L10.88467645,14.150774923437499Q10.74771115,13.9781980234375,10.528880149999999,13.9526033234375Q10.31004905,13.9270081234375,10.13695525,14.0633192234375L8.66536475,15.222196623437501ZM14.06891725,16.018115023437502L11.929062349999999,13.3218975234375Q11.44968315,12.7178797234375,10.683773949999999,12.6282973234375Q9.91786505,12.5387149234375,9.31203745,13.0158043234375L8.66536475,13.5250587234375L8.66536475,9.800040723437501Q8.66536475,9.3305986234375,8.99731045,8.9986532234375Q9.32925605,8.6667075234375,9.798697950000001,8.6667075234375L18.19869925,8.6667075234375Q18.668141249999998,8.6667075234375,19.000086250000003,8.9986532234375Q19.33203125,9.3305986234375,19.33203125,9.800040723437501L19.33203125,17.603058023437498L17.32508465,15.5387697234375Q16.81204125,15.011067423437499,16.076910050000002,14.9754972234375Q15.34177975,14.9399261234375,14.780183749999999,15.4156303234375L14.06891725,16.018115023437502ZM14.24316125,17.6179050234375C14.37814335,17.564095023437503,14.49135205,17.4673410234375,14.565907450000001,17.3445210234375L15.64197735,16.433025323437498Q15.80243305,16.2971096234375,16.01247025,16.3072719234375Q16.222507450000002,16.3174352234375,16.36909105,16.4682073234375L18.910870250000002,19.0826080234375Q18.60672125,19.3333740234375,18.19869925,19.3333740234375L12.21795465,19.3333740234375L14.24316125,17.6179050234375ZM16.09635445,14.0666094234375C17.29297165,14.0666094234375,18.26302125,13.0965605234375,18.26302125,11.899943323437501C18.26302125,10.7033262234375,17.29297165,9.7332763234375,16.09635445,9.7332763234375C14.899737349999999,9.7332763234375,13.92968745,10.7033262234375,13.92968745,11.899943323437501C13.92968745,13.0965605234375,14.899737349999999,14.0666094234375,16.09635445,14.0666094234375ZM16.09635445,11.0666098234375C16.55659105,11.0666098234375,16.92968745,11.4397058234375,16.92968745,11.899943323437501C16.92968745,12.3601804234375,16.55659105,12.7332763234375,16.09635445,12.7332763234375C15.63611695,12.7332763234375,15.26302055,12.3601804234375,15.26302055,11.899943323437501C15.26302055,11.4397058234375,15.63611695,11.0666098234375,16.09635445,11.0666098234375Z" fill-rule="evenodd" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g><g style="opacity:0;"></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
1
components/html-editor/assets/images/italic.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139077"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139077)"><g><path d="M12,1.34033203125L7.5,1.34033203125C7.2239532,1.34046676636,7.0002441,1.56428482125,7.0002441,1.84033188125C7.0002441,2.11637908125,7.2239532,2.34019726125,7.5,2.34033203125L9.0050001,2.34033203125L5.4059997,13.34033203125L4,13.34033203125C3.72385772,13.34033203125,3.5,13.56418903125,3.5,13.84033203125C3.5,14.11647403125,3.72385772,14.34033203125,4,14.34033203125L8.5,14.34033203125C8.7761426,14.34033203125,9,14.11647403125,9,13.84033203125C9,13.56418903125,8.7761426,13.34033203125,8.5,13.34033203125L7.0354998,13.34033203125L10.6374998,2.34033253125L12,2.34033253125C12.2760468,2.34019756125,12.4997559,2.11637938125,12.4997559,1.84033233125C12.4997559,1.56428528125,12.2760468,1.34046722412,12,1.34033203125Z" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
components/html-editor/assets/images/underline.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139080"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139080)"><g><path d="M8,12.67640234375C10.6923876,12.67640234375,12.875,10.49379154375,12.875,7.80140254375L12.875,1.39990234375L11.125,1.39990234375L11.125,7.80140254375C11.125,9.52729224375,9.7258897,10.92640204375,8,10.92640204375C6.2741098,10.92640204375,4.8749994999999995,9.52729224375,4.875,7.80140254375L4.875,1.39990234375L3.125,1.39990234375L3.125,7.80140254375C3.1249995999999998,10.49379154375,5.307611,12.67640234375,8,12.67640234375ZM13.5,13.84040234375L2.5,13.84040234375C2.22385757,13.84040234375,2,14.06425934375,2,14.34040234375C2,14.61654434375,2.22385757,14.84040234375,2.5,14.84040234375L13.5,14.84040234375C13.776142,14.84040234375,14,14.61654434375,14,14.34040234375C14,14.06425934375,13.776142,13.84040234375,13.5,13.84040234375Z" fill="#17171D" fill-opacity="1"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
126
components/html-editor/components/html-render/task-html.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface HtmlProps {
|
||||
className?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// 注入脚本,拦截 iframe 中的 _blank 链接和 window.open
|
||||
function injectInterceptScript(iframeWindow: Window) {
|
||||
try {
|
||||
// 检查是否可以访问 iframe 的 document(避免跨域错误)
|
||||
const iframeDocument = iframeWindow.document;
|
||||
if (!iframeDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存父窗口的 window.open 引用
|
||||
const parentWindowOpen = window.open.bind(window);
|
||||
|
||||
// 重写 iframe 内的 window.open
|
||||
iframeWindow.open = function (url, target, features) {
|
||||
return parentWindowOpen(url, target, features);
|
||||
};
|
||||
|
||||
// 拦截所有 <a> 标签的点击事件
|
||||
iframeDocument.addEventListener(
|
||||
'click',
|
||||
(e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const anchor = target.closest('a');
|
||||
|
||||
if (anchor && anchor.target === '_blank') {
|
||||
e.preventDefault();
|
||||
const href = anchor.href;
|
||||
if (href) {
|
||||
parentWindowOpen(href, '_blank');
|
||||
}
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// 监听动态添加的元素
|
||||
const observer = new MutationObserver(() => {
|
||||
// 重新绑定 window.open (防止被覆盖)
|
||||
if (iframeWindow.open !== parentWindowOpen) {
|
||||
iframeWindow.open = function (url, target, features) {
|
||||
return parentWindowOpen(url, target, features);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(iframeDocument.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
} catch {
|
||||
// 跨域或沙箱限制,无法注入脚本,静默失败
|
||||
// 这种情况下需要依赖 sandbox 属性中的 allow-popups-to-escape-sandbox
|
||||
}
|
||||
}
|
||||
|
||||
export const Html = React.forwardRef(
|
||||
(
|
||||
{ className, content }: HtmlProps,
|
||||
_ref: React.ForwardedRef<HTMLIFrameElement>,
|
||||
) => {
|
||||
// 当 iframe 加载完成后注入拦截脚本
|
||||
const handleIframeLoad = React.useCallback(
|
||||
(e: React.SyntheticEvent<HTMLIFrameElement, Event>) => {
|
||||
const iframe = e.currentTarget;
|
||||
if (iframe?.contentWindow) {
|
||||
injectInterceptScript(iframe.contentWindow);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// if (content) {
|
||||
// // 在 content 中添加 base 标签,确保所有链接在 iframe 内部打开
|
||||
// const contentWithBase = content.includes("<head>")
|
||||
// ? content.replace("<head>", '<head><base href="about:srcdoc">')
|
||||
// : content;
|
||||
|
||||
// return (
|
||||
// <iframe
|
||||
// ref={_ref}
|
||||
// className={cn("w-full border-none", className)}
|
||||
// srcDoc={contentWithBase}
|
||||
// sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||
// onLoad={handleIframeLoad}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={_ref}
|
||||
className={cn('w-full border-none', className)}
|
||||
srcDoc={`${content}`}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// export function copyLink(path: string, taskId: string) {
|
||||
// const encodedPath = path
|
||||
// .split('/')
|
||||
// .map(segment => encodeURIComponent(segment))
|
||||
// .join('/')
|
||||
// const url = `${window.location.origin}/web/${taskId}${encodedPath}`
|
||||
// navigator.clipboard.writeText(url)
|
||||
// }
|
||||
|
||||
// export function openHtml(path: string, taskId: string) {
|
||||
// const url = `/web/${taskId}${path}`
|
||||
// window.open(url, '_blank')
|
||||
// }
|
||||
|
||||
// export async function zipHtml(path: string, taskId: string) {
|
||||
// const api = `${process.env.API_BASE_URL_SUB}/novakit/api/v1/webpage/zip?file=${encodeURIComponent(path)}&taskId=${taskId}`
|
||||
// download({ url: api, name: `${taskId}.zip` })
|
||||
// }
|
||||
@@ -0,0 +1,354 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { rgbToHex } from '../../toolbar-web/hooks/useElementStyles'
|
||||
import {
|
||||
ChevronDown,
|
||||
Bold,
|
||||
Underline,
|
||||
Italic,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { HTMLEditor } from '../../../lib'
|
||||
import { loca } from './loco'
|
||||
import { ColorPicker } from '@/components/base/color-picker'
|
||||
import { type SelectionFormatting } from '../hooks/useSelectionFormatting'
|
||||
|
||||
interface TextToolbarProps {
|
||||
editor: HTMLEditor
|
||||
formatState: SelectionFormatting
|
||||
}
|
||||
|
||||
const biMap = new Map<number | string, number | string>([
|
||||
['h1', 60], ['h2', 40], ['h3', 32],
|
||||
['h4', 24], ['h5', 18], ['h6', 14],
|
||||
[60, 'h1'], [40, 'h2'], [32, 'h3'],
|
||||
[24, 'h4'], [18, 'h5'], [14, 'h6'],
|
||||
['p', 14], [14, 'p']
|
||||
]);
|
||||
|
||||
const TextLevelChangger: React.FC<{
|
||||
editor: HTMLEditor
|
||||
formatState: SelectionFormatting
|
||||
}> = ({ editor, formatState }) => {
|
||||
const [selectedKey, setSelectedKey] = useState<
|
||||
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'
|
||||
>(biMap.get(parseInt(formatState.fontSize || '0')) || 'p' as any)
|
||||
|
||||
useEffect(() => {
|
||||
const key = biMap.get(parseInt(formatState.fontSize || '0'))
|
||||
setSelectedKey(key || 'p' as any);
|
||||
}, [formatState])
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'h1', label: 'H1' },
|
||||
{ key: 'h2', label: 'H2' },
|
||||
{ key: 'h3', label: 'H3' },
|
||||
{ key: 'h4', label: 'H4' },
|
||||
{ key: 'h5', label: 'H5' },
|
||||
{ key: 'h6', label: 'H6' },
|
||||
{ key: 'p', label: loca.normal.content },
|
||||
]
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
h1: 'H1',
|
||||
h2: 'H2',
|
||||
h3: 'H3',
|
||||
h4: 'H4',
|
||||
h5: 'H5',
|
||||
h6: 'H6',
|
||||
p: loca.normal.content,
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span
|
||||
className='flex items-center justify-between gap-[4px] pl-[8px] pr-[4px] cursor-pointer'
|
||||
>
|
||||
<span>{labelMap[selectedKey]}</span>
|
||||
<span>
|
||||
<ChevronDown className='w-3 h-3' />
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='html-editor-heading-dropdown min-w-[204px]'
|
||||
align='start'
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedKey}
|
||||
onValueChange={(value) => {
|
||||
setSelectedKey(value as any)
|
||||
if (editor) {
|
||||
editor.globalEditable?.applySelectionFontSize(biMap.get(value) + 'px')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuRadioItem key={item.key} value={item.key}>
|
||||
{item.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const EditorColorPicker: React.FC<{
|
||||
editor: HTMLEditor
|
||||
formatState: SelectionFormatting
|
||||
}> = ({ editor, formatState }) => {
|
||||
const [color, setColor] = useState<string>(formatState.color || '#000000')
|
||||
|
||||
useEffect(() => {
|
||||
if (formatState.color) {
|
||||
setColor(formatState.color)
|
||||
}
|
||||
}, [formatState.color])
|
||||
|
||||
return (
|
||||
<div className='editor-toolbar-color-picker w-[16px] h-[16px]'>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
data-html-editor-ui="true"
|
||||
className='flex flex-row items-center justify-center w-[16px] h-[16px] rounded-[16px] bg-[#eee] cursor-pointer'
|
||||
>
|
||||
<div
|
||||
className='w-[14px] h-[14px] rounded-[14px] overflow-hidden'
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='w-full h-full'
|
||||
style={{
|
||||
backgroundSize: '8px 8px',
|
||||
backgroundImage:
|
||||
'conic-gradient(rgba(98, 105, 153, 0.1) 25%, transparent 25% 50%, rgba(98, 105, 153, 0.1) 50% 75%, transparent 75% 100%)',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto p-3'
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ColorPicker
|
||||
color={rgbToHex(color)}
|
||||
onChange={(newColor) => {
|
||||
setColor(newColor)
|
||||
editor.globalEditable?.applySelectionColor(newColor)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TextToolbar: React.FC<TextToolbarProps> = ({ editor, formatState }) => {
|
||||
const isBold = formatState.isBold
|
||||
const isItalic = formatState.isItalic
|
||||
const isUnderline = formatState.isUnderline
|
||||
const textAlign = formatState.textAlign
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const styleManager = editor.styleManager
|
||||
if (!editor || !styleManager) return null
|
||||
|
||||
const handleToggleBold = () => {
|
||||
editor.globalEditable?.applySelectionBold()
|
||||
}
|
||||
|
||||
const handleToggleItalic = () => {
|
||||
editor.globalEditable?.applySelectionItalic()
|
||||
}
|
||||
|
||||
const handleToggleUnderline = () => {
|
||||
editor.globalEditable?.applySelectionUnderline()
|
||||
}
|
||||
|
||||
const handleTextAlign = (align: 'left' | 'center' | 'right') => {
|
||||
editor.globalEditable?.applySelectionAlign(align)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'tools',
|
||||
label: '',
|
||||
icon: TextLevelChangger,
|
||||
},
|
||||
{
|
||||
key: 'color-picker',
|
||||
label: '',
|
||||
icon: EditorColorPicker,
|
||||
tooltip: loca.changeTextColor.content,
|
||||
},
|
||||
{
|
||||
key: 'bold',
|
||||
label: '',
|
||||
icon: Bold,
|
||||
tooltip: loca.textBlod.content,
|
||||
run: handleToggleBold,
|
||||
isActive: isBold,
|
||||
},
|
||||
{
|
||||
key: 'italic',
|
||||
label: '',
|
||||
icon: Italic,
|
||||
tooltip: loca.textItalic.content,
|
||||
run: handleToggleItalic,
|
||||
isActive: isItalic,
|
||||
},
|
||||
{
|
||||
key: 'underline',
|
||||
label: '',
|
||||
icon: Underline,
|
||||
tooltip: loca.textUnderline.content,
|
||||
run: handleToggleUnderline,
|
||||
isActive: isUnderline,
|
||||
},
|
||||
{
|
||||
key: 'text-align-left',
|
||||
label: '',
|
||||
icon: AlignLeft,
|
||||
tooltip: loca.textAlignLeft.content,
|
||||
run: () => {
|
||||
handleTextAlign('left')
|
||||
},
|
||||
isActive: textAlign === 'left' || textAlign === 'start',
|
||||
},
|
||||
{
|
||||
key: 'text-align-center',
|
||||
label: '',
|
||||
icon: AlignCenter,
|
||||
tooltip: loca.textAlignCenter.content,
|
||||
run: () => {
|
||||
handleTextAlign('center')
|
||||
},
|
||||
isActive: textAlign === 'center',
|
||||
},
|
||||
{
|
||||
key: 'text-align-right',
|
||||
label: '',
|
||||
icon: AlignRight,
|
||||
tooltip: loca.textAlignRight.content,
|
||||
run: () => {
|
||||
handleTextAlign('right')
|
||||
},
|
||||
isActive: textAlign === 'right' || textAlign === 'end',
|
||||
},
|
||||
],
|
||||
[
|
||||
handleToggleBold,
|
||||
handleToggleItalic,
|
||||
handleToggleUnderline,
|
||||
handleTextAlign,
|
||||
textAlign,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline,
|
||||
editor,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className='html-editor-toolbar'
|
||||
style={{
|
||||
height: '36px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 100,
|
||||
zIndex: 10000,
|
||||
background: 'var(--editor-surface)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
border: '0.5px solid var(--editor-border-strong)',
|
||||
boxShadow: '0 8px 24px var(--editor-shadow)',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{actions.map(a => {
|
||||
if (a.key === 'split-line') {
|
||||
return (
|
||||
<div key={a.key} className='h-[50%] w-[1px] bg-border'></div>
|
||||
)
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`
|
||||
min-w-[28px] min-h-[28px] flex flex-row items-center justify-center text-foreground hover:bg-accent cursor-pointer transition-all rounded-[28px] ${a.isActive ? 'bg-accent text-accent-foreground' : ''} ${a.label ? 'px-[8px]' : ''}
|
||||
`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => {
|
||||
a.run?.()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center ${a.label ? 'gap-[2px]' : ''}`}
|
||||
>
|
||||
<div>
|
||||
{a.icon && (
|
||||
<a.icon
|
||||
editor={editor}
|
||||
formatState={formatState}
|
||||
className={`w-4 h-4 ${a.key === 'delete' ? 'text-destructive' : 'text-foreground'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>{a.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (a.tooltip) {
|
||||
return (
|
||||
<Tooltip key={a.key}>
|
||||
<TooltipTrigger asChild>
|
||||
{content}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='bottom'>
|
||||
<p>{a.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return <React.Fragment key={a.key}>{content}</React.Fragment>
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const localize = (key: string, defaultVal: string) => {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
export const loca = {
|
||||
normal: {
|
||||
content: localize('html-editor-tool-bar-docmode.doc', '正文'),
|
||||
tip: '正文'
|
||||
},
|
||||
changeTextColor: {
|
||||
content: localize('editor-color-picker', '更改颜色'),
|
||||
tip: '更改文本颜色'
|
||||
},
|
||||
changeBgColor: {
|
||||
content: localize('editor-bg-color-picker', '更改背景色'),
|
||||
tip: '更改背景颜色'
|
||||
},
|
||||
imageUpload: {
|
||||
content: localize('editor-image_upload', '替换图片'),
|
||||
tip: '上传图片'
|
||||
},
|
||||
textBlod: {
|
||||
content: localize('text_blod', '文字加粗'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textItalic: {
|
||||
content: localize('text_italic', '文字斜体'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textUnderline: {
|
||||
content: localize('text_underline', '文字下划线'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textAlignLeft: {
|
||||
content: localize('text_align-left', '左对齐'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textAlignCenter: {
|
||||
content: localize('text_align-center', '居中对齐'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textAlignRight: {
|
||||
content: localize('text_align-right', '右对齐'),
|
||||
tip: '加粗'
|
||||
},
|
||||
copy: {
|
||||
content: localize('element_duplicate', '复制'),
|
||||
tip: '复制'
|
||||
},
|
||||
delete: {
|
||||
content: localize('element_delete', '删除'),
|
||||
tip: '删除'
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HTMLEditor } from '../../../lib';
|
||||
|
||||
export interface SelectionFormatting {
|
||||
isBold: boolean;
|
||||
isItalic: boolean;
|
||||
isUnderline: boolean;
|
||||
isStrikeThrough: boolean;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
fontSize: string;
|
||||
fontFamily: string;
|
||||
textAlign: 'left' | 'center' | 'right' | 'start' | 'end' | 'justify' | string;
|
||||
collapsed: boolean;
|
||||
currentTextLevel: 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
}
|
||||
|
||||
export function useSelectionFormatting(editor: HTMLEditor | null): SelectionFormatting {
|
||||
const [state, setState] = useState<SelectionFormatting>({
|
||||
isBold: false,
|
||||
isItalic: false,
|
||||
isUnderline: false,
|
||||
isStrikeThrough: false,
|
||||
color: '',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '',
|
||||
fontFamily: '',
|
||||
textAlign: 'left',
|
||||
collapsed: true,
|
||||
currentTextLevel: 'p'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const doc = editor.getDoc().document;
|
||||
const view = editor.getDoc().view;
|
||||
if (!doc || !view) return;
|
||||
|
||||
const readFormatting = () => {
|
||||
const sel = doc.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) {
|
||||
const currentTextLevel = editor.globalEditable?.queryHeading() || 'p';
|
||||
setState({
|
||||
isBold: false,
|
||||
isItalic: false,
|
||||
isUnderline: false,
|
||||
isStrikeThrough: false,
|
||||
color: '',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '',
|
||||
fontFamily: '',
|
||||
textAlign: 'left',
|
||||
collapsed: true,
|
||||
currentTextLevel
|
||||
});
|
||||
return;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
|
||||
// Get all text nodes in the selection
|
||||
const textNodes: Node[] = [];
|
||||
const walker = doc.createTreeWalker(
|
||||
range.commonAncestorContainer.nodeType === Node.TEXT_NODE
|
||||
? range.commonAncestorContainer.parentNode as Node
|
||||
: range.commonAncestorContainer,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null
|
||||
);
|
||||
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (range.intersectsNode(node)) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// If no text nodes found, fall back to container
|
||||
if (textNodes.length === 0) {
|
||||
const container = range.commonAncestorContainer.nodeType === 1
|
||||
? (range.commonAncestorContainer as HTMLElement)
|
||||
: (range.commonAncestorContainer.parentNode as HTMLElement);
|
||||
textNodes.push(container);
|
||||
}
|
||||
|
||||
// Check if ALL text nodes have each style
|
||||
let isBold = true;
|
||||
let isItalic = true;
|
||||
let isUnderline = true;
|
||||
let isStrikeThrough = true;
|
||||
let color = '';
|
||||
let backgroundColor = '';
|
||||
let fontSize = '';
|
||||
let fontFamily = '';
|
||||
let textAlign: string = 'left';
|
||||
|
||||
const toHex = (input: string): string => {
|
||||
const s = input.trim().toLowerCase();
|
||||
if (!s || s === 'transparent') return '';
|
||||
const m = s.match(/^rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9.]+))?\)$/);
|
||||
if (m) {
|
||||
const r = Math.max(0, Math.min(255, parseInt(m[1], 10)));
|
||||
const g = Math.max(0, Math.min(255, parseInt(m[2], 10)));
|
||||
const b = Math.max(0, Math.min(255, parseInt(m[3], 10)));
|
||||
const a = m[4] !== undefined ? parseFloat(m[4]) : 1;
|
||||
if (a === 0) return '';
|
||||
const h = (n: number) => n.toString(16).padStart(2, '0');
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
const hx = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
|
||||
if (hx) {
|
||||
if (hx[1].length === 3) {
|
||||
const r = hx[1][0];
|
||||
const g = hx[1][1];
|
||||
const b = hx[1][2];
|
||||
return `#${r}${r}${g}${g}${b}${b}`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
textNodes.forEach((node, index) => {
|
||||
const el = node.nodeType === Node.TEXT_NODE
|
||||
? (node.parentNode as HTMLElement)
|
||||
: (node as HTMLElement);
|
||||
|
||||
if (!el) return;
|
||||
|
||||
const cs = view.getComputedStyle(el);
|
||||
|
||||
// Check bold
|
||||
const fw = cs.fontWeight;
|
||||
const nodeBold = fw === 'bold' || parseInt(fw as any, 10) >= 600;
|
||||
if (!nodeBold) isBold = false;
|
||||
|
||||
// Check italic
|
||||
const nodeItalic = cs.fontStyle === 'italic';
|
||||
if (!nodeItalic) isItalic = false;
|
||||
|
||||
// Check underline and strikethrough
|
||||
// Note: text-decoration is NOT inherited, so we must check for <u>/<s> tags in ancestor chain
|
||||
const deco = cs.textDecorationLine || cs.textDecoration;
|
||||
let nodeUnderline = typeof deco === 'string' && deco.indexOf('underline') >= 0;
|
||||
let nodeStrikeThrough = typeof deco === 'string' && deco.indexOf('line-through') >= 0;
|
||||
|
||||
// Also check for <u> and <s> tags in ancestors (since text-decoration doesn't inherit)
|
||||
if (!nodeUnderline || !nodeStrikeThrough) {
|
||||
let ancestor: HTMLElement | null = el;
|
||||
while (ancestor && ancestor !== doc.body) {
|
||||
const tagName = ancestor.tagName.toUpperCase();
|
||||
if (!nodeUnderline && tagName === 'U') {
|
||||
nodeUnderline = true;
|
||||
}
|
||||
if (!nodeStrikeThrough && (tagName === 'S' || tagName === 'STRIKE' || tagName === 'DEL')) {
|
||||
nodeStrikeThrough = true;
|
||||
}
|
||||
if (nodeUnderline && nodeStrikeThrough) break;
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (!nodeUnderline) isUnderline = false;
|
||||
if (!nodeStrikeThrough) isStrikeThrough = false;
|
||||
|
||||
// For color, fontSize, fontFamily - use first node's value
|
||||
if (index === 0) {
|
||||
color = toHex(cs.color);
|
||||
fontSize = cs.fontSize;
|
||||
fontFamily = cs.fontFamily;
|
||||
}
|
||||
});
|
||||
|
||||
// Get block ancestor for textAlign and backgroundColor
|
||||
const container = range.commonAncestorContainer.nodeType === 1
|
||||
? (range.commonAncestorContainer as HTMLElement)
|
||||
: (range.commonAncestorContainer.parentNode as HTMLElement);
|
||||
const el = container || doc.body;
|
||||
|
||||
const getBlockAncestor = (el: HTMLElement | null) => {
|
||||
let cur: HTMLElement | null = el;
|
||||
while (cur && cur !== doc.body) {
|
||||
const display = view.getComputedStyle(cur).display;
|
||||
if (display !== 'inline') return cur;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return doc.body as HTMLElement;
|
||||
};
|
||||
|
||||
const block = getBlockAncestor(el);
|
||||
const bs = view.getComputedStyle(block);
|
||||
const cs = view.getComputedStyle(el);
|
||||
const currentTextLevel = editor.globalEditable?.queryHeading() || 'p';
|
||||
backgroundColor = toHex(bs.backgroundColor || cs.backgroundColor);
|
||||
textAlign = bs.textAlign as any;
|
||||
|
||||
setState({
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline,
|
||||
isStrikeThrough,
|
||||
color,
|
||||
backgroundColor,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
collapsed: false,
|
||||
currentTextLevel
|
||||
});
|
||||
};
|
||||
|
||||
const handler = () => readFormatting();
|
||||
doc.addEventListener('selectionchange', handler);
|
||||
doc.addEventListener('keyup', handler);
|
||||
doc.addEventListener('mouseup', handler);
|
||||
doc.body.addEventListener('htmleditor:historyChange', handler as EventListener);
|
||||
doc.body.addEventListener('htmleditor:contentChange', handler as EventListener);
|
||||
readFormatting();
|
||||
|
||||
return () => {
|
||||
doc.removeEventListener('selectionchange', handler);
|
||||
doc.removeEventListener('keyup', handler);
|
||||
doc.removeEventListener('mouseup', handler);
|
||||
doc.body.removeEventListener('htmleditor:historyChange', handler as EventListener);
|
||||
doc.body.removeEventListener('htmleditor:contentChange', handler as EventListener);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export default useSelectionFormatting;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const useStopPopEvent = (visible?: boolean) => {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
document.querySelectorAll('.ant-color-picker input').forEach(input => {
|
||||
input.addEventListener('mousedown', handler, true);
|
||||
input.addEventListener('click', handler, true);
|
||||
});
|
||||
return () => {
|
||||
document.querySelectorAll('.ant-color-picker input').forEach(input => {
|
||||
input.removeEventListener('mousedown', handler, true);
|
||||
input.removeEventListener('click', handler, true);
|
||||
});
|
||||
}
|
||||
}, [visible])
|
||||
}
|
||||
22
components/html-editor/components/toolbar-doc/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import { usePPTEditContext } from '../../context'
|
||||
import { Tooltip } from './toolbar'
|
||||
|
||||
export const PPTEditToolBar: React.FC<{
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}> = props => {
|
||||
const { containerRef } = props
|
||||
const context = usePPTEditContext()
|
||||
if (!context || !context.state) {
|
||||
return null
|
||||
}
|
||||
const { selectedElement, editor, tipPosition } = context.state
|
||||
return (
|
||||
<Tooltip
|
||||
editor={editor}
|
||||
element={selectedElement}
|
||||
position={tipPosition}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
72
components/html-editor/components/toolbar-doc/toolbar.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import type { HTMLEditor } from '../../lib'
|
||||
import { TextToolbar } from './components/TextToolbar'
|
||||
import useSelectionFormatting from './hooks/useSelectionFormatting'
|
||||
|
||||
interface Position {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
bottom: number
|
||||
right: number
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
editor: HTMLEditor | null | undefined
|
||||
element: HTMLElement | null | undefined
|
||||
position: Position | null
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
editor,
|
||||
element,
|
||||
position,
|
||||
containerRef,
|
||||
}) => {
|
||||
if (!editor || !position) {
|
||||
return null
|
||||
}
|
||||
|
||||
const contaierRect =
|
||||
containerRef.current?.getBoundingClientRect() || ({} as DOMRect)
|
||||
const { left = 0, top = 0 } = contaierRect
|
||||
|
||||
const toolWidth = 288
|
||||
const toolHeight = 36
|
||||
const marginXPad = 32
|
||||
|
||||
// 右边界限制
|
||||
let releativeLeft = position.left - toolWidth / 2
|
||||
if (position.left + toolWidth + left > window.innerWidth) {
|
||||
releativeLeft = window.innerWidth - toolWidth - left - marginXPad
|
||||
}
|
||||
// 左边界限制
|
||||
if(releativeLeft < 5) {
|
||||
releativeLeft = 5
|
||||
}
|
||||
|
||||
let releativeBottom = position.bottom
|
||||
if (releativeBottom + toolHeight + 25 > window.innerHeight) {
|
||||
releativeBottom = position.top - toolHeight - 15
|
||||
}
|
||||
|
||||
const tooltipStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: `${releativeBottom + 10}px`,
|
||||
left: `${releativeLeft}px`,
|
||||
zIndex: 100000,
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const formatState = useSelectionFormatting(editor)
|
||||
|
||||
return (
|
||||
<div style={tooltipStyle} className='html-editor-floating-toolbar'>
|
||||
<TextToolbar editor={editor} formatState={formatState}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121357"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121357)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="10.666748046875" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="9.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 807 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121353"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121353)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="7.333251953125" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="7.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 806 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_121361"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_121361)"><g><rect x="7.333251953125" y="8.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="14" y="13.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g><g><rect x="11.333251953125" y="18" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 795 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139074"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139074)"><g><path d="M12.01479246875,8.98540254375C11.66879276875,8.45240214375,11.15829276875,8.09790274375,10.48379326875,7.92240234375L10.48379326875,7.88840194375C10.84129336875,7.71840194375,11.14179376875,7.52840184375,11.38529296875,7.31840184375C11.62929346875,7.11990214375,11.81629276875,6.90990254375,11.94679356875,6.68890234375C12.20179366875,6.22390224375,12.32379336875,5.73640224375,12.31229396875,5.2259023437500005C12.31229396875,4.227902443750001,12.00329396875,3.4254025437499998,11.38529486875,2.81840241375C10.77279476875,2.21740234375,9.85679486875,1.91140243475,8.63779446875,1.89990234375L3.36279296875,1.89990234375L3.36279296875,14.30190134375L9.054792868749999,14.30190134375C10.03029296875,14.30190134375,10.84379336875,13.97590134375,11.49629306875,13.32340134375C12.15429396875,12.69940234375,12.48879336875,11.85190204375,12.49979306875,10.78040124375C12.49979306875,10.13390164375,12.33829306875,9.53540134375,12.01479246875,8.98540254375ZM5.3797929687499995,3.57540254375L8.42529296875,3.57540254375C9.11129236875,3.5869023437500003,9.61629246875,3.7539025437499998,9.93929246875,4.07740254375C10.268292868749999,4.42340254375,10.43279216875,4.84590264375,10.43279216875,5.34490254375C10.43279216875,5.84390284375,10.268292868749999,6.25790264375,9.93929246875,6.58690264375C9.61629246875,6.94390294375,9.11129236875,7.12290284375,8.425291968749999,7.12290284375L5.3797929687499995,7.12290284375L5.3797929687499995,3.57540254375ZM10.13529296875,11.95440234375C9.81229306875,12.32290134375,9.301793068750001,12.51290134375,8.60429236875,12.52440134375L5.3797929687499995,12.52440134375L5.3797929687499995,8.79890254375L8.60379316875,8.79890254375C9.301293368749999,8.80990214375,9.81179286875,8.994401943749999,10.13479276875,9.35190154375C10.457793268749999,9.714902443749999,10.61979296875,10.15140244375,10.61979296875,10.66190144375C10.61979296875,11.16040134375,10.458292468749999,11.59140114375,10.13529296875,11.95440234375Z" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none"
|
||||
version="1.1" width="28" height="28" viewBox="0 0 28 28">
|
||||
<defs>
|
||||
<clipPath id="master_svg0_5344_120568">
|
||||
<rect x="6" y="6" width="16" height="16" rx="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g>
|
||||
<g clip-path="url(#master_svg0_5344_120568)">
|
||||
<g>
|
||||
<path
|
||||
d="M12.000165893750001,7L16.00016589375,7C16.36835579375,7,16.66682629375,7.29847705,16.66682629375,7.6666669800000005C16.66682629375,8.0348599,16.36835579375,8.3333299,16.00016589375,8.3333299L12.000165893750001,8.3333299C11.63197609375,8.3333299,11.33349609375,8.0348599,11.33349609375,7.6666669800000005C11.33349609375,7.29847705,11.63197609375,7,12.000165893750001,7ZM8.00016307375,9.3333099L20.00019609375,9.3333099C20.36839609375,9.3333099,20.66679609375,9.6317899,20.66679609375,9.99998C20.66679609375,10.3681698,20.36839609375,10.6666498,20.00019609375,10.6666498L19.33349609375,10.6666498L19.33349609375,18.333399999999997Q19.33349609375,19.4379,18.55249609375,20.219Q17.77139609375,21,16.66683579375,21L11.33350609375,21Q10.22893619375,21,9.447885993749999,20.219Q8.66683599375,19.4379,8.66683599375,18.333399999999997L8.66683599375,10.6666498L8.00016307375,10.6666498C7.63197314375,10.6666498,7.33349609375,10.3681698,7.33349609375,9.99998C7.33349609375,9.6317899,7.63197314375,9.3333099,8.00016307375,9.3333099ZM10.00017599375,10.6666498L18.00019609375,10.6666498L18.00019609375,18.333399999999997Q18.00019609375,18.8856,17.60969609375,19.2762Q17.219125793750003,19.6667,16.66683579375,19.6667L11.33350609375,19.6667Q10.78122619375,19.6667,10.39069609375,19.2762Q10.00017599375,18.8856,10.00017599375,18.333399999999997L10.00017599375,10.6666498ZM13.33416609375,17.3333L13.33416609375,12.666669800000001C13.33416609375,12.29848,13.03568599375,12,12.66749619375,12C12.299305893749999,12,12.00083589375,12.29848,12.00083589375,12.666669800000001L12.00083589375,17.3333C12.00083589375,17.7015,12.299305893749999,18,12.66749619375,18C13.03568599375,18,13.33416609375,17.7015,13.33416609375,17.3333ZM16.00083639375,17.3333L16.00083639375,12.666669800000001C16.00083639375,12.29848,15.70236589375,12,15.33417609375,12C14.96598629375,12,14.66750619375,12.29848,14.66750619375,12.666669800000001L14.66750619375,17.3333C14.66750619375,17.7015,14.96598629375,18,15.33417609375,18C15.70236589375,18,16.00083639375,17.7015,16.00083639375,17.3333Z"
|
||||
fill-rule="evenodd" fill="currentColor" fill-opacity="1" style="mix-blend-mode:passthrough" />
|
||||
</g>
|
||||
<g style="opacity:0;">
|
||||
<rect x="6" y="6" width="16" height="16" rx="0" fill="#000000" fill-opacity="1"
|
||||
style="mix-blend-mode:passthrough" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><g transform="matrix(-1,0,0,1,32,0)"><g><rect x="16" y="0" width="16" height="16" rx="0" fill="#D8D8D8" fill-opacity="0"/></g><g><path d="M21.21485549,6.20300207C21.47528636,5.956938956,21.88281816,5.934569582,22.1699947,6.13589394L22.2522695,6.20300207L24.4994397,8.3264129L26.7477303,6.20300207C27.0081615,5.956938956,27.4156933,5.934569582,27.7028699,6.13589394L27.7851443,6.20300207C28.0455756,6.44906518,28.0692511,6.83411378,27.8561711,7.1054469000000005L27.7851443,7.1831827L25.0187068,9.796998C24.7582762,10.0430613,24.3507442,10.0654306,24.0635679,9.8641059L23.981293,9.796998L21.21485549,7.1831827C20.928381503,6.91251332,20.928381503,6.4736715,21.21485549,6.20300207Z" fill="#8D8D99" fill-opacity="1"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 879 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_120572"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_120572)"><g><path d="M18.00017309375,10.000003823437499Q18.00017309375,8.8954539234375,17.21911429375,8.1144230334375Q16.43806359375,7.3333740234375,15.33349419375,7.3333740234375L10.00016399375,7.3333740234375Q8.89559409375,7.3333740234375,8.11454510375,8.1144230334375Q7.33349621295929,8.8954739234375,7.33349609375,10.000043823437501L7.33349609375,15.3333740234375Q7.33349609375,16.4379444234375,8.11454510375,17.2189941234375Q8.89559409375,18.0000740234375,10.00016399375,18.0000740234375Q10.00025419375,19.1045740234375,10.78129389375,19.8855740234375Q11.56234409375,20.6666740234375,12.66691399375,20.6666740234375L18.00027409375,20.6666740234375Q19.104774093750002,20.6666740234375,19.885874093749997,19.8855740234375Q20.66687409375,19.1045740234375,20.66687409375,17.999974023437503L20.66687409375,12.6666641234375Q20.66687409375,11.5620942234375,19.885874093749997,10.7810440234375Q19.104774093750002,10.000003823437499,18.00017309375,10.000003823437499ZM16.666833893750002,10.000003823437499Q16.66681389375,9.4477441234375,16.27630429375,9.0572340234375Q15.88578419375,8.6667039234375,15.33349419375,8.6667039234375L10.00016399375,8.6667039234375Q9.44787409375,8.6667039234375,9.05735399375,9.0572340234375Q8.66683409375,9.4477539234375,8.66683409375,10.000043823437501L8.66683409375,15.3333740234375Q8.66683409375,15.8856640234375,9.05735399375,16.276184123437503Q9.44787409375,16.6667042234375,10.00016399375,16.6667042234375L10.00024409375,12.6666641234375Q10.00024409375,11.5620942234375,10.78129389375,10.7810440234375Q11.56234409375,10.000003823437499,12.66691399375,10.000003823437499L16.666833893750002,10.000003823437499ZM11.33357379375,17.999974023437503Q11.33357379375,18.552274023437498,11.72410389375,18.9427740234375Q12.114623993750001,19.3333740234375,12.66691399375,19.3333740234375L18.00027409375,19.3333740234375Q18.55257409375,19.3333740234375,18.94307409375,18.9427740234375Q19.333574093750002,18.552274023437498,19.333574093750002,17.999974023437503L19.333574093750002,12.6666641234375Q19.333574093750002,12.1143842234375,18.94307409375,11.7238540234375Q18.55257409375,11.3333339234375,18.00027409375,11.3333339234375L12.66691399375,11.3333339234375Q12.114623993750001,11.3333339234375,11.72410389375,11.7238540234375Q11.33357379375,12.1143842234375,11.33357379375,12.6666641234375L11.33357379375,17.999974023437503Z" fill-rule="evenodd" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g><g style="opacity:0;"><rect x="6" y="6" width="16" height="16" rx="0" fill="currentColor" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="28" height="28" viewBox="0 0 28 28"><defs><clipPath id="master_svg0_5344_124419"><rect x="6" y="6" width="16" height="16" rx="0"/></clipPath></defs><g><g clip-path="url(#master_svg0_5344_124419)"><g><path d="M7.33203125,14.8999405234375L7.33203125,18.2000420234375Q7.33203125,19.2217670234375,8.0545013,19.9442380234375Q8.77697205,20.6667080234375,9.798697950000001,20.6667080234375L18.19869925,20.6667080234375Q19.22042325,20.6667080234375,19.94289525,19.9442380234375Q20.66536525,19.2217660234375,20.66536525,18.2000420234375L20.66536525,9.800040723437501Q20.66536525,8.7783148234375,19.94289525,8.0558440734375Q19.22042425,7.3333740234375,18.19869925,7.3333740234375L9.798697950000001,7.3333740234375Q8.77697135,7.3333740234375,8.0545013,8.0558440734375Q7.33203125,8.7783139234375,7.33203125,9.800040723437501L7.33203125,14.8999405234375ZM8.66536475,15.222196623437501L8.66536475,18.2000420234375Q8.66536475,18.669484023437498,8.99731045,19.001429023437503Q9.32925555,19.3333740234375,9.798697950000001,19.3333740234375L10.15507075,19.3333740234375L13.05100445,16.8803472234375L10.88467645,14.150774923437499Q10.74771115,13.9781980234375,10.528880149999999,13.9526033234375Q10.31004905,13.9270081234375,10.13695525,14.0633192234375L8.66536475,15.222196623437501ZM14.06891725,16.018115023437502L11.929062349999999,13.3218975234375Q11.44968315,12.7178797234375,10.683773949999999,12.6282973234375Q9.91786505,12.5387149234375,9.31203745,13.0158043234375L8.66536475,13.5250587234375L8.66536475,9.800040723437501Q8.66536475,9.3305986234375,8.99731045,8.9986532234375Q9.32925605,8.6667075234375,9.798697950000001,8.6667075234375L18.19869925,8.6667075234375Q18.668141249999998,8.6667075234375,19.000086250000003,8.9986532234375Q19.33203125,9.3305986234375,19.33203125,9.800040723437501L19.33203125,17.603058023437498L17.32508465,15.5387697234375Q16.81204125,15.011067423437499,16.076910050000002,14.9754972234375Q15.34177975,14.9399261234375,14.780183749999999,15.4156303234375L14.06891725,16.018115023437502ZM14.24316125,17.6179050234375C14.37814335,17.564095023437503,14.49135205,17.4673410234375,14.565907450000001,17.3445210234375L15.64197735,16.433025323437498Q15.80243305,16.2971096234375,16.01247025,16.3072719234375Q16.222507450000002,16.3174352234375,16.36909105,16.4682073234375L18.910870250000002,19.0826080234375Q18.60672125,19.3333740234375,18.19869925,19.3333740234375L12.21795465,19.3333740234375L14.24316125,17.6179050234375ZM16.09635445,14.0666094234375C17.29297165,14.0666094234375,18.26302125,13.0965605234375,18.26302125,11.899943323437501C18.26302125,10.7033262234375,17.29297165,9.7332763234375,16.09635445,9.7332763234375C14.899737349999999,9.7332763234375,13.92968745,10.7033262234375,13.92968745,11.899943323437501C13.92968745,13.0965605234375,14.899737349999999,14.0666094234375,16.09635445,14.0666094234375ZM16.09635445,11.0666098234375C16.55659105,11.0666098234375,16.92968745,11.4397058234375,16.92968745,11.899943323437501C16.92968745,12.3601804234375,16.55659105,12.7332763234375,16.09635445,12.7332763234375C15.63611695,12.7332763234375,15.26302055,12.3601804234375,15.26302055,11.899943323437501C15.26302055,11.4397058234375,15.63611695,11.0666098234375,16.09635445,11.0666098234375Z" fill-rule="evenodd" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g><g style="opacity:0;"></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139077"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139077)"><g><path d="M12,1.34033203125L7.5,1.34033203125C7.2239532,1.34046676636,7.0002441,1.56428482125,7.0002441,1.84033188125C7.0002441,2.11637908125,7.2239532,2.34019726125,7.5,2.34033203125L9.0050001,2.34033203125L5.4059997,13.34033203125L4,13.34033203125C3.72385772,13.34033203125,3.5,13.56418903125,3.5,13.84033203125C3.5,14.11647403125,3.72385772,14.34033203125,4,14.34033203125L8.5,14.34033203125C8.7761426,14.34033203125,9,14.11647403125,9,13.84033203125C9,13.56418903125,8.7761426,13.34033203125,8.5,13.34033203125L7.0354998,13.34033203125L10.6374998,2.34033253125L12,2.34033253125C12.2760468,2.34019756125,12.4997559,2.11637938125,12.4997559,1.84033233125C12.4997559,1.56428528125,12.2760468,1.34046722412,12,1.34033203125Z" fill="#000000" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_5146_139080"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5146_139080)"><g><path d="M8,12.67640234375C10.6923876,12.67640234375,12.875,10.49379154375,12.875,7.80140254375L12.875,1.39990234375L11.125,1.39990234375L11.125,7.80140254375C11.125,9.52729224375,9.7258897,10.92640204375,8,10.92640204375C6.2741098,10.92640204375,4.8749994999999995,9.52729224375,4.875,7.80140254375L4.875,1.39990234375L3.125,1.39990234375L3.125,7.80140254375C3.1249995999999998,10.49379154375,5.307611,12.67640234375,8,12.67640234375ZM13.5,13.84040234375L2.5,13.84040234375C2.22385757,13.84040234375,2,14.06425934375,2,14.34040234375C2,14.61654434375,2.22385757,14.84040234375,2.5,14.84040234375L13.5,14.84040234375C13.776142,14.84040234375,14,14.61654434375,14,14.34040234375C14,14.06425934375,13.776142,13.84040234375,13.5,13.84040234375Z" fill="#17171D" fill-opacity="1"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Copy, Trash2 } from 'lucide-react'
|
||||
|
||||
import { HTMLEditor } from '../../../lib'
|
||||
import { ColorPicker } from '@/components/base/color-picker'
|
||||
import { rgbToHex } from '../hooks/useElementStyles'
|
||||
import { loca } from '../loco'
|
||||
|
||||
interface BockToolbarProps {
|
||||
editor: HTMLEditor
|
||||
element: HTMLElement
|
||||
}
|
||||
|
||||
const EditorColorPicker: React.FC<{
|
||||
editor: HTMLEditor
|
||||
element: HTMLElement
|
||||
}> = ({ editor, element }) => {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const currentEleColor = computedStyle.background;
|
||||
const [color, setColor] = useState<string>(currentEleColor)
|
||||
|
||||
useEffect(() => {
|
||||
setColor(currentEleColor)
|
||||
}, [currentEleColor])
|
||||
|
||||
return (
|
||||
<div className='editor-toolbar-color-picker w-[16px] h-[16px]'>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
data-html-editor-ui="true"
|
||||
className='flex flex-row items-center justify-center w-[16px] h-[16px] rounded-[16px] bg-[#eee] cursor-pointer'
|
||||
>
|
||||
<div className='w-[14px] h-[14px] rounded-[14px] overflow-hidden' style={{
|
||||
background: color
|
||||
}}>
|
||||
<div className='w-full h-full' style={{
|
||||
backgroundSize:'8px 8px',
|
||||
backgroundImage:'conic-gradient(rgba(98, 105, 153, 0.1) 25%, transparent 25% 50%, rgba(98, 105, 153, 0.1) 50% 75%, transparent 75% 100%)'
|
||||
}}></div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto p-3'
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ColorPicker
|
||||
color={rgbToHex(color)}
|
||||
onChange={(newColor) => {
|
||||
setColor(newColor)
|
||||
editor.styleManager?.changeBackground(element, newColor);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BlockToolbar: React.FC<BockToolbarProps> = ({
|
||||
editor,
|
||||
element,
|
||||
}) => {
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null)
|
||||
const styleManager = editor.styleManager
|
||||
if (!editor || !element || !styleManager) return null
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'color-picker',
|
||||
label: '',
|
||||
icon: EditorColorPicker,
|
||||
tooltip: loca.changeBgColor.content,
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
label: '',
|
||||
icon: Copy,
|
||||
tooltip: loca.copy.content,
|
||||
run: () => {
|
||||
editor.copyElement(element)
|
||||
},
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '',
|
||||
icon: Trash2,
|
||||
tooltip: loca.delete.content,
|
||||
run: () => {
|
||||
editor.deleteElement(element)
|
||||
},
|
||||
isActive: false
|
||||
},
|
||||
],
|
||||
[editor, element],
|
||||
)
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className='html-editor-toolbar'
|
||||
style={{
|
||||
height: '36px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 100,
|
||||
zIndex: 10000,
|
||||
background: 'var(--editor-surface)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
border: '0.5px solid var(--editor-border-strong)',
|
||||
boxShadow: '0 8px 24px var(--editor-shadow)',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{actions.map(a => {
|
||||
if (a.key === 'split-line') {
|
||||
return (
|
||||
<div key={a.key} className='h-[50%] w-[1px] bg-border'></div>
|
||||
)
|
||||
}
|
||||
|
||||
const IconComp = a.icon as any;
|
||||
const isDelete = a.key === 'delete';
|
||||
const iconClassName = `w-4 h-4 ${isDelete ? 'text-destructive' : 'text-foreground'}`;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`
|
||||
min-w-[28px] min-h-[28px] flex flex-row items-center justify-center text-foreground hover:bg-accent cursor-pointer transition-all rounded-[28px] ${a.isActive ? 'bg-accent text-accent-foreground' : ''} ${a.label ? 'px-[8px]' : ''}
|
||||
`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => {
|
||||
a.run?.()
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center ${a.label ? 'gap-[2px]' : ''}`}>
|
||||
<div>
|
||||
{IconComp && (
|
||||
a.key === 'color-picker' ? (
|
||||
<IconComp editor={editor} element={element} className={iconClassName} />
|
||||
) : (
|
||||
<IconComp className={iconClassName} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div>{a.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip key={a.key}>
|
||||
<TooltipTrigger asChild>
|
||||
{content}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{a.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Copy, Trash2, ImagePlus } from 'lucide-react'
|
||||
|
||||
|
||||
import { HTMLEditor } from '../../../lib'
|
||||
import { loca } from '../loco'
|
||||
// import { customUpload } from '../utils/upload'
|
||||
|
||||
interface BockToolbarProps {
|
||||
editor: HTMLEditor
|
||||
element: HTMLElement
|
||||
}
|
||||
|
||||
|
||||
const EditorImageUpload: React.FC<{
|
||||
editor: HTMLEditor,
|
||||
element: HTMLElement
|
||||
}> = ({ editor: _editor, element: _element }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const onFileChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try{
|
||||
// const res = await customUpload({ file })
|
||||
// if (res) {
|
||||
// editor.replaceImage(element, res.url)
|
||||
// }
|
||||
}catch{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-[28px] h-[28px] relative flex flex-row items-center justify-center cursor-pointer'>
|
||||
<input type="file" accept='.jpeg,.jpg,.png,.gif,.bmp,.webp' multiple={false} ref={inputRef} className='absolute w-full h-full top-0 left-0 border-none outline-none cursor-pointer opacity-0 text-[0px]' onChange={onFileChange}/>
|
||||
<ImagePlus className="w-4 h-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ImageToolbar: React.FC<BockToolbarProps> = ({
|
||||
editor,
|
||||
element
|
||||
}) => {
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null)
|
||||
if (!editor || !element) return null
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'image-replace',
|
||||
icon: EditorImageUpload,
|
||||
tooltip: loca.imageUpload.content
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
label: '',
|
||||
icon: Copy,
|
||||
tooltip: loca.copy.content,
|
||||
run: () => {
|
||||
editor.copyElement(element)
|
||||
},
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '',
|
||||
icon: Trash2,
|
||||
tooltip: loca.delete.content,
|
||||
run: () => {
|
||||
editor.deleteElement(element)
|
||||
},
|
||||
isActive: false
|
||||
},
|
||||
],
|
||||
[editor, element],
|
||||
)
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className='html-editor-toolbar'
|
||||
style={{
|
||||
height: '36px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 100,
|
||||
zIndex: 10000,
|
||||
background: 'var(--editor-surface)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
border: '0.5px solid var(--editor-border-strong)',
|
||||
boxShadow: '0 8px 24px var(--editor-shadow)',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{actions.map(a => {
|
||||
if (a.key === 'split-line') {
|
||||
return (
|
||||
<div key={a.key} className='h-[50%] w-[1px] bg-border'></div>
|
||||
)
|
||||
}
|
||||
|
||||
const IconComp = a.icon as any;
|
||||
const isDelete = a.key === 'delete';
|
||||
const iconClassName = `w-4 h-4 ${isDelete ? 'text-destructive' : 'text-foreground'}`;
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`
|
||||
min-w-[28px] min-h-[28px] flex flex-row items-center justify-center text-foreground hover:bg-accent cursor-pointer transition-all rounded-[28px] ${a.isActive ? 'bg-accent text-accent-foreground' : ''} ${a.label ? 'px-[8px]' : ''}
|
||||
`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => {
|
||||
a.run?.()
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center ${a.label ? 'gap-[2px]' : ''}`}>
|
||||
<div>
|
||||
{IconComp && (
|
||||
a.key === 'image-replace' ? (
|
||||
<IconComp editor={editor} element={element} className={iconClassName} />
|
||||
) : (
|
||||
<IconComp className={iconClassName} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div>{a.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip key={a.key}>
|
||||
<TooltipTrigger asChild>
|
||||
{content}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{a.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
ChevronDown,
|
||||
Bold,
|
||||
Underline,
|
||||
Italic,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Copy,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { HTMLEditor } from '../../../lib'
|
||||
import {
|
||||
useFontSize,
|
||||
useTextColor,
|
||||
useFontWeight,
|
||||
useFontStyle,
|
||||
useTextDecoration,
|
||||
useTextAlign,
|
||||
} from '../hooks/useElementStyles'
|
||||
import { ColorPicker } from '@/components/base/color-picker'
|
||||
import { loca } from '../loco'
|
||||
|
||||
interface TextToolbarProps {
|
||||
editor: HTMLEditor
|
||||
element: HTMLElement
|
||||
}
|
||||
|
||||
const TextLevelChangger: React.FC<{
|
||||
editor: HTMLEditor
|
||||
element: HTMLElement
|
||||
}> = ({ editor, element }) => {
|
||||
const [fontSize] = useFontSize(element)
|
||||
const [selectedKey, setSelectedKey] = useState<
|
||||
'H1' | 'H2' | 'H3' | 'H4' | 'H5' | 'H6' | 'doc' | 'ol' | 'ul'
|
||||
>('doc')
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'H1', label: 'H1' },
|
||||
{ key: 'H2', label: 'H2' },
|
||||
{ key: 'H3', label: 'H3' },
|
||||
{ key: 'H4', label: 'H4' },
|
||||
{ key: 'H5', label: 'H5' },
|
||||
{ key: 'H6', label: 'H6' },
|
||||
{ key: 'doc', label: '正文' },
|
||||
]
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
|
||||
const activeMap = {
|
||||
H1: { isActive: fontSize === 60, label: 'H1', key: 'H1' },
|
||||
H2: { isActive: fontSize === 40, label: 'H2', key: 'H2' },
|
||||
H3: { isActive: fontSize === 32, label: 'H3', key: 'H3' },
|
||||
H4: { isActive: fontSize === 24, label: 'H4', key: 'H4' },
|
||||
H5: { isActive: fontSize === 18, label: 'H5', key: 'H5' },
|
||||
H6: { isActive: fontSize === 14, label: 'H6', key: 'H6' },
|
||||
doc: {
|
||||
isActive: (tagName === 'p' || tagName === 'span') && fontSize === 18,
|
||||
label: '正文',
|
||||
key: 'doc',
|
||||
},
|
||||
}
|
||||
const defaultSelectedItem = Object.values(activeMap).find(i => i.isActive)
|
||||
|
||||
console.log('defaultSelectedItem:',defaultSelectedItem)
|
||||
const textTypes: { [key: string]: string } = {
|
||||
doc: '18px',
|
||||
H1: '60px',
|
||||
H2: '40px',
|
||||
H3: '32px',
|
||||
H4: '24px',
|
||||
H5: '18px',
|
||||
H6: '14px',
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultSelectedItem) {
|
||||
setSelectedKey('doc')
|
||||
return
|
||||
}
|
||||
setSelectedKey(defaultSelectedItem.key as any)
|
||||
}, [defaultSelectedItem?.key])
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span className='flex html-editor-heading-dropdown items-center justify-between gap-[4px] pl-[8px] pr-[4px] cursor-pointer'>
|
||||
<span>{activeMap[selectedKey as keyof typeof activeMap]?.label}</span>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[204px]">
|
||||
<DropdownMenuRadioGroup
|
||||
value={selectedKey}
|
||||
onValueChange={(value) => {
|
||||
setSelectedKey(value as any)
|
||||
const newSize = textTypes[value]
|
||||
if (editor) {
|
||||
editor.styleManager?.changeFontSize(element, newSize)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{menuItems.map(item => (
|
||||
<DropdownMenuRadioItem key={item.key} value={item.key}>
|
||||
{item.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const EditorColorPicker: React.FC<{
|
||||
editor: HTMLEditor
|
||||
element: HTMLElement
|
||||
}> = ({ editor, element }) => {
|
||||
const [color, setColor] = useTextColor(element)
|
||||
const bgColor = useMemo<string>(
|
||||
() => (typeof color === 'string' ? color : (color as any)?.toHexString?.() || '#000000'),
|
||||
[color],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='editor-toolbar-color-picker w-[16px] h-[16px]'>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
data-html-editor-ui="true"
|
||||
className='flex flex-row items-center justify-center w-[16px] h-[16px] rounded-[16px] bg-[#eee] cursor-pointer'
|
||||
>
|
||||
<div
|
||||
className='w-[14px] h-[14px] rounded-[14px] overflow-hidden'
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
<div
|
||||
className='w-full h-full'
|
||||
style={{
|
||||
backgroundSize: '8px 8px',
|
||||
backgroundImage:
|
||||
'conic-gradient(rgba(98, 105, 153, 0.1) 25%, transparent 25% 50%, rgba(98, 105, 153, 0.1) 50% 75%, transparent 75% 100%)',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto p-3'
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ColorPicker
|
||||
color={bgColor.startsWith('#') ? bgColor : '#000000'}
|
||||
onChange={(newColor) => {
|
||||
setColor(newColor)
|
||||
editor.styleManager?.changeColor(element, newColor)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TextToolbar: React.FC<TextToolbarProps> = ({
|
||||
editor,
|
||||
element,
|
||||
}) => {
|
||||
const isBold = useFontWeight(element)
|
||||
const isItalic = useFontStyle(element)
|
||||
const isUnderline = useTextDecoration(element)
|
||||
const textAlign = useTextAlign(element)
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const styleManager = editor.styleManager
|
||||
if (!editor || !element || !styleManager) return null
|
||||
|
||||
const handleToggleBold = () => {
|
||||
styleManager.changeFontWeight(element, isBold ? 'normal' : 'bold')
|
||||
}
|
||||
|
||||
const handleToggleItalic = () => {
|
||||
styleManager.changeFontStyle(element, isItalic ? 'normal' : 'italic')
|
||||
}
|
||||
|
||||
const handleToggleUnderline = () => {
|
||||
styleManager.changeTextDecoration(element, isUnderline ? 'none' : 'underline')
|
||||
}
|
||||
|
||||
const handleTextAlign = (align: string) => {
|
||||
styleManager.changeTextAlign(element, align)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'tools',
|
||||
label: '',
|
||||
icon: TextLevelChangger,
|
||||
},
|
||||
{
|
||||
key: 'color-picker',
|
||||
label: '',
|
||||
icon: EditorColorPicker,
|
||||
tooltip: loca.changeTextColor.content,
|
||||
},
|
||||
{
|
||||
key: 'bold',
|
||||
label: '',
|
||||
icon: Bold,
|
||||
tooltip: loca.textBlod.content,
|
||||
run: handleToggleBold,
|
||||
isActive: isBold,
|
||||
},
|
||||
{
|
||||
key: 'italic',
|
||||
label: '',
|
||||
icon: Italic,
|
||||
tooltip: loca.textItalic.content,
|
||||
run: handleToggleItalic,
|
||||
isActive: isItalic,
|
||||
},
|
||||
{
|
||||
key: 'underline',
|
||||
label: '',
|
||||
icon: Underline,
|
||||
tooltip: loca.textUnderline.content,
|
||||
run: handleToggleUnderline,
|
||||
isActive: isUnderline,
|
||||
},
|
||||
{
|
||||
key: 'text-align-left',
|
||||
label: '',
|
||||
icon: AlignLeft,
|
||||
tooltip: loca.textAlignLeft.content,
|
||||
run: () => {
|
||||
handleTextAlign('left')
|
||||
},
|
||||
isActive: textAlign === 'left' || textAlign === 'start',
|
||||
},
|
||||
{
|
||||
key: 'text-align-center',
|
||||
label: '',
|
||||
icon: AlignCenter,
|
||||
tooltip: loca.textAlignCenter.content,
|
||||
run: () => {
|
||||
handleTextAlign('center')
|
||||
},
|
||||
isActive: textAlign === 'center',
|
||||
},
|
||||
{
|
||||
key: 'text-align-right',
|
||||
label: '',
|
||||
icon: AlignRight,
|
||||
tooltip: loca.textAlignRight.content,
|
||||
run: () => {
|
||||
handleTextAlign('right')
|
||||
},
|
||||
isActive: textAlign === 'right' || textAlign === 'end',
|
||||
},
|
||||
{
|
||||
key: 'split-line',
|
||||
label: '',
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
label: '',
|
||||
icon: Copy,
|
||||
tooltip: loca.copy.content,
|
||||
run: () => {
|
||||
editor.copyElement(element)
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '',
|
||||
icon: Trash2,
|
||||
tooltip: loca.delete.content,
|
||||
run: () => {
|
||||
editor.deleteElement(element)
|
||||
},
|
||||
isActive: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
handleToggleBold,
|
||||
handleToggleItalic,
|
||||
handleToggleUnderline,
|
||||
handleTextAlign,
|
||||
textAlign,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline,
|
||||
editor,
|
||||
element,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className='html-editor-toolbar'
|
||||
style={{
|
||||
height: '36px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 100,
|
||||
zIndex: 10000,
|
||||
background: 'var(--editor-surface)',
|
||||
backdropFilter: 'blur(18px)',
|
||||
border: '0.5px solid var(--editor-border-strong)',
|
||||
boxShadow: '0 8px 24px var(--editor-shadow)',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{actions.map((a: any) => {
|
||||
if (a.key === 'split-line') {
|
||||
return (
|
||||
<div key={a.key} className='h-[50%] w-[1px] bg-border'></div>
|
||||
)
|
||||
}
|
||||
|
||||
const IconComp = a.icon as any;
|
||||
const isDelete = a.key === 'delete';
|
||||
const iconClassName = `w-4 h-4 ${isDelete ? 'text-destructive' : 'text-foreground'}`;
|
||||
const content = (
|
||||
<div
|
||||
className={`
|
||||
min-w-[28px] min-h-[28px] flex flex-row items-center justify-center text-foreground hover:bg-accent cursor-pointer transition-all rounded-[28px] ${a.isActive ? 'bg-accent text-accent-foreground' : ''} ${a.label ? 'px-[8px]' : ''}
|
||||
`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => {
|
||||
a.run && a.run()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center ${a.label ? 'gap-[2px]' : ''}`}
|
||||
>
|
||||
<div>
|
||||
{IconComp && (
|
||||
['tools', 'color-picker'].includes(a.key) ? (
|
||||
<IconComp editor={editor} element={element} className={iconClassName} />
|
||||
) : (
|
||||
<IconComp className={iconClassName} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div>{a.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip key={a.key}>
|
||||
<TooltipTrigger asChild>
|
||||
{content}
|
||||
</TooltipTrigger>
|
||||
{a.tooltip ? <TooltipContent side="bottom">
|
||||
<p>{a.tooltip}</p>
|
||||
</TooltipContent> : null}
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// import { ColorPickerProps, GetProp } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// 辅助函数:将 rgb/rgba 转换为 hex
|
||||
export const rgbToHex = (rgb: string): string => {
|
||||
if (rgb.startsWith('#')) return rgb;
|
||||
|
||||
const match = rgb.match(/\d+/g);
|
||||
if (!match) return '#000000';
|
||||
|
||||
const [r, g, b, a] = match.map(Number);
|
||||
return '#' + [r, g, b, a].map(x => {
|
||||
const hex = x ? x.toString(16) : 'ff'
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// Hook: 读取字体大小
|
||||
export const useFontSize = (element: HTMLElement | null) => {
|
||||
const [fontSize, setFontSize] = useState(18);
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateFontSize = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const fontSizeValue = computedStyle.fontSize;
|
||||
const fontSize = parseInt(fontSizeValue);
|
||||
setFontSize(fontSize);
|
||||
};
|
||||
|
||||
updateFontSize();
|
||||
|
||||
// 监听样式变化
|
||||
const observer = new MutationObserver(() => {
|
||||
updateFontSize();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
element.addEventListener('transitionend', updateFontSize);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
element.removeEventListener('transitionend', updateFontSize);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
return [fontSize, setFontSize] as const;
|
||||
};
|
||||
|
||||
// Hook: 读取背景色
|
||||
export const useBackgroundColor = (element: HTMLElement | null) => {
|
||||
const [bgColor, setBgColor] = useState('#ffffff');
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateBgColor = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const bgColorValue = computedStyle.backgroundColor;
|
||||
|
||||
if (bgColorValue && bgColorValue !== 'rgba(0, 0, 0, 0)' && bgColorValue !== 'transparent') {
|
||||
setBgColor(bgColorValue);
|
||||
} else {
|
||||
setBgColor('#ffffff');
|
||||
}
|
||||
};
|
||||
|
||||
updateBgColor();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateBgColor();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
element.addEventListener('transitionend', updateBgColor);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
element.removeEventListener('transitionend', updateBgColor);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
return [bgColor, setBgColor] as const;
|
||||
};
|
||||
|
||||
// Hook: 读取文字颜色
|
||||
type Color = string;
|
||||
export const useTextColor = (element: HTMLElement | null) => {
|
||||
const [textColor, setTextColor] = useState<Color>('#000000');
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateTextColor = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const textColorValue = computedStyle.color;
|
||||
|
||||
if (textColorValue) {
|
||||
setTextColor(textColorValue);
|
||||
}
|
||||
};
|
||||
|
||||
updateTextColor();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateTextColor();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
element.addEventListener('transitionend', updateTextColor);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
element.removeEventListener('transitionend', updateTextColor);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
return [textColor, setTextColor] as const;
|
||||
};
|
||||
|
||||
// Hook: 读取圆角
|
||||
export const useBorderRadius = (element: HTMLElement | null) => {
|
||||
const [borderRadius, setBorderRadius] = useState('0');
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateBorderRadius = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const borderRadiusValue = computedStyle.borderRadius;
|
||||
setBorderRadius(parseInt(borderRadiusValue).toString());
|
||||
};
|
||||
|
||||
updateBorderRadius();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateBorderRadius();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
element.addEventListener('transitionend', updateBorderRadius);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
element.removeEventListener('transitionend', updateBorderRadius);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
return [borderRadius, setBorderRadius] as const;
|
||||
};
|
||||
|
||||
// Hook: 读取边框
|
||||
export const useBorder = (element: HTMLElement | null) => {
|
||||
const [border, setBorder] = useState('');
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateBorder = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const borderValue = computedStyle.border;
|
||||
setBorder(borderValue);
|
||||
};
|
||||
|
||||
updateBorder();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateBorder();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
element.addEventListener('transitionend', updateBorder);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
element.removeEventListener('transitionend', updateBorder);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
return [border, setBorder] as const;
|
||||
};
|
||||
|
||||
// Hook: 读取边距
|
||||
export const useMargin = (element: HTMLElement | null) => {
|
||||
const [margin, setMargin] = useState('');
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateMargin = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const marginValue = computedStyle.margin;
|
||||
setMargin(marginValue);
|
||||
};
|
||||
|
||||
updateMargin();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateMargin();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
element.addEventListener('transitionend', updateMargin);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
element.removeEventListener('transitionend', updateMargin);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
return [margin, setMargin] as const;
|
||||
};
|
||||
|
||||
// Hook: 读取字体粗细
|
||||
export const useFontWeight = (element: HTMLElement | null) => {
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateFontWeight = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const fontWeight = computedStyle.fontWeight;
|
||||
// fontWeight 可能是数字(400, 700)或字符串(normal, bold)
|
||||
setIsBold(fontWeight === 'bold' || fontWeight === '700' || parseInt(fontWeight) >= 700);
|
||||
};
|
||||
|
||||
updateFontWeight();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateFontWeight();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
element.addEventListener('transitionend', updateFontWeight);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
element.removeEventListener('transitionend', updateFontWeight);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
return isBold;
|
||||
};
|
||||
|
||||
// Hook: 读取字体样式(斜体)
|
||||
export const useFontStyle = (element: HTMLElement | null) => {
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateFontStyle = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const fontStyle = computedStyle.fontStyle;
|
||||
setIsItalic(fontStyle === 'italic');
|
||||
};
|
||||
|
||||
updateFontStyle();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateFontStyle();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
element.addEventListener('transitionend', updateFontStyle);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
element.removeEventListener('transitionend', updateFontStyle);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
return isItalic;
|
||||
};
|
||||
|
||||
// Hook: 读取文字装饰(下划线)
|
||||
export const useTextDecoration = (element: HTMLElement | null) => {
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateTextDecoration = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const textDecoration = computedStyle.textDecoration;
|
||||
setIsUnderline(textDecoration.includes('underline'));
|
||||
};
|
||||
|
||||
updateTextDecoration();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateTextDecoration();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [element]);
|
||||
|
||||
return isUnderline;
|
||||
};
|
||||
|
||||
export const useTextAlign = (element: HTMLElement | null) => {
|
||||
const [textAlign, setTextAlign] = useState('left');
|
||||
const win = element?.ownerDocument.defaultView || window;
|
||||
useEffect(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updateTextAlign = () => {
|
||||
const computedStyle = win.getComputedStyle(element);
|
||||
const align = computedStyle.textAlign;
|
||||
setTextAlign(align);
|
||||
};
|
||||
|
||||
updateTextAlign();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateTextAlign();
|
||||
});
|
||||
|
||||
observer.observe(element, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [element]);
|
||||
|
||||
return textAlign;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const useStopPopEvent = (visible?: boolean) => {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
document.querySelectorAll('.ant-color-picker input').forEach(input => {
|
||||
input.addEventListener('mousedown', handler, true);
|
||||
input.addEventListener('click', handler, true);
|
||||
});
|
||||
return () => {
|
||||
document.querySelectorAll('.ant-color-picker input').forEach(input => {
|
||||
input.removeEventListener('mousedown', handler, true);
|
||||
input.removeEventListener('click', handler, true);
|
||||
});
|
||||
}
|
||||
}, [visible])
|
||||
}
|
||||
23
components/html-editor/components/toolbar-web/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { usePPTEditContext } from '../../context'
|
||||
import { Tooltip } from './toolbar'
|
||||
|
||||
export const PPTEditToolBar: React.FC<{
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
className?: string
|
||||
}> = props => {
|
||||
const { containerRef, className } = props
|
||||
const context = usePPTEditContext()
|
||||
if (!context || !context.state) {
|
||||
return null
|
||||
}
|
||||
const { selectedElement, editor, tipPosition } = context.state
|
||||
return (
|
||||
<Tooltip
|
||||
editor={editor}
|
||||
element={selectedElement}
|
||||
position={tipPosition}
|
||||
containerRef={containerRef}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
47
components/html-editor/components/toolbar-web/loco.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
const localize = (key: string, defaultValue: string) => defaultValue
|
||||
export const loca = {
|
||||
changeTextColor: {
|
||||
content: localize('editor-color-picker', '更改颜色'),
|
||||
tip: '更改文本颜色'
|
||||
},
|
||||
changeBgColor: {
|
||||
content: localize('editor-bg-color-picker', '更改背景色'),
|
||||
tip: '更改背景颜色'
|
||||
},
|
||||
imageUpload: {
|
||||
content: localize('editor-image_upload', '替换图片'),
|
||||
tip: '上传图片'
|
||||
},
|
||||
textBlod: {
|
||||
content: localize('text_blod', '文字加粗'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textItalic: {
|
||||
content: localize('text_italic', '文字斜体'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textUnderline: {
|
||||
content: localize('text_underline', '文字下划线'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textAlignLeft: {
|
||||
content: localize('text_align-left', '左对齐'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textAlignCenter: {
|
||||
content: localize('text_align-center', '居中对齐'),
|
||||
tip: '加粗'
|
||||
},
|
||||
textAlignRight: {
|
||||
content: localize('text_align-right', '右对齐'),
|
||||
tip: '加粗'
|
||||
},
|
||||
copy: {
|
||||
content: localize('element_duplicate', '复制'),
|
||||
tip: '复制'
|
||||
},
|
||||
delete: {
|
||||
content: localize('element_delete', '删除'),
|
||||
tip: '删除'
|
||||
},
|
||||
}
|
||||
175
components/html-editor/components/toolbar-web/styles.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import React from 'react';
|
||||
|
||||
export const styles: { [key: string]: React.CSSProperties } = {
|
||||
container: {
|
||||
position: 'absolute',
|
||||
backgroundColor: 'var(--editor-surface)',
|
||||
border: '1px solid var(--editor-border)',
|
||||
borderRadius: '12px',
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 14px 36px var(--editor-shadow)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
backdropFilter: 'blur(18px)',
|
||||
},
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
},
|
||||
section: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
divider: {
|
||||
width: '1px',
|
||||
height: '24px',
|
||||
backgroundColor: 'var(--editor-border)',
|
||||
},
|
||||
buttonGroup: {
|
||||
display: 'flex',
|
||||
gap: '2px',
|
||||
backgroundColor: 'var(--editor-surface-muted)',
|
||||
borderRadius: '6px',
|
||||
padding: '2px',
|
||||
},
|
||||
iconButton: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.15s ease',
|
||||
color: 'var(--editor-text)',
|
||||
},
|
||||
iconButtonActive: {
|
||||
backgroundColor: 'var(--editor-accent)',
|
||||
color: 'var(--primary-foreground)',
|
||||
},
|
||||
inputGroup: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
backgroundColor: 'var(--editor-surface-muted)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--editor-text-muted)',
|
||||
userSelect: 'none',
|
||||
},
|
||||
numberInput: {
|
||||
width: '40px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '14px',
|
||||
padding: '2px 4px',
|
||||
textAlign: 'center',
|
||||
outline: 'none',
|
||||
fontWeight: '500',
|
||||
color: 'var(--editor-text)',
|
||||
},
|
||||
textInput: {
|
||||
width: '100px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '13px',
|
||||
padding: '2px 4px',
|
||||
outline: 'none',
|
||||
color: 'var(--editor-text)',
|
||||
},
|
||||
colorGroup: {
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
},
|
||||
colorItem: {
|
||||
position: 'relative',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
},
|
||||
colorInput: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
colorLabel: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
pointerEvents: 'none',
|
||||
color: 'var(--editor-text-muted)',
|
||||
mixBlendMode: 'difference',
|
||||
},
|
||||
bgIcon: {
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: '2px solid currentColor',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
sliderGroup: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
backgroundColor: 'var(--editor-surface-muted)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
},
|
||||
sliderLabel: {
|
||||
fontSize: '14px',
|
||||
color: 'var(--editor-text-muted)',
|
||||
userSelect: 'none',
|
||||
},
|
||||
rangeInput: {
|
||||
width: '70px',
|
||||
height: '4px',
|
||||
cursor: 'pointer',
|
||||
appearance: 'none',
|
||||
background:
|
||||
'linear-gradient(to right, var(--editor-accent) 0%, var(--editor-accent) var(--value), color-mix(in srgb, var(--editor-border-strong) 72%, white) var(--value), color-mix(in srgb, var(--editor-border-strong) 72%, white) 100%)',
|
||||
borderRadius: '2px',
|
||||
outline: 'none',
|
||||
},
|
||||
sliderValue: {
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: 'var(--editor-text)',
|
||||
minWidth: '24px',
|
||||
textAlign: 'right',
|
||||
},
|
||||
deleteButton: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'var(--editor-danger-soft)',
|
||||
color: 'var(--editor-danger)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.15s ease',
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
hint: {
|
||||
fontSize: '13px',
|
||||
color: 'var(--editor-text-muted)',
|
||||
padding: '0 8px',
|
||||
},
|
||||
};
|
||||
73
components/html-editor/components/toolbar-web/toolbar.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import type { HTMLEditor } from '../../lib'
|
||||
import { isBlockElement, isImageElement, isTextElement } from '../../lib'
|
||||
import { TextToolbar } from './components/TextToolbar'
|
||||
import { BlockToolbar } from './components/BlockToolbar'
|
||||
import { ImageToolbar } from './components/ImageToolbar'
|
||||
|
||||
interface Position {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
bottom: number
|
||||
right: number
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
editor: HTMLEditor | null | undefined
|
||||
element: HTMLElement | null | undefined
|
||||
position: Position | null
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
editor,
|
||||
element,
|
||||
position,
|
||||
containerRef,
|
||||
className,
|
||||
}) => {
|
||||
if (!editor || !element || !position || !editor.styleManager) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isText = isTextElement(element)
|
||||
const isBlock = isBlockElement(element)
|
||||
const isImage = isImageElement(element)
|
||||
|
||||
const contaierRect =
|
||||
containerRef.current?.getBoundingClientRect() || ({} as DOMRect)
|
||||
const { left = 0, top = 0 } = contaierRect
|
||||
|
||||
const toolWidth = isText ? 365 : 105
|
||||
const toolHeight = 36
|
||||
// 右边界限制
|
||||
let releativeLeft = position.left
|
||||
if (position.left + toolWidth + left > window.innerWidth) {
|
||||
releativeLeft = window.innerWidth - toolWidth - left
|
||||
}
|
||||
|
||||
let releativeBottom = position.bottom
|
||||
if (releativeBottom + toolHeight + 25 > window.innerHeight) {
|
||||
releativeBottom = position.top - toolHeight - 15
|
||||
}
|
||||
|
||||
const tooltipStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: `${releativeBottom - top + 10}px`,
|
||||
left: `${releativeLeft}px`,
|
||||
zIndex: 100000,
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={tooltipStyle} className={`html-editor-floating-toolbar ${className}`}>
|
||||
{isText && <TextToolbar editor={editor} element={element} />}
|
||||
{isBlock && <BlockToolbar editor={editor} element={element} />}
|
||||
{isImage && <ImageToolbar editor={editor} element={element} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
153
components/html-editor/components/toolbar-web/utils/upload.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import mime from 'mime'
|
||||
import { createCustomOSSUploader } from '@bty/uploader'
|
||||
import { createFileUploadRecord } from '@apis/mindnote/next-agent-chat'
|
||||
import { getOssSignatureUrl, getSTSToken } from '@apis/mindnote/oss'
|
||||
|
||||
export const ACCEPT_FILE_TYPE_LIST = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.webp',
|
||||
]
|
||||
|
||||
function getMimeByAcceptList(filename: string): string | undefined {
|
||||
const ext = `.${(filename.split('.').pop() || '').toLowerCase()}`
|
||||
if (!ext) return undefined
|
||||
const map: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
if (ACCEPT_FILE_TYPE_LIST.includes(ext)) {
|
||||
return map[ext]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export interface RcFile extends File {
|
||||
uid: string;
|
||||
}
|
||||
|
||||
|
||||
export interface MessageFile {
|
||||
uid?: string
|
||||
name: string
|
||||
type: string
|
||||
path?: string
|
||||
url?: string
|
||||
upload_file_id?: string
|
||||
byte_size?: number
|
||||
progress?: number
|
||||
uploadStatus?: 'pending' | 'success' | 'error'
|
||||
}
|
||||
|
||||
interface UploadFile extends MessageFile {
|
||||
uid?: string
|
||||
}
|
||||
interface UploadResult {
|
||||
name: string,
|
||||
type: string,
|
||||
byte_size: number,
|
||||
url: string,
|
||||
upload_file_id: string
|
||||
}
|
||||
export const customUpload = async (params: {
|
||||
file: File
|
||||
onProgress?: (args: {
|
||||
progress: number
|
||||
file: RcFile
|
||||
uploadStatus: 'success' | 'error' | 'pending'
|
||||
}) => void,
|
||||
onFail?: (message?: any) => void
|
||||
}): Promise<UploadResult | null> => {
|
||||
const { file, onProgress,onFail } = params
|
||||
const ossUploader = createCustomOSSUploader(getSTSToken)
|
||||
try {
|
||||
// 构建文件路径,参考 util.ts 中的格式
|
||||
const timestamp = new Date().valueOf()
|
||||
const uuid = uuidv4()
|
||||
const filePath = `super_agent/user_upload_file/${uuid}/_${timestamp}_${file.name}`
|
||||
// 上传文件到 OSS
|
||||
await ossUploader.multipartUpload({
|
||||
filePath,
|
||||
file,
|
||||
options: {
|
||||
headers: (() => {
|
||||
const contentType = getMimeByAcceptList(file.name)
|
||||
return {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': 'inline',
|
||||
} as Record<string, string>
|
||||
})(),
|
||||
progress(
|
||||
progress: number,
|
||||
ossFile: { file: RcFile },
|
||||
{ status }: { status: number } = { status: 0 },
|
||||
) {
|
||||
const uploadStatus =
|
||||
progress >= 1 ? (status === 200 ? 'success' : 'error') : 'pending'
|
||||
|
||||
if (uploadStatus !== 'pending') {
|
||||
setTimeout(() => {
|
||||
onProgress?.({
|
||||
progress,
|
||||
file: ossFile?.file || file,
|
||||
uploadStatus,
|
||||
})
|
||||
}, 500)
|
||||
} else {
|
||||
onProgress?.({
|
||||
progress,
|
||||
file: ossFile?.file || file,
|
||||
uploadStatus,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const signatureUrl = await getOssSignatureUrl(filePath)
|
||||
|
||||
// 获取文件类型
|
||||
const file_type =
|
||||
file.type || mime.getType(file.name) || 'application/octet-stream'
|
||||
|
||||
// 只分割最后一个点,例如 'a.b.c' -> ['a.b', 'c']
|
||||
const lastDotIndex = file.name.lastIndexOf('.')
|
||||
const splitName =
|
||||
lastDotIndex !== -1
|
||||
? [
|
||||
file.name.substring(0, lastDotIndex),
|
||||
file.name.substring(lastDotIndex + 1),
|
||||
]
|
||||
: [file.name]
|
||||
const name = `${splitName[0]}-${Math.random().toString(36).substring(2, 5)}${splitName.length > 1 ? `.${splitName[1]}` : ''}`
|
||||
const res = await createFileUploadRecord({
|
||||
file_url: filePath || '',
|
||||
file_type: file.type || 'application/octet-stream',
|
||||
file_name: name,
|
||||
file_byte_size: file.size || 0,
|
||||
conversation_id: uuid,
|
||||
})
|
||||
|
||||
// 构造返回的文件对象
|
||||
const result: UploadFile = {
|
||||
name,
|
||||
type: file_type,
|
||||
byte_size: file.size,
|
||||
url: signatureUrl,
|
||||
upload_file_id: res.file_upload_record_id,
|
||||
}
|
||||
return result as UploadResult
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', error)
|
||||
onFail && onFail(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
56
components/html-editor/context/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import type { UseInjectModeReturn } from '../hooks/useIframeMode';
|
||||
|
||||
|
||||
export interface SlideJson {
|
||||
outline: Array<{
|
||||
id: string;
|
||||
summary: string;
|
||||
title: string;
|
||||
}>;
|
||||
project_dir: string;
|
||||
slide_ids: string[];
|
||||
slide_list: SliderListItem[]
|
||||
}
|
||||
|
||||
export interface SliderListItem {
|
||||
content: string
|
||||
file_name: string
|
||||
file_type: string
|
||||
id: string
|
||||
path : string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const PPTEditContext = createContext<{
|
||||
state: UseInjectModeReturn | null,
|
||||
setState: React.Dispatch<React.SetStateAction<UseInjectModeReturn | null>>
|
||||
originalSlide: React.MutableRefObject<SliderListItem[]>
|
||||
} | null>(null);
|
||||
|
||||
export const usePPTEditContext = () => {
|
||||
return useContext(PPTEditContext)
|
||||
}
|
||||
|
||||
export const PPTEditProvider: React.FC<{
|
||||
children: React.ReactNode
|
||||
slides?: SliderListItem[]
|
||||
}> = (props) => {
|
||||
const originalSlide = useRef<SliderListItem[]>(JSON.parse(JSON.stringify(props.slides || [])));
|
||||
const [state, setState] = useState<UseInjectModeReturn | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
originalSlide.current = JSON.parse(JSON.stringify(props.slides || []))
|
||||
}, [props.slides])
|
||||
|
||||
return (
|
||||
<PPTEditContext.Provider value={{
|
||||
state,
|
||||
setState,
|
||||
originalSlide
|
||||
}}>
|
||||
{props.children}
|
||||
</PPTEditContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
35
components/html-editor/hooks/useDiff.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usePPTEditContext } from '../context'
|
||||
import { useToolPostion } from './useToolPostion'
|
||||
import type { UseInjectModeReturn } from '../hooks/useIframeMode'
|
||||
|
||||
export const useDiff = (
|
||||
useIframeReturn: UseInjectModeReturn,
|
||||
isDoc?: boolean,
|
||||
) => {
|
||||
const editorContext = usePPTEditContext()
|
||||
const { selectedElement, editor, tipPosition } = useIframeReturn
|
||||
|
||||
const docModeToolPosition = useToolPostion(editor, !!isDoc)
|
||||
|
||||
useEffect(() => {
|
||||
const hasActive = editor?.EditorRegistry.hasActiveEditor()
|
||||
const reWriteState = { ...useIframeReturn }
|
||||
if (isDoc) {
|
||||
// 重写状态
|
||||
reWriteState.position = docModeToolPosition
|
||||
reWriteState.tipPosition = docModeToolPosition
|
||||
editorContext?.setState(reWriteState)
|
||||
return
|
||||
}
|
||||
if (hasActive && selectedElement) {
|
||||
// 有激活的实例且为当前的实例
|
||||
editorContext?.setState(reWriteState)
|
||||
} else if (!selectedElement && !hasActive) {
|
||||
// 失焦后清空所有状态,关闭tip
|
||||
reWriteState.position = null
|
||||
reWriteState.tipPosition = null
|
||||
editorContext?.setState(reWriteState)
|
||||
}
|
||||
}, [selectedElement, editor, tipPosition, isDoc, docModeToolPosition])
|
||||
}
|
||||
42
components/html-editor/hooks/useEditState.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { HTMLEditor } from '../lib';
|
||||
|
||||
export type SaveType = 'manual' | 'auto' | null;
|
||||
|
||||
export const useEditState = () => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveType, setSaveType] = useState<SaveType>(null);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
|
||||
const redo = useRef(() => { });
|
||||
const undo = useRef(() => { });
|
||||
|
||||
const handleHistoryChangeEvent = (instance: HTMLEditor) => {
|
||||
if (!instance) return null
|
||||
const canRedo = instance.EditorRegistry.canRedo()
|
||||
const canUndo = instance.EditorRegistry.canUndo()
|
||||
setCanRedo(canRedo)
|
||||
setCanUndo(canUndo)
|
||||
redo.current = () => {
|
||||
instance.EditorRegistry.redo()
|
||||
}
|
||||
undo.current = () => {
|
||||
instance.EditorRegistry.undo()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSaving,
|
||||
setIsSaving,
|
||||
saveType,
|
||||
setSaveType,
|
||||
handleHistoryChangeEvent,
|
||||
canRedo,
|
||||
setCanRedo,
|
||||
canUndo,
|
||||
setCanUndo,
|
||||
redo,
|
||||
undo,
|
||||
}
|
||||
}
|
||||
261
components/html-editor/hooks/useIframeMode.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Position, EditorStyleConfig, HistoryState } from '../lib'
|
||||
import { cleanDom, HTMLEditor } from '../lib'
|
||||
|
||||
interface UseInjectModeOptions {
|
||||
styleConfig?: EditorStyleConfig
|
||||
enableGlobalContentEditable?: boolean
|
||||
onContentChange?: (srcDoc: string) => void
|
||||
onHistoryChange?: (state: HistoryState, editor: HTMLEditor) => void
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const isValidDom = (innerText: string) => {
|
||||
try {
|
||||
const data = JSON.parse(innerText)
|
||||
if (data && !data.success) {
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const isIfrmae = (target: HTMLElement) => {
|
||||
return target.tagName === 'IFRAME' || target instanceof HTMLIFrameElement
|
||||
}
|
||||
|
||||
export interface UseInjectModeReturn {
|
||||
editor: HTMLEditor | null
|
||||
editorIns: HTMLEditor | null
|
||||
selectedElement: HTMLElement | null
|
||||
position: Position | null
|
||||
tipPosition: Position | null
|
||||
injectScript: (targetContainer: HTMLElement) => Promise<void>
|
||||
// 历史记录相关
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
clearHistory: () => void
|
||||
loadSuccess: boolean
|
||||
}
|
||||
|
||||
function waitForIframeReady(
|
||||
iframe: HTMLIFrameElement,
|
||||
timeout = 5000,
|
||||
): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const start = performance.now()
|
||||
|
||||
const tryAttach = () => {
|
||||
const doc = iframe.contentDocument
|
||||
if (!doc) {
|
||||
if (performance.now() - start > timeout) return resolve()
|
||||
return requestAnimationFrame(tryAttach)
|
||||
}
|
||||
|
||||
// 如果已经 complete,直接 resolve
|
||||
if (doc.readyState === 'complete') {
|
||||
return resolve()
|
||||
}
|
||||
|
||||
let stableTimer: any
|
||||
const observer = new MutationObserver(() => {
|
||||
clearTimeout(stableTimer)
|
||||
stableTimer = setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve()
|
||||
}, 800)
|
||||
})
|
||||
|
||||
observer.observe(doc.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
})
|
||||
|
||||
// 超时保护
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
clearTimeout(stableTimer)
|
||||
console.error('iframe ready timeout')
|
||||
resolve()
|
||||
}, timeout)
|
||||
|
||||
// 同时监听 readyState
|
||||
const checkReady = () => {
|
||||
if (doc.readyState === 'complete') {
|
||||
observer.disconnect()
|
||||
clearTimeout(stableTimer)
|
||||
resolve()
|
||||
} else if (performance.now() - start < timeout) {
|
||||
requestAnimationFrame(checkReady)
|
||||
}
|
||||
}
|
||||
checkReady()
|
||||
}
|
||||
|
||||
tryAttach()
|
||||
})
|
||||
}
|
||||
|
||||
export function useIframeMode(
|
||||
id: string,
|
||||
container: React.RefObject<HTMLIFrameElement | HTMLElement | null>,
|
||||
options?: UseInjectModeOptions,
|
||||
scale = 1,
|
||||
): UseInjectModeReturn {
|
||||
const editorRef = useRef<HTMLEditor | null>(null)
|
||||
const [editorIns, setEditorIns] = useState<HTMLEditor | null>(null)
|
||||
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
|
||||
null,
|
||||
)
|
||||
const [position, setPosition] = useState<Position | null>(null)
|
||||
const [canUndo, setCanUndo] = useState(false)
|
||||
const [canRedo, setCanRedo] = useState(false)
|
||||
const [loadSuccess, setLoadSuccess] = useState(false)
|
||||
|
||||
const injectScript = useCallback(async (targetContainer: HTMLElement) => {
|
||||
if (!targetContainer) {
|
||||
console.error('ifrmae is null')
|
||||
return
|
||||
}
|
||||
if (editorRef.current) {
|
||||
console.error('ifrmae is destroyed')
|
||||
editorRef.current.destroy()
|
||||
}
|
||||
const editor = new HTMLEditor({
|
||||
id,
|
||||
styleConfig: options?.styleConfig,
|
||||
enableGlobalContentEditable: options?.enableGlobalContentEditable,
|
||||
onElementSelect: (element: HTMLElement | null, pos?: Position) => {
|
||||
setSelectedElement(element)
|
||||
if (element && pos) {
|
||||
setPosition(pos)
|
||||
} else {
|
||||
setPosition(null)
|
||||
}
|
||||
},
|
||||
onStyleChange: (element: HTMLElement) => {
|
||||
if (element) {
|
||||
const pos = element.getBoundingClientRect()
|
||||
setPosition(pos)
|
||||
}
|
||||
},
|
||||
onContentChange: () => {
|
||||
// 触发内容变化回调,数据清洗
|
||||
if (options?.onContentChange) {
|
||||
if (editor.isIframe) {
|
||||
const iframe = container.current as HTMLIFrameElement
|
||||
const iframeDoc = iframe.contentDocument?.documentElement
|
||||
if (iframeDoc) {
|
||||
const srcDoc = cleanDom(iframeDoc)
|
||||
options.onContentChange(srcDoc)
|
||||
}
|
||||
} else {
|
||||
const srcDoc = cleanDom(container.current as HTMLElement)
|
||||
options.onContentChange(srcDoc)
|
||||
}
|
||||
}
|
||||
},
|
||||
onHistoryChange: (state: HistoryState) => {
|
||||
setCanUndo(state.canUndo)
|
||||
setCanRedo(state.canRedo)
|
||||
options?.onHistoryChange?.(state, editor)
|
||||
},
|
||||
enableMoveable: true,
|
||||
helperBox: true,
|
||||
enableHistory: true,
|
||||
historyOptions: {
|
||||
maxHistorySize: 100,
|
||||
mergeInterval: 1000,
|
||||
},
|
||||
})
|
||||
editor.init(targetContainer)
|
||||
editorRef.current = editor
|
||||
setEditorIns(editor)
|
||||
}, [id, options, container])
|
||||
|
||||
useEffect(() => {
|
||||
const element = container.current
|
||||
if (!element || options?.enabled === false) {
|
||||
return
|
||||
}
|
||||
const isIframeEle = isIfrmae(element)
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// macos 快捷键 command + z 撤销 / command + shift + z 重做
|
||||
if (e.metaKey && e.key === 'z' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
editorRef.current?.redo()
|
||||
}
|
||||
if (e.metaKey && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
editorRef.current?.undo()
|
||||
}
|
||||
}
|
||||
if (isIframeEle) {
|
||||
// iframe 加载
|
||||
const onLoad = async () => {
|
||||
await waitForIframeReady(element as HTMLIFrameElement)
|
||||
const doc = (element as HTMLIFrameElement).contentDocument!
|
||||
// 判断是否是合法的 iframe 内容
|
||||
const loadSuccess = isValidDom(doc.body.innerText)
|
||||
if (loadSuccess) {
|
||||
injectScript(doc.body)
|
||||
console.log('编辑器加载成功')
|
||||
setLoadSuccess(true)
|
||||
const document = (element as HTMLIFrameElement).contentDocument
|
||||
?.documentElement
|
||||
document?.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document?.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}
|
||||
}
|
||||
; (element as HTMLIFrameElement).onload = onLoad
|
||||
} else {
|
||||
injectScript(element)
|
||||
setLoadSuccess(true)
|
||||
document?.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document?.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}
|
||||
}, [injectScript, container, options?.enabled])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (editorRef.current && loadSuccess) {
|
||||
console.log('销毁编辑器')
|
||||
editorRef.current.destroy()
|
||||
}
|
||||
}
|
||||
}, [loadSuccess])
|
||||
|
||||
const tipPosition = useMemo(() => {
|
||||
if (!container.current || !position) {
|
||||
return null
|
||||
}
|
||||
const elementRect = container.current.getBoundingClientRect()
|
||||
return {
|
||||
top: position.top * scale + elementRect.top,
|
||||
left: position.left * scale,
|
||||
width: position.width * scale,
|
||||
height: position.height * scale,
|
||||
bottom: position.bottom * scale + elementRect.top,
|
||||
right: position.right * scale + elementRect.left,
|
||||
}
|
||||
}, [position, scale, container])
|
||||
|
||||
return {
|
||||
editor: editorRef.current,
|
||||
editorIns,
|
||||
selectedElement,
|
||||
position,
|
||||
tipPosition,
|
||||
injectScript,
|
||||
canUndo,
|
||||
canRedo,
|
||||
loadSuccess,
|
||||
clearHistory: () => editorRef.current?.clearHistory(),
|
||||
}
|
||||
}
|
||||
25
components/html-editor/hooks/useLoadContent.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNovaKit } from '@/components/nova-sdk/context/useNovaKit';
|
||||
import type { TaskArtifact } from '@/components/nova-sdk/types';
|
||||
|
||||
export const useLoadContent = (
|
||||
taskArtifact: TaskArtifact
|
||||
) => {
|
||||
const [content, setContent] = useState('');
|
||||
const { api } = useNovaKit();
|
||||
useEffect(() => {
|
||||
if (taskArtifact.path) {
|
||||
api
|
||||
.getArtifactUrl?.(taskArtifact)
|
||||
.then(async (res) => {
|
||||
const url = res?.data || '';
|
||||
if (url) {
|
||||
const content = await fetch(url).then((res) => res.text());
|
||||
setContent(content);
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
}
|
||||
}, [taskArtifact, api]);
|
||||
return content;
|
||||
};
|
||||
82
components/html-editor/hooks/useToolPostion.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { HTMLEditor } from '../lib'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export const useToolPostion = (editor: HTMLEditor | null, isDoc: boolean) => {
|
||||
const [position, setPosition] = useState<{ left: number; top: number, bottom: number, right: number, width: number, height: number } | null>(null)
|
||||
// 标识是否从编辑器内开始选择
|
||||
const isSelectingRef = useRef(false)
|
||||
const updatePositionFromSelection = () => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
const view = editor.getDoc().view
|
||||
if (!view) {
|
||||
return
|
||||
}
|
||||
const selection = view.getSelection()
|
||||
if (!selection) return
|
||||
// 如果选区为空,不需要更新
|
||||
const hasSelectAnything = selection.rangeCount === 0 || selection.toString().trim() === ''
|
||||
if (selection.isCollapsed || selection.rangeCount === 0 || hasSelectAnything) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取选区的 Range 对象
|
||||
const range = selection.getRangeAt(0);
|
||||
// 获取选区末尾的位置
|
||||
// 创建一个新的 Range,只包含选区的末尾点
|
||||
const endRange = range.cloneRange();
|
||||
endRange.collapse(false); // false 表示折叠到末尾
|
||||
const rect = endRange.getBoundingClientRect();
|
||||
|
||||
const { top, left, bottom, right, width, height } = rect
|
||||
setPosition({ top, left, bottom, right, width, height })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !isDoc) {
|
||||
return
|
||||
}
|
||||
const dom = editor.getDoc().document
|
||||
if (!dom) {
|
||||
return
|
||||
}
|
||||
|
||||
const onMouseDown = () => {
|
||||
isSelectingRef.current = true
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
// 只有从编辑器内开始选择时才处理
|
||||
if (!isSelectingRef.current) {
|
||||
return
|
||||
}
|
||||
isSelectingRef.current = false
|
||||
setTimeout(() => {
|
||||
updatePositionFromSelection()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const onScrollOrResize = () => {
|
||||
updatePositionFromSelection()
|
||||
}
|
||||
|
||||
dom.addEventListener('mousedown', onMouseDown)
|
||||
dom.addEventListener('mouseup', onMouseUp)
|
||||
dom.addEventListener('scroll', onScrollOrResize)
|
||||
|
||||
window.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
|
||||
return () => {
|
||||
dom.removeEventListener('mousedown', onMouseDown)
|
||||
dom.removeEventListener('mouseup', onMouseUp)
|
||||
dom.removeEventListener('scroll', onScrollOrResize)
|
||||
window.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
}
|
||||
}, [editor, updatePositionFromSelection, isDoc])
|
||||
|
||||
return position
|
||||
}
|
||||
23
components/html-editor/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { TaskArtifact } from '../types';
|
||||
import { TaskHtmlDoc } from './mode/html-doc';
|
||||
import { TaskHtmlWeb } from './mode/html-web';
|
||||
import type { ArtifactEditState } from './types';
|
||||
|
||||
export const TaskArtifactHtml: React.FC<{
|
||||
taskId: string;
|
||||
editable?: boolean;
|
||||
taskArtifact: TaskArtifact;
|
||||
type: 'document' | 'web';
|
||||
onStateChange?: (state: ArtifactEditState) => void;
|
||||
}> = (props) => {
|
||||
const { taskId, editable, taskArtifact, type, onStateChange } = props;
|
||||
const Component = type === 'document' ? TaskHtmlDoc : TaskHtmlWeb;
|
||||
return (
|
||||
<Component
|
||||
taskId={taskId}
|
||||
taskArtifact={taskArtifact}
|
||||
editable={editable}
|
||||
onStateChange={onStateChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
178
components/html-editor/lib/config/styles.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Editor Styles Configuration
|
||||
* 编辑器样式配置
|
||||
*/
|
||||
|
||||
import { type EditorStyleConfig } from '../types';
|
||||
|
||||
export const defaultStyleConfig: EditorStyleConfig = {
|
||||
hover: {
|
||||
outline: '1px dashed var(--editor-accent)',
|
||||
outlineOffset: '0px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
selected: {
|
||||
outline: '1px solid var(--editor-accent)',
|
||||
outlineOffset: '2px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
badge: {
|
||||
enabled: true,
|
||||
position: 'top-left',
|
||||
offset: {
|
||||
top: '-24px',
|
||||
left: '0',
|
||||
},
|
||||
background: 'var(--editor-accent)',
|
||||
color: 'white',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
zIndex: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate CSS from style configuration
|
||||
* 从样式配置生成CSS
|
||||
*/
|
||||
export function generateEditorCSS(config: EditorStyleConfig = defaultStyleConfig, enableMoveable: boolean | undefined, helperBox: boolean | undefined): string {
|
||||
const { hover, selected, badge } = config;
|
||||
const scope = '.html-visual-editor';
|
||||
|
||||
let css = `
|
||||
${scope} {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
${scope} .hover-highlight {
|
||||
outline: ${helperBox ? 'none' : hover.outline} !important;
|
||||
outline-offset: ${hover.outlineOffset};
|
||||
// position: relative;
|
||||
cursor: ${hover.cursor};
|
||||
${hover.backgroundColor ? `background-color: ${hover.backgroundColor} !important;` : ''}
|
||||
}
|
||||
|
||||
${scope} .selected-element {
|
||||
outline: ${enableMoveable || helperBox ? 'none' : selected.outline} !important;
|
||||
outline-offset: ${selected.outlineOffset};
|
||||
cursor: ${selected.cursor};
|
||||
// position: relative;
|
||||
${selected.backgroundColor ? `background-color: ${selected.backgroundColor} !important;` : ''}
|
||||
}
|
||||
|
||||
${scope} [contenteditable="true"] {
|
||||
user-select: text;
|
||||
-webkit-font-smoothing: inherit !important;
|
||||
-moz-osx-font-smoothing: inherit !important;
|
||||
text-rendering: inherit !important;
|
||||
}
|
||||
|
||||
${scope} [contenteditable="true"]:focus {
|
||||
outline: ${enableMoveable || helperBox ? 'none' : selected.outline} !important;
|
||||
outline-offset: ${selected.outlineOffset};
|
||||
cursor: text !important;
|
||||
}
|
||||
|
||||
${scope} [contenteditable="true"]:empty:not(:focus)::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.moveable-control-box>.moveable-line{
|
||||
background: var(--editor-accent) !important;
|
||||
height: 2px !important;
|
||||
}
|
||||
|
||||
.moveable-control-box>.moveable-control:not(.moveable-e):not(.moveable-w){
|
||||
border: 2px solid var(--editor-accent) !important;
|
||||
background: var(--editor-surface) !important;
|
||||
}
|
||||
|
||||
.moveable-control-box>.moveable-control.moveable-e{
|
||||
width: 10px !important;
|
||||
height: 22px !important;
|
||||
border-radius: 10px !important;
|
||||
margin-top: -11px !important;
|
||||
margin-left: -5px !important;
|
||||
border: 2px solid var(--editor-accent) !important;
|
||||
background: var(--editor-surface) !important;
|
||||
}
|
||||
.moveable-control-box>.moveable-control.moveable-w{
|
||||
width: 10px !important;
|
||||
height: 22px !important;
|
||||
border-radius: 7px !important;
|
||||
border: 2px solid var(--editor-accent) !important;
|
||||
background: var(--editor-surface) !important;
|
||||
margin-top: -11px !important;
|
||||
margin-left: -5px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// 如果启用角标,添加 ::before 伪元素样式
|
||||
if (badge.enabled) {
|
||||
const badgePosition = getBadgePositionCSS(badge.position || 'top-left', badge.offset);
|
||||
css += `
|
||||
${scope} #html-editor-helper-box::before {
|
||||
content: attr(data-element-type);
|
||||
position: absolute;
|
||||
${badgePosition}
|
||||
background: ${badge.background};
|
||||
color: ${badge.color};
|
||||
padding: ${badge.padding};
|
||||
border-radius: ${badge.borderRadius};
|
||||
font-size: ${badge.fontSize};
|
||||
font-family: ${badge.fontFamily};
|
||||
white-space: nowrap;
|
||||
z-index: ${badge.zIndex};
|
||||
}}
|
||||
`;
|
||||
}
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get badge position CSS based on position type
|
||||
* 根据位置类型获取角标位置CSS
|
||||
*/
|
||||
function getBadgePositionCSS(
|
||||
position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
|
||||
offset?: { top?: string; left?: string; right?: string; bottom?: string }
|
||||
): string {
|
||||
const defaultOffset = offset || {};
|
||||
|
||||
switch (position) {
|
||||
case 'top-left':
|
||||
return `
|
||||
top: ${defaultOffset.top || '-24px'};
|
||||
left: ${defaultOffset.left || '0'};
|
||||
`;
|
||||
case 'top-right':
|
||||
return `
|
||||
top: ${defaultOffset.top || '-24px'};
|
||||
right: ${defaultOffset.right || '0'};
|
||||
`;
|
||||
case 'bottom-left':
|
||||
return `
|
||||
bottom: ${defaultOffset.bottom || '-24px'};
|
||||
left: ${defaultOffset.left || '0'};
|
||||
`;
|
||||
case 'bottom-right':
|
||||
return `
|
||||
bottom: ${defaultOffset.bottom || '-24px'};
|
||||
right: ${defaultOffset.right || '0'};
|
||||
`;
|
||||
default:
|
||||
return `
|
||||
top: ${defaultOffset.top || '-24px'};
|
||||
left: ${defaultOffset.left || '0'};
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
defaultStyleConfig,
|
||||
generateEditorCSS,
|
||||
};
|
||||
754
components/html-editor/lib/core/editor/index.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* HTML Visual Editor Core Class
|
||||
* 核心编辑器类,提供基础的编辑功能
|
||||
*/
|
||||
import { EventManager } from '../eventManager'
|
||||
import StyleManager from '../styleManager';
|
||||
import { MoveableManager } from '../moveableManager';
|
||||
import { HistoryManager } from '../historyManager';
|
||||
import {
|
||||
createElementAddCommand,
|
||||
createElementDeleteCommand,
|
||||
createAttributeChangeCommand,
|
||||
createStyleChangeCommand,
|
||||
createContentChangeCommand
|
||||
} from '../historyManager/commands';
|
||||
import { defaultStyleConfig, generateEditorCSS } from '../../config/styles';
|
||||
import type { HTMLEditorOptions, Position, EditorStyleConfig } from '../../types';
|
||||
import { createElement, elementWatcher, getElementType, isImageElement, isInlineElement, isTableElement } from '../utils';
|
||||
import EditorRegistry from '../editorRegistry';
|
||||
import { HelperBoxManager } from '../helperBoxManager';
|
||||
import GlobalEditable from '../globalEditable';
|
||||
|
||||
|
||||
|
||||
export class HTMLEditor {
|
||||
id: string;
|
||||
options: HTMLEditorOptions;
|
||||
selectedElement: HTMLElement | null;
|
||||
|
||||
eventManager: EventManager | null;
|
||||
styleManager: StyleManager | null;
|
||||
moveableManager: MoveableManager | null;
|
||||
historyManager: HistoryManager | null;
|
||||
helperBoxManager: HelperBoxManager | null;
|
||||
container: HTMLElement | null;
|
||||
EditorRegistry: typeof EditorRegistry;
|
||||
elementWatcher!: ReturnType<typeof elementWatcher>;
|
||||
globalEditable: GlobalEditable | null;
|
||||
|
||||
// 操作状态
|
||||
isDragging: boolean = false;
|
||||
isResizing: boolean = false;
|
||||
isChangingBackground: boolean = false;
|
||||
isInsertMode: boolean = false;
|
||||
isChangingColor: boolean = false;
|
||||
isIframe: boolean;
|
||||
__globalEditHandlers: { handleInput?: (e: Event) => void; handleKeyDown?: (e: KeyboardEvent) => void } | null = null;
|
||||
suppressBodyInputRecord: boolean = false;
|
||||
|
||||
constructor(options: HTMLEditorOptions) {
|
||||
this.options = {
|
||||
container: null,
|
||||
theme: 'default',
|
||||
autoSave: false,
|
||||
styleConfig: defaultStyleConfig,
|
||||
enableContentEditable: true, // 默认启用
|
||||
enableMoveable: false, // 默认启用拖拽与缩放
|
||||
enableHistory: true, // 默认启用历史记录
|
||||
historyOptions: {
|
||||
maxHistorySize: 100,
|
||||
mergeInterval: 1000,
|
||||
},
|
||||
onElementSelect: null,
|
||||
onStyleChange: null,
|
||||
onContentChange: null,
|
||||
onReady: null,
|
||||
onHistoryChange: null,
|
||||
ignoreSelectTags: ['body', 'html'],
|
||||
enableGlobalContentEditable: false,
|
||||
...options
|
||||
};
|
||||
|
||||
// 合并用户自定义样式配置
|
||||
if (options.styleConfig) {
|
||||
this.options.styleConfig = {
|
||||
...defaultStyleConfig,
|
||||
hover: { ...defaultStyleConfig.hover, ...options.styleConfig.hover },
|
||||
selected: { ...defaultStyleConfig.selected, ...options.styleConfig.selected },
|
||||
badge: { ...defaultStyleConfig.badge, ...options.styleConfig.badge },
|
||||
};
|
||||
}
|
||||
|
||||
// 合并历史记录配置
|
||||
if (options.historyOptions) {
|
||||
this.options.historyOptions = {
|
||||
...this.options.historyOptions,
|
||||
...options.historyOptions,
|
||||
};
|
||||
}
|
||||
|
||||
this.id = this.options.id;
|
||||
this.selectedElement = null;
|
||||
|
||||
this.eventManager = null;
|
||||
this.styleManager = null;
|
||||
this.moveableManager = null;
|
||||
this.historyManager = null;
|
||||
this.helperBoxManager = null;
|
||||
this.container = null;
|
||||
this.EditorRegistry = EditorRegistry;
|
||||
this.isIframe = false;
|
||||
this.__globalEditHandlers = null as any;
|
||||
this.globalEditable = null;
|
||||
}
|
||||
|
||||
init(container?: HTMLElement | string): void {
|
||||
if (container) {
|
||||
this.options.container = container;
|
||||
}
|
||||
|
||||
this.setupContainer();
|
||||
// 注册到全局编辑器注册表
|
||||
this.EditorRegistry.register(this);
|
||||
this.initializeManagers();
|
||||
this.globalEditable = new GlobalEditable(this);
|
||||
this.setGlobalContentEditableEnabled(!!this.options.enableGlobalContentEditable);
|
||||
this.bindEvents();
|
||||
this.elementWatcher = elementWatcher(this);
|
||||
if (this.options.helperBox) {
|
||||
this.helperBoxManager?.init();
|
||||
}
|
||||
this.emit('ready');
|
||||
}
|
||||
|
||||
setupContainer(): void {
|
||||
const container = typeof this.options.container === 'string'
|
||||
? document.querySelector<HTMLElement>(this.options.container)
|
||||
: this.options.container;
|
||||
|
||||
if (!container) {
|
||||
throw new Error('Container not found');
|
||||
}
|
||||
|
||||
this.container = container;
|
||||
this.container.classList.add('html-visual-editor');
|
||||
// 检测container是否是iframe
|
||||
this.detectIframe();
|
||||
|
||||
// 注入编辑器样式
|
||||
this.injectStyles();
|
||||
}
|
||||
|
||||
// 获取当前的window/document
|
||||
getDoc() {
|
||||
const container = this.container;
|
||||
return {
|
||||
view: container ? container.ownerDocument.defaultView : window,
|
||||
document: container ? container.ownerDocument : document
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查container是否是iframe中
|
||||
*/
|
||||
detectIframe(): void {
|
||||
// 检查container是否在iframe中
|
||||
if (this.container && this.getDoc().document !== document) {
|
||||
this.isIframe = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入编辑器样式到文档中
|
||||
*/
|
||||
injectStyles(): void {
|
||||
if (!this.container) return;
|
||||
|
||||
const doc = this.getDoc().document;
|
||||
const styleId = 'html-editor-styles';
|
||||
|
||||
// 检查是否已经注入过样式
|
||||
const oldStyleElement = doc.getElementById(styleId);
|
||||
if (oldStyleElement) {
|
||||
doc.head.removeChild(doc.getElementById(styleId) as Node);
|
||||
};
|
||||
|
||||
const styleElement = doc.createElement('style');
|
||||
styleElement.id = styleId;
|
||||
styleElement.textContent = generateEditorCSS(this.options.styleConfig as EditorStyleConfig, this.options.enableMoveable, this.options.helperBox);
|
||||
doc.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
initializeManagers(): void {
|
||||
this.eventManager = new EventManager(this);
|
||||
this.styleManager = new StyleManager(this);
|
||||
this.moveableManager = new MoveableManager(this, (this.options as any).moveableOptions ?? {});
|
||||
this.helperBoxManager = new HelperBoxManager(this);
|
||||
|
||||
// 初始化历史管理器
|
||||
if (this.options.enableHistory !== false) {
|
||||
this.historyManager = new HistoryManager(this, this.options.historyOptions);
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents(): void {
|
||||
if (this.eventManager) {
|
||||
this.eventManager.bindAll();
|
||||
}
|
||||
}
|
||||
|
||||
// 元素选择
|
||||
selectElement(element: HTMLElement): void {
|
||||
if (this.isInsertMode) return;
|
||||
const lastSelectedElement = this.selectedElement;
|
||||
// 清除上一个选择
|
||||
this.clearSelection();
|
||||
|
||||
element.classList.add('selected-element');
|
||||
element.setAttribute('data-element-type', getElementType(element));
|
||||
if (isInlineElement(element)) {
|
||||
// 如果元素是内联元素,设置为 inline-block,解决moveable无法拖拽的问题
|
||||
element.style.display = 'inline-block';
|
||||
element.setAttribute('original-display', 'inline');
|
||||
}
|
||||
this.selectedElement = element;
|
||||
const isTable = isTableElement(element);
|
||||
// 启用 moveable
|
||||
if (this.options.enableMoveable && this.moveableManager) {
|
||||
const defaultMoveableOptions = (this.options as any).moveableOptions ?? {};
|
||||
const keepRatio = isImageElement(element) ? true : (defaultMoveableOptions.keepRatio ?? false);
|
||||
if (!isTable) {
|
||||
element.style.cursor = 'move';
|
||||
this.moveableManager.enableFor(element, { keepRatio });
|
||||
}
|
||||
}
|
||||
|
||||
// 如果元素不再是 contenteditable,重新启用编辑
|
||||
if (this.options.enableContentEditable && element.getAttribute('contenteditable') !== 'true') {
|
||||
if (isTable) {
|
||||
this.enableElementEditing(element);
|
||||
}
|
||||
// 如果是同一个元素,检查是否需要重新启用编辑
|
||||
if (element === lastSelectedElement) {
|
||||
this.enableElementEditing(element);
|
||||
}
|
||||
}
|
||||
|
||||
const position = this.getBoundPostion(element);
|
||||
this.emit('elementSelect', element, position);
|
||||
this.elementWatcher.start(element, () => {
|
||||
this.emit('styleChange', element);
|
||||
this.moveableManager?.update();
|
||||
});
|
||||
|
||||
// 如果启用了 helperBox,则更新其位置
|
||||
if (this.options.helperBox && this.helperBoxManager) {
|
||||
this.helperBoxManager.updatePostion(position);
|
||||
this.helperBoxManager.visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素的边界相对位置
|
||||
*/
|
||||
getBoundPostion(target: HTMLElement) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const containerRect = this.container!.getBoundingClientRect();
|
||||
const position: Position = {
|
||||
top: this.isIframe ? rect.top : rect.top - containerRect.top,
|
||||
left: this.isIframe ? rect.left : rect.left - containerRect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
bottom: this.isIframe ? rect.bottom : rect.bottom - containerRect.top,
|
||||
right: this.isIframe ? rect.right : rect.right - containerRect.left
|
||||
};
|
||||
return position
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用元素编辑
|
||||
*/
|
||||
enableElementEditing(element: HTMLElement): void {
|
||||
const existingHandlers = (element as any).__editHandlers;
|
||||
if (existingHandlers) {
|
||||
this.removeEditListeners(element);
|
||||
}
|
||||
|
||||
// 保存原始的contenteditable状态
|
||||
if (!element.hasAttribute('data-original-contenteditable')) {
|
||||
const originalValue = element.getAttribute('contenteditable') || 'inherit';
|
||||
element.setAttribute('data-original-contenteditable', originalValue);
|
||||
}
|
||||
|
||||
document.execCommand('defaultParagraphSeparator', false, 'br');
|
||||
// 设置为可编辑
|
||||
element.setAttribute('contenteditable', 'true');
|
||||
|
||||
element.focus();
|
||||
|
||||
const initialContent = element.innerHTML;
|
||||
let lastRecordedContent = initialContent;
|
||||
const handleInput = () => {
|
||||
const newContent = element.innerHTML;
|
||||
if (this.historyManager && newContent !== lastRecordedContent) {
|
||||
const cmd = createContentChangeCommand(element, lastRecordedContent, newContent);
|
||||
this.historyManager.push(cmd);
|
||||
lastRecordedContent = newContent;
|
||||
}
|
||||
this.emit('contentChange');
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// 失去焦点时禁用contenteditable
|
||||
this.disableElementEditing(element);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selection = this.isIframe ? this.getDoc().document.getSelection() : window.getSelection()
|
||||
if (!selection || !selection.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
const br = document.createElement('br');
|
||||
range.insertNode(br);
|
||||
// 光标移动到 <br> 之后
|
||||
range.setStartAfter(br);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
const newContent = element.innerHTML;
|
||||
if (this.historyManager && newContent !== lastRecordedContent) {
|
||||
const cmd = createContentChangeCommand(element, lastRecordedContent, newContent);
|
||||
this.historyManager.push(cmd);
|
||||
lastRecordedContent = newContent;
|
||||
}
|
||||
this.emit('contentChange');
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener('input', handleInput);
|
||||
element.addEventListener('blur', handleBlur);
|
||||
element.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// 保存事件处理器引用,便于后续清理
|
||||
(element as any).__editHandlers = { handleInput, handleBlur, handleKeyDown };
|
||||
}
|
||||
|
||||
private removeEditListeners(element: HTMLElement): void {
|
||||
const handlers = (element as any).__editHandlers;
|
||||
if (!handlers) return;
|
||||
const pairs: Array<[string, EventListener | undefined]> = [
|
||||
['input', handlers.handleInput],
|
||||
['blur', handlers.handleBlur],
|
||||
['keydown', handlers.handleKeyDown],
|
||||
];
|
||||
for (const [type, fn] of pairs) {
|
||||
if (fn) element.removeEventListener(type, fn as EventListener);
|
||||
}
|
||||
delete (element as any).__editHandlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用元素编辑
|
||||
*/
|
||||
disableElementEditing(element: HTMLElement): void {
|
||||
// 恢复原始的contenteditable状态
|
||||
const originalValue = element.getAttribute('data-original-contenteditable');
|
||||
if (originalValue) {
|
||||
if (originalValue === 'inherit') {
|
||||
element.removeAttribute('contenteditable');
|
||||
} else {
|
||||
element.setAttribute('contenteditable', originalValue);
|
||||
}
|
||||
element.removeAttribute('data-original-contenteditable');
|
||||
} else {
|
||||
element.removeAttribute('contenteditable');
|
||||
}
|
||||
|
||||
this.removeEditListeners(element);
|
||||
}
|
||||
|
||||
setInsertMode(value: boolean): void {
|
||||
this.isInsertMode = value;
|
||||
if (this.container) {
|
||||
if (value) {
|
||||
this.container.style.cursor = this.isIframe ? 'crosshair' : 'text';
|
||||
} else {
|
||||
this.container.style.cursor = '';
|
||||
}
|
||||
if (value) {
|
||||
const doc = this.getDoc().document;
|
||||
doc.querySelectorAll('.hover-highlight').forEach((el: Element) => {
|
||||
(el as HTMLElement).classList.remove('hover-highlight');
|
||||
(el as HTMLElement).removeAttribute('data-element-type');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableInsertMode(): void {
|
||||
this.EditorRegistry.enableInsertMode(this);
|
||||
}
|
||||
|
||||
disableInsertMode(): void {
|
||||
this.EditorRegistry.disableAllInsertMode();
|
||||
}
|
||||
|
||||
insertTextAtPosition(clientX: number, clientY: number): HTMLElement | null {
|
||||
if (!this.container) return null;
|
||||
const rect = this.container.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.textContent = '请输入文字';
|
||||
element.style.position = 'absolute';
|
||||
element.style.left = `${Math.max(0, Math.round(x))}px`;
|
||||
element.style.top = `${Math.max(0, Math.round(y))}px`;
|
||||
element.style.fontSize = '32px';
|
||||
element.style.lineHeight = '1.2';
|
||||
element.style.backgroundColor = 'transparent';
|
||||
element.style.border = 'none';
|
||||
element.style.padding = '0';
|
||||
element.style.margin = '0';
|
||||
|
||||
if (this.historyManager) {
|
||||
const command = createElementAddCommand(element, (this.selectedElement ?? this.container!) as HTMLElement, this.container!, null);
|
||||
command.execute();
|
||||
this.historyManager.push(command);
|
||||
} else {
|
||||
this.container.appendChild(element);
|
||||
}
|
||||
|
||||
this.disableInsertMode();
|
||||
this.selectElement(element);
|
||||
// this.enableElementEditing(element);
|
||||
this.emit('contentChange');
|
||||
return element;
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
if (this.selectedElement) {
|
||||
this.elementWatcher.stop(this.selectedElement);
|
||||
this.selectedElement.classList.remove('selected-element');
|
||||
this.selectedElement.removeAttribute('data-element-type');
|
||||
if (this.selectedElement.getAttribute('original-display') === 'inline') {
|
||||
// 如果元素是内联元素,设置为 inline-block,解决moveable无法拖拽的问题
|
||||
this.selectedElement.style.display = 'inline';
|
||||
this.selectedElement.removeAttribute('original-display');
|
||||
}
|
||||
// 禁用编辑功能
|
||||
if (this.options.enableContentEditable) {
|
||||
this.disableElementEditing(this.selectedElement);
|
||||
}
|
||||
// 销毁 moveable
|
||||
if (this.moveableManager) {
|
||||
this.moveableManager.destroy();
|
||||
this.selectedElement.style.cursor = '';
|
||||
}
|
||||
this.selectedElement = null;
|
||||
this.emit('elementSelect', null);
|
||||
}
|
||||
// 清除主文档中的hover样式
|
||||
document.querySelectorAll('.hover-highlight').forEach(el => {
|
||||
el.classList.remove('hover-highlight');
|
||||
el.removeAttribute('data-element-type');
|
||||
});
|
||||
// 如果在iframe中,也清除iframe文档中的样式
|
||||
if (this.container) {
|
||||
const ownerDoc = this.getDoc().document
|
||||
if (ownerDoc !== document) {
|
||||
ownerDoc.querySelectorAll('.hover-highlight').forEach(el => {
|
||||
el.classList.remove('hover-highlight');
|
||||
el.removeAttribute('data-element-type');
|
||||
});
|
||||
ownerDoc.querySelectorAll('.selected-element').forEach(el => {
|
||||
el.classList.remove('selected-element');
|
||||
el.removeAttribute('data-element-type');
|
||||
});
|
||||
}
|
||||
}
|
||||
// 如果启用了 helperBox,则隐藏
|
||||
if (this.options.helperBox && this.helperBoxManager) {
|
||||
this.helperBoxManager.visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启/关闭全局 contenteditable 模式
|
||||
*/
|
||||
setGlobalContentEditableEnabled(enabled: boolean): void {
|
||||
this.globalEditable?.setEnabled(enabled);
|
||||
}
|
||||
|
||||
// 样式应用
|
||||
applyTextStyle(property: string, value: string): boolean {
|
||||
if (!this.styleManager) return false;
|
||||
return this.styleManager.applyTextStyle(property, value);
|
||||
}
|
||||
|
||||
applyBlockStyle(property: string, value: string): boolean {
|
||||
if (!this.styleManager) return false;
|
||||
return this.styleManager.applyBlockStyle(property, value);
|
||||
}
|
||||
|
||||
// 元素操作
|
||||
addElement(selectedElement: HTMLElement, type: string, content: string = ''): HTMLElement {
|
||||
const element = createElement(type, content);
|
||||
if (this.container) {
|
||||
// 记录添加操作
|
||||
if (this.historyManager) {
|
||||
const command = createElementAddCommand(element, selectedElement, this.container, null);
|
||||
command.execute();
|
||||
this.historyManager.push(command);
|
||||
} else {
|
||||
this.container.appendChild(element);
|
||||
}
|
||||
}
|
||||
this.selectElement(element);
|
||||
this.emit('contentChange');
|
||||
return element;
|
||||
}
|
||||
|
||||
deleteElement(element: HTMLElement | null = this.selectedElement): boolean {
|
||||
if (!element || element === this.container) return false;
|
||||
|
||||
const parent = element.parentElement;
|
||||
const nextSibling = element.nextSibling as HTMLElement | null;
|
||||
|
||||
if (!parent) return false;
|
||||
|
||||
// 记录删除操作
|
||||
if (this.historyManager) {
|
||||
const command = createElementDeleteCommand(element, parent, nextSibling);
|
||||
command.execute();
|
||||
this.historyManager.push(command);
|
||||
} else {
|
||||
element.remove();
|
||||
}
|
||||
|
||||
this.clearSelection();
|
||||
this.emit('contentChange');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 复制元素并插入到当前元素的同级下方
|
||||
copyElement(element: HTMLElement | null = this.selectedElement): HTMLElement | null {
|
||||
if (!element || element === this.container) return null;
|
||||
|
||||
const parent = element.parentElement;
|
||||
const nextSibling = element.nextSibling as HTMLElement | null;
|
||||
if (!parent) return null;
|
||||
|
||||
// 深拷贝节点,包括子元素与样式
|
||||
const cloned = element.cloneNode(true) as HTMLElement;
|
||||
|
||||
// 清理编辑器相关状态类与属性
|
||||
cloned.classList.remove('selected-element', 'hover-highlight');
|
||||
cloned.removeAttribute('data-element-type');
|
||||
|
||||
if (this.historyManager) {
|
||||
const command = createElementAddCommand(cloned, element, parent, nextSibling);
|
||||
command.execute();
|
||||
this.historyManager.push(command);
|
||||
} else {
|
||||
parent.insertBefore(cloned, nextSibling);
|
||||
}
|
||||
|
||||
// 选中新复制的元素
|
||||
this.selectElement(cloned);
|
||||
this.emit('contentChange');
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换图片/背景图为远程 URL
|
||||
*/
|
||||
replaceImage(element: HTMLElement | null = this.selectedElement, url: string): boolean {
|
||||
if (!element || !url) return false;
|
||||
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (tag === 'img') {
|
||||
const oldSrc = element.getAttribute('src');
|
||||
const newSrc = url;
|
||||
if (this.historyManager) {
|
||||
const cmd = createAttributeChangeCommand(element, 'src', oldSrc, newSrc);
|
||||
cmd.execute();
|
||||
this.historyManager.push(cmd);
|
||||
} else {
|
||||
element.setAttribute('src', newSrc);
|
||||
}
|
||||
} else {
|
||||
const oldBg = element.style.backgroundImage || '';
|
||||
const newBg = `url(${url})`;
|
||||
if (this.historyManager) {
|
||||
const cmd = createStyleChangeCommand(element, 'background-image', oldBg, newBg);
|
||||
cmd.execute();
|
||||
this.historyManager.push(cmd);
|
||||
} else {
|
||||
element.style.backgroundImage = newBg;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('contentChange');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 事件系统
|
||||
emit(eventName: string, ...args: any[]): void {
|
||||
const callbackName = `on${eventName.charAt(0).toUpperCase() + eventName.slice(1)}` as keyof HTMLEditorOptions;
|
||||
const callback = this.options[callbackName];
|
||||
if (typeof callback === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
(callback as Function).apply(this, args);
|
||||
}
|
||||
|
||||
// 触发自定义事件
|
||||
const event = new CustomEvent(`htmleditor:${eventName}`, {
|
||||
detail: { editor: this, args }
|
||||
});
|
||||
if (this.container) {
|
||||
this.container.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// 公共API
|
||||
getContent(): string {
|
||||
return this.container ? this.container.innerHTML : '';
|
||||
}
|
||||
|
||||
setContent(html: string): void {
|
||||
if (this.container) {
|
||||
this.container.innerHTML = html;
|
||||
this.emit('contentChange');
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedElement(): HTMLElement | null {
|
||||
return this.selectedElement;
|
||||
}
|
||||
|
||||
// 操作状态管理
|
||||
setDragging(value: boolean): void {
|
||||
this.isDragging = value;
|
||||
}
|
||||
|
||||
setResizing(value: boolean): void {
|
||||
this.isResizing = value;
|
||||
}
|
||||
|
||||
setChangingBackground(value: boolean): void {
|
||||
this.isChangingBackground = value;
|
||||
}
|
||||
|
||||
setChangingColor(value: boolean): void {
|
||||
this.isChangingColor = value;
|
||||
}
|
||||
/**
|
||||
* 检查是否有任何操作正在进行
|
||||
*/
|
||||
isOperating(): boolean {
|
||||
return (
|
||||
this.isDragging ||
|
||||
this.isResizing ||
|
||||
this.isChangingBackground ||
|
||||
this.isChangingColor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有操作状态
|
||||
*/
|
||||
resetOperationStates(): void {
|
||||
this.isDragging = false;
|
||||
this.isResizing = false;
|
||||
this.isChangingBackground = false;
|
||||
this.isChangingColor = false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 历史记录相关 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 撤销上一个操作
|
||||
*/
|
||||
undo(): boolean {
|
||||
return this.historyManager?.undo() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做已撤销的操作
|
||||
*/
|
||||
redo(): boolean {
|
||||
return this.historyManager?.redo() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以撤销
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
return this.historyManager?.canUndo() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以重做
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
return this.historyManager?.canRedo() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始批量操作
|
||||
*/
|
||||
beginBatch(): void {
|
||||
this.historyManager?.beginBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束批量操作
|
||||
*/
|
||||
endBatch(): void {
|
||||
this.historyManager?.endBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消批量操作
|
||||
*/
|
||||
cancelBatch(): void {
|
||||
this.historyManager?.cancelBatch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空历史记录
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.historyManager?.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史状态
|
||||
*/
|
||||
getHistoryState() {
|
||||
return this.historyManager?.getState();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.eventManager) {
|
||||
this.eventManager.unbindAll();
|
||||
}
|
||||
if (this.moveableManager) {
|
||||
this.moveableManager.destroy();
|
||||
}
|
||||
if (this.historyManager) {
|
||||
this.historyManager.destroy();
|
||||
}
|
||||
if (this.container) {
|
||||
this.container.classList.remove('html-visual-editor');
|
||||
}
|
||||
this.container = null;
|
||||
// 从注册表中移除
|
||||
this.EditorRegistry.unregister(this);
|
||||
}
|
||||
}
|
||||
122
components/html-editor/lib/core/editorRegistry/index.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { HTMLEditor } from '../editor';
|
||||
|
||||
const editors = new Set<HTMLEditor>();
|
||||
let lastActiveEditor: HTMLEditor | null = null;
|
||||
|
||||
export const EditorRegistry = {
|
||||
register(editor: HTMLEditor) {
|
||||
editors.add(editor);
|
||||
if (!lastActiveEditor) {
|
||||
lastActiveEditor = editor;
|
||||
}
|
||||
},
|
||||
unregister(editor: HTMLEditor) {
|
||||
editors.delete(editor);
|
||||
if (lastActiveEditor === editor) {
|
||||
lastActiveEditor = null;
|
||||
}
|
||||
},
|
||||
clearOthers(current: HTMLEditor) {
|
||||
editors.forEach(ed => {
|
||||
if (ed !== current) {
|
||||
ed.clearSelection();
|
||||
if (ed.isInsertMode) {
|
||||
ed.disableInsertMode();
|
||||
}
|
||||
}
|
||||
});
|
||||
lastActiveEditor = current;
|
||||
},
|
||||
getAll() {
|
||||
return Array.from(editors);
|
||||
},
|
||||
|
||||
hasActiveEditor() {
|
||||
return Array.from(editors).some(ed => ed.selectedElement);
|
||||
},
|
||||
getActiveEditor(): HTMLEditor | null {
|
||||
const activeBySelection = Array.from(editors).find(ed => ed.selectedElement);
|
||||
if (activeBySelection) return activeBySelection;
|
||||
return lastActiveEditor;
|
||||
},
|
||||
getGlobalUndoTarget(): HTMLEditor | null {
|
||||
let target: HTMLEditor | null = null;
|
||||
let maxTs = -1;
|
||||
editors.forEach(ed => {
|
||||
const hm = ed.historyManager;
|
||||
const ts = hm?.getTopUndoTimestamp?.();
|
||||
if (typeof ts === 'number' && hm?.canUndo?.()) {
|
||||
if (ts > maxTs) {
|
||||
maxTs = ts;
|
||||
target = ed;
|
||||
}
|
||||
}
|
||||
});
|
||||
return target;
|
||||
},
|
||||
getGlobalRedoTarget(): HTMLEditor | null {
|
||||
let target: HTMLEditor | null = null;
|
||||
let maxTs = -1;
|
||||
editors.forEach(ed => {
|
||||
const hm = ed.historyManager;
|
||||
const ts = hm?.getTopRedoTimestamp?.();
|
||||
if (typeof ts === 'number' && hm?.canRedo?.()) {
|
||||
if (ts > maxTs) {
|
||||
maxTs = ts;
|
||||
target = ed;
|
||||
}
|
||||
}
|
||||
});
|
||||
return target;
|
||||
},
|
||||
undo(): boolean {
|
||||
const target = this.getGlobalUndoTarget();
|
||||
return target ? target.undo() : false;
|
||||
},
|
||||
redo(): boolean {
|
||||
const target = this.getGlobalRedoTarget();
|
||||
return target ? target.redo() : false;
|
||||
},
|
||||
canUndo(): boolean {
|
||||
return Array.from(editors).some(ed => ed.historyManager?.canUndo());
|
||||
},
|
||||
canRedo(): boolean {
|
||||
return Array.from(editors).some(ed => ed.historyManager?.canRedo());
|
||||
},
|
||||
|
||||
enableInsertMode(target: HTMLEditor): void {
|
||||
editors.forEach(ed => {
|
||||
if (ed === target) {
|
||||
ed.setInsertMode(true);
|
||||
} else if (ed.isInsertMode) {
|
||||
ed.setInsertMode(false);
|
||||
}
|
||||
});
|
||||
lastActiveEditor = target;
|
||||
},
|
||||
|
||||
disableAllInsertMode(): void {
|
||||
editors.forEach(ed => {
|
||||
if (ed.isInsertMode) ed.setInsertMode(false);
|
||||
});
|
||||
},
|
||||
|
||||
isAnyInInsertMode(): boolean {
|
||||
return Array.from(editors).some(ed => ed.isInsertMode);
|
||||
},
|
||||
|
||||
getInsertModeEditors(): HTMLEditor[] {
|
||||
return Array.from(editors).filter(ed => ed.isInsertMode);
|
||||
}
|
||||
,
|
||||
setGlobalContentEditable(enabled: boolean): void {
|
||||
editors.forEach(ed => {
|
||||
ed.setGlobalContentEditableEnabled(enabled);
|
||||
if (enabled) {
|
||||
ed.clearSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default EditorRegistry;
|
||||
202
components/html-editor/lib/core/eventManager/index.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Event Manager
|
||||
* 事件管理器,处理所有DOM事件
|
||||
*/
|
||||
|
||||
import { type HTMLEditor } from '../editor';
|
||||
import { getElementType } from '../utils';
|
||||
|
||||
type EventHandler = (e: Event) => void;
|
||||
|
||||
export class EventManager {
|
||||
private editor: HTMLEditor;
|
||||
private boundHandlers: Map<string, EventHandler>;
|
||||
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
this.boundHandlers = new Map<string, EventHandler>();
|
||||
}
|
||||
|
||||
bindAll(): void {
|
||||
this.bindHoverEvents();
|
||||
this.bindClickEvents();
|
||||
this.bindDocumentEvents();
|
||||
}
|
||||
|
||||
|
||||
|
||||
bindHoverEvents(): void {
|
||||
const highlightTracker = this.editor.helperBoxManager!.createHighlightTracker();
|
||||
const handleMouseOver = (e: Event) => {
|
||||
if (this.editor.globalEditable?.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (this.editor.isInsertMode) {
|
||||
return;
|
||||
}
|
||||
// 如果正在进行拖动、缩放等操作,不处理 hover
|
||||
if (this.editor.isOperating()) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
const target = e.target as HTMLElement;
|
||||
const targetTagName = target.tagName.toLowerCase();
|
||||
// 如果包含在忽略的标签中,不处理
|
||||
if ((this.editor.options.ignoreSelectTags || []).includes(targetTagName)) return;
|
||||
|
||||
if (target.classList.contains('selected-element') || target.classList.contains('moveable-line')) return;
|
||||
|
||||
// 先清除容器内所有非选中元素的hover样式
|
||||
if (this.editor.container) {
|
||||
const doc = this.editor.getDoc().document;
|
||||
doc.querySelectorAll('.hover-highlight').forEach((el: Element) => {
|
||||
if (!el.classList.contains('selected-element')) {
|
||||
el.classList.remove('hover-highlight');
|
||||
el.removeAttribute('data-element-type');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
target.classList.add('hover-highlight');
|
||||
target.setAttribute('data-element-type', getElementType(target));
|
||||
|
||||
if (this.editor.options.helperBox && this.editor.helperBoxManager && this.editor.container) {
|
||||
highlightTracker.start(target);
|
||||
// const position = this.editor.getBoundPostion(target);
|
||||
// this.editor.helperBoxManager.updatePostion(position);
|
||||
this.editor.helperBoxManager.visible(!(this.editor.selectedElement === target));
|
||||
}
|
||||
|
||||
this.editor.emit('hover', target);
|
||||
};
|
||||
|
||||
const handleMouseOut = (e: Event) => {
|
||||
if (this.editor.globalEditable?.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
if (this.editor.isInsertMode) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.classList.contains('selected-element')) {
|
||||
target.classList.remove('hover-highlight');
|
||||
target.removeAttribute('data-element-type');
|
||||
}
|
||||
|
||||
// 如果启用了 helperBox,则隐藏
|
||||
if (this.editor.options.helperBox && this.editor.helperBoxManager) {
|
||||
highlightTracker.stop(target);
|
||||
this.editor.helperBoxManager.visible(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (this.editor.container) {
|
||||
this.editor.container.addEventListener('mouseover', handleMouseOver);
|
||||
this.editor.container.addEventListener('mouseout', handleMouseOut);
|
||||
|
||||
this.boundHandlers.set('mouseover', handleMouseOver);
|
||||
this.boundHandlers.set('mouseout', handleMouseOut);
|
||||
}
|
||||
}
|
||||
|
||||
bindClickEvents(): void {
|
||||
const handleClick = (e: Event) => {
|
||||
if (this.editor.globalEditable?.isEnabled()) {
|
||||
// 在全局模式下不进行元素选择,让浏览器原生选择/caret工作
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (this.editor.isInsertMode) {
|
||||
e.stopPropagation();
|
||||
const me = e as MouseEvent;
|
||||
this.editor.insertTextAtPosition(me.clientX, me.clientY);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTagName = target.tagName.toLowerCase();
|
||||
// 如果包含在忽略的标签中,不处理
|
||||
if ((this.editor.options.ignoreSelectTags || []).includes(targetTagName)) return;
|
||||
|
||||
// 如果正在进行拖动或缩放操作,不处理点击
|
||||
if (this.editor.isDragging || this.editor.isResizing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 当点击当前容器时,清空其他编辑器的选中样式
|
||||
this.editor.EditorRegistry.clearOthers(this.editor);
|
||||
|
||||
// 如果点击的元素已经被选中且可编辑,不要stopPropagation,让contenteditable正常工作
|
||||
if (target === this.editor.selectedElement && target.getAttribute('contenteditable') === 'true') {
|
||||
// 不阻止事件,让用户可以在元素内部点击定位光标
|
||||
return;
|
||||
}
|
||||
|
||||
// 选中元素
|
||||
e.stopPropagation();
|
||||
this.editor.selectElement(target);
|
||||
};
|
||||
|
||||
if (this.editor.container) {
|
||||
this.editor.container.addEventListener('click', handleClick);
|
||||
this.boundHandlers.set('click', handleClick);
|
||||
}
|
||||
}
|
||||
|
||||
bindDocumentEvents(): void {
|
||||
const handleDocumentMouseDown = (e: Event) => {
|
||||
// 使用 composedPath 处理可能的 DOM 节点在事件处理过程中被移除或在 portal 中的情况
|
||||
const path = e.composedPath?.() || [];
|
||||
const target = (path[0] || e.target) as HTMLElement;
|
||||
|
||||
if (!target || !target.closest) return;
|
||||
|
||||
// 如果正在进行操作,不清除选择
|
||||
if (this.editor.isOperating()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isInEditorUI = path.some(node => {
|
||||
if (!(node instanceof HTMLElement)) return false;
|
||||
return (
|
||||
node.classList.contains('html-editor-toolbar') ||
|
||||
node.classList.contains('html-editor-heading-dropdown') ||
|
||||
node.classList.contains('html-editor-popover') ||
|
||||
node.classList.contains('ant-color-picker-inner') ||
|
||||
node.hasAttribute('data-radix-popper-content-wrapper') ||
|
||||
node.hasAttribute('data-radix-portal') ||
|
||||
node.hasAttribute('data-html-editor-ui')
|
||||
);
|
||||
});
|
||||
|
||||
if (this.editor.container &&
|
||||
!this.editor.container.contains(target) &&
|
||||
!isInEditorUI
|
||||
) {
|
||||
this.editor.clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 mousedown 且开启 capture: true 确保在任何组件阻止冒泡前进行检查
|
||||
document.addEventListener('mousedown', handleDocumentMouseDown, true);
|
||||
this.boundHandlers.set('documentMouseDown', handleDocumentMouseDown);
|
||||
}
|
||||
|
||||
unbindAll(): void {
|
||||
this.boundHandlers.forEach((handler, event) => {
|
||||
if (event === 'documentMouseDown') {
|
||||
document.removeEventListener('mousedown', handler, true);
|
||||
} else if (event === 'documentClick') {
|
||||
document.removeEventListener('click', handler);
|
||||
} else if (this.editor.container) {
|
||||
this.editor.container.removeEventListener(event, handler);
|
||||
}
|
||||
});
|
||||
this.boundHandlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default EventManager;
|
||||
243
components/html-editor/lib/core/globalEditable/index.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { HTMLEditor } from '../editor';
|
||||
import { createContentChangeCommand } from '../historyManager/commands';
|
||||
import { Editor } from './markEngine'
|
||||
import type { MarkSpec } from './markEngine/type';
|
||||
|
||||
export class GlobalEditable {
|
||||
private editor: HTMLEditor;
|
||||
private enabled: boolean = false;
|
||||
private lastRecorded: string = '';
|
||||
private handlers: { input?: (e: Event) => void; keydown?: (e: KeyboardEvent) => void; selectionchange?: () => void } = {};
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
if (enabled === this.enabled) return;
|
||||
if (enabled) this.enable(); else this.disable();
|
||||
}
|
||||
|
||||
private getTarget(): HTMLElement {
|
||||
const doc = this.editor.getDoc();
|
||||
return doc.document.body as HTMLElement;
|
||||
}
|
||||
|
||||
private attachBodyEditable(): void {
|
||||
const doc = this.editor.getDoc();
|
||||
const body = doc.document.body;
|
||||
if (!body) return;
|
||||
if (!body.hasAttribute('data-original-contenteditable')) {
|
||||
const original = body.getAttribute('contenteditable') || 'inherit';
|
||||
body.setAttribute('data-original-contenteditable', original);
|
||||
}
|
||||
doc.document.execCommand('defaultParagraphSeparator', false, 'br');
|
||||
body.setAttribute('contenteditable', 'true');
|
||||
body.focus();
|
||||
}
|
||||
|
||||
private detachBodyEditable(): void {
|
||||
const doc = this.editor.getDoc();
|
||||
const body = doc.document.body;
|
||||
const original = body.getAttribute('data-original-contenteditable');
|
||||
if (original) {
|
||||
if (original === 'inherit') body.removeAttribute('contenteditable');
|
||||
else body.setAttribute('contenteditable', original);
|
||||
body.removeAttribute('data-original-contenteditable');
|
||||
} else {
|
||||
body.removeAttribute('contenteditable');
|
||||
}
|
||||
}
|
||||
|
||||
private bindListeners(): void {
|
||||
const doc = this.editor.getDoc();
|
||||
const target = this.getTarget();
|
||||
this.lastRecorded = target.innerHTML;
|
||||
const onInput = () => {
|
||||
const after = target.innerHTML;
|
||||
if (this.editor.suppressBodyInputRecord) {
|
||||
this.lastRecorded = after;
|
||||
return;
|
||||
}
|
||||
if (this.editor.historyManager && after !== this.lastRecorded) {
|
||||
const cmd = createContentChangeCommand(target, this.lastRecorded, after);
|
||||
this.editor.historyManager.push(cmd);
|
||||
this.lastRecorded = after;
|
||||
}
|
||||
this.editor.emit('contentChange');
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const selection = doc.document.getSelection();
|
||||
if (!selection || !selection.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
const br = doc.document.createElement('br');
|
||||
range.insertNode(br);
|
||||
range.setStartAfter(br);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
onInput();
|
||||
}
|
||||
};
|
||||
|
||||
doc.document.body.addEventListener('input', onInput);
|
||||
doc.document.body.addEventListener('keydown', onKeydown);
|
||||
this.handlers = { input: onInput, keydown: onKeydown };
|
||||
}
|
||||
|
||||
private unbindListeners(): void {
|
||||
const doc = this.editor.getDoc();
|
||||
if (this.handlers.input) doc.document.body.removeEventListener('input', this.handlers.input);
|
||||
if (this.handlers.keydown) doc.document.body.removeEventListener('keydown', this.handlers.keydown);
|
||||
if (this.handlers.selectionchange) doc.document.removeEventListener('selectionchange', this.handlers.selectionchange);
|
||||
this.handlers = {};
|
||||
}
|
||||
|
||||
private enable(): void {
|
||||
this.editor.clearSelection();
|
||||
this.editor.eventManager?.unbindAll();
|
||||
this.editor.moveableManager?.destroy();
|
||||
if (this.editor.options.helperBox && this.editor.helperBoxManager) {
|
||||
this.editor.helperBoxManager.visible(false);
|
||||
}
|
||||
this.attachBodyEditable();
|
||||
this.bindListeners();
|
||||
this.enabled = true;
|
||||
}
|
||||
|
||||
private disable(): void {
|
||||
this.detachBodyEditable();
|
||||
this.unbindListeners();
|
||||
this.enabled = false;
|
||||
this.editor.eventManager?.bindAll();
|
||||
}
|
||||
|
||||
private withContentHistory(fn: () => void): boolean {
|
||||
const target = this.getTarget();
|
||||
const before = target.innerHTML;
|
||||
this.editor.suppressBodyInputRecord = true;
|
||||
fn();
|
||||
const after = target.innerHTML;
|
||||
this.editor.suppressBodyInputRecord = false;
|
||||
if (this.editor.historyManager && before !== after) {
|
||||
const cmd = createContentChangeCommand(target, before, after);
|
||||
this.editor.historyManager.push(cmd);
|
||||
}
|
||||
this.lastRecorded = after;
|
||||
this.editor.emit('contentChange');
|
||||
return true;
|
||||
}
|
||||
|
||||
private toggleMark(spec: MarkSpec): boolean {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
const action = () => engine.toggle(spec);
|
||||
return this.withContentHistory(action);
|
||||
}
|
||||
|
||||
applySelectionBold(): boolean {
|
||||
return this.toggleMark({ type: 'bold' });
|
||||
}
|
||||
applySelectionItalic(): boolean {
|
||||
return this.toggleMark({ type: 'italic' });
|
||||
}
|
||||
applySelectionUnderline(): boolean {
|
||||
return this.toggleMark({ type: 'underline' });
|
||||
}
|
||||
applySelectionStrikeThrough(): boolean {
|
||||
return this.toggleMark({ type: 'strike' });
|
||||
}
|
||||
applySelectionFontSize(px: string): boolean {
|
||||
return this.toggleMark({ type: 'fontSize', value: px });
|
||||
}
|
||||
applySelectionFontFamily(name: string): boolean {
|
||||
return this.toggleMark({ type: 'fontFamily', value: name });
|
||||
}
|
||||
applySelectionColor(color: string): boolean {
|
||||
return this.toggleMark({ type: 'color', value: color });
|
||||
}
|
||||
applySelectionBackground(color: string): boolean {
|
||||
return this.toggleMark({ type: 'background', value: color });
|
||||
}
|
||||
applySelectionHighlight(color?: string): boolean {
|
||||
return this.toggleMark({ type: 'highlight', value: color });
|
||||
}
|
||||
applySelectionCode(): boolean {
|
||||
return this.toggleMark({ type: 'code' });
|
||||
}
|
||||
applySelectionLink(href: string): boolean {
|
||||
return this.toggleMark({ type: 'link', attrs: { href } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文本对齐方式
|
||||
* @param alignment - 'left' | 'center' | 'right'
|
||||
*/
|
||||
applySelectionAlign(alignment: 'left' | 'center' | 'right'): boolean {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
const action = () => engine.align(alignment);
|
||||
return this.withContentHistory(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* 左对齐
|
||||
*/
|
||||
applySelectionAlignLeft(): boolean {
|
||||
return this.applySelectionAlign('left');
|
||||
}
|
||||
|
||||
/**
|
||||
* 居中对齐
|
||||
*/
|
||||
applySelectionAlignCenter(): boolean {
|
||||
return this.applySelectionAlign('center');
|
||||
}
|
||||
|
||||
/**
|
||||
* 右对齐
|
||||
*/
|
||||
applySelectionAlignRight(): boolean {
|
||||
return this.applySelectionAlign('right');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前段落的对齐方式
|
||||
* @returns 'left' | 'center' | 'right' | null
|
||||
*/
|
||||
queryAlign(): 'left' | 'center' | 'right' | null {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
return engine.queryAlign();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置标题级别
|
||||
* @param level - 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' (普通段落)
|
||||
*/
|
||||
setHeading(level: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p'): boolean {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
const action = () => engine.setHeading(level);
|
||||
return this.withContentHistory(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前标题级别
|
||||
* @returns 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | null
|
||||
*/
|
||||
queryHeading(): 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | null {
|
||||
const ctx = this.editor.getDoc();
|
||||
const engine = new Editor(ctx as any, { placeholder: '' });
|
||||
return engine.queryHeading();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default GlobalEditable;
|
||||
1378
components/html-editor/lib/core/globalEditable/markEngine/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type MarkType =
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'underline'
|
||||
| 'strike'
|
||||
| 'color'
|
||||
| 'background'
|
||||
| 'fontSize'
|
||||
| 'fontFamily'
|
||||
| 'highlight'
|
||||
| 'code'
|
||||
| 'link'
|
||||
|
||||
export type MarkSpec = {
|
||||
type: MarkType
|
||||
value?: string
|
||||
attrs?: Record<string, string> // 用于 link 等需要额外属性的标记
|
||||
}
|
||||
|
||||
export type DocCtx = {
|
||||
view: Window
|
||||
document: Document
|
||||
}
|
||||
|
||||
/** 将元素按 range 拆分为三段 */
|
||||
export interface SplitResult {
|
||||
pre: DocumentFragment
|
||||
mid: DocumentFragment
|
||||
post: DocumentFragment
|
||||
}
|
||||
82
components/html-editor/lib/core/helperBoxManager/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { type Position } from '../../types';
|
||||
import { HTMLEditor } from '../editor';
|
||||
import { elementWatcher } from '../utils';
|
||||
|
||||
export class HelperBoxManager {
|
||||
editor: HTMLEditor;
|
||||
element: HTMLElement | null;
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
this.element = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.editor.container) return;
|
||||
const doc = this.editor.getDoc().document;
|
||||
const helperBox = doc.getElementById('html-editor-helper-box') || doc.createElement('div');
|
||||
this.element = helperBox;
|
||||
this.element.id = 'html-editor-helper-box';
|
||||
this.element.style.position = 'absolute';
|
||||
this.element.style.zIndex = '9999';
|
||||
this.element.style.display = 'none';
|
||||
this.element.style.pointerEvents = 'none';
|
||||
this.element.style.border = '1px dashed var(--editor-accent)';
|
||||
this.element.style.backgroundColor = 'color-mix(in srgb, var(--editor-accent) 8%, transparent)';
|
||||
|
||||
if (this.editor.isIframe) {
|
||||
doc.body.appendChild(this.element);
|
||||
} else {
|
||||
this.editor.container.style.position = 'relative';
|
||||
this.editor.container.appendChild(this.element);
|
||||
}
|
||||
}
|
||||
|
||||
updatePostion(position: Position) {
|
||||
if (!this.element) return;
|
||||
const { document, view } = this.editor.getDoc();
|
||||
if (!view || !document) return;
|
||||
const scrollTop = view.scrollY || this.editor.container?.scrollTop || 0;
|
||||
const scrollLeft = view.scrollX || this.editor.container?.scrollLeft || 0;
|
||||
|
||||
const doc = this.editor.container?.ownerDocument || document;
|
||||
let offsetTop = position.top;
|
||||
let offsetLeft = position.left;
|
||||
if (this.editor.isIframe && document?.body) {
|
||||
const cs = view?.getComputedStyle(doc.body);
|
||||
const mt = cs ? parseFloat(cs.marginTop || '0') : 0;
|
||||
const ml = cs ? parseFloat(cs.marginLeft || '0') : 0;
|
||||
offsetTop -= mt;
|
||||
offsetLeft -= ml;
|
||||
}
|
||||
this.element.style.width = `${position.width}px`;
|
||||
this.element.style.height = `${position.height}px`;
|
||||
this.element.style.top = `${offsetTop + scrollTop}px`;
|
||||
this.element.style.left = `${offsetLeft + scrollLeft}px`;
|
||||
}
|
||||
|
||||
// 创建高亮框,根据渲染帧刷新位置(解决dom有动画的case)
|
||||
createHighlightTracker() {
|
||||
const watcher = elementWatcher(this.editor);
|
||||
return {
|
||||
start: (element: HTMLElement) => {
|
||||
watcher.start(element, (postion) => {
|
||||
this.updatePostion(postion);
|
||||
this.element!.setAttribute('data-element-type', element.getAttribute('data-element-type') || '')
|
||||
});
|
||||
},
|
||||
stop: watcher.stop,
|
||||
};
|
||||
}
|
||||
|
||||
visible(visible: boolean) {
|
||||
if (!this.element) return;
|
||||
this.element.style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
|
||||
delete() {
|
||||
if (!this.element) return;
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
}
|
||||
}
|
||||
293
components/html-editor/lib/core/historyManager/commands.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Command Implementations
|
||||
* 操作命令的具体实现
|
||||
*/
|
||||
|
||||
import {
|
||||
type Command,
|
||||
OperationType,
|
||||
type StyleChangeCommand,
|
||||
type ContentChangeCommand,
|
||||
type ElementAddCommand,
|
||||
type ElementDeleteCommand,
|
||||
type ElementTagChangeCommand,
|
||||
type BatchCommand,
|
||||
type AttributeChangeCommand
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 创建元素标签变更命令
|
||||
*/
|
||||
export function createElementTagChangeCommand(
|
||||
element: HTMLElement,
|
||||
newTag: string
|
||||
): ElementTagChangeCommand {
|
||||
const oldTag = element.tagName;
|
||||
const newElement = document.createElement(newTag);
|
||||
|
||||
// Copy attributes
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
newElement.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
|
||||
const headingLevels: { [key: string]: string } = {
|
||||
H1: '28px',
|
||||
H2: '26px',
|
||||
H3: '24px',
|
||||
H4: '22px',
|
||||
H5: '20px',
|
||||
H6: '18px',
|
||||
};
|
||||
const upperCaseNewTag = newTag.toUpperCase();
|
||||
if (headingLevels[upperCaseNewTag]) {
|
||||
newElement.style.fontSize = headingLevels[upperCaseNewTag];
|
||||
} else {
|
||||
newElement.style.fontSize = '18px';
|
||||
}
|
||||
|
||||
// Copy content
|
||||
newElement.innerHTML = element.innerHTML;
|
||||
|
||||
return {
|
||||
type: OperationType.ELEMENT_TAG_CHANGE,
|
||||
timestamp: Date.now(),
|
||||
element,
|
||||
oldTag,
|
||||
newTag,
|
||||
newElement,
|
||||
|
||||
execute() {
|
||||
if (element.parentNode) {
|
||||
element.parentNode.replaceChild(newElement, element);
|
||||
this.element = newElement;
|
||||
}
|
||||
},
|
||||
|
||||
undo() {
|
||||
if (newElement.parentNode) {
|
||||
newElement.parentNode.replaceChild(element, newElement);
|
||||
this.element = element;
|
||||
}
|
||||
},
|
||||
|
||||
merge(): boolean {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量操作命令
|
||||
*/
|
||||
export function createStyleChangeCommand(
|
||||
element: HTMLElement,
|
||||
property: string,
|
||||
oldValue: string,
|
||||
newValue: string
|
||||
): StyleChangeCommand {
|
||||
const time = Date.now();
|
||||
return {
|
||||
type: OperationType.STYLE_CHANGE,
|
||||
timestamp: time,
|
||||
element,
|
||||
property,
|
||||
oldValue,
|
||||
newValue,
|
||||
|
||||
execute() {
|
||||
element.style.setProperty(property, newValue);
|
||||
},
|
||||
|
||||
undo() {
|
||||
if (oldValue) {
|
||||
element.style.setProperty(property, oldValue);
|
||||
} else {
|
||||
element.style.removeProperty(property);
|
||||
}
|
||||
},
|
||||
|
||||
merge(command: Command): boolean {
|
||||
if (
|
||||
command.type === OperationType.STYLE_CHANGE &&
|
||||
(command as StyleChangeCommand).element === element &&
|
||||
(command as StyleChangeCommand).property === property &&
|
||||
command.timestamp - time < 1000
|
||||
) {
|
||||
this.newValue = (command as StyleChangeCommand).newValue;
|
||||
this.timestamp = command.timestamp;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建属性变更命令
|
||||
*/
|
||||
export function createAttributeChangeCommand(
|
||||
element: HTMLElement,
|
||||
attrName: string,
|
||||
oldValue: string | null,
|
||||
newValue: string | null
|
||||
): AttributeChangeCommand {
|
||||
const time = Date.now();
|
||||
return {
|
||||
type: OperationType.ATTRIBUTE_CHANGE,
|
||||
timestamp: time,
|
||||
element,
|
||||
attrName,
|
||||
oldValue,
|
||||
newValue,
|
||||
|
||||
execute() {
|
||||
if (newValue === null || newValue === undefined) {
|
||||
element.removeAttribute(attrName);
|
||||
} else {
|
||||
element.setAttribute(attrName, newValue);
|
||||
}
|
||||
},
|
||||
|
||||
undo() {
|
||||
if (oldValue === null || oldValue === undefined) {
|
||||
element.removeAttribute(attrName);
|
||||
} else {
|
||||
element.setAttribute(attrName, oldValue);
|
||||
}
|
||||
},
|
||||
|
||||
merge(command: Command): boolean {
|
||||
if (
|
||||
command.type === OperationType.ATTRIBUTE_CHANGE &&
|
||||
(command as AttributeChangeCommand).element === element &&
|
||||
(command as AttributeChangeCommand).attrName === attrName &&
|
||||
command.timestamp - time < 1000
|
||||
) {
|
||||
this.newValue = (command as AttributeChangeCommand).newValue;
|
||||
this.timestamp = command.timestamp;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建内容变更命令
|
||||
*/
|
||||
export function createContentChangeCommand(
|
||||
element: HTMLElement,
|
||||
oldContent: string,
|
||||
newContent: string
|
||||
): ContentChangeCommand {
|
||||
const time = Date.now();
|
||||
return {
|
||||
type: OperationType.CONTENT_CHANGE,
|
||||
timestamp: time,
|
||||
element,
|
||||
oldContent,
|
||||
newContent,
|
||||
|
||||
execute() {
|
||||
element.innerHTML = newContent;
|
||||
},
|
||||
|
||||
undo() {
|
||||
element.innerHTML = oldContent;
|
||||
},
|
||||
|
||||
merge(command: Command): boolean {
|
||||
if (
|
||||
command.type === OperationType.CONTENT_CHANGE &&
|
||||
(command as ContentChangeCommand).element === element &&
|
||||
command.timestamp - time < 2000
|
||||
) {
|
||||
this.newContent = (command as ContentChangeCommand).newContent;
|
||||
this.timestamp = command.timestamp;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建元素添加命令
|
||||
*/
|
||||
export function createElementAddCommand(
|
||||
element: HTMLElement,
|
||||
selectedElement: HTMLElement,
|
||||
parent: HTMLElement,
|
||||
nextSibling: HTMLElement | null
|
||||
): ElementAddCommand {
|
||||
return {
|
||||
type: OperationType.ELEMENT_ADD,
|
||||
timestamp: Date.now(),
|
||||
element,
|
||||
parent,
|
||||
nextSibling,
|
||||
selectedElement,
|
||||
execute() {
|
||||
if (nextSibling) {
|
||||
parent.insertBefore(element, nextSibling);
|
||||
} else {
|
||||
parent.appendChild(element);
|
||||
}
|
||||
},
|
||||
|
||||
undo() {
|
||||
parent.removeChild(element);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建元素删除命令
|
||||
*/
|
||||
export function createElementDeleteCommand(
|
||||
element: HTMLElement,
|
||||
parent: HTMLElement,
|
||||
nextSibling: Node | null
|
||||
): ElementDeleteCommand {
|
||||
return {
|
||||
type: OperationType.ELEMENT_DELETE,
|
||||
timestamp: Date.now(),
|
||||
element,
|
||||
parent,
|
||||
nextSibling,
|
||||
|
||||
execute() {
|
||||
if (element.parentNode) {
|
||||
parent.removeChild(element);
|
||||
}
|
||||
},
|
||||
|
||||
undo() {
|
||||
// 插回原节点对象
|
||||
if (!element.parentNode) {
|
||||
parent.insertBefore(element, nextSibling);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量操作命令
|
||||
*/
|
||||
export function createBatchCommand(commands: Command[]): BatchCommand {
|
||||
return {
|
||||
type: OperationType.BATCH,
|
||||
timestamp: Date.now(),
|
||||
commands,
|
||||
|
||||
execute() {
|
||||
commands.forEach((cmd) => cmd.execute());
|
||||
},
|
||||
|
||||
undo() {
|
||||
for (let i = commands.length - 1; i >= 0; i--) {
|
||||
commands[i].undo();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
233
components/html-editor/lib/core/historyManager/index.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* History Manager 历史记录管理
|
||||
*/
|
||||
|
||||
import { type Command, type HistoryManagerOptions, type HistoryState, OperationType, type BatchCommand } from './types';
|
||||
import type { HTMLEditor } from '../editor';
|
||||
|
||||
export class HistoryManager {
|
||||
private editor: HTMLEditor;
|
||||
private undoStack: Command[] = [];
|
||||
private redoStack: Command[] = [];
|
||||
private options: Required<HistoryManagerOptions>;
|
||||
private isExecuting: boolean = false;
|
||||
private batchCommands: Command[] | null = null;
|
||||
|
||||
constructor(editor: HTMLEditor, options: HistoryManagerOptions = {}) {
|
||||
this.editor = editor;
|
||||
this.options = {
|
||||
maxHistorySize: options.maxHistorySize ?? 100,
|
||||
mergeInterval: options.mergeInterval ?? 1000,
|
||||
enableAutoSnapshot: options.enableAutoSnapshot ?? false,
|
||||
snapshotInterval: options.snapshotInterval ?? 10,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一个操作
|
||||
*/
|
||||
push(command: Command): void {
|
||||
if (this.isExecuting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果在批量操作中,暂存命令
|
||||
if (this.batchCommands) {
|
||||
this.batchCommands.push(command);
|
||||
return;
|
||||
}
|
||||
const lastCommand = this.undoStack[this.undoStack.length - 1];
|
||||
if (lastCommand?.merge && lastCommand.merge(command)) {
|
||||
this.notifyStateChange();
|
||||
return;
|
||||
}
|
||||
this.undoStack.push(command);
|
||||
|
||||
// 清空重做栈
|
||||
this.redoStack = [];
|
||||
if (this.undoStack.length > this.options.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销操作
|
||||
*/
|
||||
undo(): boolean {
|
||||
if (!this.canUndo()) return false;
|
||||
|
||||
const command = this.undoStack.pop()!;
|
||||
this.isExecuting = true;
|
||||
try {
|
||||
command.undo();
|
||||
if (command.type === OperationType.ELEMENT_ADD) {
|
||||
// 如果是添加元素操作,撤销时选中添加前的元素
|
||||
this.editor.selectElement(command.selectedElement || null);
|
||||
}
|
||||
this.redoStack.push(command);
|
||||
this.notifyStateChange(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.undoStack.push(command);
|
||||
return false;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做操作
|
||||
*/
|
||||
redo(): boolean {
|
||||
if (!this.canRedo()) return false;
|
||||
|
||||
const command = this.redoStack.pop()!;
|
||||
this.isExecuting = true;
|
||||
try {
|
||||
command.execute();
|
||||
this.undoStack.push(command);
|
||||
this.notifyStateChange(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.redoStack.push(command);
|
||||
return false;
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以撤销
|
||||
*/
|
||||
canUndo(): boolean {
|
||||
const result = this.undoStack.length > 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以重做
|
||||
*/
|
||||
canRedo(): boolean {
|
||||
const result = this.redoStack.length > 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始批量操作
|
||||
*/
|
||||
beginBatch(): void {
|
||||
this.batchCommands = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束批量操作
|
||||
*/
|
||||
endBatch(): void {
|
||||
if (!this.batchCommands || this.batchCommands.length === 0) {
|
||||
this.batchCommands = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果只有一个命令,直接添加
|
||||
if (this.batchCommands.length === 1) {
|
||||
const command = this.batchCommands[0];
|
||||
this.batchCommands = null;
|
||||
this.push(command);
|
||||
} else {
|
||||
const timestamp = Date.now();
|
||||
// 创建批量命令
|
||||
const batchCommand: BatchCommand = {
|
||||
type: OperationType.BATCH,
|
||||
timestamp,
|
||||
commands: this.batchCommands,
|
||||
|
||||
execute() {
|
||||
this.commands.forEach((cmd) => cmd.execute());
|
||||
},
|
||||
|
||||
undo() {
|
||||
// 反向执行撤销
|
||||
for (let i = this.commands.length - 1; i >= 0; i--) {
|
||||
this.commands[i].undo();
|
||||
}
|
||||
},
|
||||
};
|
||||
this.batchCommands = null;
|
||||
this.push(batchCommand);
|
||||
}
|
||||
|
||||
this.batchCommands = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消批量操作
|
||||
*/
|
||||
cancelBatch(): void {
|
||||
this.batchCommands = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空历史记录
|
||||
*/
|
||||
clear(): void {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史状态
|
||||
*/
|
||||
getState(): HistoryState {
|
||||
const state = {
|
||||
canUndo: this.canUndo(),
|
||||
canRedo: this.canRedo(),
|
||||
historySize: this.undoStack.length,
|
||||
currentIndex: this.undoStack.length,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取撤销栈大小
|
||||
*/
|
||||
getUndoStackSize(): number {
|
||||
return this.undoStack.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取重做栈大小
|
||||
*/
|
||||
getRedoStackSize(): number {
|
||||
return this.redoStack.length;
|
||||
}
|
||||
|
||||
getTopUndoTimestamp(): number | null {
|
||||
const cmd = this.undoStack[this.undoStack.length - 1];
|
||||
return cmd ? cmd.timestamp : null;
|
||||
}
|
||||
|
||||
getTopRedoTimestamp(): number | null {
|
||||
const cmd = this.redoStack[this.redoStack.length - 1];
|
||||
return cmd ? cmd.timestamp : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知状态变化
|
||||
*/
|
||||
private notifyStateChange(contentChange?: boolean): void {
|
||||
this.editor.emit('historyChange', this.getState());
|
||||
if (contentChange) {
|
||||
// 临时处理,之后所有的操作都需要通知
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁
|
||||
*/
|
||||
destroy(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
119
components/html-editor/lib/core/historyManager/types.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* History Manager Types
|
||||
* 历史记录管理相关类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 操作类型枚举
|
||||
*/
|
||||
export enum OperationType {
|
||||
STYLE_CHANGE = 'style_change',
|
||||
CONTENT_CHANGE = 'content_change',
|
||||
ELEMENT_ADD = 'element_add',
|
||||
ELEMENT_DELETE = 'element_delete',
|
||||
ELEMENT_MOVE = 'element_move',
|
||||
ELEMENT_TAG_CHANGE = 'element_tag_change',
|
||||
ATTRIBUTE_CHANGE = 'attribute_change',
|
||||
BATCH = 'batch',
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作命令基础接口
|
||||
*/
|
||||
export interface Command {
|
||||
type: OperationType;
|
||||
timestamp: number;
|
||||
execute: () => void;
|
||||
undo: () => void;
|
||||
merge?: (command: Command) => boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 样式变更命令
|
||||
*/
|
||||
export interface StyleChangeCommand extends Command {
|
||||
type: OperationType.STYLE_CHANGE;
|
||||
element: HTMLElement;
|
||||
property: string;
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容变更命令
|
||||
*/
|
||||
export interface ContentChangeCommand extends Command {
|
||||
type: OperationType.CONTENT_CHANGE;
|
||||
element: HTMLElement;
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 属性变更命令
|
||||
*/
|
||||
export interface AttributeChangeCommand extends Command {
|
||||
type: OperationType.ATTRIBUTE_CHANGE;
|
||||
element: HTMLElement;
|
||||
attrName: string;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 元素添加命令
|
||||
*/
|
||||
export interface ElementAddCommand extends Command {
|
||||
type: OperationType.ELEMENT_ADD;
|
||||
element: HTMLElement;
|
||||
selectedElement: HTMLElement;
|
||||
parent: HTMLElement;
|
||||
nextSibling: HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 元素删除命令
|
||||
*/
|
||||
export interface ElementDeleteCommand extends Command {
|
||||
type: OperationType.ELEMENT_DELETE;
|
||||
element: HTMLElement;
|
||||
parent: HTMLElement;
|
||||
nextSibling: Node | null;
|
||||
}
|
||||
|
||||
export interface ElementTagChangeCommand extends Command {
|
||||
type: OperationType.ELEMENT_TAG_CHANGE;
|
||||
element: HTMLElement;
|
||||
oldTag: string;
|
||||
newTag: string;
|
||||
newElement?: HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作命令
|
||||
*/
|
||||
export interface BatchCommand extends Command {
|
||||
type: OperationType.BATCH;
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史记录管理器配置
|
||||
*/
|
||||
export interface HistoryManagerOptions {
|
||||
maxHistorySize?: number;
|
||||
mergeInterval?: number;
|
||||
enableAutoSnapshot?: boolean;
|
||||
snapshotInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史状态
|
||||
*/
|
||||
export interface HistoryState {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
historySize: number;
|
||||
currentIndex: number;
|
||||
}
|
||||
167
components/html-editor/lib/core/moveableManager/events.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Moveable Events Handler
|
||||
* 拖拽与缩放事件处理逻辑
|
||||
*/
|
||||
import type Moveable from 'moveable';
|
||||
import type { HTMLEditor } from '../editor';
|
||||
import { createStyleChangeCommand } from '../historyManager/commands';
|
||||
|
||||
export class MoveableEventsHandler {
|
||||
private editor: HTMLEditor;
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定拖拽事件
|
||||
*/
|
||||
bindDragEvents(instance: Moveable) {
|
||||
let originalTransform: string | null = null;
|
||||
let originTransition : string | null = null;
|
||||
|
||||
instance.on('dragStart', ({ target, inputEvent }) => {
|
||||
const el = target as HTMLElement;
|
||||
originalTransform = el.style.transform || '';
|
||||
originTransition = el.style.transition || '';
|
||||
try {
|
||||
inputEvent?.preventDefault();
|
||||
} catch {}
|
||||
el.style.userSelect = 'none';
|
||||
el.style.transition = 'none';
|
||||
this.editor.setDragging(true);
|
||||
});
|
||||
|
||||
instance.on('drag', ({ target, transform }) => {
|
||||
const el = target as HTMLElement;
|
||||
el.style.transform = transform;
|
||||
this.editor.emit('styleChange', el, { transform });
|
||||
});
|
||||
|
||||
instance.on('dragEnd', ({ target }) => {
|
||||
const el = target as HTMLElement;
|
||||
// 记录历史
|
||||
if (this.editor.historyManager && originalTransform !== null) {
|
||||
const newTransform = el.style.transform || '';
|
||||
|
||||
if (originalTransform !== newTransform) {
|
||||
const command = createStyleChangeCommand(el, 'transform', originalTransform, newTransform);
|
||||
command.execute();
|
||||
this.editor.historyManager.push(command);
|
||||
}
|
||||
}
|
||||
el.style.transition = originTransition || '';
|
||||
this.editor.setDragging(false);
|
||||
this.editor.emit('contentChange');
|
||||
originalTransform = null;
|
||||
originTransition = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定缩放事件
|
||||
*/
|
||||
bindScaleEvents(instance: Moveable) {
|
||||
let originalTransform: string | null = null;
|
||||
|
||||
instance.on('scaleStart', (e) => {
|
||||
this.editor.setResizing(true);
|
||||
e.target.blur();
|
||||
// 记录初始状态
|
||||
const el = e.target as HTMLElement;
|
||||
originalTransform = el.style.transform || '';
|
||||
});
|
||||
|
||||
instance.on('scale', ({ target, transform, drag }) => {
|
||||
const el = target as HTMLElement;
|
||||
el.style.transform = drag.transform;
|
||||
this.editor.emit('styleChange', el, {
|
||||
transform: drag && drag.transform ? drag.transform : transform,
|
||||
});
|
||||
});
|
||||
|
||||
instance.on('scaleEnd', ({ target }) => {
|
||||
const el = target as HTMLElement;
|
||||
// 记录历史
|
||||
if (this.editor.historyManager && originalTransform !== null) {
|
||||
const newTransform = el.style.transform || '';
|
||||
|
||||
if (originalTransform !== newTransform) {
|
||||
const command = createStyleChangeCommand(el, 'transform', originalTransform, newTransform);
|
||||
command.execute();
|
||||
this.editor.historyManager.push(command);
|
||||
}
|
||||
}
|
||||
|
||||
this.editor.setResizing(false);
|
||||
this.editor.emit('contentChange');
|
||||
originalTransform = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定缩放事件
|
||||
*/
|
||||
bindResizeEvents(instance: Moveable) {
|
||||
let originalSize = { width: '0px', height: '0px' };
|
||||
let originalTransform: string | null = '';
|
||||
let originTransition : string | null = null;
|
||||
instance.on('resizeStart', (e) => {
|
||||
const ele = e.target as HTMLElement;
|
||||
const style = window.getComputedStyle(ele);
|
||||
this.editor.setResizing(true);
|
||||
e.target.blur();
|
||||
// 记录初始状态
|
||||
originalTransform = e.target.style.transform || '';
|
||||
originTransition = e.target.style.transition || '';
|
||||
// 记录初始大小
|
||||
originalSize = {
|
||||
width: style.width,
|
||||
height: style.height,
|
||||
};
|
||||
})
|
||||
instance.on('resize', (e) => {
|
||||
const el = e.target as HTMLElement;
|
||||
el.style.width = e.width + 'px'
|
||||
el.style.height = e.height + 'px';
|
||||
el.style.transform = e.transform;
|
||||
this.editor.emit('styleChange', el, {
|
||||
transform: e && e.transform ? e.transform : '',
|
||||
});
|
||||
});
|
||||
|
||||
instance.on('resizeEnd', (e) => {
|
||||
const el = e.target as HTMLElement;
|
||||
this.editor.setResizing(false);
|
||||
this.editor.emit('contentChange');
|
||||
const newTransform = el.style.transform
|
||||
if (originalTransform !== newTransform) {
|
||||
//
|
||||
this.editor.historyManager?.beginBatch();
|
||||
const command = createStyleChangeCommand(el, 'transform', originalTransform, newTransform);
|
||||
command.execute();
|
||||
this.editor.historyManager?.push(command);
|
||||
const sizeCommand = createStyleChangeCommand(el, 'width', originalSize.width, el.style.width);
|
||||
sizeCommand.execute();
|
||||
this.editor.historyManager?.push(sizeCommand);
|
||||
const heightCommand = createStyleChangeCommand(el, 'height', originalSize.height, el.style.height);
|
||||
heightCommand.execute();
|
||||
this.editor.historyManager?.push(heightCommand);
|
||||
this.editor.historyManager?.endBatch();
|
||||
}
|
||||
|
||||
el.style.transition = originTransition || '';
|
||||
originalTransform = null;
|
||||
originTransition = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定所有事件
|
||||
*/
|
||||
bindAllEvents(instance: Moveable) {
|
||||
this.bindDragEvents(instance);
|
||||
this.bindScaleEvents(instance);
|
||||
this.bindResizeEvents(instance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export class MoveableGuidelinesHandler {
|
||||
/**
|
||||
* 计算自动对齐参考线
|
||||
*/
|
||||
static calculateAutoGuidelines(
|
||||
element: HTMLElement,
|
||||
container: HTMLElement,
|
||||
elementGuidelinesOption: HTMLElement[] | undefined
|
||||
): HTMLElement[] {
|
||||
if (elementGuidelinesOption) {
|
||||
return elementGuidelinesOption;
|
||||
}
|
||||
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('*')).filter((el) => {
|
||||
if (el === element) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(el);
|
||||
const visible =
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0;
|
||||
return visible;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算水平标尺线
|
||||
*/
|
||||
static calculateHorizontalGuidelines(
|
||||
container: HTMLElement,
|
||||
horizontalGuidelinesOption: number[] | undefined
|
||||
): number[] {
|
||||
if (horizontalGuidelinesOption) {
|
||||
return horizontalGuidelinesOption;
|
||||
}
|
||||
|
||||
return [
|
||||
0,
|
||||
Math.round(container.clientHeight / 2),
|
||||
container.clientHeight,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算垂直标尺线
|
||||
*/
|
||||
static calculateVerticalGuidelines(
|
||||
container: HTMLElement,
|
||||
verticalGuidelinesOption: number[] | undefined
|
||||
): number[] {
|
||||
if (verticalGuidelinesOption) {
|
||||
return verticalGuidelinesOption;
|
||||
}
|
||||
|
||||
return [
|
||||
0,
|
||||
Math.round(container.clientWidth / 2),
|
||||
container.clientWidth,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取容器元素
|
||||
*/
|
||||
static getContainer(
|
||||
element: HTMLElement,
|
||||
editorContainer: HTMLElement | null,
|
||||
snapContainerOption: HTMLElement | null
|
||||
): HTMLElement {
|
||||
const root = element.ownerDocument?.body || document.body;
|
||||
return snapContainerOption ?? (editorContainer || element.parentElement || root);
|
||||
}
|
||||
}
|
||||
193
components/html-editor/lib/core/moveableManager/index.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Moveable Manager
|
||||
* 实现选中元素的拖拽与四角缩放
|
||||
*/
|
||||
import Moveable from 'moveable';
|
||||
import { type HTMLEditor } from '../editor';
|
||||
import type { MoveableOptions } from '../../types';
|
||||
import { MoveableEventsHandler } from './events';
|
||||
import { MoveableGuidelinesHandler } from './guidelines';
|
||||
|
||||
export class MoveableManager {
|
||||
private editor: HTMLEditor;
|
||||
private instance: Moveable | null = null;
|
||||
private options: MoveableOptions;
|
||||
private eventsHandler: MoveableEventsHandler;
|
||||
|
||||
// 记录启用前的属性,便于恢复
|
||||
private originalState: {
|
||||
contenteditable?: string | null;
|
||||
userSelect?: string | null;
|
||||
transformOrigin?: string | null;
|
||||
} = {};
|
||||
|
||||
constructor(editor: HTMLEditor, options: MoveableOptions = {}) {
|
||||
this.editor = editor;
|
||||
this.eventsHandler = new MoveableEventsHandler(editor);
|
||||
this.options = {
|
||||
draggable: true,
|
||||
scalable: false,
|
||||
resizable: true,
|
||||
renderDirections: ['nw', 'ne', 'sw', 'se', 'n', 's', 'w', 'e'],
|
||||
keepRatio: false,
|
||||
throttleDrag: 0,
|
||||
throttleResize: 0,
|
||||
throttleScale: 0,
|
||||
// 默认开启吸附与标尺线
|
||||
snappable: true,
|
||||
snapCenter: true,
|
||||
snapThreshold: 5,
|
||||
snapGridWidth: undefined,
|
||||
snapGridHeight: undefined,
|
||||
snapContainer: null,
|
||||
elementGuidelines: undefined,
|
||||
horizontalGuidelines: undefined,
|
||||
verticalGuidelines: undefined,
|
||||
snapDirections: {
|
||||
left: true,
|
||||
top: true,
|
||||
right: true,
|
||||
bottom: true,
|
||||
center: true,
|
||||
middle: true,
|
||||
},
|
||||
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
enableFor(element: HTMLElement, options?: Partial<MoveableOptions>): void {
|
||||
this.destroy();
|
||||
|
||||
// 启用前准备:禁用 contenteditable 与选择,避免拖拽被当作文本选择
|
||||
this.prepareElement(element);
|
||||
const mergedOptions: MoveableOptions = { ...this.options, ...(options || {}) };
|
||||
|
||||
// 获取容器元素
|
||||
const container = MoveableGuidelinesHandler.getContainer(
|
||||
element,
|
||||
this.editor.container,
|
||||
mergedOptions.snapContainer ?? null
|
||||
);
|
||||
|
||||
// 计算自动对齐参考线
|
||||
const autoGuidelines = MoveableGuidelinesHandler.calculateAutoGuidelines(
|
||||
element,
|
||||
container,
|
||||
mergedOptions.elementGuidelines
|
||||
);
|
||||
|
||||
// 计算水平标尺线
|
||||
const hGuides = MoveableGuidelinesHandler.calculateHorizontalGuidelines(
|
||||
container,
|
||||
mergedOptions.horizontalGuidelines
|
||||
);
|
||||
|
||||
// 计算垂直标尺线
|
||||
const vGuides = MoveableGuidelinesHandler.calculateVerticalGuidelines(
|
||||
container,
|
||||
mergedOptions.verticalGuidelines
|
||||
);
|
||||
|
||||
const root = element.ownerDocument?.body || document.body;
|
||||
|
||||
this.instance = new Moveable(root, {
|
||||
target: element,
|
||||
draggable: mergedOptions.draggable,
|
||||
scalable: mergedOptions.scalable,
|
||||
resizable: mergedOptions.resizable,
|
||||
edgeDraggable: true,
|
||||
checkInput: true,
|
||||
origin: false,
|
||||
|
||||
// 缩放手柄
|
||||
renderDirections: mergedOptions.renderDirections,
|
||||
keepRatio: mergedOptions.keepRatio,
|
||||
|
||||
// 性能相关
|
||||
throttleDrag: mergedOptions.throttleDrag,
|
||||
throttleScale: mergedOptions.throttleScale,
|
||||
|
||||
// 吸附与对齐线
|
||||
snappable: mergedOptions.snappable,
|
||||
snapContainer: container,
|
||||
elementGuidelines: autoGuidelines,
|
||||
horizontalGuidelines: hGuides,
|
||||
verticalGuidelines: vGuides,
|
||||
// 提高阈值,避免吸附过强导致"拖不动"的感觉
|
||||
snapThreshold: mergedOptions.snapThreshold ?? 10,
|
||||
snapGridWidth: mergedOptions.snapGridWidth,
|
||||
snapGridHeight: mergedOptions.snapGridHeight,
|
||||
snapDirections: mergedOptions.snapDirections,
|
||||
});
|
||||
|
||||
// 绑定拖拽和缩放事件
|
||||
this.eventsHandler.bindAllEvents(this.instance);
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.instance) {
|
||||
this.instance.updateRect();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.instance) {
|
||||
this.instance.destroy();
|
||||
this.instance = null;
|
||||
}
|
||||
// 恢复元素的原始状态
|
||||
const el = this.editor.selectedElement;
|
||||
if (el) {
|
||||
this.restoreElement(el);
|
||||
}
|
||||
}
|
||||
|
||||
private prepareElement(element: HTMLElement) {
|
||||
// 保存原始状态
|
||||
this.originalState.contenteditable =
|
||||
element.getAttribute('contenteditable');
|
||||
this.originalState.userSelect = element.style.userSelect || null;
|
||||
this.originalState.transformOrigin = element.style.transformOrigin || null;
|
||||
|
||||
element.setAttribute('contenteditable', 'false');
|
||||
element.style.userSelect = 'none';
|
||||
(element.style as any).touchAction = 'none';
|
||||
}
|
||||
|
||||
private restoreElement(element: HTMLElement) {
|
||||
// 恢复 contenteditable
|
||||
if (this.originalState.contenteditable != null) {
|
||||
if (this.originalState.contenteditable === '') {
|
||||
element.removeAttribute('contenteditable');
|
||||
} else {
|
||||
element.setAttribute(
|
||||
'contenteditable',
|
||||
this.originalState.contenteditable
|
||||
);
|
||||
}
|
||||
} else {
|
||||
element.removeAttribute('contenteditable');
|
||||
}
|
||||
|
||||
// 恢复 user-select
|
||||
if (this.originalState.userSelect != null) {
|
||||
element.style.userSelect = this.originalState.userSelect || '';
|
||||
} else {
|
||||
element.style.removeProperty('user-select');
|
||||
}
|
||||
|
||||
// 恢复 transform-origin
|
||||
if (this.originalState.transformOrigin != null) {
|
||||
element.style.transformOrigin = this.originalState.transformOrigin || '';
|
||||
} else {
|
||||
element.style.removeProperty('transform-origin');
|
||||
}
|
||||
|
||||
// 清理 will-change
|
||||
element.style.removeProperty('will-change');
|
||||
|
||||
// 清空记录
|
||||
this.originalState = {};
|
||||
}
|
||||
}
|
||||
281
components/html-editor/lib/core/styleManager/index.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Style Manager
|
||||
* 样式管理器,处理元素样式的应用和获取
|
||||
*/
|
||||
|
||||
import { type HTMLEditor } from '../editor';
|
||||
import type { ElementStyles } from '../../types';
|
||||
import { createStyleChangeCommand, createElementTagChangeCommand, createContentChangeCommand } from '../historyManager/commands';
|
||||
|
||||
export class StyleManager {
|
||||
private editor: HTMLEditor;
|
||||
|
||||
constructor(editor: HTMLEditor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用样式并记录历史
|
||||
*/
|
||||
private applyStyleWithHistory(element: HTMLElement, property: string, value: string): void {
|
||||
const oldValue = element.style.getPropertyValue(property) || window.getComputedStyle(element).getPropertyValue(property);
|
||||
|
||||
// 创建命令并执行
|
||||
if (this.editor.historyManager) {
|
||||
const command = createStyleChangeCommand(element, property, oldValue, value);
|
||||
command.execute();
|
||||
this.editor.historyManager.push(command);
|
||||
} else {
|
||||
// 如果没有历史管理器,直接应用样式
|
||||
element.style.setProperty(property, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 字体相关方法
|
||||
changeFont(element: HTMLElement | null, fontFamily: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'font-family', fontFamily);
|
||||
this.editor.emit('styleChange', element, { fontFamily });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeFontSize(element: HTMLElement | null, fontSize: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'font-size', fontSize);
|
||||
this.editor.emit('styleChange', element, { fontSize });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeFontWeight(element: HTMLElement | null, fontWeight: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'font-weight', fontWeight);
|
||||
this.editor.emit('styleChange', element, { fontWeight });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeFontStyle(element: HTMLElement | null, fontStyle: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'font-style', fontStyle);
|
||||
this.editor.emit('styleChange', element, { fontStyle });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeTextDecoration(element: HTMLElement | null, textDecoration: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'text-decoration', textDecoration);
|
||||
this.editor.emit('styleChange', element, { textDecoration });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeTextAlign(element: HTMLElement | null, textAlign: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'text-align', textAlign);
|
||||
this.editor.emit('styleChange', element, { textAlign });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 边距相关方法
|
||||
changeMargin(element: HTMLElement | null, margin: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'margin', margin);
|
||||
this.editor.emit('styleChange', element, { margin });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changePadding(element: HTMLElement | null, padding: string,triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'padding', padding);
|
||||
this.editor.emit('styleChange', element, { padding });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 颜色相关方法
|
||||
changeColor(element: HTMLElement | null, color: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'color', color);
|
||||
this.editor.emit('styleChange', element, { color });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeBackground(element: HTMLElement | null, backgroundColor: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
// 使用批量操作记录 background 和 backgroundColor
|
||||
// this.editor.beginBatch();
|
||||
this.applyStyleWithHistory(element, 'background', backgroundColor);
|
||||
// this.editor.endBatch();
|
||||
this.editor.emit('styleChange', element, { backgroundColor, background: backgroundColor });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 边框相关方法
|
||||
changeBorder(element: HTMLElement | null, border: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'border', border);
|
||||
this.editor.emit('styleChange', element, { border });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
changeBorderRadius(element: HTMLElement | null, borderRadius: string, triggerContentChange = true): boolean {
|
||||
if (!element) element = this.editor.selectedElement;
|
||||
if (!element) return false;
|
||||
this.applyStyleWithHistory(element, 'border-radius', borderRadius);
|
||||
this.editor.emit('styleChange', element, { borderRadius });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
applyTextStyle(property: string, value: string, triggerContentChange = true): boolean {
|
||||
if (!this.editor.selectedElement) return false;
|
||||
this.applyStyleWithHistory(this.editor.selectedElement, property, value);
|
||||
this.editor.emit('styleChange', this.editor.selectedElement, { [property]: value });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
applyBlockStyle(property: string, value: string, triggerContentChange = true): boolean {
|
||||
if (!this.editor.selectedElement) return false;
|
||||
|
||||
const el = this.editor.selectedElement;
|
||||
|
||||
// 兼容传入 'background-color' 字符串
|
||||
const prop = property === 'background-color' ? 'background-color' : property;
|
||||
|
||||
// 当设置背景相关属性时,同时更新 background 与 backgroundColor
|
||||
if (prop === 'background' || prop === 'background-color') {
|
||||
this.editor.beginBatch();
|
||||
this.applyStyleWithHistory(el, 'background-color', value);
|
||||
this.applyStyleWithHistory(el, 'background', value);
|
||||
this.editor.endBatch();
|
||||
this.editor.emit('styleChange', el, { backgroundColor: value, background: value });
|
||||
return true;
|
||||
}
|
||||
|
||||
this.applyStyleWithHistory(el, prop, value);
|
||||
this.editor.emit('styleChange', el, { [prop]: value });
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getComputedStyle(element: HTMLElement, property: string): string {
|
||||
return window.getComputedStyle(element)[property as any];
|
||||
}
|
||||
|
||||
getElementStyles(element: HTMLElement): ElementStyles {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
return {
|
||||
fontSize: computedStyle.fontSize,
|
||||
color: computedStyle.color,
|
||||
fontWeight: computedStyle.fontWeight,
|
||||
backgroundColor: computedStyle.backgroundColor,
|
||||
borderWidth: computedStyle.borderWidth,
|
||||
padding: computedStyle.padding,
|
||||
margin: computedStyle.margin,
|
||||
borderRadius: computedStyle.borderRadius
|
||||
};
|
||||
}
|
||||
|
||||
changeElementTag(element: HTMLElement, newTag: string, triggerContentChange = true): HTMLElement | null {
|
||||
if (!element || !element.parentNode || !newTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const command = createElementTagChangeCommand(element, newTag);
|
||||
command.execute();
|
||||
|
||||
const newElement = (command as any).newElement as HTMLElement;
|
||||
if (!newElement) return null;
|
||||
|
||||
if (this.editor.historyManager) {
|
||||
this.editor.historyManager.push(command);
|
||||
}
|
||||
|
||||
this.editor.selectElement(newElement);
|
||||
|
||||
this.editor.emit('contentChange');
|
||||
if (triggerContentChange) {
|
||||
this.editor.emit('contentChange');
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
rgbToHex(rgb: string): string {
|
||||
if (!rgb || rgb === 'rgba(0, 0, 0, 0)' || rgb === 'transparent') {
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
const result = rgb.match(/\d+/g);
|
||||
if (!result) return '#000000';
|
||||
|
||||
const r = parseInt(result[0]);
|
||||
const g = parseInt(result[1]);
|
||||
const b = parseInt(result[2]);
|
||||
|
||||
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||||
}
|
||||
|
||||
// =============================
|
||||
// 选区样式操作(全局contenteditable模式)
|
||||
// =============================
|
||||
applySelectionBold(): boolean { return this.editor.globalEditable?.applySelectionBold() ?? false; }
|
||||
applySelectionItalic(): boolean { return this.editor.globalEditable?.applySelectionItalic() ?? false; }
|
||||
applySelectionUnderline(): boolean { return this.editor.globalEditable?.applySelectionUnderline() ?? false; }
|
||||
applySelectionStrikeThrough(): boolean { return this.editor.globalEditable?.applySelectionStrikeThrough() ?? false; }
|
||||
applySelectionFontSize(px: string): boolean { return this.editor.globalEditable?.applySelectionFontSize(px) ?? false; }
|
||||
applySelectionFontFamily(name: string): boolean { return this.editor.globalEditable?.applySelectionFontFamily(name) ?? false; }
|
||||
applySelectionColor(color: string): boolean { return this.editor.globalEditable?.applySelectionColor(color) ?? false; }
|
||||
applySelectionBackground(color: string): boolean { return this.editor.globalEditable?.applySelectionBackground(color) ?? false; }
|
||||
applySelectionAlign(align: 'left' | 'center' | 'right'): boolean { return this.editor.globalEditable?.applySelectionAlign(align as any) ?? false; }
|
||||
}
|
||||
|
||||
export default StyleManager;
|
||||
160
components/html-editor/lib/core/utils.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 通用工具函数集合:与 HTMLEditor 实例 (this) 无关的逻辑
|
||||
*/
|
||||
|
||||
import { type Position } from '../types';
|
||||
import { type HTMLEditor } from './editor';
|
||||
|
||||
export function getElementType(element: HTMLElement): string {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const typeMap: Record<string, string> = {
|
||||
'h1': '标题1', 'h2': '标题2', 'h3': '标题3',
|
||||
'h4': '标题4', 'h5': '标题5', 'h6': '标题6',
|
||||
'p': '段落', 'div': '区块', 'span': '文本',
|
||||
'a': '链接', 'img': '图片',
|
||||
'ul': '无序列表', 'ol': '有序列表', 'li': '列表项'
|
||||
};
|
||||
return typeMap[tagName] || tagName.toUpperCase();
|
||||
}
|
||||
|
||||
export function isDivWithText(element: HTMLElement): boolean {
|
||||
const elementWithText = !!element.textContent?.trim() && element.children.length === 0;
|
||||
return element.tagName.toLowerCase() === 'div' && elementWithText
|
||||
}
|
||||
|
||||
export function isTextElement(element: HTMLElement): boolean {
|
||||
const textTags = ['p', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'strong', 'em'];
|
||||
return textTags.includes(element.tagName.toLowerCase()) || isDivWithText(element);
|
||||
}
|
||||
|
||||
export function isDivWithImage(element: HTMLElement): boolean {
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const { backgroundImage, background } = computedStyle
|
||||
const elementWithBgImage = !!backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('url(')
|
||||
const elementBgWithUrl = !!background && background.includes('url(')
|
||||
const divWithbg = element.tagName.toLowerCase() === 'div' && element.children.length === 0 && (elementWithBgImage || elementBgWithUrl);
|
||||
return divWithbg
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isBlockElement(element: HTMLElement): boolean {
|
||||
const blockTags = ['div', 'section', 'article', 'header', 'footer', 'main', 'body', 'ol', 'ul', 'li', 'button', 'i'];
|
||||
return blockTags.includes(element.tagName.toLowerCase()) && !isDivWithImage(element) && !isDivWithText(element);
|
||||
}
|
||||
|
||||
export const isInlineElement = (element: HTMLElement): boolean => {
|
||||
const display = getComputedStyle(element).display;
|
||||
return display.startsWith('inline') && display !== 'inline-block';
|
||||
}
|
||||
|
||||
export const isTableElement = (element: HTMLElement): boolean => {
|
||||
const tableTags = ['tr', 'td', 'th', 'tbody', 'thead', 'tfoot', 'caption'];
|
||||
const tagName = element.tagName.toLowerCase().toLowerCase();
|
||||
return tableTags.includes(tagName);
|
||||
}
|
||||
|
||||
export function isImageElement(element: HTMLElement): boolean {
|
||||
return isDivWithImage(element) || element.tagName.toLowerCase() === 'img';
|
||||
}
|
||||
|
||||
export function createElement(type: string, content: string = ''): HTMLElement {
|
||||
const element = document.createElement(type);
|
||||
|
||||
if (content) {
|
||||
element.textContent = content;
|
||||
} else {
|
||||
element.textContent = type === 'div' ? '新区块' : '新文本';
|
||||
}
|
||||
|
||||
// 添加基础样式
|
||||
element.style.padding = '10px';
|
||||
element.style.margin = '5px';
|
||||
element.style.backgroundColor = '#f8f9fa';
|
||||
element.style.border = '1px dashed #dee2e6';
|
||||
element.style.borderRadius = '4px';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
export const elementWatcher = (editor: HTMLEditor) => {
|
||||
let ele: HTMLElement | null = null;
|
||||
let running = false;
|
||||
let frameId: number | null = null;
|
||||
let lastRect: Position | null = null;
|
||||
const update = (element: HTMLElement, callback?: (postition: Position) => void) => {
|
||||
if (!running || !element.isConnected) return;
|
||||
|
||||
const postition = editor.getBoundPostion(element);
|
||||
|
||||
const hasChanged =
|
||||
!lastRect ||
|
||||
postition.left !== lastRect.left ||
|
||||
postition.top !== lastRect.top ||
|
||||
postition.width !== lastRect.width ||
|
||||
postition.height !== lastRect.height ||
|
||||
postition.right !== lastRect.right ||
|
||||
postition.bottom !== lastRect.bottom;
|
||||
|
||||
if (hasChanged) {
|
||||
lastRect = postition;
|
||||
callback?.(postition);
|
||||
}
|
||||
|
||||
// 下一帧继续
|
||||
frameId = requestAnimationFrame(() => update(element, callback));
|
||||
}
|
||||
return {
|
||||
start: (element: HTMLElement, callback?: (position: Position) => void) => {
|
||||
if (!running) {
|
||||
ele = element;
|
||||
running = true;
|
||||
update(element, callback);
|
||||
}
|
||||
},
|
||||
stop: (element: HTMLElement) => {
|
||||
if (ele !== element) return;
|
||||
running = false;
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const cleanDom = (doc: HTMLElement): string => {
|
||||
// 克隆一份
|
||||
const ele = doc.cloneNode(true) as HTMLElement
|
||||
// 移除所有hover以及selected样式
|
||||
ele.querySelectorAll('*').forEach(node => {
|
||||
const tagName = node.tagName.toLowerCase()
|
||||
const classList = node.classList
|
||||
const nodeStyle = (node as HTMLElement).style
|
||||
if (classList.contains('hover-highlight')) {
|
||||
node.classList.remove('hover-highlight')
|
||||
node.removeAttribute('data-element-type')
|
||||
}
|
||||
if (classList.contains('selected-element')) {
|
||||
node.classList.remove('selected-element')
|
||||
node.removeAttribute('data-element-type')
|
||||
}
|
||||
if (tagName === 'style' && node.hasAttribute('data-styled-id')) {
|
||||
node.parentNode?.removeChild(node)
|
||||
}
|
||||
if (
|
||||
node.getAttribute('id') === 'html-editor-helper-box' ||
|
||||
classList.contains('moveable-control-box')
|
||||
) {
|
||||
node.parentNode?.removeChild(node)
|
||||
}
|
||||
if (nodeStyle && nodeStyle.cursor === 'move') {
|
||||
nodeStyle.cursor = 'auto'
|
||||
}
|
||||
if (node.hasAttribute('contenteditable')) {
|
||||
node.removeAttribute('contenteditable')
|
||||
}
|
||||
if (nodeStyle.userSelect === 'none') {
|
||||
nodeStyle.userSelect = 'auto'
|
||||
}
|
||||
})
|
||||
return ele.outerHTML
|
||||
}
|
||||
5
components/html-editor/lib/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { HTMLEditor } from './core/editor';
|
||||
export { HistoryManager } from './core/historyManager';
|
||||
export * from './core/historyManager/commands';
|
||||
export * from './core/utils'
|
||||
export * from './types';
|
||||
40
components/html-editor/lib/types/core/editor.ts
Normal 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[];
|
||||
}
|
||||
28
components/html-editor/lib/types/core/events.ts
Normal 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;
|
||||
5
components/html-editor/lib/types/core/history.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* History Manager Types Export
|
||||
*/
|
||||
|
||||
export * from '../../core/historyManager/types';
|
||||
32
components/html-editor/lib/types/core/moveable.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
52
components/html-editor/lib/types/core/style.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
9
components/html-editor/lib/types/index.ts
Normal 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';
|
||||
121
components/html-editor/mode/baseEdit.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
components/html-editor/mode/html-doc.tsx
Normal 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)
|
||||
39
components/html-editor/mode/html-web.tsx
Normal 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)
|
||||
9
components/html-editor/server/index.ts
Normal 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)
|
||||
}
|
||||
7
components/html-editor/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
export interface ArtifactEditState {
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
redo: () => void
|
||||
undo: () => void
|
||||
}
|
||||
1
components/image-editor/components/canvas/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DOMINO_EL_PREFIX = 'domino-el-'
|
||||
10
components/image-editor/components/canvas/dom-utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DOMINO_EL_PREFIX } from './constants'
|
||||
|
||||
/**
|
||||
* 获取 Domino 元素的 DOM 节点
|
||||
*/
|
||||
export function getDominoDOM(id: string): HTMLElement | null {
|
||||
if (typeof document === 'undefined' || !id) return null
|
||||
const finalId = id.startsWith(DOMINO_EL_PREFIX) ? id : DOMINO_EL_PREFIX + id
|
||||
return document.getElementById(finalId)
|
||||
}
|
||||
236
components/image-editor/components/canvas/domino-anchor.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useDominoAnchor, type AnchorRect } from './use-domino-anchor'
|
||||
|
||||
export type Placement =
|
||||
| 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
|
||||
export interface DominoAnchorProps {
|
||||
/** The ID of the element on the canvas to anchor to */
|
||||
id: string | null
|
||||
/** The container ref where the canvas is rendered */
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
/** Alignment preference */
|
||||
placement?: Placement
|
||||
/** Margin from the anchor element */
|
||||
offset?: number
|
||||
/** Whether to use absolute screen space coordinates (for body portals) */
|
||||
screenSpace?: boolean
|
||||
|
||||
// --- Props for Simple/Advanced Usage ---
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
/**
|
||||
* Children can be either a ReactNode (simple usage) or a render function (advanced control).
|
||||
* If a function is provided, it receives calculated style, anchor, and measureRef.
|
||||
*/
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((props: {
|
||||
style: React.CSSProperties
|
||||
anchor: AnchorRect
|
||||
measureRef: (node: HTMLElement | null) => void
|
||||
}) => React.ReactNode)
|
||||
}
|
||||
|
||||
export const DominoAnchor: React.FC<DominoAnchorProps> = ({
|
||||
id,
|
||||
containerRef,
|
||||
placement = 'right-start',
|
||||
offset = 12,
|
||||
screenSpace = false,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}) => {
|
||||
const anchor = useDominoAnchor(id, containerRef, screenSpace)
|
||||
const [size, setSize] = useState({ width: 0, height: 0 })
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null)
|
||||
|
||||
const measureRef = useCallback((node: HTMLElement | null) => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect()
|
||||
}
|
||||
|
||||
if (node) {
|
||||
// Immediate measurement
|
||||
const rect = node.getBoundingClientRect()
|
||||
setSize(prev => {
|
||||
if (prev.width === rect.width && prev.height === rect.height)
|
||||
return prev
|
||||
return { width: rect.width, height: rect.height }
|
||||
})
|
||||
|
||||
// Setup observer for dynamic changes
|
||||
if (!resizeObserverRef.current) {
|
||||
resizeObserverRef.current = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
const { width, height } = entry.contentRect
|
||||
setSize(prev => {
|
||||
if (prev.width === width && prev.height === height) return prev
|
||||
return { width, height }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
resizeObserverRef.current.observe(node)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const finalStyle = useMemo((): React.CSSProperties | null => {
|
||||
if (!anchor) return null
|
||||
|
||||
let left = 0
|
||||
let top = 0
|
||||
const { width: floatingWidth, height: floatingHeight } = size
|
||||
|
||||
// Initial placement logic (Standard 12 positions)
|
||||
switch (placement) {
|
||||
// TOP
|
||||
case 'top':
|
||||
left = anchor.centerX - floatingWidth / 2
|
||||
top = anchor.top - floatingHeight - offset
|
||||
break
|
||||
case 'top-start':
|
||||
left = anchor.left
|
||||
top = anchor.top - floatingHeight - offset
|
||||
break
|
||||
case 'top-end':
|
||||
left = anchor.right - floatingWidth
|
||||
top = anchor.top - floatingHeight - offset
|
||||
break
|
||||
|
||||
// BOTTOM
|
||||
case 'bottom':
|
||||
left = anchor.centerX - floatingWidth / 2
|
||||
top = anchor.bottom + offset
|
||||
break
|
||||
case 'bottom-start':
|
||||
left = anchor.left
|
||||
top = anchor.bottom + offset
|
||||
break
|
||||
case 'bottom-end':
|
||||
left = anchor.right - floatingWidth
|
||||
top = anchor.bottom + offset
|
||||
break
|
||||
|
||||
// LEFT
|
||||
case 'left':
|
||||
left = anchor.left - floatingWidth - offset
|
||||
top = anchor.centerY - floatingHeight / 2
|
||||
break
|
||||
case 'left-start':
|
||||
left = anchor.left - floatingWidth - offset
|
||||
top = anchor.top
|
||||
break
|
||||
case 'left-end':
|
||||
left = anchor.left - floatingWidth - offset
|
||||
top = anchor.bottom - floatingHeight
|
||||
break
|
||||
|
||||
// RIGHT
|
||||
case 'right':
|
||||
left = anchor.right + offset
|
||||
top = anchor.centerY - floatingHeight / 2
|
||||
break
|
||||
case 'right-start':
|
||||
left = anchor.right + offset
|
||||
top = anchor.top
|
||||
break
|
||||
case 'right-end':
|
||||
left = anchor.right + offset
|
||||
top = anchor.bottom - floatingHeight
|
||||
break
|
||||
|
||||
default:
|
||||
left = anchor.right + offset
|
||||
top = anchor.top
|
||||
}
|
||||
|
||||
// --- Automatic Boundary Avoidance (Collision Detection) ---
|
||||
const viewportWidth =
|
||||
screenSpace || !containerRef.current
|
||||
? window.innerWidth
|
||||
: containerRef.current.clientWidth
|
||||
const viewportHeight =
|
||||
screenSpace || !containerRef.current
|
||||
? window.innerHeight
|
||||
: containerRef.current.clientHeight
|
||||
|
||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||
// Define screen-space or relative boundaries
|
||||
const minX = screenSpace && containerRect ? containerRect.left : 0
|
||||
const maxX =
|
||||
screenSpace && containerRect ? containerRect.right : viewportWidth
|
||||
const minY = screenSpace && containerRect ? containerRect.top : 0
|
||||
const maxY =
|
||||
screenSpace && containerRect ? containerRect.bottom : viewportHeight
|
||||
|
||||
// 3. Clamping & Sticking Logic (Horizontal)
|
||||
// We treat the "near" edge of the popup (the one relative to the anchor) as the sticky point
|
||||
if (placement.includes('left')) {
|
||||
// "当弹窗在元素左侧出现时,弹窗的 right side edge 与窗口的 left side edge 相吸附"
|
||||
const currentRight = left + floatingWidth
|
||||
const clampedRight = Math.max(minX, Math.min(currentRight, maxX))
|
||||
left = clampedRight - floatingWidth
|
||||
} else if (placement.includes('right')) {
|
||||
left = Math.max(minX, Math.min(left, maxX))
|
||||
}
|
||||
|
||||
// Vertical stay-within-bounds (Keep it in view vertically)
|
||||
if (top + floatingHeight > maxY) {
|
||||
top = maxY - floatingHeight
|
||||
}
|
||||
if (top < minY) {
|
||||
top = minY
|
||||
}
|
||||
|
||||
return {
|
||||
position: screenSpace ? 'fixed' : ('absolute' as const),
|
||||
left,
|
||||
top,
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'auto' as const,
|
||||
visibility: (size.width === 0
|
||||
? 'hidden'
|
||||
: 'visible') as React.CSSProperties['visibility'],
|
||||
}
|
||||
}, [anchor, placement, offset, size, containerRef, screenSpace])
|
||||
|
||||
if (!anchor || !finalStyle) return null
|
||||
|
||||
// Advanced Usage: children is a render function
|
||||
if (typeof children === 'function') {
|
||||
return <>{children({ style: finalStyle, anchor, measureRef })}</>
|
||||
}
|
||||
|
||||
// Simple Usage: wrap children in a div with position styles
|
||||
return (
|
||||
<div
|
||||
ref={measureRef}
|
||||
style={{ ...finalStyle, ...style }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
280
components/image-editor/components/canvas/domino-canvas.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, {
|
||||
memo,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { useInteractions } from './use-interactions'
|
||||
import { useGestures } from './use-gestures'
|
||||
import {
|
||||
useDominoStore,
|
||||
useDominoStoreInstance,
|
||||
useDominoDOMContext,
|
||||
DominoRenderHooksContext,
|
||||
} from './domino-hooks'
|
||||
import { ElementRenderer } from './element-renderer'
|
||||
import { SelectionOverlay } from './selection-overlay'
|
||||
import { useDominoScrollIntoView } from './use-domino-scroll-into-view'
|
||||
import {
|
||||
type Viewport,
|
||||
type DominoCanvasProps,
|
||||
type ScrollIntoViewOptions,
|
||||
} from './domino'
|
||||
|
||||
export interface DominoCanvasRef {
|
||||
scrollIntoView: (elementId: string, options?: ScrollIntoViewOptions) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽框选框渲染器:独立订阅避免主画布重绘
|
||||
*/
|
||||
const MarqueeOverlay = memo(() => {
|
||||
const selectionBox = useDominoStore(state => state.selectionBox)
|
||||
if (!selectionBox) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='domino-selection-box absolute pointer-events-none z-[1000]'
|
||||
style={{
|
||||
left: selectionBox.x,
|
||||
top: selectionBox.y,
|
||||
width: selectionBox.width,
|
||||
height: selectionBox.height,
|
||||
borderWidth: 'calc(1px / var(--domino-scale))',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'color-mix(in srgb, var(--editor-accent) 55%, transparent)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--editor-accent) 8%, transparent)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
MarqueeOverlay.displayName = 'MarqueeOverlay'
|
||||
|
||||
export const DominoCanvas = memo(
|
||||
forwardRef<DominoCanvasRef, DominoCanvasProps>(
|
||||
function DominoCanvas(props, ref) {
|
||||
const {
|
||||
className,
|
||||
style,
|
||||
minScale = 0.1,
|
||||
maxScale = 20,
|
||||
padding,
|
||||
readOnly = false,
|
||||
onClick,
|
||||
onSelect,
|
||||
onElementClick,
|
||||
onTransformEnd,
|
||||
onViewportChange,
|
||||
onContextMenu,
|
||||
onHoverChange,
|
||||
onElementsRemove,
|
||||
elements: externalElements,
|
||||
elementOrder: externalElementOrder,
|
||||
renderElement,
|
||||
renderElementMetadata,
|
||||
} = props
|
||||
const store = useDominoStoreInstance()
|
||||
const elementOrder = useDominoStore(state => state.elementOrder)
|
||||
|
||||
const setViewport = useDominoStore(state => state.setViewport)
|
||||
const setPadding = useDominoStore(state => state.setPadding)
|
||||
const setReadOnly = useDominoStore(state => state.setReadOnly)
|
||||
const setElementsData = useDominoStore(state => state.setElementsData)
|
||||
|
||||
const hostRef = useDominoDOMContext()
|
||||
const localRef = React.useRef<HTMLDivElement>(null)
|
||||
const containerRef = (hostRef ||
|
||||
localRef) as React.RefObject<HTMLDivElement>
|
||||
|
||||
const worldRef = React.useRef<HTMLDivElement>(null)
|
||||
const scrollIntoView = useDominoScrollIntoView(containerRef)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollIntoView,
|
||||
}))
|
||||
|
||||
// Sync controlled data
|
||||
useLayoutEffect(() => {
|
||||
if (externalElements && externalElementOrder) {
|
||||
setElementsData(externalElements, externalElementOrder)
|
||||
}
|
||||
}, [externalElements, externalElementOrder, setElementsData])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (padding) {
|
||||
setPadding(padding)
|
||||
}
|
||||
}, [padding, setPadding])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setReadOnly(readOnly)
|
||||
}, [readOnly, setReadOnly])
|
||||
|
||||
useEffect(() => {
|
||||
if (props.initViewport !== undefined) {
|
||||
setViewport(props.initViewport)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fast-path for viewport updates: bypass React renders
|
||||
useEffect(() => {
|
||||
const updateTransform = (v: Viewport) => {
|
||||
if (worldRef.current) {
|
||||
const x = Math.round(v.x * 100) / 100
|
||||
const y = Math.round(v.y * 100) / 100
|
||||
const s = Math.round(v.scale * 10000) / 10000
|
||||
worldRef.current.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${s})`
|
||||
}
|
||||
|
||||
containerRef.current?.style.setProperty(
|
||||
'--domino-scale',
|
||||
(Math.round(v.scale * 10000) / 10000).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
updateTransform(store.getState().viewport)
|
||||
|
||||
// Subscribe to changes
|
||||
let lastViewport = store.getState().viewport
|
||||
const unsubscribe = store.subscribe(state => {
|
||||
const v = state.viewport
|
||||
if (v === lastViewport) return
|
||||
lastViewport = v
|
||||
updateTransform(v)
|
||||
onViewportChange?.(v)
|
||||
})
|
||||
return unsubscribe
|
||||
}, [store, onViewportChange])
|
||||
|
||||
useEffect(() => {
|
||||
let lastSelectedIds = store.getState().selectedIds
|
||||
return store.subscribe(state => {
|
||||
const ids = state.selectedIds
|
||||
if (ids === lastSelectedIds) return
|
||||
lastSelectedIds = ids
|
||||
onSelect?.(ids)
|
||||
})
|
||||
}, [store, onSelect])
|
||||
|
||||
// Subscribe to removals
|
||||
useEffect(() => {
|
||||
let lastOrder = store.getState().elementOrder
|
||||
return store.subscribe(state => {
|
||||
const newOrder = state.elementOrder
|
||||
if (newOrder === lastOrder) return
|
||||
const removedIds = lastOrder.filter(id => !newOrder.includes(id))
|
||||
lastOrder = newOrder
|
||||
if (removedIds.length > 0) {
|
||||
onElementsRemove?.(removedIds)
|
||||
}
|
||||
})
|
||||
}, [store, onElementsRemove])
|
||||
|
||||
// Subscribe to hover changes
|
||||
useEffect(() => {
|
||||
let lastHoveredId = store.getState().hoveredElementId
|
||||
return store.subscribe(state => {
|
||||
const id = state.hoveredElementId
|
||||
if (id === lastHoveredId) return
|
||||
lastHoveredId = id
|
||||
onHoverChange?.(id)
|
||||
})
|
||||
}, [store, onHoverChange])
|
||||
|
||||
// Force zero scroll to prevent coordinate misalignment
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const resetScroll = () => {
|
||||
if (container.scrollTop !== 0) container.scrollTop = 0
|
||||
if (container.scrollLeft !== 0) container.scrollLeft = 0
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', resetScroll)
|
||||
// Initial reset
|
||||
resetScroll()
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', resetScroll)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useGestures(containerRef, { minScale, maxScale })
|
||||
|
||||
const interactionOptions = React.useMemo(
|
||||
() => ({
|
||||
minScale,
|
||||
maxScale,
|
||||
onTransformEnd,
|
||||
onClick,
|
||||
onElementClick,
|
||||
}),
|
||||
[minScale, maxScale, onTransformEnd, onClick, onElementClick],
|
||||
)
|
||||
|
||||
const {
|
||||
onDoubleClickCapture,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
} = useInteractions(containerRef, interactionOptions)
|
||||
const renderHooksValue = React.useMemo(
|
||||
() => ({
|
||||
renderElement,
|
||||
renderElementMetadata,
|
||||
}),
|
||||
[renderElement, renderElementMetadata],
|
||||
)
|
||||
const mode = useDominoStore(state => state.mode)
|
||||
|
||||
return (
|
||||
<DominoRenderHooksContext.Provider value={renderHooksValue}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'domino-canvas-root domino-canvas relative w-full h-full overflow-hidden bg-muted outline-none select-none touch-none',
|
||||
`${mode}-mode`,
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
tabIndex={-1}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault()
|
||||
const target = e.target as HTMLElement
|
||||
const elementEl = target.closest('.domino-element')
|
||||
onContextMenu?.(e, elementEl?.id)
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onDoubleClick={onDoubleClickCapture}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
>
|
||||
<div
|
||||
ref={worldRef}
|
||||
className='absolute origin-top-left pointer-events-none'
|
||||
>
|
||||
{elementOrder.map(id => (
|
||||
<ElementRenderer key={id} id={id} />
|
||||
))}
|
||||
|
||||
<SelectionOverlay />
|
||||
|
||||
<MarqueeOverlay />
|
||||
</div>
|
||||
|
||||
{/* Overlay UI Layer - Separated to prevent event bubbling to interaction layer */}
|
||||
<div className='absolute inset-0 pointer-events-none overflow-hidden'>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</DominoRenderHooksContext.Provider>
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
112
components/image-editor/components/canvas/domino-hooks.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { createContext, useContext, useMemo, type RefObject } from 'react'
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||
import type { DominoStore, DominoStoreState } from './domino-store'
|
||||
import { getElementsBounds } from './math'
|
||||
import type { DominoCanvasProps } from './domino'
|
||||
|
||||
// --- 1. Context Definitions ---
|
||||
|
||||
/**
|
||||
* Store Context: 核心状态管理器
|
||||
*/
|
||||
export const DominoContext = createContext<DominoStore | null>(null)
|
||||
|
||||
/**
|
||||
* DOM 引用上下文:提供容器节点的稳定引用
|
||||
*/
|
||||
export const DominoDOMContext =
|
||||
createContext<RefObject<HTMLElement | null> | null>(null)
|
||||
|
||||
/**
|
||||
* 渲染钩子上下文:提供业务自定义渲染函数
|
||||
*/
|
||||
export const DominoRenderHooksContext = createContext<{
|
||||
renderElement?: DominoCanvasProps['renderElement']
|
||||
renderElementMetadata?: DominoCanvasProps['renderElementMetadata']
|
||||
}>({})
|
||||
|
||||
// --- 2. Store Consumption Hooks ---
|
||||
|
||||
export function useDominoStore<T>(
|
||||
selector: (state: DominoStoreState) => T,
|
||||
equalityFn?: (a: T, b: T) => boolean,
|
||||
): T {
|
||||
const store = useContext(DominoContext)
|
||||
if (!store) {
|
||||
throw new Error('useDominoStore must be used within a DominoProvider')
|
||||
}
|
||||
return useStoreWithEqualityFn(store, selector, equalityFn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape hatch hook to get direct access to the raw Zustand store.
|
||||
* Prefer using useDominoInstance or specialized useDomino* hooks.
|
||||
*/
|
||||
export function useDominoStoreInstance() {
|
||||
const store = useContext(DominoContext)
|
||||
if (!store) {
|
||||
throw new Error(
|
||||
'useDominoStoreInstance must be used within a DominoProvider',
|
||||
)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the current viewport data (x, y, scale)
|
||||
*/
|
||||
export function useDominoViewportData() {
|
||||
return useDominoStore(state => state.viewport)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get all elements in the canvas
|
||||
*/
|
||||
export function useDominoElements() {
|
||||
return useDominoStore(state => state.elements)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the currently selected element IDs
|
||||
*/
|
||||
export function useDominoSelectedIds() {
|
||||
return useDominoStore(state => state.selectedIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the current interaction mode (select | pan)
|
||||
*/
|
||||
export function useDominoMode() {
|
||||
return useDominoStore(state => state.mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get all element UI states (status, loading text, etc.)
|
||||
*/
|
||||
export function useDominoElementUIStates() {
|
||||
return useDominoStore(state => state.elementUIStates)
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic hook to get the current selection boundary (derived state).
|
||||
* Correctly handles single vs multi selection.
|
||||
*/
|
||||
export function useDominoSelectionBounds() {
|
||||
const selectedIds = useDominoSelectedIds()
|
||||
const elements = useDominoElements()
|
||||
const [placeholders] = useDominoStore(state => [state.placeholders])
|
||||
|
||||
return useMemo(() => {
|
||||
if (selectedIds.length === 0) return null
|
||||
|
||||
// For single selection, we can just return the element's bounds directly
|
||||
// but getElementsBounds handles it correctly too.
|
||||
const allItems = { ...elements, ...placeholders }
|
||||
return getElementsBounds(selectedIds, allItems)
|
||||
}, [selectedIds, elements, placeholders])
|
||||
}
|
||||
|
||||
// --- 3. Internal Canvas Context Hooks ---
|
||||
|
||||
export const useDominoDOMContext = () => useContext(DominoDOMContext)
|
||||
export const useDominoRenderHooks = () => useContext(DominoRenderHooksContext)
|
||||
@@ -0,0 +1,21 @@
|
||||
import React, { type ReactNode, useRef } from 'react'
|
||||
import type { DominoStore } from './domino-store'
|
||||
import { DominoContext, DominoDOMContext } from './domino-hooks'
|
||||
|
||||
export function DominoProvider({
|
||||
children,
|
||||
store,
|
||||
}: {
|
||||
children: ReactNode
|
||||
store: DominoStore
|
||||
}) {
|
||||
const containerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
return (
|
||||
<DominoContext.Provider value={store}>
|
||||
<DominoDOMContext.Provider value={containerRef}>
|
||||
{children}
|
||||
</DominoDOMContext.Provider>
|
||||
</DominoContext.Provider>
|
||||
)
|
||||
}
|
||||
637
components/image-editor/components/canvas/domino-store.ts
Normal file
@@ -0,0 +1,637 @@
|
||||
import { createStore } from 'zustand'
|
||||
import { subscribeWithSelector } from 'zustand/middleware'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import type {
|
||||
DominoState,
|
||||
SceneElement,
|
||||
Viewport,
|
||||
Snapshot,
|
||||
PlaceholderElement,
|
||||
Padding,
|
||||
GroupElement,
|
||||
ArtboardElement,
|
||||
} from './domino'
|
||||
import { getElementWorldRect } from './math'
|
||||
|
||||
export function createSnapshot(
|
||||
elements: Record<string, SceneElement>,
|
||||
elementOrder: string[],
|
||||
): Snapshot {
|
||||
// We keep the full elementOrder to preserve placeholder positions
|
||||
const filteredElements = { ...elements }
|
||||
return { elements: filteredElements, elementOrder: [...elementOrder] }
|
||||
}
|
||||
|
||||
export interface CanvasActions {
|
||||
setViewport: (viewport: Partial<Viewport>) => void
|
||||
addElement: (element: SceneElement) => void
|
||||
removeElement: (id: string) => void
|
||||
updateElement: (id: string, updates: Partial<SceneElement>) => void
|
||||
updateElementUIState: (
|
||||
id: string,
|
||||
updates: Partial<DominoState['elementUIStates'][string]>,
|
||||
) => void
|
||||
setSelectedIds: (ids: string[]) => void
|
||||
moveViewport: (dx: number, dy: number) => void
|
||||
zoomViewport: (
|
||||
scaleMultiplier: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
minScale?: number,
|
||||
maxScale?: number,
|
||||
) => void
|
||||
moveElements: (ids: string[], dx: number, dy: number) => void
|
||||
setSelectionBox: (box: DominoState['selectionBox']) => void
|
||||
setMode: (mode: 'select' | 'pan') => void
|
||||
clearElements: (initialViewport?: Partial<Viewport>) => void
|
||||
setFocusedElementId: (id: string | null) => void
|
||||
setHoveredElementId: (id: string | null) => void
|
||||
setSnapLines: (lines: DominoState['snapLines']) => void
|
||||
moveElementUp: (id: string) => void
|
||||
moveElementDown: (id: string) => void
|
||||
moveElementToTop: (id: string) => void
|
||||
moveElementToBottom: (id: string) => void
|
||||
setPadding: (padding: Partial<Padding>) => void
|
||||
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
takeSnapshot: () => void
|
||||
resetHistory: () => void
|
||||
addPlaceholder: (element: PlaceholderElement) => void
|
||||
removePlaceholder: (id: string) => void
|
||||
updatePlaceholder: (id: string, updates: Partial<PlaceholderElement>) => void
|
||||
setElementsData: (
|
||||
elements: Record<string, SceneElement>,
|
||||
order: string[],
|
||||
) => void
|
||||
setReadOnly: (readOnly: boolean) => void
|
||||
moveElementToParent: (
|
||||
id: string,
|
||||
targetParentId: string | null,
|
||||
targetIndex?: number,
|
||||
) => void
|
||||
}
|
||||
|
||||
export type DominoStoreState = DominoState & CanvasActions
|
||||
export type DominoStore = ReturnType<typeof createDominoStore>
|
||||
|
||||
export function createDominoStore(initialState?: Partial<DominoState>) {
|
||||
return createStore<DominoStoreState>()(
|
||||
subscribeWithSelector(
|
||||
immer(set => ({
|
||||
elements: {},
|
||||
placeholders: {},
|
||||
elementUIStates: {},
|
||||
elementOrder: [],
|
||||
selectedIds: [],
|
||||
focusedElementId: null,
|
||||
hoveredElementId: null,
|
||||
selectionBox: null,
|
||||
metadata: {},
|
||||
mode: 'select',
|
||||
viewport: { x: 0, y: 0, scale: 0.5 },
|
||||
snapLines: null,
|
||||
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
readOnly: false,
|
||||
past: [],
|
||||
future: [],
|
||||
...initialState,
|
||||
|
||||
setViewport: viewport =>
|
||||
set(state => {
|
||||
const hasChange = Object.entries(viewport).some(
|
||||
([key, value]) => state.viewport[key as keyof Viewport] !== value,
|
||||
)
|
||||
if (!hasChange) return
|
||||
state.viewport = { ...state.viewport, ...viewport }
|
||||
}),
|
||||
|
||||
setElementsData: (
|
||||
elements: Record<string, SceneElement>,
|
||||
order: string[],
|
||||
) =>
|
||||
set(state => {
|
||||
state.elements = elements
|
||||
state.elementOrder = order
|
||||
}),
|
||||
|
||||
moveViewport: (dx, dy) =>
|
||||
set(state => {
|
||||
state.viewport.x += dx
|
||||
state.viewport.y += dy
|
||||
}),
|
||||
|
||||
zoomViewport: (
|
||||
multiplier,
|
||||
centerX,
|
||||
centerY,
|
||||
minScale = 0.1,
|
||||
maxScale = 20,
|
||||
) =>
|
||||
set(state => {
|
||||
const oldScale = state.viewport.scale
|
||||
const newScale = Math.max(
|
||||
minScale,
|
||||
Math.min(maxScale, oldScale * multiplier),
|
||||
)
|
||||
|
||||
state.viewport.x =
|
||||
centerX - (centerX - state.viewport.x) * (newScale / oldScale)
|
||||
state.viewport.y =
|
||||
centerY - (centerY - state.viewport.y) * (newScale / oldScale)
|
||||
state.viewport.scale = newScale
|
||||
}),
|
||||
|
||||
takeSnapshot: () =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
state.past.push(createSnapshot(state.elements, state.elementOrder))
|
||||
if (state.past.length > 100) {
|
||||
state.past.shift()
|
||||
}
|
||||
state.future = []
|
||||
}),
|
||||
|
||||
undo: () =>
|
||||
set(state => {
|
||||
if (state.readOnly || state.past.length === 0) return
|
||||
const previous = state.past.pop()
|
||||
if (previous) {
|
||||
state.future.push(
|
||||
createSnapshot(state.elements, state.elementOrder),
|
||||
)
|
||||
|
||||
// Revert elements
|
||||
state.elements = previous.elements
|
||||
|
||||
// Smart merge elementOrder:
|
||||
// We want to keep placeholders that are currently active
|
||||
const currentPlaceholders = { ...state.placeholders }
|
||||
state.elementOrder = previous.elementOrder.filter(
|
||||
id => state.elements[id] || currentPlaceholders[id],
|
||||
)
|
||||
|
||||
// Add back any active placeholders that weren't in the snapshot's order
|
||||
Object.keys(currentPlaceholders).forEach(pid => {
|
||||
if (!state.elementOrder.includes(pid)) {
|
||||
state.elementOrder.push(pid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
redo: () =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
const next = state.future.pop()
|
||||
if (next) {
|
||||
state.past.push(
|
||||
createSnapshot(state.elements, state.elementOrder),
|
||||
)
|
||||
|
||||
// Revert elements
|
||||
state.elements = next.elements
|
||||
|
||||
// Smart merge elementOrder to preserve active placeholders
|
||||
const currentPlaceholders = { ...state.placeholders }
|
||||
state.elementOrder = next.elementOrder.filter(
|
||||
id => state.elements[id] || currentPlaceholders[id],
|
||||
)
|
||||
|
||||
// Ensure current placeholders are still in the order
|
||||
Object.keys(currentPlaceholders).forEach(pid => {
|
||||
if (!state.elementOrder.includes(pid)) {
|
||||
state.elementOrder.push(pid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
resetHistory: () =>
|
||||
set(state => {
|
||||
state.past = []
|
||||
state.future = []
|
||||
}),
|
||||
|
||||
addElement: element =>
|
||||
set(state => {
|
||||
if (state.readOnly || state.elements[element.id]) return
|
||||
|
||||
// Record history before change
|
||||
state.past.push(createSnapshot(state.elements, state.elementOrder))
|
||||
state.future = []
|
||||
if (state.past.length > 100) state.past.shift()
|
||||
|
||||
state.elements[element.id] = element
|
||||
|
||||
if (!element.parentId) {
|
||||
if (!state.elementOrder.includes(element.id)) {
|
||||
state.elementOrder.push(element.id)
|
||||
}
|
||||
} else {
|
||||
const parent = state.elements[element.parentId]
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === 'artboard' || parent.type === 'group')
|
||||
) {
|
||||
if (!parent.childrenIds.includes(element.id)) {
|
||||
parent.childrenIds.push(element.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
addPlaceholder: element =>
|
||||
set(state => {
|
||||
if (state.elements[element.id] || state.placeholders[element.id])
|
||||
return
|
||||
|
||||
state.placeholders[element.id] = element
|
||||
|
||||
if (!element.parentId) {
|
||||
if (!state.elementOrder.includes(element.id)) {
|
||||
state.elementOrder.push(element.id)
|
||||
}
|
||||
} else {
|
||||
const parent =
|
||||
state.elements[element.parentId] ||
|
||||
state.placeholders[element.parentId]
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === 'artboard' || parent.type === 'group')
|
||||
) {
|
||||
if (!parent.childrenIds.includes(element.id)) {
|
||||
parent.childrenIds.push(element.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
removeElement: id =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
const element = state.elements[id]
|
||||
if (!element) return
|
||||
|
||||
// Record history
|
||||
state.past.push(createSnapshot(state.elements, state.elementOrder))
|
||||
state.future = []
|
||||
if (state.past.length > 100) state.past.shift()
|
||||
|
||||
const { parentId } = element
|
||||
delete state.elements[id]
|
||||
state.elementOrder = state.elementOrder.filter(oid => oid !== id)
|
||||
|
||||
if (parentId) {
|
||||
const parent = state.elements[parentId]
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === 'artboard' || parent.type === 'group')
|
||||
) {
|
||||
parent.childrenIds = parent.childrenIds.filter(
|
||||
(cid: string) => cid !== id,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.selectedIds.includes(id)) {
|
||||
state.selectedIds = state.selectedIds.filter(sid => sid !== id)
|
||||
}
|
||||
if (state.focusedElementId === id) {
|
||||
state.focusedElementId = null
|
||||
}
|
||||
delete state.elementUIStates[id]
|
||||
}),
|
||||
|
||||
removePlaceholder: id =>
|
||||
set(state => {
|
||||
const element = state.placeholders[id]
|
||||
if (!element) return
|
||||
|
||||
const { parentId } = element
|
||||
delete state.placeholders[id]
|
||||
state.elementOrder = state.elementOrder.filter(oid => oid !== id)
|
||||
|
||||
if (parentId) {
|
||||
const parent =
|
||||
state.elements[parentId] || state.placeholders[parentId]
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === 'artboard' || parent.type === 'group')
|
||||
) {
|
||||
parent.childrenIds = parent.childrenIds.filter(
|
||||
(cid: string) => cid !== id,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.selectedIds.includes(id)) {
|
||||
state.selectedIds = state.selectedIds.filter(sid => sid !== id)
|
||||
}
|
||||
if (state.focusedElementId === id) {
|
||||
state.focusedElementId = null
|
||||
}
|
||||
delete state.elementUIStates[id]
|
||||
}),
|
||||
|
||||
updateElement: (id, updates) =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
const element = state.elements[id]
|
||||
if (element) {
|
||||
state.elements[id] = {
|
||||
...element,
|
||||
...updates,
|
||||
} as SceneElement
|
||||
}
|
||||
}),
|
||||
|
||||
updatePlaceholder: (id, updates) =>
|
||||
set(state => {
|
||||
if (state.placeholders[id]) {
|
||||
state.placeholders[id] = {
|
||||
...state.placeholders[id],
|
||||
...updates,
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
updateElementUIState: (id, updates) =>
|
||||
set(state => {
|
||||
if (!state.elementUIStates[id]) {
|
||||
state.elementUIStates[id] = { status: 'idle' }
|
||||
}
|
||||
state.elementUIStates[id] = {
|
||||
...state.elementUIStates[id],
|
||||
...updates,
|
||||
}
|
||||
}),
|
||||
|
||||
setSelectedIds: ids =>
|
||||
set(state => {
|
||||
state.selectedIds = ids
|
||||
}),
|
||||
|
||||
moveElements: (ids, dx, dy) =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
ids.forEach(id => {
|
||||
const el = state.elements[id]
|
||||
if (el) {
|
||||
el.x += dx
|
||||
el.y += dy
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
setSelectionBox: box =>
|
||||
set(state => {
|
||||
state.selectionBox = box
|
||||
}),
|
||||
|
||||
setMode: mode =>
|
||||
set(state => {
|
||||
state.mode = mode
|
||||
}),
|
||||
|
||||
clearElements: (initialViewport?: Partial<Viewport>) =>
|
||||
set(state => {
|
||||
// Record history if not already empty
|
||||
if (state.elementOrder.length > 0) {
|
||||
state.past.push(
|
||||
createSnapshot(state.elements, state.elementOrder),
|
||||
)
|
||||
state.future = []
|
||||
if (state.past.length > 100) state.past.shift()
|
||||
}
|
||||
|
||||
state.elements = {}
|
||||
state.placeholders = {}
|
||||
state.elementUIStates = {}
|
||||
state.elementOrder = []
|
||||
state.selectedIds = []
|
||||
state.viewport = { x: 0, y: 0, scale: 0.5, ...initialViewport }
|
||||
state.mode = 'select'
|
||||
state.focusedElementId = null
|
||||
state.hoveredElementId = null
|
||||
}),
|
||||
|
||||
setFocusedElementId: (id: string | null) =>
|
||||
set(state => {
|
||||
state.focusedElementId = id
|
||||
}),
|
||||
|
||||
setHoveredElementId: (id: string | null) =>
|
||||
set(state => {
|
||||
state.hoveredElementId = id
|
||||
}),
|
||||
setSnapLines: lines =>
|
||||
set(state => {
|
||||
state.snapLines = lines
|
||||
}),
|
||||
moveElementUp: (id: string) =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
const element = state.elements[id]
|
||||
if (!element) return
|
||||
|
||||
const orderArray = element.parentId
|
||||
? (
|
||||
state.elements[element.parentId] as
|
||||
| ArtboardElement
|
||||
| GroupElement
|
||||
)?.childrenIds
|
||||
: state.elementOrder
|
||||
|
||||
if (!orderArray) return
|
||||
|
||||
const index = orderArray.indexOf(id)
|
||||
if (index > -1 && index < orderArray.length - 1) {
|
||||
// Record history
|
||||
state.past.push(
|
||||
createSnapshot(state.elements, state.elementOrder),
|
||||
)
|
||||
state.future = []
|
||||
if (state.past.length > 100) state.past.shift()
|
||||
|
||||
const temp = orderArray[index]
|
||||
orderArray[index] = orderArray[index + 1]
|
||||
orderArray[index + 1] = temp
|
||||
}
|
||||
}),
|
||||
moveElementDown: (id: string) =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
const element = state.elements[id]
|
||||
if (!element) return
|
||||
|
||||
const orderArray = element.parentId
|
||||
? (
|
||||
state.elements[element.parentId] as
|
||||
| ArtboardElement
|
||||
| GroupElement
|
||||
)?.childrenIds
|
||||
: state.elementOrder
|
||||
|
||||
if (!orderArray) return
|
||||
|
||||
const index = orderArray.indexOf(id)
|
||||
if (index > 0) {
|
||||
// Record history
|
||||
state.past.push(
|
||||
createSnapshot(state.elements, state.elementOrder),
|
||||
)
|
||||
state.future = []
|
||||
if (state.past.length > 100) state.past.shift()
|
||||
|
||||
const temp = orderArray[index]
|
||||
orderArray[index] = orderArray[index - 1]
|
||||
orderArray[index - 1] = temp
|
||||
}
|
||||
}),
|
||||
moveElementToTop: (id: string) =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
const element = state.elements[id]
|
||||
if (!element) return
|
||||
|
||||
const orderArray = element.parentId
|
||||
? (
|
||||
state.elements[element.parentId] as
|
||||
| ArtboardElement
|
||||
| GroupElement
|
||||
)?.childrenIds
|
||||
: state.elementOrder
|
||||
|
||||
if (!orderArray) return
|
||||
|
||||
const index = orderArray.indexOf(id)
|
||||
if (index > -1 && index < orderArray.length - 1) {
|
||||
// Record history
|
||||
state.past.push(
|
||||
createSnapshot(state.elements, state.elementOrder),
|
||||
)
|
||||
state.future = []
|
||||
if (state.past.length > 100) state.past.shift()
|
||||
|
||||
orderArray.splice(index, 1)
|
||||
orderArray.push(id)
|
||||
}
|
||||
}),
|
||||
moveElementToBottom: (id: string) =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
const element = state.elements[id]
|
||||
if (!element) return
|
||||
|
||||
const orderArray = element.parentId
|
||||
? (
|
||||
state.elements[element.parentId] as
|
||||
| ArtboardElement
|
||||
| GroupElement
|
||||
)?.childrenIds
|
||||
: state.elementOrder
|
||||
|
||||
if (!orderArray) return
|
||||
|
||||
const index = orderArray.indexOf(id)
|
||||
if (index > 0) {
|
||||
// Record history
|
||||
state.past.push(
|
||||
createSnapshot(state.elements, state.elementOrder),
|
||||
)
|
||||
state.future = []
|
||||
if (state.past.length > 100) state.past.shift()
|
||||
|
||||
orderArray.splice(index, 1)
|
||||
orderArray.unshift(id)
|
||||
}
|
||||
}),
|
||||
|
||||
setPadding: padding =>
|
||||
set(state => {
|
||||
const hasChange = Object.entries(padding).some(
|
||||
([key, value]) => state.padding[key as keyof Padding] !== value,
|
||||
)
|
||||
if (!hasChange) return
|
||||
state.padding = { ...state.padding, ...padding }
|
||||
}),
|
||||
setReadOnly: readOnly =>
|
||||
set(state => {
|
||||
if (state.readOnly === readOnly) return
|
||||
state.readOnly = readOnly
|
||||
}),
|
||||
|
||||
moveElementToParent: (id, targetParentId, targetIndex) =>
|
||||
set(state => {
|
||||
if (state.readOnly) return
|
||||
const element = state.elements[id]
|
||||
if (!element || element.parentId === targetParentId) return
|
||||
|
||||
// 记录历史快照
|
||||
state.past.push(createSnapshot(state.elements, state.elementOrder))
|
||||
state.future = []
|
||||
if (state.past.length > 100) state.past.shift()
|
||||
|
||||
// 1. 从当前父级中移除
|
||||
if (!element.parentId) {
|
||||
state.elementOrder = state.elementOrder.filter(oid => oid !== id)
|
||||
} else {
|
||||
const oldParent = state.elements[element.parentId] as
|
||||
| ArtboardElement
|
||||
| GroupElement
|
||||
if (oldParent && oldParent.childrenIds) {
|
||||
oldParent.childrenIds = oldParent.childrenIds.filter(
|
||||
(cid: string) => cid !== id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 计算新坐标以维持世界坐标位置不变
|
||||
const worldRect = getElementWorldRect(element, state.elements)
|
||||
let newX = worldRect.x
|
||||
let newY = worldRect.y
|
||||
|
||||
if (targetParentId) {
|
||||
const newParent = state.elements[targetParentId]
|
||||
if (newParent) {
|
||||
const parentWorld = getElementWorldRect(
|
||||
newParent,
|
||||
state.elements,
|
||||
)
|
||||
newX -= parentWorld.x
|
||||
newY -= parentWorld.y
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 更新父级关联和局部坐标
|
||||
element.parentId = targetParentId || undefined
|
||||
element.x = newX
|
||||
element.y = newY
|
||||
|
||||
// 4. 将其添加到新父级的子元素列表或顶层列表中
|
||||
if (!targetParentId) {
|
||||
if (!state.elementOrder.includes(id)) {
|
||||
if (typeof targetIndex === 'number') {
|
||||
state.elementOrder.splice(targetIndex, 0, id)
|
||||
} else {
|
||||
state.elementOrder.push(id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newParent = state.elements[targetParentId] as
|
||||
| ArtboardElement
|
||||
| GroupElement
|
||||
if (newParent && newParent.childrenIds) {
|
||||
if (!newParent.childrenIds.includes(id)) {
|
||||
if (typeof targetIndex === 'number') {
|
||||
newParent.childrenIds.splice(targetIndex, 0, id)
|
||||
} else {
|
||||
newParent.childrenIds.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
251
components/image-editor/components/canvas/domino.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
export type ElementStatus =
|
||||
| 'pending'
|
||||
| 'idle'
|
||||
| 'error'
|
||||
| 'success'
|
||||
| 'redrawing'
|
||||
| 'readonly'
|
||||
|
||||
export type ElementType =
|
||||
| 'text'
|
||||
| 'group'
|
||||
| 'artboard'
|
||||
| 'image'
|
||||
| 'shape'
|
||||
| 'placeholder'
|
||||
|
||||
export interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface ElementUIState {
|
||||
status: ElementStatus
|
||||
statusText?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ResizeType = 'none' | 'both' | 'horizontal' | 'vertical'
|
||||
|
||||
export interface BaseElement extends Rect {
|
||||
id: string
|
||||
type: ElementType
|
||||
rotation: number
|
||||
originalWidth: number
|
||||
originalHeight: number
|
||||
parentId?: string
|
||||
hideMetadata?: boolean
|
||||
lockAspectRatio?: boolean
|
||||
resize?: ResizeType
|
||||
rotatable?: boolean
|
||||
scalable?: boolean
|
||||
selectable?: boolean
|
||||
draggable?: boolean
|
||||
data?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArtboardElement extends BaseElement {
|
||||
type: 'artboard'
|
||||
name?: string
|
||||
childrenIds: string[]
|
||||
background?: string
|
||||
}
|
||||
|
||||
export interface GroupElement extends BaseElement {
|
||||
type: 'group'
|
||||
childrenIds: string[]
|
||||
}
|
||||
|
||||
export interface ImageElement extends BaseElement {
|
||||
type: 'image'
|
||||
src: string
|
||||
fileName: string
|
||||
data?: {
|
||||
path?: string
|
||||
size?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface TextStyle {
|
||||
fontSize?: number
|
||||
fontWeight?: string | number
|
||||
color?: string
|
||||
fontFamily?: string
|
||||
lineHeight?: number
|
||||
textAlign?: 'left' | 'center' | 'right'
|
||||
fontStyle?: 'normal' | 'italic'
|
||||
}
|
||||
|
||||
export interface TextElement extends BaseElement, TextStyle {
|
||||
type: 'text'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface PlaceholderElement extends BaseElement {
|
||||
type: 'placeholder'
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂时没有这种类型
|
||||
*/
|
||||
export interface ShapeElement extends BaseElement {
|
||||
type: 'shape'
|
||||
shapeType: 'rect' | 'ellipse' | 'polygon'
|
||||
fill?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
export type SceneElement =
|
||||
| ArtboardElement
|
||||
| GroupElement
|
||||
| ImageElement
|
||||
| TextElement
|
||||
| ShapeElement
|
||||
| PlaceholderElement
|
||||
|
||||
export interface Viewport {
|
||||
x: number
|
||||
y: number
|
||||
scale: number
|
||||
}
|
||||
|
||||
export interface SnapLine {
|
||||
type: 'x' | 'y'
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
elements: Record<string, SceneElement>
|
||||
elementOrder: string[]
|
||||
}
|
||||
|
||||
export interface Padding {
|
||||
top?: number
|
||||
right?: number
|
||||
bottom?: number
|
||||
left?: number
|
||||
}
|
||||
|
||||
export type ScrollAlignment = 'start' | 'center' | 'end' | 'nearest'
|
||||
|
||||
export interface ScrollIntoViewOptions {
|
||||
/**
|
||||
* 避开 UI 的内边距
|
||||
*/
|
||||
padding?: Partial<Padding>
|
||||
/**
|
||||
* 垂直方向对齐方式
|
||||
* @default 'nearest'
|
||||
*/
|
||||
block?: ScrollAlignment
|
||||
/**
|
||||
* 水平方向对齐方式
|
||||
* @default 'nearest'
|
||||
*/
|
||||
inline?: ScrollAlignment
|
||||
/**
|
||||
* 是否强制平移(即使已在范围内)
|
||||
*/
|
||||
force?: boolean
|
||||
/**
|
||||
* 缩放级别,如果不提供则使用当前缩放
|
||||
*/
|
||||
scale?: number
|
||||
}
|
||||
|
||||
export interface DominoState {
|
||||
elements: Record<string, SceneElement>
|
||||
placeholders: Record<string, PlaceholderElement>
|
||||
elementUIStates: Record<string, ElementUIState>
|
||||
elementOrder: string[] // Top-level elements ordering
|
||||
selectedIds: string[]
|
||||
focusedElementId: string | null
|
||||
hoveredElementId: string | null
|
||||
selectionBox: { x: number; y: number; width: number; height: number } | null
|
||||
mode: 'select' | 'pan'
|
||||
viewport: Viewport
|
||||
snapLines: SnapLine[] | null
|
||||
readOnly: boolean
|
||||
// Metadata for persistence
|
||||
metadata?: {
|
||||
createdAt?: number
|
||||
updatedAt?: number
|
||||
}
|
||||
padding: Padding
|
||||
// History
|
||||
past: Snapshot[]
|
||||
future: Snapshot[]
|
||||
}
|
||||
|
||||
export interface DominoCanvasData {
|
||||
elements: Record<string, SceneElement>
|
||||
elementOrder: string[]
|
||||
viewport?: Viewport
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface DominoActions {
|
||||
setViewport: (viewport: Partial<Viewport>) => void
|
||||
setElementsData: (
|
||||
elements: Record<string, SceneElement>,
|
||||
order: string[],
|
||||
) => void
|
||||
setFocusedElementId: (id: string | null) => void
|
||||
setHoveredElementId: (id: string | null) => void
|
||||
updateElementSize: (
|
||||
id: string,
|
||||
width: number,
|
||||
height: number,
|
||||
x?: number,
|
||||
y?: number,
|
||||
) => void
|
||||
|
||||
// History Actions
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
takeSnapshot: () => void
|
||||
resetHistory: () => void
|
||||
setReadOnly: (readOnly: boolean) => void
|
||||
}
|
||||
|
||||
export interface DominoEvents {
|
||||
onClick?: (e: React.MouseEvent | React.PointerEvent, id?: string) => void
|
||||
onSelect?: (ids: string[]) => void
|
||||
onElementClick?: (id: string, element: SceneElement) => void
|
||||
onTransformEnd?: (ids: string[]) => void
|
||||
onViewportChange?: (viewport: Viewport) => void
|
||||
onContextMenu?: (e: React.MouseEvent, id?: string) => void
|
||||
onHoverChange?: (id: string | null) => void
|
||||
onBeforeRemove?: (ids: string[]) => Promise<boolean> | boolean
|
||||
onElementsRemove?: (ids: string[]) => void
|
||||
}
|
||||
|
||||
export interface DominoCanvasProps extends DominoEvents {
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
minScale?: number
|
||||
maxScale?: number
|
||||
initViewport?: Partial<Viewport>
|
||||
padding?: Partial<Padding>
|
||||
readOnly?: boolean
|
||||
children?: React.ReactNode
|
||||
|
||||
// Controlled data
|
||||
elements?: Record<string, SceneElement>
|
||||
elementOrder?: string[]
|
||||
|
||||
// Render hooks
|
||||
renderElement?: (props: {
|
||||
element: SceneElement
|
||||
defaultRender: React.ReactNode
|
||||
}) => React.ReactNode
|
||||
renderElementMetadata?: (props: { element: SceneElement }) => React.ReactNode
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { memo } from 'react'
|
||||
import type {
|
||||
ArtboardElement,
|
||||
GroupElement,
|
||||
SceneElement,
|
||||
TextElement,
|
||||
} from './domino'
|
||||
import { useDominoStore } from './domino-hooks'
|
||||
import Placeholder from './placeholder'
|
||||
import { TextRender } from './text-render'
|
||||
import { ElementRenderer } from './element-renderer'
|
||||
|
||||
/**
|
||||
* 核心内容渲染器:只管元素长什么样
|
||||
*/
|
||||
function ElementContent({ element }: { element: SceneElement }) {
|
||||
const uiState = useDominoStore(state => state.elementUIStates[element.id])
|
||||
|
||||
switch (element.type) {
|
||||
case 'image':
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={element.src}
|
||||
alt={element.fileName}
|
||||
crossOrigin='anonymous'
|
||||
loading='eager'
|
||||
decoding='async'
|
||||
className='w-full h-full object-contain pointer-events-none bg-white/5'
|
||||
/>
|
||||
{uiState?.status === 'pending' && (
|
||||
<Placeholder className='z-1 h-full w-full'>
|
||||
<span
|
||||
className='text-white'
|
||||
style={{ fontSize: 'calc(14px / var(--domino-scale))' }}
|
||||
>
|
||||
{uiState?.statusText}
|
||||
</span>
|
||||
</Placeholder>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
case 'text': {
|
||||
return <TextRender element={element as TextElement} />
|
||||
}
|
||||
case 'group': {
|
||||
const childrenIds = (element as GroupElement).childrenIds
|
||||
return (
|
||||
<div className='relative'>
|
||||
{childrenIds.map((childId: string) => (
|
||||
<ElementRenderer key={childId} id={childId} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'artboard': {
|
||||
const childrenIds = (element as ArtboardElement).childrenIds
|
||||
return (
|
||||
<div
|
||||
className='relative w-full h-full'
|
||||
style={{
|
||||
background: element.background || '#fff',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{childrenIds.map((childId: string) => (
|
||||
<ElementRenderer key={childId} id={childId} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'placeholder':
|
||||
return (
|
||||
<Placeholder>
|
||||
<span style={{ fontSize: 'calc(16px / var(--domino-scale))' }}>
|
||||
{element.label}
|
||||
</span>
|
||||
</Placeholder>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const MemoizedElementContent = memo(ElementContent)
|
||||
189
components/image-editor/components/canvas/element-renderer.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { type SceneElement } from './domino'
|
||||
import { DOMINO_EL_PREFIX } from './constants'
|
||||
import {
|
||||
useDominoStore,
|
||||
useDominoStoreInstance,
|
||||
useDominoRenderHooks,
|
||||
} from './domino-hooks'
|
||||
|
||||
import { MemoizedElementContent } from './element-content'
|
||||
|
||||
export interface ElementRendererProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一交互外壳:处理坐标、选中、悬停、焦点、递归
|
||||
*/
|
||||
export const ElementRenderer: React.FC<ElementRendererProps> = React.memo(
|
||||
function ElementRenderer({ id }) {
|
||||
const store = useDominoStoreInstance()
|
||||
const element = useDominoStore(
|
||||
state => state.elements[id] || state.placeholders[id],
|
||||
)
|
||||
const isFocused = useDominoStore(state => state.focusedElementId === id)
|
||||
const setHoveredElementId = useDominoStore(
|
||||
state => state.setHoveredElementId,
|
||||
)
|
||||
const setFocusedElementId = useDominoStore(
|
||||
state => state.setFocusedElementId,
|
||||
)
|
||||
|
||||
const { renderElement } = useDominoRenderHooks()
|
||||
|
||||
const elementRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// For text elements, we want the inner contentEditable to have focus, not the wrapper
|
||||
if (!element || element?.type === 'text') return
|
||||
|
||||
if (isFocused && document.activeElement !== elementRef.current) {
|
||||
elementRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
}, [isFocused, element?.type])
|
||||
|
||||
if (!element) return null
|
||||
|
||||
// 1. 统一位置样式
|
||||
const style: React.CSSProperties = {
|
||||
width: element.type === 'text' && !element.width ? 'auto' : element.width,
|
||||
height: element.type === 'text' ? 'auto' : element.height,
|
||||
minHeight: element.type === 'text' ? element.height : undefined,
|
||||
minWidth: element.type === 'text' && !element.width ? 20 : undefined,
|
||||
transform: `translate3d(${element.x}px, ${element.y}px, 0) rotate(${element.rotation}deg)`,
|
||||
overflow: 'visible',
|
||||
zIndex: element.type === 'artboard' ? -1 : undefined,
|
||||
}
|
||||
|
||||
// 2. 统一交互逻辑
|
||||
const handleFocus = (e: React.FocusEvent) => {
|
||||
e.stopPropagation()
|
||||
if (store.getState().focusedElementId === id) return
|
||||
setFocusedElementId(id)
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// If text is already focused, bale out to allow native click behavior (move cursor)
|
||||
if (element.type === 'text' && isFocused) {
|
||||
return
|
||||
}
|
||||
|
||||
// If clicking non-text, clear focus (Interactions took care of selection)
|
||||
if (element.type !== 'text') {
|
||||
setFocusedElementId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (element.type === 'text') {
|
||||
setFocusedElementId(id)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultContent = <MemoizedElementContent element={element} />
|
||||
const content = renderElement
|
||||
? renderElement({ element, defaultRender: defaultContent })
|
||||
: defaultContent
|
||||
|
||||
return (
|
||||
<div
|
||||
id={DOMINO_EL_PREFIX + id}
|
||||
ref={elementRef}
|
||||
tabIndex={element.type === 'text' ? undefined : 0}
|
||||
className={cn(
|
||||
'domino-element absolute flex flex-col pointer-events-auto box-border',
|
||||
|
||||
element.type === 'text'
|
||||
? 'items-start justify-start'
|
||||
: 'items-center justify-center',
|
||||
)}
|
||||
style={style}
|
||||
onPointerEnter={() => setHoveredElementId(id)}
|
||||
onPointerLeave={() => setHoveredElementId(null)}
|
||||
onFocus={handleFocus}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* A. 装饰层 (仅外部钩子存在时渲染壳) */}
|
||||
<ElementMetadataWrapper element={element} />
|
||||
|
||||
{/* B. 内容渲染器 */}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 元数据定位壳,完全由业务层提供内容
|
||||
*/
|
||||
function ElementMetadataWrapper({ element }: { element: SceneElement }) {
|
||||
const { renderElementMetadata } = useDominoRenderHooks()
|
||||
|
||||
// 1. 昂贵的碰撞检测逻辑依然保留在内核,用于自动隐藏可能被遮挡的元信息
|
||||
const isMetadataHidden = useDominoStore(
|
||||
state => {
|
||||
// 如果业务层未定义钩子,虽然这个 Hook 会运行,但我们让他返回一个简单值
|
||||
if (!renderElementMetadata) return true
|
||||
|
||||
// 画板元信息通常总是显示,但业务层也可以自行控制其内容
|
||||
if (element.type === 'artboard') return false
|
||||
// 目前主要为图片和画板支持元信息展示
|
||||
if (element.type !== 'image') return true
|
||||
|
||||
const el = state.elements[element.id] || state.placeholders[element.id]
|
||||
if (!el) return true
|
||||
|
||||
// 检测是否被其他元素遮盖了元信息显示区域
|
||||
const metaHeight = 22
|
||||
const metaYMin = el.y - metaHeight
|
||||
const metaYMax = el.y
|
||||
const metaXMin = el.x
|
||||
const metaXMax = el.x + el.width
|
||||
|
||||
const allElements = [
|
||||
...Object.values(state.elements),
|
||||
...Object.values(state.placeholders),
|
||||
]
|
||||
|
||||
return allElements.some(other => {
|
||||
if (other.id === el.id || other.id === el.parentId) return false
|
||||
const otherXMax = other.x + (other.width || 0)
|
||||
const otherYMax = other.y + (other.height || 0)
|
||||
return (
|
||||
!(otherXMax < metaXMin || other.x > metaXMax) &&
|
||||
!(otherYMax < metaYMin || other.y > metaYMax)
|
||||
)
|
||||
})
|
||||
},
|
||||
(a, b) => a === b,
|
||||
)
|
||||
|
||||
// 2. 只有定义了钩子且未被隐藏时才渲染
|
||||
if (!renderElementMetadata || isMetadataHidden || element.hideMetadata)
|
||||
return null
|
||||
|
||||
const renderedContent = renderElementMetadata({ element })
|
||||
if (!renderedContent) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='domino-element-metadata absolute left-0 w-full z-50 pointer-events-none'
|
||||
style={{
|
||||
bottom: '100%',
|
||||
width: 'calc(var(--domino-scale) * 100%)',
|
||||
transform: 'scale(calc(1 / var(--domino-scale)))',
|
||||
transformOrigin: '0 100%',
|
||||
paddingBottom: 4,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{renderedContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
components/image-editor/components/canvas/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './domino'
|
||||
export * from './domino-canvas'
|
||||
export * from './domino-store'
|
||||
export * from './domino-hooks'
|
||||
export * from './domino-provider'
|
||||
export * from './use-domino-instance'
|
||||
export * from './use-domino-container'
|
||||
export * from './use-domino-anchor'
|
||||
export * from './domino-anchor'
|
||||
export * from './constants'
|
||||
export * from './math'
|
||||
export * from './dom-utils'
|
||||
@@ -0,0 +1,184 @@
|
||||
import type {
|
||||
SceneElement,
|
||||
TextElement,
|
||||
ImageElement,
|
||||
PlaceholderElement,
|
||||
} from '../domino'
|
||||
|
||||
interface BoundingBox {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
fontSize?: number
|
||||
}
|
||||
|
||||
export function calculateResize(
|
||||
id: string,
|
||||
startEl: BoundingBox,
|
||||
el: SceneElement | PlaceholderElement,
|
||||
handle: string,
|
||||
totalWdx: number,
|
||||
totalWdy: number,
|
||||
scale: number,
|
||||
shiftKey: boolean,
|
||||
altKey: boolean,
|
||||
) {
|
||||
const rad = ((el.rotation || 0) * Math.PI) / 180
|
||||
const cos = Math.cos(rad)
|
||||
const sin = Math.sin(rad)
|
||||
|
||||
const ldx = totalWdx * cos + totalWdy * sin
|
||||
const ldy = -totalWdx * sin + totalWdy * cos
|
||||
|
||||
let isProportional = el.lockAspectRatio !== false
|
||||
const isSideHandle =
|
||||
handle === 'ml' || handle === 'mr' || handle === 'mt' || handle === 'mb'
|
||||
if (isSideHandle) {
|
||||
isProportional = false
|
||||
}
|
||||
|
||||
if (shiftKey) {
|
||||
isProportional = !isProportional
|
||||
}
|
||||
const ratio = startEl.width / (startEl.height || 1)
|
||||
|
||||
let mw = 0
|
||||
let mh = 0
|
||||
let fx = 0
|
||||
let fy = 0
|
||||
|
||||
if (handle === 'nw') {
|
||||
mw = -1
|
||||
mh = -1
|
||||
fx = 0.5
|
||||
fy = 0.5
|
||||
} else if (handle === 'ne') {
|
||||
mw = 1
|
||||
mh = -1
|
||||
fx = -0.5
|
||||
fy = 0.5
|
||||
} else if (handle === 'sw') {
|
||||
mw = -1
|
||||
mh = 1
|
||||
fx = 0.5
|
||||
fy = -0.5
|
||||
} else if (handle === 'se') {
|
||||
mw = 1
|
||||
mh = 1
|
||||
fx = -0.5
|
||||
fy = -0.5
|
||||
} else if (handle === 'ml') {
|
||||
mw = -1
|
||||
mh = 0
|
||||
fx = 0.5
|
||||
fy = 0
|
||||
} else if (handle === 'mr') {
|
||||
mw = 1
|
||||
mh = 0
|
||||
fx = -0.5
|
||||
fy = 0
|
||||
} else if (handle === 'mt') {
|
||||
mw = 0
|
||||
mh = -1
|
||||
fx = 0
|
||||
fy = 0.5
|
||||
} else if (handle === 'mb') {
|
||||
mw = 0
|
||||
mh = 1
|
||||
fx = 0
|
||||
fy = -0.5
|
||||
}
|
||||
|
||||
let targetW = startEl.width + mw * ldx
|
||||
let targetH = startEl.height + mh * ldy
|
||||
|
||||
if (isProportional) {
|
||||
if (!isSideHandle) {
|
||||
if (Math.abs(mw * ldx) > Math.abs(mh * ldy)) {
|
||||
targetH = targetW / ratio
|
||||
} else {
|
||||
targetW = targetH * ratio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let isSnappedToOriginal = false
|
||||
if (el.type === 'image' && !altKey) {
|
||||
const img = el as ImageElement
|
||||
if (img.originalWidth && img.originalHeight) {
|
||||
const snapThreshold = 5 / scale
|
||||
const distW = Math.abs(targetW - img.originalWidth)
|
||||
const distH = Math.abs(targetH - img.originalHeight)
|
||||
if (distW < snapThreshold || distH < snapThreshold) {
|
||||
targetW = img.originalWidth
|
||||
targetH = img.originalHeight
|
||||
isSnappedToOriginal = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minSize = 20
|
||||
if (targetW < minSize) targetW = minSize
|
||||
if (targetH < minSize) targetH = minSize
|
||||
if (isProportional) {
|
||||
if (targetW / targetH > ratio) {
|
||||
targetW = targetH * ratio
|
||||
} else {
|
||||
targetH = targetW / ratio
|
||||
}
|
||||
}
|
||||
|
||||
const startCx = startEl.x + startEl.width / 2
|
||||
const startCy = startEl.y + startEl.height / 2
|
||||
|
||||
const fpx = startCx + (fx * startEl.width * cos - fy * startEl.height * sin)
|
||||
const fpy = startCy + (fx * startEl.width * sin + fy * startEl.height * cos)
|
||||
|
||||
const ncx = fpx - (fx * targetW * cos - fy * targetH * sin)
|
||||
const ncy = fpy - (fx * targetW * sin + fy * targetH * cos)
|
||||
|
||||
const newX = ncx - targetW / 2
|
||||
const newY = ncy - targetH / 2
|
||||
|
||||
if (el.type === 'text') {
|
||||
const isCorner = !isSideHandle
|
||||
const startFontSize = startEl.fontSize || 16
|
||||
|
||||
let finalW = targetW
|
||||
let finalFontSize = (el as TextElement).fontSize
|
||||
let finalH = isCorner ? targetW / ratio : startEl.height
|
||||
|
||||
if (isCorner) {
|
||||
finalFontSize = Math.round(startFontSize * (targetW / startEl.width))
|
||||
finalW = (finalFontSize / startFontSize) * startEl.width
|
||||
finalH = finalW / ratio
|
||||
} else {
|
||||
finalW = targetW + 0.5
|
||||
}
|
||||
|
||||
const ncx = fpx - (fx * finalW * cos - fy * finalH * sin)
|
||||
const ncy = fpy - (fx * finalW * sin + fy * finalH * cos)
|
||||
|
||||
return {
|
||||
updates: {
|
||||
x: ncx - finalW / 2,
|
||||
y: ncy - finalH / 2,
|
||||
width: finalW,
|
||||
fontSize: finalFontSize,
|
||||
height: isCorner ? finalH : undefined,
|
||||
},
|
||||
isSnappedToOriginal,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updates: {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: targetW,
|
||||
height: targetH,
|
||||
},
|
||||
isSnappedToOriginal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { getElementWorldRect } from '../math'
|
||||
import type { SceneElement, SnapLine, PlaceholderElement } from '../domino'
|
||||
|
||||
interface BoundingBox {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function calculateSnap(
|
||||
selectedIds: string[],
|
||||
dragStartElements: Record<string, BoundingBox>,
|
||||
elements: Record<string, SceneElement>,
|
||||
placeholders: Record<string, PlaceholderElement>,
|
||||
totalDx: number,
|
||||
totalDy: number,
|
||||
scale: number,
|
||||
shouldSnap: boolean,
|
||||
) {
|
||||
let snapOffsetX = 0
|
||||
let snapOffsetY = 0
|
||||
const activeSnapLines: SnapLine[] = []
|
||||
|
||||
if (!shouldSnap || selectedIds.length === 0) {
|
||||
return { snapOffsetX, snapOffsetY, finalSnapLines: null }
|
||||
}
|
||||
|
||||
// 1. 计算选中项在世界坐标系下的“理想包围盒” (无吸附时的位置)
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
selectedIds.forEach(id => {
|
||||
const start = dragStartElements[id]
|
||||
const rawEl = elements[id] || placeholders[id]
|
||||
if (start && rawEl) {
|
||||
// 获取父级当前的绝对位置(如果父级也在动,这里能拿到动之后的位置)
|
||||
let parentWorldX = 0
|
||||
let parentWorldY = 0
|
||||
let curr = rawEl
|
||||
while (curr.parentId) {
|
||||
const parent = elements[curr.parentId]
|
||||
if (!parent) break
|
||||
parentWorldX += parent.x
|
||||
parentWorldY += parent.y
|
||||
curr = parent
|
||||
}
|
||||
|
||||
// 理想世界位置 = 当前父级位置 + 初始局部位置 + 鼠标偏移
|
||||
const idealWorldX = parentWorldX + start.x + totalDx
|
||||
const idealWorldY = parentWorldY + start.y + totalDy
|
||||
|
||||
minX = Math.min(minX, idealWorldX)
|
||||
minY = Math.min(minY, idealWorldY)
|
||||
maxX = Math.max(maxX, idealWorldX + rawEl.width)
|
||||
maxY = Math.max(maxY, idealWorldY + rawEl.height)
|
||||
}
|
||||
})
|
||||
|
||||
const selectionRect = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
}
|
||||
const threshold = 5 / scale // 5px 屏幕空间阈值
|
||||
|
||||
// 2. 找到画布上所有“其他”元素的世界位置作为吸附目标
|
||||
const others = [
|
||||
...Object.values(elements),
|
||||
...Object.values(placeholders),
|
||||
].filter(el => !selectedIds.includes(el.id) && el.type !== 'artboard')
|
||||
|
||||
let bestDistX = threshold
|
||||
let bestDistY = threshold
|
||||
|
||||
const movingBounds = {
|
||||
left: selectionRect.x,
|
||||
right: selectionRect.x + selectionRect.width,
|
||||
centerX: selectionRect.x + selectionRect.width / 2,
|
||||
top: selectionRect.y,
|
||||
bottom: selectionRect.y + selectionRect.height,
|
||||
centerY: selectionRect.y + selectionRect.height / 2,
|
||||
}
|
||||
|
||||
others.forEach(other => {
|
||||
const worldOther = getElementWorldRect(other, elements)
|
||||
const otherBounds = {
|
||||
left: worldOther.x,
|
||||
right: worldOther.x + worldOther.width,
|
||||
centerX: worldOther.x + worldOther.width / 2,
|
||||
top: worldOther.y,
|
||||
bottom: worldOther.y + worldOther.height,
|
||||
centerY: worldOther.y + worldOther.height / 2,
|
||||
}
|
||||
|
||||
// 比对 X 方向 (左、中、右)
|
||||
const mX = [movingBounds.left, movingBounds.centerX, movingBounds.right]
|
||||
const oX = [otherBounds.left, otherBounds.centerX, otherBounds.right]
|
||||
mX.forEach(mx => {
|
||||
oX.forEach(ox => {
|
||||
const d = Math.abs(mx - ox)
|
||||
if (d < bestDistX) {
|
||||
bestDistX = d
|
||||
snapOffsetX = ox - mx
|
||||
activeSnapLines.push({ type: 'x', value: ox })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 比对 Y 方向 (上、中、下)
|
||||
const mY = [movingBounds.top, movingBounds.centerY, movingBounds.bottom]
|
||||
const oY = [otherBounds.top, otherBounds.centerY, otherBounds.bottom]
|
||||
mY.forEach(my => {
|
||||
oY.forEach(oy => {
|
||||
const d = Math.abs(my - oy)
|
||||
if (d < bestDistY) {
|
||||
bestDistY = d
|
||||
snapOffsetY = oy - my
|
||||
activeSnapLines.push({ type: 'y', value: oy })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 3. 过滤并返回最终的吸附线
|
||||
const finalSnapLines = activeSnapLines.filter(line => {
|
||||
if (line.type === 'x') {
|
||||
const currentL = minX + snapOffsetX
|
||||
const currentC = minX + (maxX - minX) / 2 + snapOffsetX
|
||||
const currentR = maxX + snapOffsetX
|
||||
return (
|
||||
Math.abs(line.value - currentL) < 0.01 ||
|
||||
Math.abs(line.value - currentC) < 0.01 ||
|
||||
Math.abs(line.value - currentR) < 0.01
|
||||
)
|
||||
} else {
|
||||
const currentT = minY + snapOffsetY
|
||||
const currentC = minY + (maxY - minY) / 2 + snapOffsetY
|
||||
const currentB = maxY + snapOffsetY
|
||||
return (
|
||||
Math.abs(line.value - currentT) < 0.01 ||
|
||||
Math.abs(line.value - currentC) < 0.01 ||
|
||||
Math.abs(line.value - currentB) < 0.01
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
snapOffsetX,
|
||||
snapOffsetY,
|
||||
finalSnapLines: finalSnapLines.length > 0 ? finalSnapLines : null,
|
||||
}
|
||||
}
|
||||
227
components/image-editor/components/canvas/math.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import type { Padding, SceneElement, Viewport } from './domino'
|
||||
|
||||
/**
|
||||
* 计算元素移动到安全视野内的 Viewport 变换 (纯算法,无 DOM 依赖)
|
||||
*/
|
||||
export function calculateScrollIntoViewTransform(
|
||||
element: SceneElement,
|
||||
viewport: Viewport,
|
||||
containerRect: { width: number; height: number },
|
||||
padding: Required<Padding>,
|
||||
options: {
|
||||
force?: boolean
|
||||
block?: 'start' | 'center' | 'end' | 'nearest'
|
||||
inline?: 'start' | 'center' | 'end' | 'nearest'
|
||||
targetScale?: number
|
||||
},
|
||||
): Viewport | null {
|
||||
const { x: currentX, y: currentY, scale: currentScale } = viewport
|
||||
const { force, block = 'nearest', inline = 'nearest', targetScale } = options
|
||||
const scale = targetScale ?? currentScale
|
||||
|
||||
// 计算安全区域
|
||||
const safeLeft = padding.left
|
||||
const safeTop = padding.top
|
||||
const safeRight = containerRect.width - padding.right
|
||||
const safeBottom = containerRect.height - padding.bottom
|
||||
const safeWidth = safeRight - safeLeft
|
||||
const safeHeight = safeBottom - safeTop
|
||||
|
||||
// 元素即时边界
|
||||
const elW = element.width * scale
|
||||
const elH = element.height * scale
|
||||
const elLeft = element.x * scale + currentX
|
||||
const elRight = elLeft + elW
|
||||
const elTop = element.y * scale + currentY
|
||||
const elBottom = elTop + elH
|
||||
|
||||
const isXOutOfView = force || elLeft < safeLeft || elRight > safeRight
|
||||
const isYOutOfView = force || elTop < safeTop || elBottom > safeBottom
|
||||
|
||||
let nextX = currentX
|
||||
let nextY = currentY
|
||||
|
||||
if (isXOutOfView) {
|
||||
if (inline === 'center') {
|
||||
nextX = safeLeft + (safeWidth - elW) / 2 - element.x * scale
|
||||
} else if (
|
||||
inline === 'start' ||
|
||||
(inline === 'nearest' && elLeft < safeLeft)
|
||||
) {
|
||||
nextX = safeLeft - element.x * scale
|
||||
} else if (
|
||||
inline === 'end' ||
|
||||
(inline === 'nearest' && elRight > safeRight)
|
||||
) {
|
||||
nextX = safeRight - elW - element.x * scale
|
||||
}
|
||||
}
|
||||
|
||||
if (isYOutOfView) {
|
||||
if (block === 'center') {
|
||||
nextY = safeTop + (safeHeight - elH) / 2 - element.y * scale
|
||||
} else if (block === 'start' || (block === 'nearest' && elTop < safeTop)) {
|
||||
nextY = safeTop - element.y * scale
|
||||
} else if (
|
||||
block === 'end' ||
|
||||
(block === 'nearest' && elBottom > safeBottom)
|
||||
) {
|
||||
nextY = safeBottom - elH - element.y * scale
|
||||
}
|
||||
}
|
||||
|
||||
const hasChange =
|
||||
nextX !== currentX ||
|
||||
nextY !== currentY ||
|
||||
(targetScale !== undefined && targetScale !== currentScale)
|
||||
|
||||
return hasChange ? { x: nextX, y: nextY, scale } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算适应屏幕的 Viewport 变换 (纯算法,无 DOM 依赖)
|
||||
*/
|
||||
export function calculateZoomToFitTransform(
|
||||
elements: SceneElement[],
|
||||
containerRect: { width: number; height: number },
|
||||
padding: Required<Padding>,
|
||||
): Viewport | null {
|
||||
if (elements.length === 0) return null
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
elements.forEach(el => {
|
||||
minX = Math.min(minX, el.x)
|
||||
minY = Math.min(minY, el.y)
|
||||
maxX = Math.max(maxX, el.x + el.width)
|
||||
maxY = Math.max(maxY, el.y + el.height)
|
||||
})
|
||||
|
||||
const contentWidth = maxX - minX
|
||||
const contentHeight = maxY - minY
|
||||
|
||||
const safeWidth = containerRect.width - padding.left - padding.right
|
||||
const safeHeight = containerRect.height - padding.top - padding.bottom
|
||||
|
||||
if (safeWidth <= 0 || safeHeight <= 0) return null
|
||||
|
||||
const scaleX = safeWidth / contentWidth
|
||||
const scaleY = safeHeight / contentHeight
|
||||
const targetScale = Math.max(
|
||||
0.02,
|
||||
Math.min(4, Math.min(scaleX, scaleY) * 0.95),
|
||||
)
|
||||
|
||||
const nextX =
|
||||
padding.left +
|
||||
(safeWidth - contentWidth * targetScale) / 2 -
|
||||
minX * targetScale
|
||||
const nextY =
|
||||
padding.top +
|
||||
(safeHeight - contentHeight * targetScale) / 2 -
|
||||
minY * targetScale
|
||||
|
||||
return { x: nextX, y: nextY, scale: targetScale }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算元素的绝对位置和旋转 (世界坐标)
|
||||
*/
|
||||
export function getElementWorldRect(
|
||||
element: SceneElement,
|
||||
elements: Record<string, SceneElement>,
|
||||
) {
|
||||
let x = element.x
|
||||
let y = element.y
|
||||
let rotation = element.rotation || 0
|
||||
let curr = element
|
||||
|
||||
const visited = new Set<string>([element.id])
|
||||
|
||||
while (curr.parentId) {
|
||||
if (visited.has(curr.parentId)) {
|
||||
console.warn(
|
||||
'Circular dependency detected in element tree',
|
||||
curr.parentId,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
const parent = elements[curr.parentId]
|
||||
if (!parent) break
|
||||
|
||||
x += parent.x
|
||||
y += parent.y
|
||||
rotation += parent.rotation || 0
|
||||
|
||||
visited.add(curr.parentId)
|
||||
curr = parent
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
rotation,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算一组元素的包围盒 (考虑旋转和父级偏移)
|
||||
*/
|
||||
export function getElementsBounds(
|
||||
ids: string[],
|
||||
elements: Record<string, SceneElement>,
|
||||
) {
|
||||
if (ids.length === 0) return null
|
||||
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
|
||||
ids.forEach(id => {
|
||||
const rawEl = elements[id]
|
||||
if (!rawEl) return
|
||||
|
||||
const el = getElementWorldRect(rawEl, elements)
|
||||
|
||||
const rad = (el.rotation * Math.PI) / 180
|
||||
const cx = el.x + el.width / 2
|
||||
const cy = el.y + el.height / 2
|
||||
const corners = [
|
||||
[-el.width / 2, -el.height / 2],
|
||||
[el.width / 2, -el.height / 2],
|
||||
[el.width / 2, el.height / 2],
|
||||
[-el.width / 2, el.height / 2],
|
||||
]
|
||||
|
||||
corners.forEach(([px, py]) => {
|
||||
const rx = px * Math.cos(rad) - py * Math.sin(rad)
|
||||
const ry = px * Math.sin(rad) + py * Math.cos(rad)
|
||||
const wx = rx + cx
|
||||
const wy = ry + cy
|
||||
minX = Math.min(minX, wx)
|
||||
minY = Math.min(minY, wy)
|
||||
maxX = Math.max(maxX, wx)
|
||||
maxY = Math.max(maxY, wy)
|
||||
})
|
||||
})
|
||||
|
||||
if (minX === Infinity) return null
|
||||
|
||||
return {
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
right: maxX,
|
||||
bottom: maxY,
|
||||
centerX: (minX + maxX) / 2,
|
||||
centerY: (minY + maxY) / 2,
|
||||
}
|
||||
}
|
||||
30
components/image-editor/components/canvas/placeholder.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type CSSProperties, memo } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface PlaceholderProps {
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
children?: React.ReactNode
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
function Placeholder(props: PlaceholderProps) {
|
||||
const { className, style, children, onClick } = props
|
||||
return (
|
||||
<div className='absolute z-1 w-full h-full flex items-center justify-center overflow-hidden'>
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse',
|
||||
'absolute top-0 left-0 backdrop-blur-11px w-100% h-100% flex items-center justify-center text-white font-w-[16px] font-weight-500',
|
||||
'bg-[radial-gradient(241%_81%_at_3%_0%,rgba(123,97,255,0.4)_0%,rgba(255,255,255,0)_100%),linear-gradient(143deg,rgba(255,255,255,0.7)_4%,rgba(97,205,255,0.7)_99%)]',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
></div>
|
||||
<span className='text-white z-1'>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Placeholder)
|
||||
484
components/image-editor/components/canvas/selection-overlay.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import React, { memo } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { useDominoStore, useDominoSelectionBounds } from './domino-hooks'
|
||||
import { getElementWorldRect } from './math'
|
||||
import type { ResizeType } from './domino'
|
||||
import { icons } from '../ui/icon-mapping'
|
||||
|
||||
const getIconDataUri = (path: string) => {
|
||||
const content = icons[path]
|
||||
if (!content) return ''
|
||||
try {
|
||||
// Standard SVG data URI with base64 for maximum compatibility
|
||||
if (typeof window !== 'undefined') {
|
||||
return `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(content)))}`
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to encode icon ${path}`, e)
|
||||
}
|
||||
return `data:image/svg+xml,${encodeURIComponent(content)}`
|
||||
}
|
||||
|
||||
// --- Sub-components (Fine-grained Subscriptions) ---
|
||||
|
||||
const HoverBorder = memo(() => {
|
||||
const hoveredElementId = useDominoStore(state => state.hoveredElementId)
|
||||
const isSelected = useDominoStore(state =>
|
||||
hoveredElementId ? state.selectedIds.includes(hoveredElementId) : false,
|
||||
)
|
||||
const elements = useDominoStore(state => state.elements)
|
||||
const el = hoveredElementId ? elements[hoveredElementId] : null
|
||||
|
||||
if (!el || isSelected) return null
|
||||
|
||||
const worldRect = getElementWorldRect(el, elements)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute pointer-events-none z-[98]'
|
||||
style={{
|
||||
width: worldRect.width,
|
||||
height: worldRect.height,
|
||||
transform: `translate3d(${worldRect.x}px, ${worldRect.y}px, 0) rotate(${worldRect.rotation}deg)`,
|
||||
borderWidth: 'max(0.2px, calc(1px / var(--domino-scale)))',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'var(--editor-accent)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const FocusBorder = memo(() => {
|
||||
const focusedElementId = useDominoStore(state => state.focusedElementId)
|
||||
const isSelected = useDominoStore(state =>
|
||||
focusedElementId ? state.selectedIds.includes(focusedElementId) : false,
|
||||
)
|
||||
const elements = useDominoStore(state => state.elements)
|
||||
const el = focusedElementId ? elements[focusedElementId] : null
|
||||
|
||||
if (!el || isSelected || el.type === 'text') return null
|
||||
|
||||
const worldRect = getElementWorldRect(el, elements)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute pointer-events-none z-[99]'
|
||||
style={{
|
||||
width: worldRect.width,
|
||||
height: worldRect.height,
|
||||
transform: `translate3d(${worldRect.x}px, ${worldRect.y}px, 0) rotate(${worldRect.rotation}deg)`,
|
||||
borderWidth: 'max(0.4px, calc(1.5px / var(--domino-scale)))',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'color-mix(in srgb, var(--editor-accent) 62%, transparent)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const MultiSelectionOverlay = memo(() => {
|
||||
const selectedIds = useDominoStore(state => state.selectedIds)
|
||||
const selectionBounds = useDominoSelectionBounds()
|
||||
const readOnly = useDominoStore(state => state.readOnly)
|
||||
|
||||
if (selectedIds.length <= 1 || !selectionBounds) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'domino-multi-select-area absolute pointer-events-auto z-[100]',
|
||||
!readOnly && 'cursor-move',
|
||||
)}
|
||||
data-multi-select='true'
|
||||
style={{
|
||||
transform: `translate3d(${selectionBounds.left}px, ${selectionBounds.top}px, 0)`,
|
||||
width: selectionBounds.width,
|
||||
height: selectionBounds.height,
|
||||
borderWidth: 'max(0.2px, calc(1px / var(--domino-scale)))',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'var(--editor-accent)',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
function RotateHandles() {
|
||||
const nwRotateIcon = getIconDataUri('../../icons/nw-rotate.svg')
|
||||
const neRotateIcon = getIconDataUri('../../icons/ne-rotate.svg')
|
||||
const swRotateIcon = getIconDataUri('../../icons/sw-rotate.svg')
|
||||
const seRotateIcon = getIconDataUri('../../icons/se-rotate.svg')
|
||||
|
||||
const rotateOffset = 'calc(-12px / var(--domino-scale))'
|
||||
const totalRotateSize = 'calc(22px / var(--domino-scale))'
|
||||
const rotateHandleClassName =
|
||||
'domino-rotate-handle absolute pointer-events-auto overflow-visible'
|
||||
|
||||
return (
|
||||
<span>
|
||||
{/* NW (左上): 避开右下象限 */}
|
||||
<svg
|
||||
data-rotate='nw'
|
||||
className={rotateHandleClassName}
|
||||
viewBox='0 0 21 21'
|
||||
style={{
|
||||
top: rotateOffset,
|
||||
left: rotateOffset,
|
||||
width: totalRotateSize,
|
||||
height: totalRotateSize,
|
||||
cursor: `url("${nwRotateIcon}") 12 12, auto`
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d='M 10.5 10.5 L 10.5 21 A 10.5 10.5 0 1 1 21 10.5 Z'
|
||||
fill='transparent'
|
||||
className='pointer-events-auto'
|
||||
/>
|
||||
</svg>
|
||||
{/* NE (右上): 避开左下象限 */}
|
||||
<svg
|
||||
data-rotate='ne'
|
||||
className={rotateHandleClassName}
|
||||
viewBox='0 0 21 21'
|
||||
style={{
|
||||
top: rotateOffset,
|
||||
right: rotateOffset,
|
||||
width: totalRotateSize,
|
||||
height: totalRotateSize,
|
||||
cursor: `url("${neRotateIcon}") 12 12, auto`,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d='M 9.5 10.5 L -1 10.5 A 10.5 10.5 0 1 1 9.5 21 Z'
|
||||
fill='transparent'
|
||||
className='pointer-events-auto'
|
||||
/>
|
||||
</svg>
|
||||
{/* SW (左下): 避开右上象限 */}
|
||||
<svg
|
||||
data-rotate='sw'
|
||||
className={rotateHandleClassName}
|
||||
viewBox='0 0 21 21'
|
||||
style={{
|
||||
bottom: rotateOffset,
|
||||
left: rotateOffset,
|
||||
width: totalRotateSize,
|
||||
height: totalRotateSize,
|
||||
cursor: `url("${swRotateIcon}") 12 12, auto`,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d='M 10.5 9.5 L 21 9.5 A 10.5 10.5 0 1 1 10.5 -1 Z'
|
||||
fill='transparent'
|
||||
className='pointer-events-auto'
|
||||
/>
|
||||
</svg>
|
||||
{/* SE (右下): 避开左上象限 */}
|
||||
<svg
|
||||
data-rotate='se'
|
||||
className={rotateHandleClassName}
|
||||
viewBox='0 0 21 21'
|
||||
style={{
|
||||
bottom: rotateOffset,
|
||||
right: rotateOffset,
|
||||
width: totalRotateSize,
|
||||
height: totalRotateSize,
|
||||
cursor: `url("${seRotateIcon}") 12 12, auto`,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d='M 9.5 9.5 L 9.5 -1 A 10.5 10.5 0 1 1 -1 9.5 Z'
|
||||
fill='transparent'
|
||||
className='pointer-events-auto'
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizeHandles() {
|
||||
const hitHandleSize = 'calc(14px / var(--domino-scale))'
|
||||
const hitOffset = 'calc(-7.5px / var(--domino-scale))'
|
||||
|
||||
const visualHandleStyle: React.CSSProperties = {
|
||||
width: 'calc(12px / var(--domino-scale))',
|
||||
height: 'calc(12px / var(--domino-scale))',
|
||||
borderWidth: 'calc(2px / var(--domino-scale))',
|
||||
borderStyle: 'solid',
|
||||
borderRadius: '50%',
|
||||
boxShadow:
|
||||
'0 calc(1px / var(--domino-scale)) calc(3px / var(--domino-scale)) var(--editor-shadow)',
|
||||
background: 'var(--editor-surface)',
|
||||
borderColor: 'var(--editor-accent)',
|
||||
}
|
||||
|
||||
const hitHandleClassName =
|
||||
'domino-resize-handle box-border absolute pointer-events-auto z-10 flex items-center justify-center rounded-50%'
|
||||
const visualHandleClassName = ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-handle='nw'
|
||||
className={cn(hitHandleClassName, 'cursor-nwse-resize')}
|
||||
style={{
|
||||
width: hitHandleSize,
|
||||
height: hitHandleSize,
|
||||
top: hitOffset,
|
||||
left: hitOffset,
|
||||
}}
|
||||
>
|
||||
<div className={visualHandleClassName} style={visualHandleStyle} />
|
||||
</div>
|
||||
<div
|
||||
data-handle='ne'
|
||||
className={cn(hitHandleClassName, 'cursor-nesw-resize')}
|
||||
style={{
|
||||
width: hitHandleSize,
|
||||
height: hitHandleSize,
|
||||
top: hitOffset,
|
||||
right: hitOffset,
|
||||
}}
|
||||
>
|
||||
<div className={visualHandleClassName} style={visualHandleStyle} />
|
||||
</div>
|
||||
<div
|
||||
data-handle='sw'
|
||||
className={cn(hitHandleClassName, 'cursor-nesw-resize')}
|
||||
style={{
|
||||
width: hitHandleSize,
|
||||
height: hitHandleSize,
|
||||
bottom: hitOffset,
|
||||
left: hitOffset,
|
||||
}}
|
||||
>
|
||||
<div className={visualHandleClassName} style={visualHandleStyle} />
|
||||
</div>
|
||||
<div
|
||||
data-handle='se'
|
||||
className={cn(hitHandleClassName, 'cursor-nwse-resize')}
|
||||
style={{
|
||||
width: hitHandleSize,
|
||||
height: hitHandleSize,
|
||||
bottom: hitOffset,
|
||||
right: hitOffset,
|
||||
}}
|
||||
>
|
||||
<div className={visualHandleClassName} style={visualHandleStyle} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SideHandles({
|
||||
resize,
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
}: {
|
||||
resize?: ResizeType
|
||||
width: number
|
||||
height: number
|
||||
scale: number
|
||||
}) {
|
||||
if (resize === 'none') return null
|
||||
|
||||
const hitHandleSize = 'calc(14px / var(--domino-scale))'
|
||||
const hitOffset = 'calc(-7.5px / var(--domino-scale))'
|
||||
|
||||
const sideHandleStyle: React.CSSProperties = {
|
||||
width: 'calc(24px / var(--domino-scale))',
|
||||
height: 'calc(8px / var(--domino-scale))',
|
||||
backgroundColor: 'var(--editor-surface)',
|
||||
border: 'calc(2px / var(--domino-scale)) solid var(--editor-accent)',
|
||||
borderRadius: 999,
|
||||
boxShadow:
|
||||
'0 calc(1px / var(--domino-scale)) calc(3px / var(--domino-scale)) var(--editor-shadow)',
|
||||
}
|
||||
|
||||
const sideHandleVerticalStyle: React.CSSProperties = {
|
||||
width: 'calc(8px / var(--domino-scale))',
|
||||
height: 'calc(24px / var(--domino-scale))',
|
||||
backgroundColor: 'var(--editor-surface)',
|
||||
border: 'calc(2px / var(--domino-scale)) solid var(--editor-accent)',
|
||||
borderRadius: 999,
|
||||
boxShadow:
|
||||
'0 calc(1px / var(--domino-scale)) calc(3px / var(--domino-scale)) var(--editor-shadow)',
|
||||
}
|
||||
|
||||
const hitHandleClassName =
|
||||
'domino-resize-handle box-border absolute pointer-events-auto z-10 flex items-center justify-center'
|
||||
|
||||
const VISUAL_THRESHOLD = 38 // 屏幕像素阈值
|
||||
const canShowVertical = width * scale > VISUAL_THRESHOLD
|
||||
const canShowHorizontal = height * scale > VISUAL_THRESHOLD
|
||||
|
||||
const showVertical =
|
||||
(resize === 'both' || resize === 'vertical' || !resize) && canShowVertical
|
||||
const showHorizontal =
|
||||
(resize === 'both' || resize === 'horizontal' || !resize) &&
|
||||
canShowHorizontal
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Middle Top */}
|
||||
{showVertical && (
|
||||
<div
|
||||
data-handle='mt'
|
||||
className={cn(hitHandleClassName, 'cursor-ns-resize')}
|
||||
style={{
|
||||
width: 'calc(24px / var(--domino-scale))',
|
||||
height: hitHandleSize,
|
||||
top: hitOffset,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<div style={sideHandleStyle} />
|
||||
</div>
|
||||
)}
|
||||
{/* Middle Bottom */}
|
||||
{showVertical && (
|
||||
<div
|
||||
data-handle='mb'
|
||||
className={cn(hitHandleClassName, 'cursor-ns-resize')}
|
||||
style={{
|
||||
width: 'calc(24px / var(--domino-scale))',
|
||||
height: hitHandleSize,
|
||||
bottom: hitOffset,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<div style={sideHandleStyle} />
|
||||
</div>
|
||||
)}
|
||||
{/* Middle Left */}
|
||||
{showHorizontal && (
|
||||
<div
|
||||
data-handle='ml'
|
||||
className={cn(hitHandleClassName, 'cursor-ew-resize')}
|
||||
style={{
|
||||
width: hitHandleSize,
|
||||
height: 'calc(24px / var(--domino-scale))',
|
||||
left: hitOffset,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
<div style={sideHandleVerticalStyle} />
|
||||
</div>
|
||||
)}
|
||||
{/* Middle Right */}
|
||||
{showHorizontal && (
|
||||
<div
|
||||
data-handle='mr'
|
||||
className={cn(hitHandleClassName, 'cursor-ew-resize')}
|
||||
style={{
|
||||
width: hitHandleSize,
|
||||
height: 'calc(24px / var(--domino-scale))',
|
||||
right: hitOffset,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
<div style={sideHandleVerticalStyle} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SingleSelectionOverlay = memo(() => {
|
||||
const selectedIds = useDominoStore(state => state.selectedIds)
|
||||
const elementId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
const elements = useDominoStore(state => state.elements)
|
||||
const el = elementId ? elements[elementId] : null
|
||||
const uiState = useDominoStore(state =>
|
||||
elementId ? state.elementUIStates[elementId] : null,
|
||||
)
|
||||
const scale = useDominoStore(state => state.viewport.scale)
|
||||
const readOnly = useDominoStore(state => state.readOnly)
|
||||
|
||||
if (!el) return null
|
||||
|
||||
const isLocked = readOnly || uiState?.status === 'readonly'
|
||||
const worldRect = getElementWorldRect(el, elements)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute pointer-events-none z-[100]'
|
||||
style={{
|
||||
width: worldRect.width,
|
||||
height: worldRect.height,
|
||||
transform: `translate3d(${worldRect.x}px, ${worldRect.y}px, 0) rotate(${worldRect.rotation}deg)`,
|
||||
borderWidth: 'max(0.4px, calc(2px / var(--domino-scale)))',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'var(--editor-accent)',
|
||||
}}
|
||||
>
|
||||
{!isLocked && (
|
||||
<>
|
||||
{el.rotatable !== false && <RotateHandles />}
|
||||
{el.scalable !== false && (
|
||||
<>
|
||||
<ResizeHandles />
|
||||
{el.resize !== 'none' &&
|
||||
(el.lockAspectRatio === false || el.resize) && (
|
||||
<SideHandles
|
||||
resize={el.resize}
|
||||
width={el.width}
|
||||
height={el.height}
|
||||
scale={scale}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const SnapLinesOverlay = memo(() => {
|
||||
const snapLines = useDominoStore(state => state.snapLines)
|
||||
|
||||
if (!snapLines || snapLines.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{snapLines.map((line, i) => (
|
||||
<div
|
||||
key={`${line.type}-${line.value}-${i}`}
|
||||
className='absolute border-[#FF00FF] pointer-events-none z-[120]'
|
||||
style={{
|
||||
left: line.type === 'x' ? line.value : -10000,
|
||||
top: line.type === 'y' ? line.value : -10000,
|
||||
width:
|
||||
line.type === 'x' ? 'calc(1px / var(--domino-scale))' : 20000,
|
||||
height:
|
||||
line.type === 'y' ? 'calc(1px / var(--domino-scale))' : 20000,
|
||||
borderStyle: 'dashed',
|
||||
borderWidth:
|
||||
line.type === 'x'
|
||||
? '0 0 0 calc(1px / var(--domino-scale))'
|
||||
: 'calc(1px / var(--domino-scale)) 0 0 0',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export const SelectionOverlay: React.FC = memo(() => {
|
||||
return (
|
||||
<>
|
||||
<HoverBorder />
|
||||
<FocusBorder />
|
||||
<MultiSelectionOverlay />
|
||||
<SingleSelectionOverlay />
|
||||
<SnapLinesOverlay />
|
||||
</>
|
||||
)
|
||||
})
|
||||
SelectionOverlay.displayName = 'SelectionOverlay'
|
||||
108
components/image-editor/components/canvas/text-render.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { TextElement } from './domino'
|
||||
import { useDominoStore } from './domino-hooks'
|
||||
|
||||
interface TextRenderProps {
|
||||
element: TextElement
|
||||
}
|
||||
|
||||
export function TextRender({ element }: TextRenderProps) {
|
||||
const updateElement = useDominoStore(state => state.updateElement)
|
||||
const setFocusedElementId = useDominoStore(state => state.setFocusedElementId)
|
||||
const isFocused = useDominoStore(
|
||||
state => state.focusedElementId === element.id,
|
||||
)
|
||||
const isSelected = useDominoStore(state =>
|
||||
state.selectedIds.includes(element.id),
|
||||
)
|
||||
const isEditing = isFocused && !isSelected
|
||||
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
const prevFocused = useRef(false)
|
||||
|
||||
// Focus and Cursor Positioning
|
||||
useEffect(() => {
|
||||
if (isFocused && !prevFocused.current && textRef.current) {
|
||||
const el = textRef.current
|
||||
if (document.activeElement !== el) {
|
||||
el.focus({ preventScroll: true })
|
||||
}
|
||||
// Only move cursor to end once on initial focus
|
||||
el.innerText = element.content
|
||||
const range = document.createRange()
|
||||
const sel = window.getSelection()
|
||||
if (sel) {
|
||||
range.selectNodeContents(el)
|
||||
range.collapse(false)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
}
|
||||
}
|
||||
prevFocused.current = isFocused
|
||||
}, [isFocused, element.content])
|
||||
|
||||
// Height Synchronization
|
||||
useEffect(() => {
|
||||
if (textRef.current) {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
const newHeight = entry.contentRect.height
|
||||
// Update store height if strictly different
|
||||
if (Math.abs(newHeight - (element.height || 0)) > 0.5) {
|
||||
updateElement(element.id, { height: newHeight })
|
||||
}
|
||||
}
|
||||
})
|
||||
observer.observe(textRef.current)
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
}, [element.id, element.height, updateElement])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={textRef}
|
||||
contentEditable={isFocused}
|
||||
suppressContentEditableWarning
|
||||
className={cn(
|
||||
'w-full h-auto outline-none break-words cursor-text select-text p-0 m-0',
|
||||
!isFocused && 'pointer-events-none',
|
||||
isEditing &&
|
||||
'text-is-editing bg-transparent border-none shadow-none ring-0',
|
||||
)}
|
||||
draggable={!isEditing}
|
||||
style={{
|
||||
fontSize: element.fontSize,
|
||||
fontWeight: element.fontWeight,
|
||||
color: element.color,
|
||||
fontFamily: element.fontFamily,
|
||||
lineHeight: 1.2,
|
||||
textAlign: element.textAlign,
|
||||
fontStyle: element.fontStyle,
|
||||
minHeight: '1em',
|
||||
minWidth: '20px',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
cursor: isFocused ? 'text' : 'default',
|
||||
whiteSpace: element.width ? 'pre-wrap' : 'nowrap', // auto 宽度时不换行
|
||||
paddingRight: isFocused ? '32px' : 0, // 预留光标位置
|
||||
}}
|
||||
onInput={e => {
|
||||
const content = e.currentTarget.innerText
|
||||
const height = e.currentTarget.offsetHeight
|
||||
updateElement(element.id, { content, height })
|
||||
}}
|
||||
onPaste={e => {
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
document.execCommand('insertText', false, text)
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocusedElementId(null)
|
||||
}}
|
||||
>
|
||||
{!isFocused && element.content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
components/image-editor/components/canvas/use-domino-anchor.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useDominoStore } from './domino-hooks'
|
||||
import { useTransform } from './use-transform'
|
||||
import { getDominoDOM } from './dom-utils'
|
||||
import { getElementWorldRect } from './math'
|
||||
|
||||
export interface AnchorRect {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
right: number
|
||||
bottom: number
|
||||
centerX: number
|
||||
centerY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the screen-space coordinates of a Domino element.
|
||||
* Useful for anchoring external UI components (like Modals or Tooltips) to canvas elements.
|
||||
*/
|
||||
export function useDominoAnchor(
|
||||
elementId: string | null,
|
||||
containerRef: React.RefObject<HTMLElement | null>,
|
||||
screenSpace = false,
|
||||
): AnchorRect | null {
|
||||
const elements = useDominoStore(state => state.elements)
|
||||
const placeholders = useDominoStore(state => state.placeholders)
|
||||
const element = useDominoStore(state =>
|
||||
elementId
|
||||
? state.elements[elementId] || state.placeholders[elementId]
|
||||
: null,
|
||||
)
|
||||
const viewport = useDominoStore(state => state.viewport)
|
||||
const { worldToScreen } = useTransform()
|
||||
|
||||
return useMemo((): AnchorRect | null => {
|
||||
if (!element || !containerRef.current) return null
|
||||
|
||||
// 1. [Better Calc] If screenSpace is enabled, try to use direct DOM measurement for precision
|
||||
// This automatically handles native CSS transforms, scaling, and complex nesting.
|
||||
if (screenSpace) {
|
||||
const domEl = getDominoDOM(element.id)
|
||||
if (domEl) {
|
||||
const rect = domEl.getBoundingClientRect()
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
centerX: rect.left + rect.width / 2,
|
||||
centerY: rect.top + rect.height / 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. [Original Fallback] Manual math calculation (for non-screenSpace or when DOM is not yet ready)
|
||||
// Calculate absolute world position using utility
|
||||
const worldRect = getElementWorldRect(element, elements)
|
||||
const worldX = worldRect.x
|
||||
const worldY = worldRect.y
|
||||
|
||||
// Calculate the 4 corners in world space
|
||||
const rad = ((element.rotation || 0) * Math.PI) / 180
|
||||
|
||||
// 当 width/height 为 0 时(表示 auto),从 DOM 获取实际渲染尺寸
|
||||
let elWidth = element.width
|
||||
let elHeight = element.height
|
||||
if (!elWidth || !elHeight) {
|
||||
const domEl = getDominoDOM(element.id)
|
||||
if (domEl) {
|
||||
// Use offsetWidth/Height as they reflect layout size before CSS transforms (like rotation)
|
||||
const scale = viewport.scale || 1
|
||||
if (!elWidth) elWidth = domEl.offsetWidth / scale
|
||||
if (!elHeight) elHeight = domEl.offsetHeight / scale
|
||||
}
|
||||
}
|
||||
|
||||
const cx = worldX + elWidth / 2
|
||||
const cy = worldY + elHeight / 2
|
||||
|
||||
const corners = [
|
||||
{ x: worldX, y: worldY }, // tl
|
||||
{ x: worldX + elWidth, y: worldY }, // tr
|
||||
{ x: worldX + elWidth, y: worldY + elHeight }, // br
|
||||
{ x: worldX, y: worldY + elHeight }, // bl
|
||||
]
|
||||
|
||||
const rotatedCorners = corners.map(p => {
|
||||
// Rotate around center
|
||||
const dx = p.x - cx
|
||||
const dy = p.y - cy
|
||||
const rx = dx * Math.cos(rad) - dy * Math.sin(rad)
|
||||
const ry = dx * Math.sin(rad) + dy * Math.cos(rad)
|
||||
return { x: rx + cx, y: ry + cy }
|
||||
})
|
||||
|
||||
// Convert all corners to screen space
|
||||
// If NOT screenSpace, worldToScreen returns relative to the canvas.
|
||||
// If screenSpace, we want viewport-relative coordinates for fixed positioning.
|
||||
const screenCorners = rotatedCorners.map(p => worldToScreen(p.x, p.y))
|
||||
|
||||
// Find the bounding box of the screen corners
|
||||
const minX = Math.min(...screenCorners.map(p => p.x))
|
||||
const minY = Math.min(...screenCorners.map(p => p.y))
|
||||
const maxX = Math.max(...screenCorners.map(p => p.x))
|
||||
const maxY = Math.max(...screenCorners.map(p => p.y))
|
||||
|
||||
return {
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
right: maxX,
|
||||
bottom: maxY,
|
||||
centerX: (minX + maxX) / 2,
|
||||
centerY: (minY + maxY) / 2,
|
||||
}
|
||||
}, [
|
||||
element,
|
||||
elements,
|
||||
placeholders,
|
||||
viewport,
|
||||
containerRef,
|
||||
worldToScreen,
|
||||
screenSpace,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type React from 'react'
|
||||
import { useDominoDOMContext } from './domino-hooks'
|
||||
|
||||
/**
|
||||
* Hook to get the Domino container DOM reference.
|
||||
* Priority: explicitly provided ref > container ref from DominoDOMContext
|
||||
*/
|
||||
export function useDominoContainer(
|
||||
explicitRef?: React.RefObject<HTMLElement | null>,
|
||||
) {
|
||||
const contextRef = useDominoDOMContext()
|
||||
return explicitRef || contextRef
|
||||
}
|
||||
115
components/image-editor/components/canvas/use-domino-instance.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useDominoStore, useDominoStoreInstance } from './domino-hooks'
|
||||
import { useDominoScrollIntoView } from './use-domino-scroll-into-view'
|
||||
import { useDominoZoomToFit } from './use-domino-zoom-to-fit'
|
||||
import type { Viewport, SceneElement } from './domino'
|
||||
|
||||
/**
|
||||
* The Facade hook for Domino Canvas.
|
||||
* Provides a unified object to control the canvas, inspired by useReactFlow.
|
||||
*/
|
||||
export function useDominoInstance(
|
||||
explicitContainerRef?: React.RefObject<HTMLElement | null>,
|
||||
) {
|
||||
const store = useDominoStoreInstance()
|
||||
const setViewport = useDominoStore(state => state.setViewport)
|
||||
const addElement = useDominoStore(state => state.addElement)
|
||||
const removeElement = useDominoStore(state => state.removeElement)
|
||||
const updateElement = useDominoStore(state => state.updateElement)
|
||||
const undo = useDominoStore(state => state.undo)
|
||||
const redo = useDominoStore(state => state.redo)
|
||||
const takeSnapshot = useDominoStore(state => state.takeSnapshot)
|
||||
const moveElementUp = useDominoStore(state => state.moveElementUp)
|
||||
const moveElementDown = useDominoStore(state => state.moveElementDown)
|
||||
const moveElementToTop = useDominoStore(state => state.moveElementToTop)
|
||||
const moveElementToBottom = useDominoStore(state => state.moveElementToBottom)
|
||||
|
||||
const scrollIntoView = useDominoScrollIntoView(explicitContainerRef)
|
||||
const zoomToFit = useDominoZoomToFit(explicitContainerRef)
|
||||
|
||||
const instance = useMemo(
|
||||
() => ({
|
||||
/**
|
||||
* Get current viewport state (raw access)
|
||||
*/
|
||||
getViewport: () => store.getState().viewport,
|
||||
/**
|
||||
* Set viewport state explicitly
|
||||
*/
|
||||
setViewport: (viewport: Partial<Viewport>) => setViewport(viewport),
|
||||
/**
|
||||
* Zoom the canvas to fit all elements
|
||||
*/
|
||||
zoomToFit,
|
||||
/**
|
||||
* Scroll a specific element into view
|
||||
*/
|
||||
scrollIntoView,
|
||||
/**
|
||||
* Manual zoom by multiplier at center
|
||||
*/
|
||||
zoomBy: (multiplier: number, cx: number, cy: number) => {
|
||||
store.getState().zoomViewport(multiplier, cx, cy)
|
||||
},
|
||||
/**
|
||||
* Undo the last change
|
||||
*/
|
||||
undo,
|
||||
/**
|
||||
* Redo the last undone change
|
||||
*/
|
||||
redo,
|
||||
/**
|
||||
* Manually take a history snapshot
|
||||
*/
|
||||
takeSnapshot,
|
||||
/**
|
||||
* Add a new element to the canvas
|
||||
*/
|
||||
addElement: (el: SceneElement) => addElement(el),
|
||||
/**
|
||||
* Remove an element by ID
|
||||
*/
|
||||
removeElement: (id: string) => removeElement(id),
|
||||
/**
|
||||
* Update an element's data
|
||||
*/
|
||||
updateElement: (id: string, updates: Partial<SceneElement>) =>
|
||||
updateElement(id, updates),
|
||||
/**
|
||||
* Move an element up in the rendering order
|
||||
*/
|
||||
moveElementUp: (id: string) => moveElementUp(id),
|
||||
/**
|
||||
* Move an element down in the rendering order
|
||||
*/
|
||||
moveElementDown: (id: string) => moveElementDown(id),
|
||||
/**
|
||||
* Move an element to the very top
|
||||
*/
|
||||
moveElementToTop: (id: string) => moveElementToTop(id),
|
||||
/**
|
||||
* Move an element to the very bottom
|
||||
*/
|
||||
moveElementToBottom: (id: string) => moveElementToBottom(id),
|
||||
}),
|
||||
[
|
||||
store,
|
||||
setViewport,
|
||||
zoomToFit,
|
||||
scrollIntoView,
|
||||
undo,
|
||||
redo,
|
||||
takeSnapshot,
|
||||
addElement,
|
||||
removeElement,
|
||||
updateElement,
|
||||
moveElementUp,
|
||||
moveElementDown,
|
||||
moveElementToTop,
|
||||
moveElementToBottom,
|
||||
],
|
||||
)
|
||||
|
||||
return instance
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useDominoStore, useDominoStoreInstance } from './domino-hooks'
|
||||
import type { Padding, ScrollIntoViewOptions } from './domino'
|
||||
import { useDominoContainer } from './use-domino-container'
|
||||
import { calculateScrollIntoViewTransform } from './math'
|
||||
|
||||
/**
|
||||
* Hook to scroll an element into view within the Domino canvas.
|
||||
* (Internal hook, prefer using useDominoInstance)
|
||||
*/
|
||||
export function useDominoScrollIntoView(
|
||||
explicitContainerRef?: React.RefObject<HTMLElement | null>,
|
||||
) {
|
||||
const store = useDominoStoreInstance()
|
||||
const setViewport = useDominoStore(state => state.setViewport)
|
||||
const containerRef = useDominoContainer(explicitContainerRef)
|
||||
|
||||
const scrollIntoView = useCallback(
|
||||
(elementId: string, options?: ScrollIntoViewOptions) => {
|
||||
const container = containerRef?.current
|
||||
if (!container) return false
|
||||
|
||||
const { elements, viewport, padding: globalPadding } = store.getState()
|
||||
const element = elements[elementId]
|
||||
if (!element) return false
|
||||
|
||||
const finalPadding = {
|
||||
top: options?.padding?.top ?? globalPadding.top ?? 0,
|
||||
right: options?.padding?.right ?? globalPadding.right ?? 0,
|
||||
bottom: options?.padding?.bottom ?? globalPadding.bottom ?? 0,
|
||||
left: options?.padding?.left ?? globalPadding.left ?? 0,
|
||||
} as Required<Padding>
|
||||
|
||||
const nextViewport = calculateScrollIntoViewTransform(
|
||||
element,
|
||||
viewport,
|
||||
container.getBoundingClientRect(),
|
||||
finalPadding,
|
||||
{
|
||||
force: options?.force,
|
||||
block: options?.block,
|
||||
inline: options?.inline,
|
||||
targetScale: options?.scale,
|
||||
},
|
||||
)
|
||||
|
||||
if (nextViewport) {
|
||||
setViewport(nextViewport)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[setViewport, containerRef, store],
|
||||
)
|
||||
|
||||
return scrollIntoView
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useDominoStore, useDominoStoreInstance } from './domino-hooks'
|
||||
import type { Padding, ScrollIntoViewOptions, SceneElement } from './domino'
|
||||
import { useDominoContainer } from './use-domino-container'
|
||||
import { calculateZoomToFitTransform } from './math'
|
||||
|
||||
/**
|
||||
* Hook to zoom the Domino canvas to fit all elements.
|
||||
* (Internal hook, prefer using useDominoInstance)
|
||||
*/
|
||||
export function useDominoZoomToFit(
|
||||
explicitContainerRef?: React.RefObject<HTMLElement | null>,
|
||||
) {
|
||||
const store = useDominoStoreInstance()
|
||||
const setViewport = useDominoStore(state => state.setViewport)
|
||||
const containerRef = useDominoContainer(explicitContainerRef)
|
||||
|
||||
const zoomToFit = useCallback(
|
||||
(options?: Omit<ScrollIntoViewOptions, 'force' | 'scale'>) => {
|
||||
const container = containerRef?.current
|
||||
if (!container) return
|
||||
|
||||
const { elements, padding: globalPadding } = store.getState()
|
||||
const elementList = Object.values(elements) as SceneElement[]
|
||||
if (elementList.length === 0) return
|
||||
|
||||
const finalPadding = {
|
||||
top: options?.padding?.top ?? globalPadding.top ?? 0,
|
||||
right: options?.padding?.right ?? globalPadding.right ?? 0,
|
||||
bottom: options?.padding?.bottom ?? globalPadding.bottom ?? 0,
|
||||
left: options?.padding?.left ?? globalPadding.left ?? 0,
|
||||
} as Required<Padding>
|
||||
|
||||
const nextViewport = calculateZoomToFitTransform(
|
||||
elementList,
|
||||
container.getBoundingClientRect(),
|
||||
finalPadding,
|
||||
)
|
||||
|
||||
if (nextViewport) {
|
||||
setViewport(nextViewport)
|
||||
}
|
||||
},
|
||||
[setViewport, containerRef, store],
|
||||
)
|
||||
|
||||
return zoomToFit
|
||||
}
|
||||
115
components/image-editor/components/canvas/use-gestures.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useWheel, usePinch } from '@use-gesture/react'
|
||||
import { useDominoStoreInstance } from './domino-hooks'
|
||||
|
||||
const isMac =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/Mac|iPhone|iPod|iPad/.test(navigator.userAgent)
|
||||
|
||||
export function useGestures(
|
||||
containerRef: React.RefObject<HTMLElement | null>,
|
||||
options: {
|
||||
minScale?: number
|
||||
maxScale?: number
|
||||
} = {},
|
||||
) {
|
||||
const { minScale = 0.1, maxScale = 20 } = options
|
||||
const store = useDominoStoreInstance()
|
||||
const { moveViewport, zoomViewport, setViewport } = store.getState()
|
||||
|
||||
// 更新 DOM 叠加层的状态类名(用于鼠标样式和业务联动)
|
||||
const updateDOMOverlay = (type: 'panning' | 'zooming' | 'none') => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
container.classList.toggle('is-panning', type === 'panning')
|
||||
container.classList.toggle('is-zooming', type === 'zooming')
|
||||
}
|
||||
|
||||
useWheel(
|
||||
({ delta: [dx, dy], event, ctrlKey, active }) => {
|
||||
// 冲突检测:如果滚轮发生在可滚动 UI 内部,不移动画布
|
||||
if (!ctrlKey) {
|
||||
const target = event.target as HTMLElement
|
||||
const scrollable = target?.closest(
|
||||
'.overflow-y-auto, .overflow-y-scroll, .overflow-auto, [data-radix-scroll-area-viewport], .scrollbar-container',
|
||||
)
|
||||
if (scrollable && scrollable.scrollHeight > scrollable.clientHeight) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (active) {
|
||||
updateDOMOverlay(ctrlKey ? 'zooming' : 'panning')
|
||||
} else {
|
||||
updateDOMOverlay('none')
|
||||
}
|
||||
|
||||
if (ctrlKey) {
|
||||
event.preventDefault()
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
const cx = (event as WheelEvent).clientX - rect.left
|
||||
const cy = (event as WheelEvent).clientY - rect.top
|
||||
|
||||
const multiplier = Math.min(
|
||||
1.25,
|
||||
Math.max(0.8, isMac ? 0.99 ** dy : 0.999 ** dy),
|
||||
)
|
||||
zoomViewport(multiplier, cx, cy, minScale, maxScale)
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
moveViewport(-dx, -dy)
|
||||
},
|
||||
{
|
||||
target: containerRef,
|
||||
eventOptions: { passive: false },
|
||||
},
|
||||
)
|
||||
|
||||
usePinch(
|
||||
({ origin: [ox, oy], first, movement: [ms], memo, active }) => {
|
||||
if (active) {
|
||||
updateDOMOverlay('zooming')
|
||||
} else {
|
||||
updateDOMOverlay('none')
|
||||
}
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
if (first) {
|
||||
const { viewport } = store.getState()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const cx = ox - rect.left
|
||||
const cy = oy - rect.top
|
||||
return {
|
||||
startScale: viewport.scale,
|
||||
anchorWx: (cx - viewport.x) / viewport.scale,
|
||||
anchorWy: (cy - viewport.y) / viewport.scale,
|
||||
cx,
|
||||
cy,
|
||||
}
|
||||
}
|
||||
|
||||
const { cx, cy } = memo
|
||||
const sensitivity = 1.5
|
||||
const adjustedMs = 1 + (ms - 1) * sensitivity
|
||||
const targetScale = memo.startScale * adjustedMs
|
||||
const newScale = Math.max(minScale, Math.min(maxScale, targetScale))
|
||||
|
||||
setViewport({
|
||||
x: cx - memo.anchorWx * newScale,
|
||||
y: cy - memo.anchorWy * newScale,
|
||||
scale: newScale,
|
||||
})
|
||||
|
||||
return memo
|
||||
},
|
||||
{
|
||||
target: containerRef,
|
||||
eventOptions: { passive: false },
|
||||
},
|
||||
)
|
||||
}
|
||||
839
components/image-editor/components/canvas/use-interactions.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import { useDominoStore, useDominoStoreInstance } from './domino-hooks'
|
||||
import type { DominoEvents, SceneElement, ArtboardElement } from './domino'
|
||||
import { DOMINO_EL_PREFIX } from './constants'
|
||||
import { useTransform } from './use-transform'
|
||||
import { getElementWorldRect } from './math'
|
||||
import { calculateSnap } from './interactions/snapping'
|
||||
import { calculateResize } from './interactions/resizing'
|
||||
|
||||
function isTargetInputOrEditable(target: HTMLElement | null) {
|
||||
if (!target) return false
|
||||
|
||||
// 1. 标准输入元素
|
||||
const tagName = target.tagName.toLowerCase()
|
||||
if (tagName === 'input' || tagName === 'textarea') return true
|
||||
|
||||
// 2. 可编辑元素
|
||||
return target.isContentEditable
|
||||
}
|
||||
|
||||
type InteractionStatus =
|
||||
| 'none'
|
||||
| 'idle'
|
||||
| 'panning'
|
||||
| 'selecting'
|
||||
| 'dragging'
|
||||
| 'resizing'
|
||||
| 'rotating'
|
||||
|
||||
interface InteractionOptions extends DominoEvents {}
|
||||
|
||||
export function useInteractions(
|
||||
containerRef: React.RefObject<HTMLElement | null>,
|
||||
options: InteractionOptions,
|
||||
) {
|
||||
const { onClick, onElementClick, onTransformEnd } = options
|
||||
const store = useDominoStoreInstance()
|
||||
const statusRef = useRef<InteractionStatus>('none')
|
||||
const startPos = useRef({ x: 0, y: 0 }) // Screen coordinates
|
||||
const lastPos = useRef({ x: 0, y: 0 }) // Screen coordinates
|
||||
|
||||
const { screenToWorld } = useTransform()
|
||||
const moveViewport = useDominoStore(state => state.moveViewport)
|
||||
const setSelectionBox = useDominoStore(state => state.setSelectionBox)
|
||||
const setSnapLines = useDominoStore(state => state.setSnapLines)
|
||||
const setFocusedElementId = useDominoStore(state => state.setFocusedElementId)
|
||||
const setSelectedIds = useDominoStore(state => state.setSelectedIds)
|
||||
|
||||
/**
|
||||
* 直接通过 DOM API 更新容器状态,避免 React 频繁重绘
|
||||
*/
|
||||
const updateDOMOverlay = useCallback(
|
||||
(gestureStatus: InteractionStatus = 'none', updateCursor = true) => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const state = store.getState()
|
||||
const mainStatus = statusRef.current
|
||||
|
||||
// 更新类名:支持主状态和手势状态叠加
|
||||
const statuses: InteractionStatus[] = [
|
||||
'panning',
|
||||
'selecting',
|
||||
'dragging',
|
||||
'resizing',
|
||||
'rotating',
|
||||
]
|
||||
statuses.forEach(s => {
|
||||
const isActive = s === mainStatus || s === gestureStatus
|
||||
container.classList.toggle(`is-${s}`, isActive)
|
||||
})
|
||||
|
||||
// 更新光标:仅由主状态 (PointerDown) 和工具模式 (Mode) 决定
|
||||
if (updateCursor) {
|
||||
if (mainStatus === 'panning') {
|
||||
container.style.cursor = 'grabbing'
|
||||
} else if (mainStatus === 'none') {
|
||||
// 恢复到工具模式对应的光标
|
||||
container.style.cursor =
|
||||
state.mode === 'pan' ? 'grab' : 'url(/arrow-cursor.svg), auto'
|
||||
}
|
||||
}
|
||||
},
|
||||
[containerRef, store],
|
||||
)
|
||||
|
||||
const setStatus = useCallback(
|
||||
(newStatus: InteractionStatus, updateCursor = true) => {
|
||||
statusRef.current = newStatus
|
||||
updateDOMOverlay('none', updateCursor)
|
||||
},
|
||||
[updateDOMOverlay],
|
||||
)
|
||||
|
||||
// 监听交互模式切换(手动订阅以保持 updateDOMOverlay 恒定)
|
||||
useEffect(() => {
|
||||
// 初始执行一次
|
||||
updateDOMOverlay('none', true)
|
||||
|
||||
// 监听 store 中的模式变化
|
||||
let lastMode = store.getState().mode
|
||||
const unsubscribe = store.subscribe(state => {
|
||||
if (state.mode !== lastMode) {
|
||||
lastMode = state.mode
|
||||
updateDOMOverlay('none', true)
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [updateDOMOverlay, store])
|
||||
|
||||
const resizeHandle = useRef<string | null>(null) // 'nw', 'ne', 'sw', 'se', 'ml', 'mr'
|
||||
const startRotation = useRef(0)
|
||||
const startAngle = useRef(0)
|
||||
const dragStartElements = useRef<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
fontSize?: number
|
||||
originalParentId?: string
|
||||
originalIndex?: number
|
||||
}
|
||||
>
|
||||
>({})
|
||||
const isSnappedToOriginal = useRef(false)
|
||||
const hasMoved = useRef(false)
|
||||
|
||||
const onDoubleClickCapture = useCallback((e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (isTargetInputOrEditable(target)) return
|
||||
|
||||
const state = store.getState()
|
||||
const { elements: currentElements, selectedIds, focusedElementId } = state
|
||||
const elementId = focusedElementId || selectedIds[0]
|
||||
const element = elementId ? currentElements[elementId] : null
|
||||
|
||||
if (elementId && element?.type === 'text') {
|
||||
setFocusedElementId(elementId)
|
||||
setSelectedIds(selectedIds.filter(id => id !== elementId))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
// Ignore right click for interactions to prevent conflict with browser context menu
|
||||
if (e.button === 2) return
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
if (isTargetInputOrEditable(target)) return
|
||||
|
||||
const state = store.getState()
|
||||
const {
|
||||
elements: currentElements,
|
||||
placeholders: currentPlaceholders,
|
||||
selectedIds: currentSelectedIds,
|
||||
mode: currentMode,
|
||||
readOnly: isReadOnly,
|
||||
} = state
|
||||
|
||||
const elementEl = target.closest('.domino-element') as HTMLElement | null
|
||||
const rawId = elementEl?.id
|
||||
const elementId = rawId?.startsWith(DOMINO_EL_PREFIX)
|
||||
? rawId.slice(DOMINO_EL_PREFIX.length)
|
||||
: rawId
|
||||
const element = elementId
|
||||
? currentElements[elementId] || currentPlaceholders[elementId]
|
||||
: null
|
||||
const isElement = !!(element && element.selectable !== false)
|
||||
|
||||
const handleEl = target.closest(
|
||||
'.domino-resize-handle',
|
||||
) as HTMLElement | null
|
||||
const rotateEl = target.closest(
|
||||
'.domino-rotate-handle',
|
||||
) as HTMLElement | null
|
||||
const multiSelectEl = target.closest(
|
||||
'.domino-multi-select-area',
|
||||
) as HTMLElement | null
|
||||
const isBackground = target === containerRef.current
|
||||
|
||||
// Determine interaction type based on target and current state
|
||||
let interactionType: InteractionStatus = 'idle'
|
||||
|
||||
if (isReadOnly) {
|
||||
// If read-only, only allow panning or element focus/click
|
||||
if (
|
||||
e.button === 1 ||
|
||||
(e.button === 0 && e.altKey) ||
|
||||
currentMode === 'pan'
|
||||
) {
|
||||
interactionType = 'panning'
|
||||
} else if (isElement && elementId) {
|
||||
// Allow focusing/clicking elements even if read-only
|
||||
state.setFocusedElementId(elementId)
|
||||
if (!e.shiftKey) {
|
||||
state.setSelectedIds([elementId])
|
||||
}
|
||||
// Stay in 'idle' for click detection in onPointerUp
|
||||
interactionType = 'idle'
|
||||
} else {
|
||||
// No interaction for other cases in read-only mode
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Not read-only
|
||||
if (
|
||||
e.button === 1 ||
|
||||
(e.button === 0 && e.altKey) ||
|
||||
currentMode === 'pan'
|
||||
) {
|
||||
interactionType = 'panning'
|
||||
} else if (isElement && elementId) {
|
||||
const id = elementId
|
||||
const uiState = state.elementUIStates[id]
|
||||
|
||||
// CRITICAL: If the element is already focused (editing text),
|
||||
// we MUST BALE OUT to allow native cursor movement and avoid clearing focus.
|
||||
if (
|
||||
id === state.focusedElementId &&
|
||||
element.type === 'text' &&
|
||||
!store.getState().selectedIds.includes(id)
|
||||
) {
|
||||
setStatus('none')
|
||||
return
|
||||
}
|
||||
|
||||
if (uiState?.status === 'readonly') {
|
||||
state.setFocusedElementId(id)
|
||||
if (!e.shiftKey) {
|
||||
state.setSelectedIds([id])
|
||||
}
|
||||
// Stay in 'idle' for click detection in onPointerUp
|
||||
interactionType = 'idle'
|
||||
} else if (element.type === 'placeholder') {
|
||||
// Placeholder: Focusable but not selectable
|
||||
state.setFocusedElementId(id)
|
||||
if (!e.shiftKey) {
|
||||
state.setSelectedIds([])
|
||||
}
|
||||
// Stay in 'idle' for click detection in onPointerUp
|
||||
interactionType = 'idle'
|
||||
} else {
|
||||
// Element interaction: clicking selects it (shows dots)
|
||||
state.setFocusedElementId(null)
|
||||
|
||||
let targetIds = currentSelectedIds
|
||||
if (e.shiftKey) {
|
||||
if (currentSelectedIds.includes(id)) {
|
||||
// Toggle off
|
||||
targetIds = currentSelectedIds.filter(sid => sid !== id)
|
||||
} else {
|
||||
// Toggle on
|
||||
targetIds = [...currentSelectedIds, id]
|
||||
}
|
||||
state.setSelectedIds(targetIds)
|
||||
} else {
|
||||
if (!currentSelectedIds.includes(id)) {
|
||||
// New single selection
|
||||
targetIds = [id]
|
||||
state.setSelectedIds(targetIds)
|
||||
}
|
||||
// If already selected, we keep targetIds = selectedIds to allow group drag
|
||||
}
|
||||
|
||||
// Only start dragging if the clicked element is part of the final selection
|
||||
if (targetIds.includes(id)) {
|
||||
const draggableIds = targetIds.filter(tid => {
|
||||
const el = currentElements[tid] || currentPlaceholders[tid]
|
||||
return el?.draggable !== false
|
||||
})
|
||||
if (draggableIds.length === 0) {
|
||||
setStatus('none')
|
||||
return
|
||||
}
|
||||
// Store initial positions for all elements to be moved
|
||||
const startPositions: Record<
|
||||
string,
|
||||
{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
originalParentId?: string
|
||||
originalIndex?: number
|
||||
}
|
||||
> = {}
|
||||
draggableIds.forEach(did => {
|
||||
const el = currentElements[did] || currentPlaceholders[did]
|
||||
if (el) {
|
||||
startPositions[did] = {
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
width: el.width,
|
||||
height: el.height,
|
||||
originalParentId: el.parentId,
|
||||
originalIndex: el.parentId
|
||||
? (
|
||||
currentElements[el.parentId] as ArtboardElement
|
||||
)?.childrenIds?.indexOf(did)
|
||||
: state.elementOrder.indexOf(did),
|
||||
}
|
||||
}
|
||||
})
|
||||
dragStartElements.current = startPositions
|
||||
interactionType = 'dragging'
|
||||
} else {
|
||||
interactionType = 'none'
|
||||
}
|
||||
}
|
||||
} else if (handleEl) {
|
||||
// Handle resizing
|
||||
interactionType = 'resizing'
|
||||
resizeHandle.current = handleEl.dataset.handle || ''
|
||||
const id = currentSelectedIds[0]
|
||||
const el = currentElements[id] || currentPlaceholders[id]
|
||||
if (el) {
|
||||
dragStartElements.current = {
|
||||
[id]: {
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
width: el.width,
|
||||
height: el.height,
|
||||
fontSize: el.type === 'text' ? el.fontSize : undefined,
|
||||
},
|
||||
}
|
||||
isSnappedToOriginal.current = false
|
||||
}
|
||||
} else if (rotateEl) {
|
||||
// Handle rotating
|
||||
interactionType = 'rotating'
|
||||
const id = currentSelectedIds[0]
|
||||
const el = currentElements[id] || currentPlaceholders[id]
|
||||
if (el) {
|
||||
startRotation.current = el.rotation || 0
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY, rect)
|
||||
const worldRect = getElementWorldRect(el, currentElements)
|
||||
const centerX = worldRect.x + worldRect.width / 2
|
||||
const centerY = worldRect.y + worldRect.height / 2
|
||||
startAngle.current = Math.atan2(
|
||||
worldPos.y - centerY,
|
||||
worldPos.x - centerX,
|
||||
)
|
||||
}
|
||||
} else if (multiSelectEl && currentSelectedIds.length > 1) {
|
||||
// Handle dragging for multiple selected elements
|
||||
const draggableIds = currentSelectedIds.filter(id => {
|
||||
const el = currentElements[id] || currentPlaceholders[id]
|
||||
return el?.draggable !== false
|
||||
})
|
||||
if (draggableIds.length > 0) {
|
||||
const startPositions: Record<
|
||||
string,
|
||||
{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
originalParentId?: string
|
||||
originalIndex?: number
|
||||
}
|
||||
> = {}
|
||||
draggableIds.forEach(id => {
|
||||
const el = currentElements[id] || currentPlaceholders[id]
|
||||
if (el) {
|
||||
startPositions[id] = {
|
||||
x: el.x,
|
||||
y: el.y,
|
||||
width: el.width,
|
||||
height: el.height,
|
||||
originalParentId: el.parentId,
|
||||
originalIndex: el.parentId
|
||||
? (
|
||||
currentElements[el.parentId] as ArtboardElement
|
||||
)?.childrenIds?.indexOf(id)
|
||||
: state.elementOrder.indexOf(id),
|
||||
}
|
||||
}
|
||||
})
|
||||
dragStartElements.current = startPositions
|
||||
interactionType = 'dragging'
|
||||
} else {
|
||||
interactionType = 'none'
|
||||
}
|
||||
} else if (isBackground) {
|
||||
// Background interaction: only selection, no panning by default
|
||||
state.setFocusedElementId(null)
|
||||
if (!e.shiftKey) {
|
||||
state.setSelectedIds([])
|
||||
}
|
||||
interactionType = 'selecting'
|
||||
} else {
|
||||
// If not an element, not background, and not a special UI handle, then bail out.
|
||||
// This covers clicks on overlays that are not part of the canvas interaction.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
startPos.current = { x: e.clientX, y: e.clientY }
|
||||
lastPos.current = { x: e.clientX, y: e.clientY }
|
||||
hasMoved.current = false
|
||||
|
||||
if (containerRef.current) {
|
||||
containerRef.current.setPointerCapture(e.pointerId)
|
||||
containerRef.current.focus()
|
||||
}
|
||||
|
||||
setStatus(interactionType)
|
||||
},
|
||||
[setStatus, containerRef, screenToWorld, store],
|
||||
)
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const status = statusRef.current
|
||||
if (status === 'none') return
|
||||
|
||||
const dx = e.clientX - lastPos.current.x
|
||||
const dy = e.clientY - lastPos.current.y
|
||||
|
||||
// 距离阈值检测:防止微小手抖触发误操作
|
||||
const dist = Math.sqrt(
|
||||
(e.clientX - startPos.current.x) ** 2 +
|
||||
(e.clientY - startPos.current.y) ** 2,
|
||||
)
|
||||
const THRESHOLD = 3
|
||||
const isThresholdMet = dist >= THRESHOLD || hasMoved.current
|
||||
|
||||
// 仅针对拖拽、缩放、旋转、框选应用阈值;平移(panning)保持即时响应
|
||||
if (status !== 'panning' && status !== 'idle' && !isThresholdMet) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const state = store.getState()
|
||||
const { scale } = state.viewport
|
||||
|
||||
const {
|
||||
selectedIds: currentSelectedIds,
|
||||
elements: currentElements,
|
||||
placeholders: currentPlaceholders,
|
||||
} = state
|
||||
|
||||
if (status === 'panning') {
|
||||
moveViewport(dx, dy)
|
||||
lastPos.current = { x: e.clientX, y: e.clientY }
|
||||
} else if (status === 'dragging') {
|
||||
const totalDx = (e.clientX - startPos.current.x) / scale
|
||||
const totalDy = (e.clientY - startPos.current.y) / scale
|
||||
|
||||
// Only snap if not holding Command (Mac) or Control (Windows/Linux)
|
||||
const shouldSnap = !e.metaKey && !e.ctrlKey
|
||||
const { snapOffsetX, snapOffsetY, finalSnapLines } = calculateSnap(
|
||||
currentSelectedIds,
|
||||
dragStartElements.current,
|
||||
currentElements,
|
||||
currentPlaceholders,
|
||||
totalDx,
|
||||
totalDy,
|
||||
scale,
|
||||
shouldSnap,
|
||||
)
|
||||
|
||||
state.setSnapLines(finalSnapLines)
|
||||
|
||||
// 脱离/吸附画板逻辑
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
const mouseWorld = screenToWorld(e.clientX, e.clientY, rect)
|
||||
|
||||
// 查找当前鼠标下方的画板(排除当前选中的元素本身及其子树,避免循环嵌套)
|
||||
const artboardUnderMouse = Object.values(currentElements)
|
||||
.filter(
|
||||
el => el.type === 'artboard' && !currentSelectedIds.includes(el.id),
|
||||
)
|
||||
.reverse()
|
||||
.find(ab => {
|
||||
const abWorld = getElementWorldRect(ab, currentElements)
|
||||
return (
|
||||
mouseWorld.x >= abWorld.x &&
|
||||
mouseWorld.x <= abWorld.x + abWorld.width &&
|
||||
mouseWorld.y >= abWorld.y &&
|
||||
mouseWorld.y <= abWorld.y + abWorld.height
|
||||
)
|
||||
})
|
||||
|
||||
// 按照当前的视觉层级顺序(从小到大)进行排序,避免在批量移动到同一个父级时层级被打乱
|
||||
const sortedSelectedIds = [...currentSelectedIds].sort((a, b) => {
|
||||
const elA = currentElements[a]
|
||||
const elB = currentElements[b]
|
||||
if (elA?.parentId === elB?.parentId) {
|
||||
const siblings = elA?.parentId
|
||||
? (currentElements[elA.parentId] as ArtboardElement)?.childrenIds
|
||||
: state.elementOrder
|
||||
return siblings.indexOf(a) - siblings.indexOf(b)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
sortedSelectedIds.forEach(id => {
|
||||
const el = currentElements[id]
|
||||
if (!el || el.type === 'artboard') return
|
||||
|
||||
// 仅处理选中项中的顶层元素
|
||||
let isTopSelected = true
|
||||
let pId = el.parentId
|
||||
while (pId) {
|
||||
if (currentSelectedIds.includes(pId)) {
|
||||
isTopSelected = false
|
||||
break
|
||||
}
|
||||
pId = currentElements[pId]?.parentId || ''
|
||||
}
|
||||
if (!isTopSelected) return
|
||||
|
||||
// 获取元素当前所属的顶层画板
|
||||
let currentArtboard: SceneElement | null = null
|
||||
let curr = el
|
||||
while (curr.parentId) {
|
||||
const p = currentElements[curr.parentId]
|
||||
if (!p) break
|
||||
if (p.type === 'artboard') {
|
||||
currentArtboard = p
|
||||
break
|
||||
}
|
||||
curr = p
|
||||
}
|
||||
|
||||
const targetArtboardId = artboardUnderMouse?.id || null
|
||||
|
||||
// 情况 1: 如果当前在某个画板内
|
||||
if (currentArtboard) {
|
||||
const cbWorld = getElementWorldRect(
|
||||
currentArtboard,
|
||||
currentElements,
|
||||
)
|
||||
const isInsideCurrent =
|
||||
mouseWorld.x >= cbWorld.x &&
|
||||
mouseWorld.x <= cbWorld.x + cbWorld.width &&
|
||||
mouseWorld.y >= cbWorld.y &&
|
||||
mouseWorld.y <= cbWorld.y + cbWorld.height
|
||||
|
||||
// 如果鼠标移出了当前所在的画板,则执行切换(移入新画板或移回根级)
|
||||
if (!isInsideCurrent) {
|
||||
const worldRect = getElementWorldRect(el, currentElements)
|
||||
const initialWorldX = worldRect.x - totalDx
|
||||
const initialWorldY = worldRect.y - totalDy
|
||||
|
||||
const startMeta = dragStartElements.current[id]
|
||||
const targetIndex =
|
||||
targetArtboardId === startMeta?.originalParentId
|
||||
? startMeta.originalIndex
|
||||
: undefined
|
||||
|
||||
state.moveElementToParent(id, targetArtboardId, targetIndex)
|
||||
|
||||
// 关键:同步更新拖拽起始点引用,使其在新的坐标系下保持一致
|
||||
if (dragStartElements.current[id]) {
|
||||
let relX = initialWorldX
|
||||
let relY = initialWorldY
|
||||
if (targetArtboardId && artboardUnderMouse) {
|
||||
const parentWorld = getElementWorldRect(
|
||||
artboardUnderMouse,
|
||||
currentElements,
|
||||
)
|
||||
relX -= parentWorld.x
|
||||
relY -= parentWorld.y
|
||||
}
|
||||
dragStartElements.current[id].x = relX
|
||||
dragStartElements.current[id].y = relY
|
||||
}
|
||||
}
|
||||
} else if (targetArtboardId && el.parentId !== targetArtboardId) {
|
||||
// 情况 2: 如果当前不在画板内,且鼠标进入了一个画板
|
||||
const worldRect = getElementWorldRect(el, currentElements)
|
||||
const initialWorldX = worldRect.x - totalDx
|
||||
const initialWorldY = worldRect.y - totalDy
|
||||
|
||||
const startMeta = dragStartElements.current[id]
|
||||
const targetIndex =
|
||||
targetArtboardId === startMeta?.originalParentId
|
||||
? startMeta.originalIndex
|
||||
: undefined
|
||||
|
||||
state.moveElementToParent(id, targetArtboardId, targetIndex)
|
||||
|
||||
if (dragStartElements.current[id]) {
|
||||
let relX = initialWorldX
|
||||
let relY = initialWorldY
|
||||
if (artboardUnderMouse) {
|
||||
const parentWorld = getElementWorldRect(
|
||||
artboardUnderMouse,
|
||||
currentElements,
|
||||
)
|
||||
relX -= parentWorld.x
|
||||
relY -= parentWorld.y
|
||||
}
|
||||
dragStartElements.current[id].x = relX
|
||||
dragStartElements.current[id].y = relY
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Apply movement with snap offset
|
||||
currentSelectedIds.forEach(id => {
|
||||
const start = dragStartElements.current[id]
|
||||
if (start) {
|
||||
// Record history before first movement
|
||||
if (!hasMoved.current && currentElements[id]) {
|
||||
state.takeSnapshot()
|
||||
hasMoved.current = true
|
||||
}
|
||||
|
||||
state.updateElement(id, {
|
||||
x: start.x + totalDx + snapOffsetX,
|
||||
y: start.y + totalDy + snapOffsetY,
|
||||
})
|
||||
}
|
||||
})
|
||||
} else if (status === 'resizing' && currentSelectedIds.length > 0) {
|
||||
const id = currentSelectedIds[0]
|
||||
const startEl = dragStartElements.current[id]
|
||||
if (!startEl) return
|
||||
|
||||
const handle = resizeHandle.current
|
||||
if (!handle) return
|
||||
|
||||
const el = currentElements[id] || currentPlaceholders[id]
|
||||
if (!el) return
|
||||
|
||||
const totalWdx = (e.clientX - startPos.current.x) / scale
|
||||
const totalWdy = (e.clientY - startPos.current.y) / scale
|
||||
|
||||
const { updates, isSnappedToOriginal: snapped } = calculateResize(
|
||||
id,
|
||||
startEl,
|
||||
el,
|
||||
handle,
|
||||
totalWdx,
|
||||
totalWdy,
|
||||
scale,
|
||||
e.shiftKey,
|
||||
e.altKey,
|
||||
)
|
||||
|
||||
isSnappedToOriginal.current = snapped
|
||||
|
||||
// Record history before first movement
|
||||
if (!hasMoved.current && currentElements[id]) {
|
||||
state.takeSnapshot()
|
||||
hasMoved.current = true
|
||||
}
|
||||
|
||||
state.updateElement(id, updates)
|
||||
} else if (status === 'rotating' && currentSelectedIds.length > 0) {
|
||||
const id = currentSelectedIds[0]
|
||||
const el = currentElements[id] || currentPlaceholders[id]
|
||||
if (!el) return
|
||||
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
const worldPos = screenToWorld(e.clientX, e.clientY, rect)
|
||||
const worldRect = getElementWorldRect(el, currentElements)
|
||||
const centerX = worldRect.x + worldRect.width / 2
|
||||
const centerY = worldRect.y + worldRect.height / 2
|
||||
|
||||
const currentAngle = Math.atan2(
|
||||
worldPos.y - centerY,
|
||||
worldPos.x - centerX,
|
||||
)
|
||||
const angleDiff = ((currentAngle - startAngle.current) * 180) / Math.PI
|
||||
|
||||
let newRotation = startRotation.current + angleDiff
|
||||
|
||||
// Snap to 15 degree increments if Shift is held
|
||||
if (e.shiftKey) {
|
||||
newRotation = Math.round(newRotation / 15) * 15
|
||||
} else {
|
||||
// Magnetic snapping to cardinal angles
|
||||
const snapAngles = [0, 45, 90, 135, 180, 225, 270, 315]
|
||||
const snapThreshold = 3
|
||||
const normalized = ((newRotation % 360) + 360) % 360
|
||||
for (const target of snapAngles) {
|
||||
let diff = normalized - target
|
||||
if (diff > 180) diff -= 360
|
||||
if (diff < -180) diff += 360
|
||||
if (Math.abs(diff) < snapThreshold) {
|
||||
newRotation -= diff
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record history before first movement
|
||||
if (!hasMoved.current && currentElements[id]) {
|
||||
state.takeSnapshot()
|
||||
hasMoved.current = true
|
||||
}
|
||||
|
||||
state.updateElement(id, { rotation: newRotation })
|
||||
} else if (status === 'selecting') {
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
const currentWorld = screenToWorld(e.clientX, e.clientY, rect)
|
||||
const startWorld = screenToWorld(
|
||||
startPos.current.x,
|
||||
startPos.current.y,
|
||||
rect,
|
||||
)
|
||||
|
||||
const x = Math.min(startWorld.x, currentWorld.x)
|
||||
const y = Math.min(startWorld.y, currentWorld.y)
|
||||
const width = Math.abs(startWorld.x - currentWorld.x)
|
||||
const height = Math.abs(startWorld.y - currentWorld.y)
|
||||
|
||||
const box = { x, y, width, height }
|
||||
state.setSelectionBox(box)
|
||||
state.setFocusedElementId(null)
|
||||
|
||||
// Update selection in real-time
|
||||
const newSelectedIds = Object.values(currentElements)
|
||||
.filter(el => {
|
||||
if (
|
||||
el.type === 'artboard' ||
|
||||
el.type === 'placeholder' ||
|
||||
el.selectable === false
|
||||
)
|
||||
return false
|
||||
const worldRect = getElementWorldRect(el, currentElements)
|
||||
const elXMax = worldRect.x + worldRect.width
|
||||
const elYMax = worldRect.y + worldRect.height
|
||||
const boxXMax = x + width
|
||||
const boxYMax = y + height
|
||||
|
||||
return !(
|
||||
elXMax < x ||
|
||||
worldRect.x > boxXMax ||
|
||||
elYMax < y ||
|
||||
worldRect.y > boxYMax
|
||||
)
|
||||
})
|
||||
.map(el => el.id)
|
||||
|
||||
state.setSelectedIds(newSelectedIds)
|
||||
|
||||
// Manual hover detection during selection (pointer is captured)
|
||||
const hovered = Object.values(currentElements)
|
||||
.reverse() // Check from top to bottom
|
||||
.find(el => {
|
||||
if (el.type === 'artboard' || el.selectable === false) return false
|
||||
const worldRect = getElementWorldRect(el, currentElements)
|
||||
return (
|
||||
currentWorld.x >= worldRect.x &&
|
||||
currentWorld.x <= worldRect.x + worldRect.width &&
|
||||
currentWorld.y >= worldRect.y &&
|
||||
currentWorld.y <= worldRect.y + worldRect.height
|
||||
)
|
||||
})
|
||||
state.setHoveredElementId(hovered ? hovered.id : null)
|
||||
}
|
||||
|
||||
lastPos.current = { x: e.clientX, y: e.clientY }
|
||||
},
|
||||
[moveViewport, screenToWorld, store, containerRef],
|
||||
)
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
// ONLY handle if we started an interaction in onPointerDown
|
||||
if (statusRef.current === 'none') return
|
||||
|
||||
const state = store.getState()
|
||||
const dist = Math.sqrt(
|
||||
(e.clientX - startPos.current.x) ** 2 +
|
||||
(e.clientY - startPos.current.y) ** 2,
|
||||
)
|
||||
|
||||
// Click detection: distance moved is small
|
||||
if (dist < 5) {
|
||||
const target = e.target as HTMLElement
|
||||
const elementEl = target.closest('.domino-element')
|
||||
const id = elementEl?.id
|
||||
|
||||
onClick?.(e, id)
|
||||
|
||||
if (id) {
|
||||
const element = state.elements[id] || state.placeholders[id]
|
||||
if (element) {
|
||||
onElementClick?.(id, element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transform end detection
|
||||
if (hasMoved.current) {
|
||||
onTransformEnd?.(state.selectedIds)
|
||||
}
|
||||
|
||||
setStatus('none')
|
||||
hasMoved.current = false
|
||||
setSelectionBox(null)
|
||||
setSnapLines(null)
|
||||
if (containerRef.current) {
|
||||
containerRef.current.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
},
|
||||
[
|
||||
containerRef,
|
||||
setSnapLines,
|
||||
setSelectionBox,
|
||||
setStatus,
|
||||
onClick,
|
||||
onElementClick,
|
||||
onTransformEnd,
|
||||
store,
|
||||
],
|
||||
)
|
||||
|
||||
const onPointerCancel = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
setStatus('none')
|
||||
setSelectionBox(null)
|
||||
setSnapLines(null)
|
||||
if (containerRef.current) {
|
||||
containerRef.current.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
},
|
||||
[setStatus, setSelectionBox, setSnapLines, containerRef],
|
||||
)
|
||||
|
||||
return {
|
||||
onDoubleClickCapture,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
}
|
||||
}
|
||||
33
components/image-editor/components/canvas/use-transform.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useDominoStore } from './domino-hooks'
|
||||
|
||||
export function useTransform() {
|
||||
const viewport = useDominoStore(state => state.viewport)
|
||||
|
||||
const screenToWorld = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
containerRect?: DOMRect,
|
||||
) => {
|
||||
const x = containerRect ? clientX - containerRect.left : clientX
|
||||
const y = containerRect ? clientY - containerRect.top : clientY
|
||||
return {
|
||||
x: (x - viewport.x) / viewport.scale,
|
||||
y: (y - viewport.y) / viewport.scale,
|
||||
}
|
||||
}
|
||||
|
||||
const worldToScreen = (
|
||||
worldX: number,
|
||||
worldY: number,
|
||||
containerRect?: DOMRect,
|
||||
) => {
|
||||
const x = worldX * viewport.scale + viewport.x
|
||||
const y = worldY * viewport.scale + viewport.y
|
||||
return {
|
||||
x: containerRect ? x + containerRect.left : x,
|
||||
y: containerRect ? y + containerRect.top : y,
|
||||
}
|
||||
}
|
||||
|
||||
return { screenToWorld, worldToScreen }
|
||||
}
|
||||