Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/docs/fluent-editor/demos/file-upload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ onMounted(async () => {
</script>

<template>
<div ref="editorRef" />
<div ref="editorRef">
<img src="https://res-static.opentiny.design/tiny-vue-web-doc/3.27.0/static/images/mountain.png" />
</div>
<br>
</template>
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -108,6 +109,17 @@ const defaultButtons: Record<string, ToolButtonOption> = {
alignmentHandler.copy(el, toolbarButtons)
},
},
[PREVIEW]: {
name: PREVIEW,
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path class="ql-fill" d="M16 7c-4.96 0-9.23 3.13-11 7.5 1.77 4.37 6.04 7.5 11 7.5s9.23-3.13 11-7.5c-1.77-4.37-6.04-7.5-11-7.5zm0 12c-2.49 0-4.5-2.01-4.5-4.5S13.51 10 16 10s4.5 2.01 4.5 4.5-2.01 4.5-4.5 4.5zm0-7c-1.38 0-2.5 1.12-2.5 2.5s1.12 2.5 2.5 2.5 2.5-1.12 2.5-2.5-1.12-2.5-2.5-2.5z"/></svg>`,
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<string, ToolButtonOption>
Expand Down
1 change: 1 addition & 0 deletions packages/fluent-editor/src/modules/custom-image/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './actions'
export * from './blot-formatter'
export * from './image'
export * from './specs'
export * from './preview'
2 changes: 2 additions & 0 deletions packages/fluent-editor/src/modules/custom-image/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,本地磁盘路径会暴露出去,这可能会带来安全风险,请确保你了解相关的安全隐患
Expand Down Expand Up @@ -120,6 +121,7 @@ const DefaultOptions: BlotFormatterOptions = {
[RIGHT_ALIGN]: true,
[COPY]: true,
[DOWNLOAD]: true,
[PREVIEW]: true,
},
},
resize: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './preview-modal'
Original file line number Diff line number Diff line change
@@ -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 })
Comment on lines +113 to +120
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Global event listeners are not cleaned up in destroy().

The keydown and wheel event listeners are added to document but never removed when destroy() is called. This causes memory leaks and ghost event handlers if the modal is destroyed and recreated.

🔧 Proposed fix - store handler references and remove in destroy()
  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
+   private keydownHandler: ((e: KeyboardEvent) => void) | null = null
+   private wheelHandler: ((e: WheelEvent) => void) | null = null

    // In initModal():
-   document.addEventListener('keydown', (e) => {
-     if (e.key === 'Escape') {
-       this.hide()
-     }
-   })
-   document.addEventListener('wheel', (e) => this.onMouseWheel(e), { passive: false })
+   this.keydownHandler = (e: KeyboardEvent) => {
+     if (e.key === 'Escape') {
+       this.hide()
+     }
+   }
+   this.wheelHandler = (e: WheelEvent) => this.onMouseWheel(e)
+   document.addEventListener('keydown', this.keydownHandler)
+   document.addEventListener('wheel', this.wheelHandler, { passive: false })

    // In destroy():
+   if (this.keydownHandler) {
+     document.removeEventListener('keydown', this.keydownHandler)
+   }
+   if (this.wheelHandler) {
+     document.removeEventListener('wheel', this.wheelHandler)
+   }
🤖 Prompt for AI Agents
In `@packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts`
around lines 113 - 120, The document-level keydown and wheel listeners added in
the constructor/initialization (the anonymous keydown handler that calls
this.hide() and the wheel handler that calls this.onMouseWheel) are never
removed; store both listeners as bound instance methods or properties (e.g.,
this._onKeyDown and this._onWheel using .bind(this) or arrow wrappers) when
attaching them and then remove them in destroy() via
document.removeEventListener('keydown', this._onKeyDown) and
document.removeEventListener('wheel', this._onWheel) (matching options for the
wheel listener), ensuring destroy() cleans up these handlers to prevent leaks
and duplicate handlers on recreation.


// 阻止模态框内的点击事件冒泡到遮罩层
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
}
Comment on lines +266 to +277
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Singleton instance not cleared after destroy().

If destroy() is called on the singleton, globalPreviewModal still holds a reference to the destroyed (unusable) instance. Subsequent calls to getImagePreviewModal() will return the destroyed modal.

🔧 Proposed fix

Either expose a function to reset the singleton, or clear it in destroy():

+ export function destroyImagePreviewModal(): void {
+   if (globalPreviewModal) {
+     globalPreviewModal.destroy()
+     globalPreviewModal = null
+   }
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 全局单例实例
let globalPreviewModal: ImagePreviewModal | null = null
/**
* 获取或创建全局预览模态框实例
*/
export function getImagePreviewModal(): ImagePreviewModal {
if (!globalPreviewModal) {
globalPreviewModal = new ImagePreviewModal()
}
return globalPreviewModal
}
// 全局单例实例
let globalPreviewModal: ImagePreviewModal | null = null
/**
* 获取或创建全局预览模态框实例
*/
export function getImagePreviewModal(): ImagePreviewModal {
if (!globalPreviewModal) {
globalPreviewModal = new ImagePreviewModal()
}
return globalPreviewModal
}
export function destroyImagePreviewModal(): void {
if (globalPreviewModal) {
globalPreviewModal.destroy()
globalPreviewModal = null
}
}
🤖 Prompt for AI Agents
In `@packages/fluent-editor/src/modules/custom-image/preview/preview-modal.ts`
around lines 266 - 277, The singleton globalPreviewModal can point to a
destroyed ImagePreviewModal, so update the code to clear that reference when the
instance is torn down: either set globalPreviewModal = null inside
ImagePreviewModal.destroy() (ensure destroy() is the method name used) or add
and export a resetImagePreviewModal() function that sets globalPreviewModal to
null and call that from destroy; keep getImagePreviewModal() as-is so it will
create a new ImagePreviewModal when globalPreviewModal is null.

Loading