Block Editor 图片节点增加超链接属性支持

This commit is contained in:
jingrow 2026-06-05 23:05:14 +08:00
parent f836377a7f
commit 941ff09bc5
11 changed files with 987 additions and 49 deletions

View File

@ -150,6 +150,12 @@
.blockeditor-mount .ProseMirror hr { border: none; border-top: 2px solid #e5e7eb; margin: 16px 0; }
.blockeditor-mount .ProseMirror img { max-width: 100%; height: auto; border-radius: 6px; }
.blockeditor-mount .ProseMirror a { color: #3b82f6; text-decoration: underline; cursor: pointer; }
/* 覆盖 Jeditor.vue 遗留全局样式:新 Block Editor 图片不显示绿色边框 */
.blockeditor-mount .ProseMirror [data-type="imageResizable"]:hover,
.blockeditor-mount .ProseMirror [data-type="imageResizable"].ProseMirror-selectednode {
box-shadow: none;
}
.blockeditor-mount .ProseMirror table { width: 100%; border-collapse: collapse; margin: 8px 0; }
.blockeditor-mount .ProseMirror table th,
.blockeditor-mount .ProseMirror table td { border: 1px solid #d1d5db; padding: 8px 12px; text-align: left; }

View File

@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
const props = defineProps<{
alt: string
anchorX: number
anchorY: number
}>()
const emit = defineEmits<{
apply: [value: string]
cancel: []
}>()
const text = ref(props.alt)
const inputRef = ref<HTMLInputElement | null>(null)
const popoverStyle = computed(() => ({
position: 'fixed' as const,
left: `${props.anchorX}px`,
top: `${props.anchorY}px`,
transform: 'translateX(-50%) translateY(calc(-100% - 16px))',
zIndex: 2147483647,
}))
function apply() {
emit('apply', text.value.trim())
}
onMounted(() => {
nextTick(() => inputRef.value?.focus())
})
</script>
<template>
<div
class="floating-popover"
:style="popoverStyle"
@mousedown.stop
@click.stop
@mouseup.stop
>
<div class="fp-row">
<input
ref="inputRef"
v-model="text"
type="text"
class="fp-input"
placeholder="Alt text"
@keydown.enter="apply"
@keydown.esc="emit('cancel')"
/>
</div>
<div class="fp-actions">
<button class="fp-btn fp-btn-primary" @click="apply">Apply</button>
<button class="fp-btn" @click="emit('cancel')">Cancel</button>
</div>
<div class="fp-arrow" />
</div>
</template>
<style scoped>
.floating-popover {
min-width: 240px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(229, 231, 235, 0.5);
border-radius: 10px;
box-shadow:
0 8px 28px rgba(0,0,0,0.10),
0 2px 8px rgba(0,0,0,0.04);
padding: 10px 12px;
}
.fp-arrow {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid rgba(255, 255, 255, 0.92);
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.06));
}
.fp-row { margin-bottom: 8px; }
.fp-row:last-child { margin-bottom: 0; }
.fp-input {
width: 100%;
box-sizing: border-box;
padding: 7px 10px;
border: 1px solid rgba(209, 213, 219, 0.7);
border-radius: 6px;
font-size: 13px;
outline: none;
background: rgba(255, 255, 255, 0.7);
transition: border-color 0.15s, box-shadow 0.15s;
}
.fp-input:focus {
border-color: #2383e2;
box-shadow: 0 0 0 2px rgba(35, 131, 226, 0.15);
}
.fp-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.fp-btn {
padding: 4px 12px;
border: 1px solid rgba(209, 213, 219, 0.7);
border-radius: 6px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(4px);
font-size: 12px;
color: #374151;
cursor: pointer;
transition: background 0.1s;
}
.fp-btn:hover { background: #f3f4f6; }
.fp-btn-primary { background: #2383e2; color: #fff; border-color: #2383e2; }
.fp-btn-primary:hover { background: #1a6bbf; }
</style>

View File

@ -0,0 +1,157 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
const props = defineProps<{
href: string
target: string
anchorX: number
anchorY: number
}>()
const emit = defineEmits<{
apply: [value: { href: string; target: string }]
remove: []
close: []
}>()
const url = ref(props.href || '')
const newWindow = ref(props.target === '_blank')
const inputRef = ref<HTMLInputElement | null>(null)
const popoverStyle = computed(() => ({
position: 'fixed' as const,
left: `${props.anchorX}px`,
top: `${props.anchorY}px`,
transform: 'translateX(-50%) translateY(calc(-100% - 16px))',
zIndex: 2147483647,
}))
function apply() {
const v = url.value.trim()
if (v) {
let normalized = v
if (!/^[a-z][a-z0-9+.-]*:/i.test(normalized)) normalized = 'https://' + normalized
emit('apply', { href: normalized, target: newWindow.value ? '_blank' : '' })
} else {
emit('remove')
}
}
onMounted(() => {
nextTick(() => inputRef.value?.focus())
})
</script>
<template>
<div
class="floating-popover"
:style="popoverStyle"
@mousedown.stop
@click.stop
@mouseup.stop
>
<div class="fp-row">
<input
ref="inputRef"
v-model="url"
type="url"
class="fp-input"
placeholder="Paste a link"
@keydown.enter="apply"
@keydown.esc="emit('close')"
/>
</div>
<div class="fp-row fp-options">
<label class="fp-checkbox">
<input type="checkbox" v-model="newWindow" />
<span>Open in new tab</span>
</label>
</div>
<div class="fp-row fp-actions">
<button class="fp-btn fp-btn-primary" @click="apply">Apply</button>
<button v-if="props.href" class="fp-btn fp-btn-danger" @click="emit('remove')">Remove</button>
<button class="fp-btn" @click="emit('close')">Cancel</button>
</div>
<!-- 向下箭头 -->
<div class="fp-arrow" />
</div>
</template>
<style scoped>
.floating-popover {
min-width: 280px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(229, 231, 235, 0.5);
border-radius: 10px;
box-shadow:
0 8px 28px rgba(0,0,0,0.10),
0 2px 8px rgba(0,0,0,0.04);
padding: 10px 12px;
}
/* ── 向下箭头 ── */
.fp-arrow {
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid rgba(255, 255, 255, 0.92);
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.06));
}
.fp-row { margin-bottom: 8px; }
.fp-row:last-child { margin-bottom: 0; }
.fp-input {
width: 100%;
box-sizing: border-box;
padding: 7px 10px;
border: 1px solid rgba(209, 213, 219, 0.7);
border-radius: 6px;
font-size: 13px;
outline: none;
background: rgba(255, 255, 255, 0.7);
transition: border-color 0.15s, box-shadow 0.15s;
}
.fp-input:focus {
border-color: #2383e2;
box-shadow: 0 0 0 2px rgba(35, 131, 226, 0.15);
}
.fp-options { display: flex; align-items: center; }
.fp-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #6b7280;
cursor: pointer;
}
.fp-checkbox input { margin: 0; }
.fp-actions { display: flex; gap: 6px; justify-content: flex-end; }
.fp-btn {
padding: 4px 12px;
border: 1px solid rgba(209, 213, 219, 0.7);
border-radius: 6px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(4px);
font-size: 12px;
color: #374151;
cursor: pointer;
transition: background 0.1s;
}
.fp-btn:hover { background: #f3f4f6; }
.fp-btn-primary { background: #2383e2; color: #fff; border-color: #2383e2; }
.fp-btn-primary:hover { background: #1a6bbf; }
.fp-btn-danger { color: #dc2626; border-color: rgba(254, 202, 202, 0.7); }
.fp-btn-danger:hover { background: #fef2f2; }
</style>

View File

@ -5,10 +5,17 @@
* 替代原 image-resize/index.ts 247 行手写 DOM
* 利用 tiptap VueNodeViewRenderer + Vue 响应式系统
* 代码量减少 70%可维护性大幅提升
*
* 链接管理入口参照 Notion 最佳实践
* 1. 悬浮工具栏 菜单 "Set image link"
* 2. 链接指示条已有链接时左下角显示 URL
* 3. 拖拽手柄右键菜单 "Edit Link"/"Add Link"
*/
import { computed } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { NodeViewWrapper } from '@tiptap/vue-3'
import type { NodeViewProps } from '@tiptap/vue-3'
import ImageLinkForm from './ImageLinkForm.vue'
import ImageAltForm from './ImageAltForm.vue'
const props = defineProps<NodeViewProps>()
@ -20,25 +27,18 @@ const hasWidth = computed(() => {
return typeof w === 'number' && w > 0
})
const containerStyle = computed(() => {
const marginMap: Record<string, string> = {
center: '0 auto',
right: 'auto 0 0 auto',
left: '',
}
return {
display: 'block',
maxWidth: '100%',
...(hasWidth.value
? { width: props.node.attrs.width + 'px' }
: { width: 'fit-content' }),
...(align.value === 'center'
? { margin: '0 auto' }
: align.value === 'right'
? { marginLeft: 'auto' }
: {}),
}
})
const containerStyle = computed(() => ({
display: 'block',
maxWidth: '100%',
...(hasWidth.value
? { width: props.node.attrs.width + 'px' }
: { width: 'fit-content' }),
...(align.value === 'center'
? { margin: '0 auto' }
: align.value === 'right'
? { marginLeft: 'auto' }
: {}),
}))
const imgStyle = computed(() => ({
display: 'block',
@ -47,6 +47,170 @@ const imgStyle = computed(() => ({
...(hasWidth.value ? { width: '100%' } : {}),
}))
//
const hasLink = computed(() => !!props.node.attrs.href)
const showLinkForm = ref(false)
//
const popoverAnchorX = ref(0)
const popoverAnchorY = ref(0)
function computePopoverPosition() {
try {
const dom = props.editor.view.nodeDOM(props.getPos())
if (dom instanceof HTMLElement) {
const rect = dom.getBoundingClientRect()
//
const centerX = rect.left + rect.width / 2
const estWidth = 320 // min-width 280px + padding + buffer
const margin = 12
const minX = estWidth / 2 + margin
const maxX = window.innerWidth - estWidth / 2 - margin
popoverAnchorX.value = Math.max(minX, Math.min(centerX, maxX))
// 160px
popoverAnchorY.value = Math.max(160, rect.top)
}
} catch {
//
}
}
//
const dotsOpen = ref(false)
function toggleDots() {
dotsOpen.value = !dotsOpen.value
}
function closeDots() {
dotsOpen.value = false
}
const displayUrl = computed(() => {
const href = props.node.attrs.href
if (!href) return ''
try {
const u = new URL(href)
return u.hostname + (u.pathname.length > 1 ? u.pathname.substring(0, 30) : '')
} catch {
return href.substring(0, 36)
}
})
const hoverToolbarVisible = ref(false)
// ALT
const showAltForm = ref(false)
function openAltForm() {
computePopoverPosition()
showAltForm.value = true
}
function applyAlt(newAlt: string) {
props.updateAttributes({ alt: newAlt })
showAltForm.value = false
}
function cancelAlt() {
showAltForm.value = false
}
//
function onEditLinkFromContextMenu(e: Event) {
const detail = (e as CustomEvent).detail
const contextPos = detail?.pos as number | undefined
if (contextPos == null) return
// pos + src NodeView
const node = props.editor.state.doc.nodeAt(contextPos)
if (node?.type?.name !== 'imageResizable') return
if (node?.attrs?.src !== props.node.attrs.src) return
computePopoverPosition()
showLinkForm.value = true
}
onMounted(() => {
window.addEventListener('blockeditor:edit-image-link', onEditLinkFromContextMenu)
})
onBeforeUnmount(() => {
window.removeEventListener('blockeditor:edit-image-link', onEditLinkFromContextMenu)
})
//
/** 点击图片直接打开链接Notion 风格) */
function onImageClick(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
openLinkInNewTab()
}
function openLinkInNewTab() {
const href = props.node.attrs.href
if (href) {
window.open(href, props.node.attrs.target || '_blank', 'noopener noreferrer')
}
}
function applyLink(value: { href: string; target: string }) {
props.updateAttributes({ href: value.href, target: value.target })
showLinkForm.value = false
}
function removeLink() {
props.updateAttributes({ href: '', target: '' })
showLinkForm.value = false
}
function openLinkForm() {
computePopoverPosition()
showLinkForm.value = true
}
//
async function downloadImage() {
const src = props.node.attrs.src
if (!src) return
const filename = props.node.attrs.alt || 'image'
// fetch Blob
try {
const res = await fetch(src, { mode: 'cors' })
if (!res.ok) throw new Error('fetch failed')
const blob = await res.blob()
const ext = blob.type.split('/')[1] || 'png'
const fullName = `${filename}.${ext}`
// Modern File System Access API
if ('showSaveFilePicker' in window) {
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: fullName,
})
const writable = await handle.createWritable()
await writable.write(blob)
await writable.close()
return
} catch (err) {
// AbortError fallback
if ((err as DOMException)?.name === 'AbortError') return
// API 退 blob
}
}
// Fallback: blob URL + download
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fullName
a.click()
URL.revokeObjectURL(url)
} catch {
//
window.open(src, '_blank')
}
}
//
function onImageLoad(e: Event) {
const img = e.target as HTMLImageElement
@ -54,7 +218,6 @@ function onImageLoad(e: Event) {
const nw = img.naturalWidth
const nh = img.naturalHeight
if (nw > 0 && nh > 0) {
//
const curNw = props.node.attrs.naturalWidth
const curNh = props.node.attrs.naturalHeight
if (curNw !== nw || curNh !== nh) {
@ -76,8 +239,6 @@ function onResizeStart(e: MouseEvent | TouchEvent) {
const ce = (e as MouseEvent).clientX ?? (e as TouchEvent).touches?.[0]?.clientX ?? 0
startX = ce
// width 0 DOM
// startW = 0 mousemove
startW = props.node.attrs.width || 0
if (startW === 0) {
try {
@ -86,7 +247,7 @@ function onResizeStart(e: MouseEvent | TouchEvent) {
startW = dom.offsetWidth
}
} catch {
// fallback: 0
// fallback
}
}
@ -100,7 +261,6 @@ function onResizeMove(e: MouseEvent | TouchEvent) {
if (!isResizing) return
e.stopPropagation()
const ce = (e as MouseEvent).clientX ?? (e as TouchEvent).touches?.[0]?.clientX ?? 0
// CSS height:auto +
const newW = Math.max(startW + (ce - startX), 40)
props.updateAttributes({ width: Math.round(newW) })
}
@ -121,14 +281,161 @@ function onResizeEnd() {
:data-text-align="align"
:style="containerStyle"
style="position: relative; user-select: none;"
@mouseenter="hoverToolbarVisible = true"
@mouseleave="hoverToolbarVisible = false"
>
<img
:src="node.attrs.src"
:alt="node.attrs.alt || ''"
:title="node.attrs.title || ''"
:style="imgStyle"
@load="onImageLoad"
<!-- 图片主体 + 遮罩层 -->
<div class="image-img-wrap">
<div
v-if="hasLink"
class="blockeditor-image-link"
@click="onImageClick"
>
<img
:src="node.attrs.src"
:alt="node.attrs.alt || ''"
:title="node.attrs.title || ''"
:style="imgStyle"
@load="onImageLoad"
/>
</div>
<img
v-else
:src="node.attrs.src"
:alt="node.attrs.alt || ''"
:title="node.attrs.title || ''"
:style="imgStyle"
@load="onImageLoad"
/>
<!-- 悬浮半透明遮罩 -->
<Transition name="be-tb-fade">
<div v-if="hoverToolbarVisible" class="image-overlay" />
</Transition>
</div>
<!-- 悬浮工具栏hover 时显示在图片上方 -->
<Transition name="be-tb-fade">
<div v-if="hoverToolbarVisible && !showLinkForm" class="image-hover-toolbar">
<!-- 标题/alt 编辑 -->
<button class="iht-btn" title="Set alt text" @mousedown.stop @click.stop="openAltForm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<!-- 下载 -->
<button class="iht-btn" title="Download" @mousedown.stop @click.stop="downloadImage">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<!-- 三点更多 -->
<div class="iht-dots-wrap" @mousedown.stop @click.stop>
<button class="iht-btn iht-dots-btn" title="More" @click="toggleDots">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/>
</svg>
</button>
<div class="iht-dots-dropdown" :class="{ open: dotsOpen }">
<div
class="iht-dd-item"
@mousedown.stop
@click.stop="closeDots(); openLinkForm()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
<span>{{ hasLink ? 'Edit link' : 'Set image link' }}</span>
</div>
<div class="iht-dd-divider"></div>
<div
class="iht-dd-item" :class="{ 'iht-dd-item-active': align === 'left' }"
@mousedown.stop
@click.stop="closeDots(); props.updateAttributes({ textAlign: 'left' })"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>
</svg>
<span>Align left</span>
</div>
<div
class="iht-dd-item" :class="{ 'iht-dd-item-active': align === 'center' }"
@mousedown.stop
@click.stop="closeDots(); props.updateAttributes({ textAlign: 'center' })"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="10" x2="6" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="18" y1="14" x2="6" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>
</svg>
<span>Align center</span>
</div>
<div
class="iht-dd-item" :class="{ 'iht-dd-item-active': align === 'right' }"
@mousedown.stop
@click.stop="closeDots(); props.updateAttributes({ textAlign: 'right' })"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="21" y1="10" x2="7" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="7" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>
</svg>
<span>Align right</span>
</div>
</div>
</div>
</div>
</Transition>
<!-- 链接信息条无工具栏样式纯图标+文字 -->
<Transition name="be-tb-fade">
<div v-if="hasLink && hoverToolbarVisible && !showLinkForm" class="image-link-bar">
<button class="ilb-btn" title="Open link in new tab" @mousedown.stop @click.stop="openLinkInNewTab">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17 17 7M7 7h10v10"/>
</svg>
</button>
<span class="ilb-url" :title="node.attrs.href">{{ displayUrl }}</span>
<button class="ilb-btn ilb-btn-edit" title="Edit link" @mousedown.stop @click.stop="openLinkForm">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</div>
</Transition>
<!-- 弹窗背景遮罩 -->
<div
v-if="showLinkForm || showAltForm"
class="popover-backdrop"
@mousedown.stop
@click="showLinkForm = false; showAltForm = false"
/>
<!-- ALT 编辑表单 -->
<ImageAltForm
v-if="showAltForm"
:alt="node.attrs.alt || ''"
:anchor-x="popoverAnchorX"
:anchor-y="popoverAnchorY"
@apply="applyAlt"
@cancel="cancelAlt"
/>
<!-- 链接编辑表单 -->
<ImageLinkForm
v-if="showLinkForm"
:href="node.attrs.href"
:target="node.attrs.target"
:anchor-x="popoverAnchorX"
:anchor-y="popoverAnchorY"
@apply="applyLink"
@remove="removeLink"
@close="showLinkForm = false"
/>
<!-- 缩放手柄 -->
<div
class="image-resize-handle"
style="position:absolute;right:0;bottom:0;width:20px;height:20px;background:#1fc76f;cursor:se-resize;border-radius:50%;box-shadow:0 0 2px #1fc76f;z-index:2;touch-action:none;"
@ -137,3 +444,183 @@ function onResizeEnd() {
/>
</NodeViewWrapper>
</template>
<style scoped>
/* ── 图片主体遮罩容器 ── */
.image-img-wrap {
position: relative;
line-height: 0;
}
/* 悬浮半透明遮罩 */
.image-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.30);
pointer-events: none;
border-radius: 3px;
}
/* ── 链接图片容器 ── */
.blockeditor-image-link {
display: block;
cursor: pointer;
line-height: 0;
}
/* ── 悬浮工具栏(与 BubbleMenu 白色风格保持一致) ── */
.image-hover-toolbar {
position: absolute;
top: 6px;
right: 6px;
left: auto;
transform: none;
display: flex;
align-items: center;
gap: 1px;
background: rgba(255, 255, 255, 0.80);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(229, 231, 235, 0.6);
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0,0,0,0.12);
padding: 2px 3px;
z-index: 10;
white-space: nowrap;
}
.iht-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: #6b7280;
cursor: pointer;
padding: 0;
transition: background 0.1s, color 0.1s;
}
.iht-btn:hover {
background: #e5e7eb;
color: #1f2937;
}
/* 三点下拉菜单 */
.iht-dots-wrap {
position: relative;
}
.iht-dots-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 160px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
padding: 4px;
z-index: 100;
}
.iht-dots-dropdown.open {
display: block;
}
.iht-dd-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
color: #374151;
cursor: pointer;
transition: background 0.1s;
}
.iht-dd-item:hover {
background: #f3f4f6;
}
.iht-dd-item svg {
flex-shrink: 0;
color: #6b7280;
}
.iht-dd-item-active {
background: #eff6ff;
color: #2383e2;
}
.iht-dd-item-active svg {
color: #2383e2;
}
.iht-dd-divider {
height: 1px;
background: #e5e7eb;
margin: 4px 0;
}
/* ── 链接信息条(无工具栏样式,纯图标+文字) ── */
.image-link-bar {
position: absolute;
bottom: 8px;
left: 8px;
display: flex;
align-items: center;
gap: 4px;
z-index: 10;
max-width: calc(100% - 40px);
}
.ilb-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: transparent;
color: #fff;
cursor: pointer;
padding: 0;
flex-shrink: 0;
border-radius: 3px;
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.55));
transition: background 0.1s;
}
.ilb-btn:hover {
background: rgba(255, 255, 255, 0.20);
color: #fff;
}
.ilb-url {
font-size: 11px;
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.6);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
line-height: 1.2;
}
/* ── 过渡动画 ── */
/* 弹窗背景遮罩 */
.popover-backdrop {
position: fixed;
inset: 0;
z-index: 2147483646;
background: transparent;
}
/* ── 过渡动画 ── */
.be-tb-fade-enter-active,
.be-tb-fade-leave-active {
transition: opacity 0.15s ease;
}
.be-tb-fade-enter-from,
.be-tb-fade-leave-to {
opacity: 0;
}
</style>

View File

@ -24,6 +24,9 @@ const imageSchema = z.object({
// width: 0 = 自动(自然尺寸),> 0 = 自定义宽度(px)
width: z.number().default(0),
textAlign: z.enum(['left', 'center', 'right']).default('left'),
// Link support: 将链接存为节点属性而非 markblock 节点不支持 marks
href: z.string().default(''),
target: z.string().default(''),
})
// ── TipTap Extension ──
@ -76,12 +79,50 @@ const ImageResizable = TiptapNode.create({
'data-text-align': attrs.textAlign || 'left',
}),
},
href: {
rendered: true,
renderHTML: (attrs: Record<string, any>): Record<string, string> =>
attrs.href ? { href: attrs.href } : {},
},
target: {
rendered: true,
renderHTML: (attrs: Record<string, any>): Record<string, string> =>
attrs.target ? { target: attrs.target } : {},
},
}),
}
},
parseHTML() {
return [{ tag: 'div[data-type="imageResizable"]' }, { tag: 'img[src]' }]
return [
{
tag: 'div[data-type="imageResizable"]',
getAttrs: (el: HTMLElement | string) => {
if (typeof el === 'string') return {}
const anchor = el.querySelector('a')
return {
href: anchor?.getAttribute('href') || '',
target: anchor?.getAttribute('target') || '',
}
},
},
{
tag: 'a[href] img[src]',
getAttrs: (el: HTMLElement | string) => {
if (typeof el === 'string') return {}
const img = el as HTMLImageElement
const anchor = img.closest('a')
return {
src: img.getAttribute('src') || '',
alt: img.getAttribute('alt') || '',
title: img.getAttribute('title') || '',
href: anchor?.getAttribute('href') || '',
target: anchor?.getAttribute('target') || '',
}
},
},
{ tag: 'img[src]' },
]
},
renderHTML({ HTMLAttributes }) {
@ -90,6 +131,30 @@ const ImageResizable = TiptapNode.create({
const alignStyle = align === 'center' ? 'margin:0 auto;'
: align === 'right' ? 'margin-left:auto;' : ''
const img = [
'img',
{
src: HTMLAttributes.src,
alt: HTMLAttributes.alt,
title: HTMLAttributes.title,
style: `display:block;max-width:100%;height:auto;${w ? 'width:100%;' : ''}`,
},
]
// 当图片有链接时,用 <a> 包裹 <img>
const inner =
HTMLAttributes.href
? [
'a',
{
href: HTMLAttributes.href,
target: HTMLAttributes.target || '_blank',
rel: 'noopener noreferrer',
},
img,
]
: img
return [
'div',
{
@ -97,15 +162,7 @@ const ImageResizable = TiptapNode.create({
'data-text-align': align,
style: `display:block;max-width:100%;${alignStyle}${w ? `width:${w}px;` : 'width:fit-content;'}`,
},
[
'img',
{
src: HTMLAttributes.src,
alt: HTMLAttributes.alt,
title: HTMLAttributes.title,
style: `display:block;max-width:100%;height:auto;${w ? 'width:100%;' : ''}`,
},
],
inner,
]
},

View File

@ -36,6 +36,17 @@ function isAlignActive(align: string): boolean {
return editor.isActive({ textAlign: align })
}
// active link mark href
const isLinkActive = computed(() => {
const editor = editorRefWrapper.value
if (!editor) return false
if (editor.isActive('link')) return true
//
const { selection } = editor.state
const node = (selection as any).node
return node?.type?.name === 'imageResizable' && !!node?.attrs?.href
})
//
const bubbleColorOpen = ref(false)
@ -133,6 +144,8 @@ function shouldShow(props: {
}) {
const { view, state } = props
const { selection } = state
// BubbleMenu
if ((selection as any).node?.type?.name === 'imageResizable') return false
return !selection.empty && view.hasFocus()
}
</script>
@ -182,7 +195,7 @@ function shouldShow(props: {
</div>
<ToolbarDivider />
<ToolbarButton icon="link" :title="t('Link')" :active="isActive('link')" @click="openLinkPanel" />
<ToolbarButton icon="link" :title="t('Link')" :active="isLinkActive" @click="openLinkPanel" />
<!-- More menu (竖向三点 转换成子菜单 + 对齐) -->
<div class="be-tb-dropdown be-bubble-dropdown">

View File

@ -32,6 +32,25 @@ const hlKey = new PluginKey('blockeditor-link-selection')
// props.decorations plugin state
let highlightRange: { from: number; to: number } | null = null
//
function isImageNodeSelected(): boolean {
const { selection } = props.editor?.state ?? { selection: null }
if (!selection) return false
// NodeSelection node
const node = (selection as any).node
return node?.type?.name === 'imageResizable'
}
function getImageNodeAttrs(): Record<string, any> | null {
const { selection } = props.editor?.state ?? { selection: null }
if (!selection) return null
const node = (selection as any).node
if (node?.type?.name === 'imageResizable') {
return node.attrs
}
return null
}
//
function open(anchorRect?: DOMRect) {
@ -70,10 +89,17 @@ function open(anchorRect?: DOMRect) {
}
}
// link mark
if (props.editor) {
const attrs = props.editor.getAttributes('link')
url.value = attrs?.href || ''
newWindow.value = attrs?.target === '_blank'
const imgAttrs = getImageNodeAttrs()
if (imgAttrs?.href) {
url.value = imgAttrs.href
newWindow.value = imgAttrs.target === '_blank'
} else {
const attrs = props.editor.getAttributes('link')
url.value = attrs?.href || ''
newWindow.value = attrs?.target === '_blank'
}
} else {
url.value = ''
newWindow.value = false
@ -84,10 +110,31 @@ function open(anchorRect?: DOMRect) {
function setLink() {
const href = url.value.trim()
if (!props.editor) return close()
if (isImageNodeSelected()) {
// updateAttributes href/target
if (href) {
let normalized = href
if (!/^[a-z][a-z0-9+.-]*:/i.test(normalized)) normalized = 'https://' + normalized
props.editor
.chain()
.focus()
.updateAttributes('imageResizable', {
href: normalized,
target: newWindow.value ? '_blank' : '',
})
.run()
}
close()
return
}
// link mark
if (href) {
let normalized = href
if (!/^[a-z][a-z0-9+.-]*:/i.test(normalized)) normalized = 'https://' + normalized
props.editor?.chain().focus().setLink({
props.editor.chain().focus().setLink({
href: normalized,
target: newWindow.value ? '_blank' : null,
}).run()
@ -96,7 +143,21 @@ function setLink() {
}
function removeLink() {
props.editor?.chain().focus().unsetLink().run()
if (!props.editor) return
if (isImageNodeSelected()) {
// href/target
props.editor
.chain()
.focus()
.updateAttributes('imageResizable', { href: '', target: '' })
.run()
close()
return
}
// link mark
props.editor.chain().focus().unsetLink().run()
close()
}

View File

@ -6,7 +6,7 @@
*/
import { dragState } from './state'
import { TURN_INTO_ICON, CHEVRON_RIGHT_ICON, COPY_ICON, CLIPBOARD_ICON, TRASH_ICON, makeSvgUtil } from './handle-dom'
import { TURN_INTO_ICON, CHEVRON_RIGHT_ICON, COPY_ICON, CLIPBOARD_ICON, TRASH_ICON, LINK_ICON, makeSvgUtil } from './handle-dom'
import { blockRegistry } from '../../registry/BlockRegistry'
import type { TurnIntoContribution } from '../../registry/types'
import { t } from '@/shared/i18n'
@ -122,6 +122,19 @@ function buildContextMenu(): HTMLElement {
closeContextMenu()
})
// ★ 图片链接编辑 — 仅在选中图片节点时显示
const node = editor.state.doc.nodeAt(pos)
if (node?.type?.name === 'imageResizable') {
const hasLink = !!node.attrs?.href
addItem(LINK_ICON, hasLink ? t('Edit Link') : t('Add Link'), () => {
closeContextMenu()
editor.chain().focus().setNodeSelection(pos).run()
window.dispatchEvent(
new CustomEvent('blockeditor:edit-image-link', { detail: { editor, pos } }),
)
})
}
return menu
}

View File

@ -42,6 +42,10 @@ export const TRASH_ICON = makeSvg(
'<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6"/><path d="M14 11v6"/>',
)
export const LINK_ICON = makeSvg(
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
)
export function makeSvgUtil(content: string, w?: number, h?: number): string {
return makeSvg(content, w, h)
}

View File

@ -18,6 +18,7 @@
<ListGroup :is-active="isActive" :exec="exec" :t="t" />
<InsertGroup
:is-active="isActive"
:is-link-active="isLinkActive"
:exec="exec"
:t="t"
@image="openImagePicker(ed())"
@ -101,4 +102,14 @@ const {
setTextColor,
setHighlightColor,
} = useBlockCommand(editorRefWrapper)
// active link mark href
const isLinkActive = computed(() => {
const editor = editorRefWrapper.value
if (!editor) return false
if (editor.isActive('link')) return true
const { selection } = editor.state
const node = (selection as any).node
return node?.type?.name === 'imageResizable' && !!node?.attrs?.href
})
</script>

View File

@ -3,7 +3,7 @@
<ToolbarButton icon="code_block" :title="t('Code Block')" :active="isActive('codeBlock')" @click="exec('codeBlock')" />
<ToolbarButton icon="image" :title="t('Image')" @click="$emit('image')" />
<span ref="linkBtnRef">
<ToolbarButton icon="link" :title="t('Link')" :active="isActive('link')" @click="handleLinkClick" />
<ToolbarButton icon="link" :title="t('Link')" :active="isLinkActive" @click="handleLinkClick" />
</span>
<ToolbarButton icon="hr_line" :title="t('Divider')" @click="exec('hr')" />
<ToolbarDivider />
@ -16,6 +16,7 @@ import ToolbarDivider from '../ToolbarDivider.vue'
const props = defineProps<{
isActive: (name: string, attrs?: Record<string, any>) => boolean
isLinkActive: boolean
exec: (name: string) => void
t: (key: string) => string
}>()