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