Block Editor 图片节点增加超链接属性支持
This commit is contained in:
parent
f836377a7f
commit
941ff09bc5
@ -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; }
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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: 将链接存为节点属性而非 mark(block 节点不支持 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,
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}>()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user