From acf9adf63dabc174946fde516050345c71c30fc5 Mon Sep 17 00:00:00 2001 From: Kagol Date: Wed, 28 Jan 2026 17:30:08 +0800 Subject: [PATCH 1/4] feat: add image preview --- .../docs/fluent-editor/demos/file-upload.vue | 4 +- .../src/modules/custom-image/index.ts | 1 + .../src/modules/custom-image/preview/index.ts | 1 + .../custom-image/preview/preview-modal.ts | 156 ++++++++++++++++++ .../modules/custom-image/preview/preview.css | 71 ++++++++ .../custom-image/specs/custom-image-spec.ts | 15 ++ 6 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 packages/fluent-editor/src/modules/custom-image/preview/index.ts create mode 100644 packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts create mode 100644 packages/fluent-editor/src/modules/custom-image/preview/preview.css 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/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/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..b3f55690 --- /dev/null +++ b/packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts @@ -0,0 +1,156 @@ +/** + * 图片预览模态框 + * 提供图片双击时的预览功能,包括遮罩层和全屏预览 + */ + +export class ImagePreviewModal { + private modal: HTMLElement | null = null + private overlay: HTMLElement | null = null + private previewImage: HTMLImageElement | 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); + ` + + // 创建关闭按钮 + 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.overlay.addEventListener('click', () => this.hide()) + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.hide() + } + }) + + // 阻止模态框内的点击事件冒泡到遮罩层 + this.modal.addEventListener('click', (e) => { + e.stopPropagation() + }) + + document.body.appendChild(this.overlay) + document.body.appendChild(this.modal) + } + + /** + * 显示预览 + * @param imageUrl 图片URL + */ + show(imageUrl: string) { + if (!this.previewImage || !this.modal || !this.overlay) { + return + } + + 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 = '' + } + } + + /** + * 销毁预览模态框 + */ + destroy() { + if (this.overlay && this.overlay.parentNode) { + this.overlay.parentNode.removeChild(this.overlay) + } + if (this.modal && this.modal.parentNode) { + this.modal.parentNode.removeChild(this.modal) + } + this.modal = null + this.overlay = null + this.previewImage = 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..17e0ca4c --- /dev/null +++ b/packages/fluent-editor/src/modules/custom-image/preview/preview.css @@ -0,0 +1,71 @@ +/** + * 图片预览样式 + */ + +/* 预览遮罩层 */ +.image-preview-overlay { + animation: fadeIn 0.3s ease-in-out; +} + +/* 预览模态框 */ +.image-preview-modal { + animation: zoomIn 0.3s ease-in-out; +} + +/* 预览图片 */ +.image-preview-img { + animation: imageShow 0.3s ease-in-out; +} + +/* 关闭按钮悬停效果 */ +.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; + } +} + +/* 响应式设计 */ +@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; + } +} 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) + } + } } From e657ea50ee58ec23398e102ba11af9f5afe42e64 Mon Sep 17 00:00:00 2001 From: Kagol Date: Wed, 28 Jan 2026 17:35:33 +0800 Subject: [PATCH 2/4] feat: add mouse wheel to scale image --- .../custom-image/preview/preview-modal.ts | 49 +++++++++++++++++++ .../modules/custom-image/preview/preview.css | 8 +++ 2 files changed, 57 insertions(+) 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 index b3f55690..98ce4863 100644 --- a/packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts +++ b/packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts @@ -7,6 +7,10 @@ export class ImagePreviewModal { private modal: HTMLElement | null = null private overlay: HTMLElement | null = null private previewImage: HTMLImageElement | null = null + private currentScale: number = 1 + private minScale: number = 0.5 + private maxScale: number = 3 + private scaleStep: number = 0.1 constructor() { this.initModal() @@ -53,6 +57,8 @@ export class ImagePreviewModal { 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; ` // 创建关闭按钮 @@ -87,6 +93,9 @@ export class ImagePreviewModal { } }) + // 绑定滚轮缩放事件 + document.addEventListener('wheel', (e) => this.onMouseWheel(e), { passive: false }) + // 阻止模态框内的点击事件冒泡到遮罩层 this.modal.addEventListener('click', (e) => { e.stopPropagation() @@ -96,6 +105,44 @@ export class ImagePreviewModal { 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) + } + + /** + * 设置缩放比例 + */ + 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 resetScale() { + this.currentScale = 1 + if (this.previewImage) { + this.previewImage.style.transform = 'scale(1)' + } + } + /** * 显示预览 * @param imageUrl 图片URL @@ -105,6 +152,7 @@ export class ImagePreviewModal { return } + this.resetScale() this.previewImage.src = imageUrl this.modal.style.display = 'flex' this.modal.style.alignItems = 'center' @@ -123,6 +171,7 @@ export class ImagePreviewModal { this.modal.style.display = 'none' this.overlay.style.display = 'none' document.body.style.overflow = '' + this.resetScale() } } diff --git a/packages/fluent-editor/src/modules/custom-image/preview/preview.css b/packages/fluent-editor/src/modules/custom-image/preview/preview.css index 17e0ca4c..97361dd8 100644 --- a/packages/fluent-editor/src/modules/custom-image/preview/preview.css +++ b/packages/fluent-editor/src/modules/custom-image/preview/preview.css @@ -10,11 +10,18 @@ /* 预览模态框 */ .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; } /* 关闭按钮悬停效果 */ @@ -69,3 +76,4 @@ top: -36px; } } + From bce63aec90849089511a7252c2567baccc1d7471 Mon Sep 17 00:00:00 2001 From: Kagol Date: Wed, 28 Jan 2026 17:38:26 +0800 Subject: [PATCH 3/4] feat: add scale tooltip --- .../custom-image/preview/preview-modal.ts | 72 +++++++++++++++++++ .../modules/custom-image/preview/preview.css | 25 +++++++ 2 files changed, 97 insertions(+) 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 index 98ce4863..2aa30fe9 100644 --- a/packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts +++ b/packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts @@ -7,10 +7,12 @@ 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() @@ -85,6 +87,27 @@ export class ImagePreviewModal { 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) => { @@ -119,6 +142,9 @@ export class ImagePreviewModal { // 根据滚轮方向调整缩放比例 const delta = event.deltaY > 0 ? -this.scaleStep : this.scaleStep this.setScale(this.currentScale + delta) + + // 显示缩放提示 + this.showScaleTooltip() } /** @@ -133,6 +159,46 @@ export class ImagePreviewModal { } } + /** + * 显示缩放百分比提示 + */ + 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' + } + } + /** * 重置缩放比例 */ @@ -141,6 +207,7 @@ export class ImagePreviewModal { if (this.previewImage) { this.previewImage.style.transform = 'scale(1)' } + this.hideScaleTooltip() } /** @@ -179,15 +246,20 @@ export class ImagePreviewModal { * 销毁预览模态框 */ 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 } } diff --git a/packages/fluent-editor/src/modules/custom-image/preview/preview.css b/packages/fluent-editor/src/modules/custom-image/preview/preview.css index 97361dd8..643b8a79 100644 --- a/packages/fluent-editor/src/modules/custom-image/preview/preview.css +++ b/packages/fluent-editor/src/modules/custom-image/preview/preview.css @@ -24,6 +24,13 @@ 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); @@ -62,6 +69,18 @@ } } +/* 提示窗口淡入动画 */ +@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 { @@ -75,5 +94,11 @@ font-size: 28px; top: -36px; } + + .image-preview-scale-tooltip { + font-size: 12px; + padding: 10px 16px; + } } + From b03e4297b0580cd7dac6cf29e1056cfa02075645 Mon Sep 17 00:00:00 2001 From: Kagol Date: Wed, 28 Jan 2026 17:56:26 +0800 Subject: [PATCH 4/4] feat: add preview button --- .../custom-image/actions/image-toolbar-buttons.ts | 14 +++++++++++++- .../src/modules/custom-image/options.ts | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) 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/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: {