diff --git a/packages/docs/fluent-editor/demos/file-upload.vue b/packages/docs/fluent-editor/demos/file-upload.vue
index 8619ced4..0cb1a1f4 100644
--- a/packages/docs/fluent-editor/demos/file-upload.vue
+++ b/packages/docs/fluent-editor/demos/file-upload.vue
@@ -47,6 +47,8 @@ onMounted(async () => {
-
+
+

+
diff --git a/packages/fluent-editor/src/modules/custom-image/actions/image-toolbar-buttons.ts b/packages/fluent-editor/src/modules/custom-image/actions/image-toolbar-buttons.ts
index f2084744..e3ef27c2 100644
--- a/packages/fluent-editor/src/modules/custom-image/actions/image-toolbar-buttons.ts
+++ b/packages/fluent-editor/src/modules/custom-image/actions/image-toolbar-buttons.ts
@@ -1,6 +1,7 @@
import type { ToolbarButtonOptions, ToolButtonOption } from '../options'
import { isBoolean, isObject } from '../../../utils/is'
-import { CENTER_ALIGN, COPY, DOWNLOAD, LEFT_ALIGN, RIGHT_ALIGN } from '../options'
+import { CENTER_ALIGN, COPY, DOWNLOAD, LEFT_ALIGN, PREVIEW, RIGHT_ALIGN } from '../options'
+import { getImagePreviewModal } from '../preview'
export const ALIGN_ATTR = 'data-align'
@@ -108,6 +109,17 @@ const defaultButtons: Record = {
alignmentHandler.copy(el, toolbarButtons)
},
},
+ [PREVIEW]: {
+ name: PREVIEW,
+ icon: ``,
+ apply: (el: HTMLImageElement, toolbarButtons: ImageToolbarButtons) => {
+ const imageSrc = el.getAttribute('src') || el.getAttribute('data-src')
+ if (imageSrc) {
+ const modal = getImagePreviewModal()
+ modal.show(imageSrc)
+ }
+ },
+ },
}
export class ImageToolbarButtons {
buttons: Record
diff --git a/packages/fluent-editor/src/modules/custom-image/index.ts b/packages/fluent-editor/src/modules/custom-image/index.ts
index 6fb8fa24..9d364d98 100644
--- a/packages/fluent-editor/src/modules/custom-image/index.ts
+++ b/packages/fluent-editor/src/modules/custom-image/index.ts
@@ -2,3 +2,4 @@ export * from './actions'
export * from './blot-formatter'
export * from './image'
export * from './specs'
+export * from './preview'
diff --git a/packages/fluent-editor/src/modules/custom-image/options.ts b/packages/fluent-editor/src/modules/custom-image/options.ts
index 2872e7f3..210aedff 100644
--- a/packages/fluent-editor/src/modules/custom-image/options.ts
+++ b/packages/fluent-editor/src/modules/custom-image/options.ts
@@ -62,6 +62,7 @@ export const CENTER_ALIGN = 'align-center'
export const RIGHT_ALIGN = 'align-right'
export const COPY = 'copy'
export const DOWNLOAD = 'download'
+export const PREVIEW = 'preview'
const DefaultOptions: BlotFormatterOptions = {
// 默认情况下,`file://` 格式的本地文件路径在浏览器环境无法读取,因此会被转换成 `//:0`,但是在一些特殊的场景下(比如:Electron),需要获取到图片的原始路径,进行后续的上传处理
// 注意:该选项一旦设置为 true,本地磁盘路径会暴露出去,这可能会带来安全风险,请确保你了解相关的安全隐患
@@ -120,6 +121,7 @@ const DefaultOptions: BlotFormatterOptions = {
[RIGHT_ALIGN]: true,
[COPY]: true,
[DOWNLOAD]: true,
+ [PREVIEW]: true,
},
},
resize: {
diff --git a/packages/fluent-editor/src/modules/custom-image/preview/index.ts b/packages/fluent-editor/src/modules/custom-image/preview/index.ts
new file mode 100644
index 00000000..7b2416ca
--- /dev/null
+++ b/packages/fluent-editor/src/modules/custom-image/preview/index.ts
@@ -0,0 +1 @@
+export * from './preview-modal'
diff --git a/packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts b/packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts
new file mode 100644
index 00000000..2aa30fe9
--- /dev/null
+++ b/packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts
@@ -0,0 +1,277 @@
+/**
+ * 图片预览模态框
+ * 提供图片双击时的预览功能,包括遮罩层和全屏预览
+ */
+
+export class ImagePreviewModal {
+ private modal: HTMLElement | null = null
+ private overlay: HTMLElement | null = null
+ private previewImage: HTMLImageElement | null = null
+ private scaleTooltip: HTMLElement | null = null
+ private currentScale: number = 1
+ private minScale: number = 0.5
+ private maxScale: number = 3
+ private scaleStep: number = 0.1
+ private tooltipHideTimer: number | null = null
+
+ constructor() {
+ this.initModal()
+ }
+
+ private initModal() {
+ // 创建遮罩层
+ this.overlay = document.createElement('div')
+ this.overlay.className = 'image-preview-overlay'
+ this.overlay.style.cssText = `
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.8);
+ display: none;
+ z-index: 9999;
+ cursor: pointer;
+ `
+
+ // 创建预览容器
+ this.modal = document.createElement('div')
+ this.modal.className = 'image-preview-modal'
+ this.modal.style.cssText = `
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background-color: transparent;
+ display: none;
+ z-index: 10000;
+ max-width: 90vw;
+ max-height: 90vh;
+ cursor: auto;
+ `
+
+ // 创建预览图片
+ this.previewImage = document.createElement('img')
+ this.previewImage.className = 'image-preview-img'
+ this.previewImage.style.cssText = `
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ transition: transform 0.2s ease-out;
+ cursor: grab;
+ `
+
+ // 创建关闭按钮
+ const closeBtn = document.createElement('button')
+ closeBtn.className = 'image-preview-close'
+ closeBtn.innerHTML = '×'
+ closeBtn.style.cssText = `
+ position: absolute;
+ top: -40px;
+ right: 0;
+ width: 40px;
+ height: 40px;
+ border: none;
+ background-color: transparent;
+ color: white;
+ font-size: 32px;
+ cursor: pointer;
+ z-index: 10001;
+ line-height: 1;
+ padding: 0;
+ `
+ closeBtn.addEventListener('click', () => this.hide())
+
+ this.modal.appendChild(this.previewImage)
+ this.modal.appendChild(closeBtn)
+
+ // 创建缩放提示窗口
+ this.scaleTooltip = document.createElement('div')
+ this.scaleTooltip.className = 'image-preview-scale-tooltip'
+ this.scaleTooltip.style.cssText = `
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background-color: rgba(0, 0, 0, 0.7);
+ color: white;
+ padding: 12px 20px;
+ border-radius: 6px;
+ font-size: 14px;
+ z-index: 10002;
+ display: none;
+ pointer-events: none;
+ white-space: nowrap;
+ font-weight: 500;
+ `
+ document.body.appendChild(this.scaleTooltip)
+
+ // 绑定事件
+ this.overlay.addEventListener('click', () => this.hide())
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ this.hide()
+ }
+ })
+
+ // 绑定滚轮缩放事件
+ document.addEventListener('wheel', (e) => this.onMouseWheel(e), { passive: false })
+
+ // 阻止模态框内的点击事件冒泡到遮罩层
+ this.modal.addEventListener('click', (e) => {
+ e.stopPropagation()
+ })
+
+ document.body.appendChild(this.overlay)
+ document.body.appendChild(this.modal)
+ }
+
+ /**
+ * 处理鼠标滚轮事件 - 缩放图片
+ */
+ private onMouseWheel = (event: WheelEvent) => {
+ // 只在预览打开时处理
+ if (!this.modal || this.modal.style.display === 'none') {
+ return
+ }
+
+ event.preventDefault()
+
+ // 根据滚轮方向调整缩放比例
+ const delta = event.deltaY > 0 ? -this.scaleStep : this.scaleStep
+ this.setScale(this.currentScale + delta)
+
+ // 显示缩放提示
+ this.showScaleTooltip()
+ }
+
+ /**
+ * 设置缩放比例
+ */
+ private setScale(scale: number) {
+ // 限制缩放范围
+ this.currentScale = Math.max(this.minScale, Math.min(scale, this.maxScale))
+
+ if (this.previewImage) {
+ this.previewImage.style.transform = `scale(${this.currentScale})`
+ }
+ }
+
+ /**
+ * 显示缩放百分比提示
+ */
+ private showScaleTooltip() {
+ if (!this.scaleTooltip) {
+ return
+ }
+
+ // 清除之前的隐藏计时器
+ if (this.tooltipHideTimer !== null) {
+ clearTimeout(this.tooltipHideTimer)
+ }
+
+ // 更新提示文本
+ const percentage = Math.round(this.currentScale * 100)
+ this.scaleTooltip.textContent = `${percentage}%`
+ this.scaleTooltip.style.display = 'block'
+
+ // 1.5秒后隐藏提示
+ this.tooltipHideTimer = window.setTimeout(() => {
+ if (this.scaleTooltip) {
+ this.scaleTooltip.style.display = 'none'
+ }
+ this.tooltipHideTimer = null
+ }, 1500)
+ }
+
+ /**
+ * 隐藏缩放提示
+ */
+ private hideScaleTooltip() {
+ if (this.tooltipHideTimer !== null) {
+ clearTimeout(this.tooltipHideTimer)
+ this.tooltipHideTimer = null
+ }
+ if (this.scaleTooltip) {
+ this.scaleTooltip.style.display = 'none'
+ }
+ }
+
+ /**
+ * 重置缩放比例
+ */
+ private resetScale() {
+ this.currentScale = 1
+ if (this.previewImage) {
+ this.previewImage.style.transform = 'scale(1)'
+ }
+ this.hideScaleTooltip()
+ }
+
+ /**
+ * 显示预览
+ * @param imageUrl 图片URL
+ */
+ show(imageUrl: string) {
+ if (!this.previewImage || !this.modal || !this.overlay) {
+ return
+ }
+
+ this.resetScale()
+ this.previewImage.src = imageUrl
+ this.modal.style.display = 'flex'
+ this.modal.style.alignItems = 'center'
+ this.modal.style.justifyContent = 'center'
+ this.overlay.style.display = 'block'
+
+ // 防止页面滚动
+ document.body.style.overflow = 'hidden'
+ }
+
+ /**
+ * 隐藏预览
+ */
+ hide() {
+ if (this.modal && this.overlay) {
+ this.modal.style.display = 'none'
+ this.overlay.style.display = 'none'
+ document.body.style.overflow = ''
+ this.resetScale()
+ }
+ }
+
+ /**
+ * 销毁预览模态框
+ */
+ destroy() {
+ this.hideScaleTooltip()
+ if (this.overlay && this.overlay.parentNode) {
+ this.overlay.parentNode.removeChild(this.overlay)
+ }
+ if (this.modal && this.modal.parentNode) {
+ this.modal.parentNode.removeChild(this.modal)
+ }
+ if (this.scaleTooltip && this.scaleTooltip.parentNode) {
+ this.scaleTooltip.parentNode.removeChild(this.scaleTooltip)
+ }
+ this.modal = null
+ this.overlay = null
+ this.previewImage = null
+ this.scaleTooltip = null
+ }
+}
+
+// 全局单例实例
+let globalPreviewModal: ImagePreviewModal | null = null
+
+/**
+ * 获取或创建全局预览模态框实例
+ */
+export function getImagePreviewModal(): ImagePreviewModal {
+ if (!globalPreviewModal) {
+ globalPreviewModal = new ImagePreviewModal()
+ }
+ return globalPreviewModal
+}
diff --git a/packages/fluent-editor/src/modules/custom-image/preview/preview.css b/packages/fluent-editor/src/modules/custom-image/preview/preview.css
new file mode 100644
index 00000000..643b8a79
--- /dev/null
+++ b/packages/fluent-editor/src/modules/custom-image/preview/preview.css
@@ -0,0 +1,104 @@
+/**
+ * 图片预览样式
+ */
+
+/* 预览遮罩层 */
+.image-preview-overlay {
+ animation: fadeIn 0.3s ease-in-out;
+}
+
+/* 预览模态框 */
+.image-preview-modal {
+ animation: zoomIn 0.3s ease-in-out;
+ overflow: auto;
+}
+
+/* 预览图片 */
+.image-preview-img {
+ animation: imageShow 0.3s ease-in-out;
+ user-select: none;
+}
+
+/* 图片缩放时显示 grab 光标 */
+.image-preview-img:active {
+ cursor: grabbing;
+}
+
+/* 缩放提示窗口 */
+.image-preview-scale-tooltip {
+ animation: tooltipFadeIn 0.2s ease-in-out;
+ backdrop-filter: blur(4px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+/* 关闭按钮悬停效果 */
+.image-preview-close:hover {
+ transform: scale(1.2);
+ text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
+}
+
+/* 淡入动画 */
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+/* 放大动画 */
+@keyframes zoomIn {
+ from {
+ transform: translate(-50%, -50%) scale(0.8);
+ opacity: 0;
+ }
+ to {
+ transform: translate(-50%, -50%) scale(1);
+ opacity: 1;
+ }
+}
+
+/* 图片显示动画 */
+@keyframes imageShow {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+/* 提示窗口淡入动画 */
+@keyframes tooltipFadeIn {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(0.9);
+ }
+ to {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .image-preview-modal {
+ max-width: 95vw;
+ max-height: 95vh;
+ }
+
+ .image-preview-close {
+ width: 36px;
+ height: 36px;
+ font-size: 28px;
+ top: -36px;
+ }
+
+ .image-preview-scale-tooltip {
+ font-size: 12px;
+ padding: 10px 16px;
+ }
+}
+
+
diff --git a/packages/fluent-editor/src/modules/custom-image/specs/custom-image-spec.ts b/packages/fluent-editor/src/modules/custom-image/specs/custom-image-spec.ts
index 7e00745c..f56a4d74 100644
--- a/packages/fluent-editor/src/modules/custom-image/specs/custom-image-spec.ts
+++ b/packages/fluent-editor/src/modules/custom-image/specs/custom-image-spec.ts
@@ -1,5 +1,6 @@
import type { BlotFormatter } from '../blot-formatter'
import { isInside } from '../../../config/editor.utils'
+import { getImagePreviewModal } from '../preview'
import { ImageSpec } from './image-spec'
export class CustomImageSpec extends ImageSpec {
@@ -24,6 +25,7 @@ export class CustomImageSpec extends ImageSpec {
init(): void {
this.editorElem.addEventListener('mouseover', this.imageMouseOver.bind(this))
this.editorElem.addEventListener('mouseout', this.imageMouseout)
+ this.editorElem.addEventListener('dblclick', this.onImageDoubleClick.bind(this))
super.init()
}
@@ -128,4 +130,17 @@ export class CustomImageSpec extends ImageSpec {
quill.setSelection(index, len)
}
}
+
+ /**
+ * 处理图片双击事件 - 显示预览
+ */
+ onImageDoubleClick = (event: MouseEvent) => {
+ const target = event.target
+ const imageSrc = target.getAttribute('src') || target.getAttribute('data-image')
+
+ if (imageSrc) {
+ const modal = getImagePreviewModal()
+ modal.show(imageSrc)
+ }
+ }
}