Skip to content

Commit f86f49a

Browse files
zzxmingkagol
andauthored
feat: image toolbar (#264)
* fix: blot formats is nullable * feat: align image * feat: merge image toolbar * docs: image toolbar * test: fix click position * fix: typo * fix: optimize image toolbar style * fix: options merge override --------- Co-authored-by: Kagol <[email protected]>
1 parent 1c51d8d commit f86f49a

22 files changed

+601
-338
lines changed

packages/docs/fluent-editor/.vitepress/sidebar.ts

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function sidebar() {
2525
{ text: '工具栏提示', link: '/docs/demo/toolbar-tip' },
2626
{ text: '只读模式', link: '/docs/demo/readonly' },
2727
{ text: '模拟语雀文档', link: 'https://opentiny.github.io/tiny-editor/projects' },
28+
{ text: '图片工具栏', link: '/docs/demo/image-tool' },
2829
],
2930
},
3031
{

packages/docs/fluent-editor/demos/file-upload.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ test('image-upload', async ({ page }) => {
3131
expect(newBox.height + moveDistance).toEqual(oldBox.height)
3232

3333
// remove overlay
34-
await page.mouse.click(newBox.x + newBox.width + 2, newBox.y + newBox.height + 2)
34+
await page.mouse.click(newBox.x + newBox.width + 20, newBox.y + newBox.height + 20)
3535
await expect(page.locator('.blot-formatter__overlay')).not.toBeVisible()
3636
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<script setup lang="ts">
2+
import type FluentEditor from '@opentiny/fluent-editor'
3+
import type { ImageToolbarButtons } from '@opentiny/fluent-editor'
4+
import { onMounted, ref } from 'vue'
5+
6+
let editor: FluentEditor
7+
const editorRef = ref<HTMLElement>()
8+
9+
const TOOLBAR_CONFIG = [
10+
[{ header: [] }],
11+
['bold', 'italic', 'underline', 'link'],
12+
[{ list: 'ordered' }, { list: 'bullet' }],
13+
['clean', 'image'],
14+
]
15+
16+
onMounted(() => {
17+
// ssr compat, reference: https://vitepress.dev/guide/ssr-compat#importing-in-mounted-hook
18+
import('@opentiny/fluent-editor').then(({ default: FluentEditor }) => {
19+
if (!editorRef.value) return
20+
editor = new FluentEditor(editorRef.value, {
21+
theme: 'snow',
22+
modules: {
23+
toolbar: TOOLBAR_CONFIG,
24+
image: {
25+
toolbar: {
26+
buttons: {
27+
copy: false,
28+
download: false,
29+
clean: {
30+
name: 'clean',
31+
icon: (FluentEditor.import('ui/icons') as Record<string, string>).clean,
32+
apply(el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) {
33+
toolbarButtons.clear(el)
34+
el.removeAttribute('width')
35+
el.removeAttribute('height')
36+
this.buttons.forEach((button) => {
37+
button.classList.remove('is-selected')
38+
button.style.removeProperty('filter')
39+
})
40+
},
41+
},
42+
},
43+
},
44+
},
45+
},
46+
})
47+
})
48+
})
49+
</script>
50+
51+
<template>
52+
<div ref="editorRef">
53+
<p />
54+
<p><img data-align="center" width="400px" src="https://res.hc-cdn.com/tiny-vue-web-doc/3.20.7.20250117141151/static/images/mountain.png"></p>
55+
<p />
56+
</div>
57+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script setup lang="ts">
2+
import type FluentEditor from '@opentiny/fluent-editor'
3+
import { onMounted, ref } from 'vue'
4+
5+
let editor: FluentEditor
6+
const editorRef = ref<HTMLElement>()
7+
8+
const TOOLBAR_CONFIG = [
9+
[{ header: [] }],
10+
['bold', 'italic', 'underline', 'link'],
11+
[{ list: 'ordered' }, { list: 'bullet' }],
12+
['clean', 'image'],
13+
]
14+
15+
onMounted(() => {
16+
// ssr compat, reference: https://vitepress.dev/guide/ssr-compat#importing-in-mounted-hook
17+
import('@opentiny/fluent-editor').then(({ default: FluentEditor }) => {
18+
if (!editorRef.value) return
19+
editor = new FluentEditor(editorRef.value, {
20+
theme: 'snow',
21+
modules: {
22+
toolbar: TOOLBAR_CONFIG,
23+
},
24+
})
25+
})
26+
})
27+
</script>
28+
29+
<template>
30+
<div ref="editorRef">
31+
<p />
32+
<p><img data-align="center" width="400px" src="https://res.hc-cdn.com/tiny-vue-web-doc/3.20.7.20250117141151/static/images/mountain.png"></p>
33+
<p />
34+
</div>
35+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# 图片操作
2+
3+
## 工具栏
4+
5+
点击图片可显示图片相关工具
6+
7+
:::demo src=demos/image-toolbar.vue
8+
:::
9+
10+
## 工具栏按钮配置
11+
12+
可通过配置项 `modules.image.toolbar.buttons` 对工具栏按钮进行配置。默认存在 `align-left``align-center``align-right``copy``download` 五个按钮,可以自行增加按钮或者通过设置 `false` 关闭某个按钮。
13+
14+
:::demo src=demos/image-toolbar-button.vue
15+
:::
16+
17+
## 配置
18+
19+
### modules.image.toolbar
20+
21+
| 属性 | 类型 | 说明 |
22+
| -------------------- | --------------------------------------------- | --------------------- |
23+
| mainClassName | `string` | toolbar 元素 class 名 |
24+
| mainStyle | `Record<string, string>` | toolbar 元素 style |
25+
| buttonClassName | `string` | button 元素 class 名 |
26+
| addButtonSelectStyle | `boolean` | 是否应用选中样式 |
27+
| buttonStyle | `Record<string, string>` | button 元素 style |
28+
| svgStyle | `Record<string, string>` | button 中 icon style |
29+
| buttons | `Record<string, ToolButtonOption \| boolean>` | toolbar 按钮配置 |
30+
31+
<details>
32+
<summary>ToolButtonOption</summary>
33+
34+
```ts
35+
export interface ToolButtonOption {
36+
name: string
37+
icon: string
38+
isActive?: (el: HTMLElement) => boolean
39+
apply: (this: ImageToolbar, el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) => void
40+
}
41+
```
42+
43+
</details>
44+
45+
### modules.image.overlay
46+
47+
| 属性 | 类型 | 说明 |
48+
| --------- | ------------------------ | --------------------- |
49+
| className | `string` | overlay 元素 class 名 |
50+
| style | `Record<string, string>` | overlay 元素 style |
51+
52+
### modules.image.resize
53+
54+
| 属性 | 类型 | 说明 |
55+
| --------------- | ------------------------ | ------------------------------ |
56+
| handleClassName | `string` | resize 元素(拖拽块) class 名 |
57+
| handleStyle | `Record<string, string>` | resize 元素(拖拽块) style |

packages/fluent-editor/src/config/types/editor-modules.interface.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ToolbarProps } from 'quill/modules/toolbar'
22
import type { I18NOptions, ICounterOption, MentionOption, ShortCutKeyInputOptions } from '../../modules'
3-
import type { BlotFormatterOptions } from '../../modules/custom-image/options'
3+
import type { BlotFormatterOptionsInput } from '../../modules/custom-image/options'
44
import type { FileUploaderOptions } from '../../modules/custom-uploader'
55

66
export type ToolbarOptions = {
@@ -43,5 +43,5 @@ export interface IEditorModules {
4343
'emoji-shortname'?: any
4444
'file'?: boolean
4545
'mathlive'?: boolean
46-
'image'?: boolean | Partial<BlotFormatterOptions>
46+
'image'?: boolean | Partial<BlotFormatterOptionsInput>
4747
}

packages/fluent-editor/src/modules/custom-image/actions/action.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import type { BlotFormatter } from '../blot-formatter'
2+
13
export class Action {
2-
formatter
4+
formatter: BlotFormatter
35

4-
constructor(formatter) {
6+
constructor(formatter: BlotFormatter) {
57
this.formatter = formatter
68
}
79

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { ToolbarButtonOptions, ToolButtonOption } from '../options'
2+
import { isBoolean, isObject } from '../../../utils/is'
3+
import { CENTER_ALIGN, COPY, DOWNLOAD, LEFT_ALIGN, RIGHT_ALIGN } from '../options'
4+
5+
export const ALIGN_ATTR = 'data-align'
6+
7+
export function setAlignStyle(el: HTMLElement, display: string | null, float: string | null, margin: string | null) {
8+
el.style.setProperty('display', display)
9+
el.style.setProperty('float', float)
10+
el.style.setProperty('margin', margin)
11+
}
12+
export const alignmentHandler = {
13+
left: (el: HTMLElement, toolbarButtons: ImageToolbarButtons) => {
14+
setAlignStyle(el, 'inline', 'left', '0 1em 1em 0')
15+
},
16+
center: (el: HTMLElement, toolbarButtons: ImageToolbarButtons) => {
17+
setAlignStyle(el, 'block', null, 'auto')
18+
},
19+
right: (el: HTMLElement, toolbarButtons: ImageToolbarButtons) => {
20+
setAlignStyle(el, 'inline', 'right', '0 0 1em 1em')
21+
},
22+
download: (el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) => {
23+
const imageName = el.dataset.title || 'image'
24+
const url = el.src || ''
25+
if (!url) return
26+
const a = document.createElement('a')
27+
a.href = url
28+
a.target = '_blank'
29+
a.download = imageName
30+
a.style.display = 'none'
31+
document.body.appendChild(a)
32+
a.click()
33+
a.parentNode.removeChild(a)
34+
},
35+
copy: async (el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) => {
36+
if (!el.src) return
37+
const imageUrl = el.src
38+
try {
39+
const response = await fetch(imageUrl)
40+
if (!response.ok) {
41+
throw new Error('Copy image failed')
42+
}
43+
const blob = await response.blob()
44+
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
45+
}
46+
catch (e) {
47+
throw new Error('Copy image failed')
48+
}
49+
},
50+
}
51+
const defaultButtons: Record<string, ToolButtonOption> = {
52+
[LEFT_ALIGN]: {
53+
name: LEFT_ALIGN,
54+
icon: `
55+
<svg viewbox="0 0 18 18">
56+
<line class="ql-stroke" x1="3" x2="15" y1="9" y2="9"></line>
57+
<line class="ql-stroke" x1="3" x2="13" y1="14" y2="14"></line>
58+
<line class="ql-stroke" x1="3" x2="9" y1="4" y2="4"></line>
59+
</svg>
60+
`,
61+
isActive: el => el.getAttribute(ALIGN_ATTR) === 'left',
62+
apply: (el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) => {
63+
el.setAttribute(ALIGN_ATTR, 'left')
64+
alignmentHandler.left(el, toolbarButtons)
65+
},
66+
},
67+
[CENTER_ALIGN]: {
68+
name: CENTER_ALIGN,
69+
icon: `
70+
<svg viewbox="0 0 18 18">
71+
<line class="ql-stroke" x1="15" x2="3" y1="9" y2="9"></line>
72+
<line class="ql-stroke" x1="14" x2="4" y1="14" y2="14"></line>
73+
<line class="ql-stroke" x1="12" x2="6" y1="4" y2="4"></line>
74+
</svg>
75+
`,
76+
isActive: el => el.getAttribute(ALIGN_ATTR) === 'center',
77+
apply: (el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) => {
78+
el.setAttribute(ALIGN_ATTR, 'center')
79+
alignmentHandler.center(el, toolbarButtons)
80+
},
81+
},
82+
[RIGHT_ALIGN]: {
83+
name: RIGHT_ALIGN,
84+
icon: `
85+
<svg viewbox="0 0 18 18">
86+
<line class="ql-stroke" x1="15" x2="3" y1="9" y2="9"></line>
87+
<line class="ql-stroke" x1="15" x2="5" y1="14" y2="14"></line>
88+
<line class="ql-stroke" x1="15" x2="9" y1="4" y2="4"></line>
89+
</svg>
90+
`,
91+
isActive: el => el.getAttribute(ALIGN_ATTR) === 'right',
92+
apply: (el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) => {
93+
el.setAttribute(ALIGN_ATTR, 'right')
94+
alignmentHandler.right(el, toolbarButtons)
95+
},
96+
},
97+
[DOWNLOAD]: {
98+
name: DOWNLOAD,
99+
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path class="ql-fill" d="M26 24v4H6v-4H4v4a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2v-4zm0-10l-1.41-1.41L17 20.17V2h-2v18.17l-7.59-7.58L6 14l10 10z"/></svg>`,
100+
apply: (el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) => {
101+
alignmentHandler.download(el, toolbarButtons)
102+
},
103+
},
104+
[COPY]: {
105+
name: COPY,
106+
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path class="ql-fill" d="M28 10v18H10V10zm0-2H10a2 2 0 0 0-2 2v18a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2"/><path class="ql-fill" d="M4 18H2V4a2 2 0 0 1 2-2h14v2H4Z"/></svg>`,
107+
apply: (el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) => {
108+
alignmentHandler.copy(el, toolbarButtons)
109+
},
110+
},
111+
}
112+
export class ImageToolbarButtons {
113+
buttons: Record<string, ToolButtonOption>
114+
115+
constructor(options: ToolbarButtonOptions) {
116+
this.buttons = Object.entries(options.buttons).reduce((acc, [name, button]) => {
117+
if (isBoolean(button) && button && defaultButtons[name]) {
118+
acc[name] = defaultButtons[name]
119+
}
120+
else if (isObject(button)) {
121+
acc[button.name] = button
122+
}
123+
return acc
124+
}, {})
125+
}
126+
127+
getItems(): ToolButtonOption[] {
128+
return Object.keys(this.buttons).map(k => this.buttons[k])
129+
}
130+
131+
clear(el: HTMLElement): void {
132+
el.removeAttribute(ALIGN_ATTR)
133+
setAlignStyle(el, null, null, null)
134+
}
135+
}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export * from './action'
22
export * from './custom-resize-action'
33
export * from './delete-action'
4+
export * from './image-toolbar-buttons'
5+
export * from './toolbar'
6+
export * from './toolbar-action'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { BlotFormatter } from '../blot-formatter'
2+
import { Action } from './action'
3+
import { ImageToolbarButtons } from './image-toolbar-buttons'
4+
import { ImageToolbar } from './toolbar'
5+
6+
export class ImageToolbarAction extends Action {
7+
toolbar: ImageToolbar
8+
buttons: ImageToolbarButtons
9+
10+
constructor(formatter: BlotFormatter) {
11+
super(formatter)
12+
this.buttons = new ImageToolbarButtons({
13+
buttons: formatter.options.toolbar.buttons,
14+
})
15+
this.toolbar = new ImageToolbar()
16+
}
17+
18+
onCreate() {
19+
const toolbar = this.toolbar.create(this.formatter, this.buttons)
20+
this.formatter.overlay.appendChild(toolbar)
21+
}
22+
23+
onDestroy() {
24+
const toolbar = this.toolbar.getElement()
25+
if (!toolbar) {
26+
return
27+
}
28+
29+
this.formatter.overlay.removeChild(toolbar)
30+
this.toolbar.destroy()
31+
}
32+
}

0 commit comments

Comments
 (0)