新增: 画布批注层 AnnotationLayer,图片连线+可拖动气泡,SVG+DOM 分层
This commit is contained in:
@@ -0,0 +1,439 @@
|
|||||||
|
<!-- =====================================================================
|
||||||
|
AnnotationLayer.vue — 画布批注叠加层
|
||||||
|
连线(SVG,1280×720 用户坐标)+ 气泡(DOM,百分比定位,可拖动)
|
||||||
|
自读 store,铺满 .canvas 与 ElementView 平级,pointer-events 精确放行
|
||||||
|
===================================================================== -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { store, resolveColor, isDarkBg } from '../../core/store'
|
||||||
|
import { markdownToSegments, segmentsToHtml } from '../../core/richtext'
|
||||||
|
import type { Annotation, SlideElement } from '../../core/types'
|
||||||
|
|
||||||
|
const CANVAS_W = 1280
|
||||||
|
const CANVAS_H = 720
|
||||||
|
|
||||||
|
const slide = computed(() => store.currentSlide.value)
|
||||||
|
|
||||||
|
/** 当前页背景是否深色 → 文字反相 */
|
||||||
|
const dark = computed(() => isDarkBg(slide.value?.background || ''))
|
||||||
|
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
|
||||||
|
|
||||||
|
/** 扁平列表:{ el, anno },供连线与气泡渲染 */
|
||||||
|
const items = computed(() => {
|
||||||
|
const out: { el: SlideElement; anno: Annotation }[] = []
|
||||||
|
for (const el of slide.value?.elements || []) {
|
||||||
|
const list = el.style?.annotations
|
||||||
|
if (list?.length) for (const anno of list) out.push({ el, anno })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 拖动中的临时覆盖:key = elId::annoId → 部分 box 字段(移动改 bx/by,缩放改 bw/bh) */
|
||||||
|
const dragOverride = ref<Record<string, { bx?: number; by?: number; bw?: number; bh?: number }>>({})
|
||||||
|
const keyOf = (elId: string, annoId: string) => elId + '::' + annoId
|
||||||
|
|
||||||
|
/** 当前选中的批注(elId::annoId),高亮 + 显示缩放手柄;
|
||||||
|
* 同步到 store.selectedAnnoId 供全局 Delete 键裁决(批注删除优先于宿主元素) */
|
||||||
|
const selectedKey = ref<string | null>(null)
|
||||||
|
watch(selectedKey, v => store.setSelectedAnnoId(v ? v.split('::')[1] : null))
|
||||||
|
|
||||||
|
/** 宿主图片被取消选中 / 切页 / 批注被删 → 清空批注选中与编辑态 */
|
||||||
|
watch(
|
||||||
|
[() => store.selectedId.value, items],
|
||||||
|
([selId]) => {
|
||||||
|
if (!selectedKey.value) return
|
||||||
|
const stillExists = items.value.some(
|
||||||
|
({ el, anno }) => keyOf(el.id, anno.id) === selectedKey.value && el.id === selId
|
||||||
|
)
|
||||||
|
if (!stillExists) {
|
||||||
|
selectedKey.value = null
|
||||||
|
editingKey.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 新增批注自动进入编辑:选中图片的 annotations 出现新 id(且文本为空)→ 直接开写 */
|
||||||
|
const seenAnnoIds = new Set<string>()
|
||||||
|
// 挂载时把存量批注标记已见(老数据不触发编辑);此后出现的新 id 才自动进入编辑
|
||||||
|
for (const { anno } of items.value) seenAnnoIds.add(anno.id)
|
||||||
|
watch(items, (list) => {
|
||||||
|
const selId = store.selectedId.value
|
||||||
|
for (const { el, anno } of list) {
|
||||||
|
if (!seenAnnoIds.has(anno.id)) {
|
||||||
|
seenAnnoIds.add(anno.id)
|
||||||
|
if (el.id === selId && !anno.text) {
|
||||||
|
selectedKey.value = keyOf(el.id, anno.id)
|
||||||
|
editingKey.value = keyOf(el.id, anno.id)
|
||||||
|
nextTick(() => { editRef.value?.focus() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { deep: false })
|
||||||
|
|
||||||
|
/** 气泡当前坐标(拖动时用临时覆盖,否则用 anno 原值) */
|
||||||
|
function box(el: SlideElement, anno: Annotation) {
|
||||||
|
const ov = dragOverride.value[keyOf(el.id, anno.id)]
|
||||||
|
return {
|
||||||
|
bx: ov?.bx ?? anno.bx, by: ov?.by ?? anno.by,
|
||||||
|
bw: ov?.bw ?? anno.bw, bh: ov?.bh ?? anno.bh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 射线-矩形边框求交:从矩形中心 (cx,cy) 朝目标 (tx,ty) 方向,
|
||||||
|
* 取与矩形边框(半宽 hw、半高 hh)的交点。用于让连线从图片/气泡「边缘」出发,
|
||||||
|
* 不穿越本体。目标与中心重合时回退中心。
|
||||||
|
*/
|
||||||
|
function edgePoint(cx: number, cy: number, hw: number, hh: number, tx: number, ty: number) {
|
||||||
|
const dx = tx - cx, dy = ty - cy
|
||||||
|
if (!dx && !dy) return { x: cx, y: cy }
|
||||||
|
// 缩放因子:让 |dx*s|<=hw 且 |dy*s|<=hh,取首先触边者
|
||||||
|
const sx = dx ? hw / Math.abs(dx) : Infinity
|
||||||
|
const sy = dy ? hh / Math.abs(dy) : Infinity
|
||||||
|
const s = Math.min(sx, sy)
|
||||||
|
return { x: cx + dx * s, y: cy + dy * s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连线几何:起点=图片边缘、终点=气泡边缘(沿两中心连线方向),坐标转 1280×720 用户单位。
|
||||||
|
* 气泡中心落入图片框内(或两框重叠到边缘交点交叉)时返回 null → 不画线。 */
|
||||||
|
function lineGeom(el: SlideElement, anno: Annotation) {
|
||||||
|
const ex = el.x / 100 * CANVAS_W, ey = el.y / 100 * CANVAS_H
|
||||||
|
const ew = el.w / 100 * CANVAS_W, eh = el.h / 100 * CANVAS_H
|
||||||
|
const b = box(el, anno)
|
||||||
|
const bx = b.bx / 100 * CANVAS_W, by = b.by / 100 * CANVAS_H
|
||||||
|
const bw = b.bw / 100 * CANVAS_W, bh = b.bh / 100 * CANVAS_H
|
||||||
|
// 图片锚点中心(ax/ay 允许自定义偏移,默认几何中心)
|
||||||
|
const icx = ex + (anno.ax ?? 50) / 100 * ew
|
||||||
|
const icy = ey + (anno.ay ?? 50) / 100 * eh
|
||||||
|
const bcx = bx + bw / 2, bcy = by + bh / 2
|
||||||
|
// 气泡中心落在图片框内 → 连线无意义,隐藏
|
||||||
|
if (bcx >= ex && bcx <= ex + ew && bcy >= ey && bcy <= ey + eh) return null
|
||||||
|
const dist = Math.hypot(bcx - icx, bcy - icy)
|
||||||
|
// 起点从图片边缘出发(朝气泡方向),终点落在气泡边缘(朝图片方向)
|
||||||
|
const s = edgePoint(icx, icy, ew / 2, eh / 2, bcx, bcy)
|
||||||
|
const t = edgePoint(bcx, bcy, bw / 2, bh / 2, icx, icy)
|
||||||
|
// 两边缘点分别从各自中心前进的距离之和 ≥ 两中心距离 → 边框重叠,画出来会反向/交叉,隐藏
|
||||||
|
const sd = Math.hypot(s.x - icx, s.y - icy)
|
||||||
|
const td = Math.hypot(t.x - bcx, t.y - bcy)
|
||||||
|
if (sd + td >= dist) return null
|
||||||
|
return { x1: s.x, y1: s.y, x2: t.x, y2: t.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineColor(anno: Annotation) {
|
||||||
|
return resolveColor(anno.line?.color, dark.value) || (dark.value ? '#ffffff' : '#64748b')
|
||||||
|
}
|
||||||
|
function lineWidth(anno: Annotation) {
|
||||||
|
return (anno.line?.width ?? 0.5) * 2 // viewBox 用户单位偏细,放大系数;默认 0.5px
|
||||||
|
}
|
||||||
|
function dash(anno: Annotation) {
|
||||||
|
return anno.line?.style === 'dashed' ? '10 8' : undefined
|
||||||
|
}
|
||||||
|
/** cap → marker url(none 返回 undefined) */
|
||||||
|
function markerUrl(cap: string | undefined, kind: 'start' | 'end') {
|
||||||
|
if (cap === 'arrow') return 'url(#anno-arrow-' + kind + ')'
|
||||||
|
if (cap === 'dot') return 'url(#anno-dot)'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 气泡文字样式 */
|
||||||
|
function bubbleTextStyle(anno: Annotation) {
|
||||||
|
return {
|
||||||
|
fontSize: (anno.fontSize || 14) + 'px',
|
||||||
|
color: resolveColor(anno.color, false) || '#1e293b',
|
||||||
|
fontWeight: anno.bold ? 700 : 400,
|
||||||
|
fontStyle: anno.italic ? 'italic' : 'normal',
|
||||||
|
textAlign: anno.align || 'left'
|
||||||
|
} as Record<string, string | number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 气泡文字 → md 渲染 HTML(复用 richtext 行内格式 + 本组件补充 # 标题块级语法) */
|
||||||
|
function renderText(anno: Annotation): string {
|
||||||
|
const t = anno.text || ''
|
||||||
|
if (!t.trim()) return '<span class="anno-empty">双击输入批注</span>'
|
||||||
|
const base = anno.fontSize || 14
|
||||||
|
const HSIZE = [1.8, 1.5, 1.25] // #, ##, ### 相对基准字号的倍数
|
||||||
|
return t.split('\n').map(line => {
|
||||||
|
const h = /^(#{1,3})\s+(.*)$/.exec(line)
|
||||||
|
if (h) {
|
||||||
|
const level = h[1].length // 1~3
|
||||||
|
const inner = segmentsToHtml(markdownToSegments(h[2]))
|
||||||
|
const size = Math.round(base * HSIZE[level - 1])
|
||||||
|
return `<span style="display:block;font-size:${size}px;font-weight:700;line-height:1.25">${inner}</span>`
|
||||||
|
}
|
||||||
|
return segmentsToHtml(markdownToSegments(line))
|
||||||
|
}).join('<br>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 气泡内联编辑(双击进入,编辑 md 源文) ---------- */
|
||||||
|
const editingKey = ref<string | null>(null)
|
||||||
|
const editRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
function startEdit(el: SlideElement, anno: Annotation) {
|
||||||
|
store.selectElement(el.id)
|
||||||
|
selectedKey.value = keyOf(el.id, anno.id)
|
||||||
|
editingKey.value = keyOf(el.id, anno.id)
|
||||||
|
nextTick(() => { editRef.value?.focus(); editRef.value?.select() })
|
||||||
|
}
|
||||||
|
function commitEdit(el: SlideElement, anno: Annotation, value: string) {
|
||||||
|
editingKey.value = null
|
||||||
|
if (value !== anno.text) store.updateAnnotation(el.id, anno.id, { text: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 气泡拖动 / 缩放 ---------- */
|
||||||
|
const MIN_BW = 6, MIN_BH = 4 // 气泡最小尺寸(百分比)
|
||||||
|
interface DragState {
|
||||||
|
mode: 'move' | 'resize'
|
||||||
|
key: string; el: SlideElement; anno: Annotation
|
||||||
|
startBx: number; startBy: number; startBw: number; startBh: number
|
||||||
|
px0: number; py0: number
|
||||||
|
rect: DOMRect
|
||||||
|
}
|
||||||
|
let ds: DragState | null = null
|
||||||
|
|
||||||
|
function startDrag(mode: 'move' | 'resize', e: MouseEvent, el: SlideElement, anno: Annotation) {
|
||||||
|
e.preventDefault(); e.stopPropagation()
|
||||||
|
store.selectElement(el.id)
|
||||||
|
selectedKey.value = keyOf(el.id, anno.id)
|
||||||
|
const canvas = (e.currentTarget as HTMLElement).closest('.canvas') as HTMLElement | null
|
||||||
|
if (!canvas) return
|
||||||
|
ds = {
|
||||||
|
mode, key: keyOf(el.id, anno.id), el, anno,
|
||||||
|
startBx: anno.bx, startBy: anno.by, startBw: anno.bw, startBh: anno.bh,
|
||||||
|
px0: e.clientX, py0: e.clientY,
|
||||||
|
rect: canvas.getBoundingClientRect()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', onDragMove)
|
||||||
|
document.addEventListener('mouseup', onDragUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBubbleDown(e: MouseEvent, el: SlideElement, anno: Annotation) {
|
||||||
|
startDrag('move', e, el, anno)
|
||||||
|
}
|
||||||
|
function onResizeDown(e: MouseEvent, el: SlideElement, anno: Annotation) {
|
||||||
|
startDrag('resize', e, el, anno)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragMove(e: MouseEvent) {
|
||||||
|
if (!ds) return
|
||||||
|
const dxPct = (e.clientX - ds.px0) / ds.rect.width * 100
|
||||||
|
const dyPct = (e.clientY - ds.py0) / ds.rect.height * 100
|
||||||
|
if (ds.mode === 'resize') {
|
||||||
|
const nbw = clamp(ds.startBw + dxPct, MIN_BW, 100 - ds.startBx)
|
||||||
|
const nbh = clamp(ds.startBh + dyPct, MIN_BH, 100 - ds.startBy)
|
||||||
|
dragOverride.value = { ...dragOverride.value, [ds.key]: { bw: nbw, bh: nbh } }
|
||||||
|
} else {
|
||||||
|
const nbx = clamp(ds.startBx + dxPct, 0, 100 - ds.startBw)
|
||||||
|
const nby = clamp(ds.startBy + dyPct, 0, 100 - ds.startBh)
|
||||||
|
dragOverride.value = { ...dragOverride.value, [ds.key]: { bx: nbx, by: nby } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragUp() {
|
||||||
|
if (ds) {
|
||||||
|
const ov = dragOverride.value[ds.key]
|
||||||
|
if (ov) {
|
||||||
|
const patch = ds.mode === 'resize' ? { bw: ov.bw, bh: ov.bh } : { bx: ov.bx, by: ov.by }
|
||||||
|
store.updateAnnotation(ds.el.id, ds.anno.id, patch)
|
||||||
|
}
|
||||||
|
const next = { ...dragOverride.value }
|
||||||
|
delete next[ds.key]
|
||||||
|
dragOverride.value = next
|
||||||
|
}
|
||||||
|
ds = null
|
||||||
|
document.removeEventListener('mousemove', onDragMove)
|
||||||
|
document.removeEventListener('mouseup', onDragUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousemove', onDragMove)
|
||||||
|
document.removeEventListener('mouseup', onDragUp)
|
||||||
|
document.removeEventListener('keydown', onLayerKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 选中批注时 Delete/Backspace 删除该批注(输入态/编辑态放行)。
|
||||||
|
* store.selectedAnnoId 已同步,App.vue 的元素删除会先让位 */
|
||||||
|
function onLayerKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'Delete' && e.key !== 'Backspace') return
|
||||||
|
if (document.body.dataset.mode === 'present') return
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable) return
|
||||||
|
if (!selectedKey.value || editingKey.value) return
|
||||||
|
const hit = items.value.find(({ el, anno }) => keyOf(el.id, anno.id) === selectedKey.value)
|
||||||
|
if (!hit) return
|
||||||
|
e.preventDefault()
|
||||||
|
store.delAnnotation(hit.el.id, hit.anno.id)
|
||||||
|
selectedKey.value = null
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', onLayerKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="anno-layer">
|
||||||
|
<!-- 连线层 -->
|
||||||
|
<svg
|
||||||
|
class="anno-svg"
|
||||||
|
viewBox="0 0 1280 720"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="anno-arrow-end" markerUnits="userSpaceOnUse"
|
||||||
|
markerWidth="16" markerHeight="16" refX="12" refY="6" orient="auto"
|
||||||
|
>
|
||||||
|
<path d="M0,0 L12,6 L0,12 Z" fill="context-stroke" />
|
||||||
|
</marker>
|
||||||
|
<marker
|
||||||
|
id="anno-arrow-start" markerUnits="userSpaceOnUse"
|
||||||
|
markerWidth="16" markerHeight="16" refX="0" refY="6" orient="auto"
|
||||||
|
>
|
||||||
|
<path d="M12,0 L0,6 L12,12 Z" fill="context-stroke" />
|
||||||
|
</marker>
|
||||||
|
<marker
|
||||||
|
id="anno-dot" markerUnits="userSpaceOnUse"
|
||||||
|
markerWidth="12" markerHeight="12" refX="5" refY="5"
|
||||||
|
>
|
||||||
|
<circle cx="5" cy="5" r="4" fill="context-stroke" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<template v-for="{ el, anno } in items" :key="'ln-' + el.id + anno.id">
|
||||||
|
<line
|
||||||
|
v-if="lineGeom(el, anno)"
|
||||||
|
v-bind="lineGeom(el, anno)!"
|
||||||
|
:stroke="lineColor(anno)"
|
||||||
|
:stroke-width="lineWidth(anno)"
|
||||||
|
:stroke-dasharray="dash(anno)"
|
||||||
|
stroke-linecap="round"
|
||||||
|
:marker-start="markerUrl(anno.line?.startCap, 'start')"
|
||||||
|
:marker-end="markerUrl(anno.line?.endCap, 'end')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 气泡层 -->
|
||||||
|
<div
|
||||||
|
v-for="{ el, anno } in items"
|
||||||
|
:key="'bb-' + el.id + anno.id"
|
||||||
|
class="anno-bubble"
|
||||||
|
:class="{ selected: selectedKey === keyOf(el.id, anno.id) }"
|
||||||
|
:data-el-id="el.id"
|
||||||
|
:data-anno-id="anno.id"
|
||||||
|
:style="{
|
||||||
|
left: box(el, anno).bx + '%',
|
||||||
|
top: box(el, anno).by + '%',
|
||||||
|
width: box(el, anno).bw + '%',
|
||||||
|
height: box(el, anno).bh + '%',
|
||||||
|
...bubbleTextStyle(anno)
|
||||||
|
}"
|
||||||
|
@mousedown="onBubbleDown($event, el, anno)"
|
||||||
|
@dblclick.stop="startEdit(el, anno)"
|
||||||
|
>
|
||||||
|
<!-- 编辑态:md 源文 textarea -->
|
||||||
|
<textarea
|
||||||
|
v-if="editingKey === keyOf(el.id, anno.id)"
|
||||||
|
ref="editRef"
|
||||||
|
class="anno-bubble-edit"
|
||||||
|
:value="anno.text"
|
||||||
|
placeholder="支持 **加粗** *斜体* ==高亮== # 标题"
|
||||||
|
@mousedown.stop
|
||||||
|
@blur="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
@keydown.enter.exact.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
@keydown.enter.ctrl.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
@keydown.esc.prevent="commitEdit(el, anno, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
></textarea>
|
||||||
|
<!-- 展示态:md 渲染 -->
|
||||||
|
<span v-else class="anno-bubble-text" v-html="renderText(anno)"></span>
|
||||||
|
<span
|
||||||
|
class="anno-resize"
|
||||||
|
title="拖动调整气泡大小"
|
||||||
|
@mousedown="onResizeDown($event, el, anno)"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.anno-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.anno-svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* SVG 子元素默认 visiblePainted 会拦截点击,父级 pointer-events:none 不继承 → 显式关闭,
|
||||||
|
否则贴着图片边缘的连线描边会挡住图片的点选(回归根因) */
|
||||||
|
.anno-svg * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.anno-bubble {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: move;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.1);
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.anno-bubble.selected {
|
||||||
|
border-color: var(--ui-primary, #4f46e5);
|
||||||
|
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.25), 0 2px 8px rgba(15, 23, 42, 0.1);
|
||||||
|
}
|
||||||
|
.anno-bubble-text {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.anno-bubble-text :deep(.anno-empty) {
|
||||||
|
color: var(--ui-muted, #94a3b8);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.anno-bubble-edit {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
resize: none;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
.anno-resize {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, transparent 50%, var(--ui-primary, #4f46e5) 50%);
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .12s;
|
||||||
|
}
|
||||||
|
.anno-bubble:hover .anno-resize,
|
||||||
|
.anno-bubble.selected .anno-resize {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,7 @@ import { store, resolveBg } from '../../core/store'
|
|||||||
import { CANVAS_W } from '../../core/sample'
|
import { CANVAS_W } from '../../core/sample'
|
||||||
import { useEditor } from '../../composables/useEditor'
|
import { useEditor } from '../../composables/useEditor'
|
||||||
import ElementView from './ElementView.vue'
|
import ElementView from './ElementView.vue'
|
||||||
|
import AnnotationLayer from './AnnotationLayer.vue'
|
||||||
|
|
||||||
const canvasRef = ref<HTMLElement>()
|
const canvasRef = ref<HTMLElement>()
|
||||||
const canvasFrameRef = ref<HTMLElement>() // 注意:绑定 .canvas-frame(不含 stage 的 padding),scale 以它为基准
|
const canvasFrameRef = ref<HTMLElement>() // 注意:绑定 .canvas-frame(不含 stage 的 padding),scale 以它为基准
|
||||||
@@ -20,6 +21,7 @@ const slide = computed(() => store.currentSlide.value)
|
|||||||
const selectedId = computed(() => store.selectedId.value)
|
const selectedId = computed(() => store.selectedId.value)
|
||||||
const selectedIds = computed(() => store.selectedIds.value || [])
|
const selectedIds = computed(() => store.selectedIds.value || [])
|
||||||
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
|
const HANDLES = ['tl', 'tm', 'tr', 'lm', 'rm', 'bl', 'bm', 'br']
|
||||||
|
const noteOpen = ref(false) // 演讲者备注栏展开态
|
||||||
|
|
||||||
/** 框选矩形样式(% → CSS) */
|
/** 框选矩形样式(% → CSS) */
|
||||||
const marqueeBox = computed(() => {
|
const marqueeBox = computed(() => {
|
||||||
@@ -45,11 +47,16 @@ function onElementBlur(id: string, field: string, value: string) {
|
|||||||
editing.value = false
|
editing.value = false
|
||||||
if (field === 'label') {
|
if (field === 'label') {
|
||||||
store.updateElement(id, { style: { label: value } })
|
store.updateElement(id, { style: { label: value } })
|
||||||
} else if (store.findElement(id)?.type === 'list') {
|
return
|
||||||
store.updateElement(id, { content: value })
|
|
||||||
} else {
|
|
||||||
store.updateElement(id, { content: value })
|
|
||||||
}
|
}
|
||||||
|
// 形状输入文字:装饰细条(h 过小)自动撑高到能容纳一行字,避免文字被裁
|
||||||
|
const el = store.findElement(id)
|
||||||
|
if (el?.type === 'shape' && value.trim() && el.h < 6) {
|
||||||
|
const grow = 6 - el.h
|
||||||
|
store.updateElement(id, { content: value, h: 6, y: Math.max(0, el.y - grow / 2) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.updateElement(id, { content: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拖拽中元素的实时位置(覆盖 state) */
|
/** 拖拽中元素的实时位置(覆盖 state) */
|
||||||
@@ -76,6 +83,10 @@ function liveMultiBox(elId: string) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onNoteInput(e: Event) {
|
||||||
|
store.setSlideNote((e.target as HTMLTextAreaElement).value)
|
||||||
|
}
|
||||||
|
|
||||||
const onResize = () => fitCanvas()
|
const onResize = () => fitCanvas()
|
||||||
|
|
||||||
/** 监听 frame 尺寸:v-show 隐藏→显示、窗口缩放、布局变化都会触发(隐藏时 rect=0,onMounted 的 rAF 会空跑) */
|
/** 监听 frame 尺寸:v-show 隐藏→显示、窗口缩放、布局变化都会触发(隐藏时 rect=0,onMounted 的 rAF 会空跑) */
|
||||||
@@ -134,10 +145,33 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 框选矩形 -->
|
<!-- 框选矩形 -->
|
||||||
<div v-if="marqueeBox" class="marquee-box" :style="marqueeBox"></div>
|
<div v-if="marqueeBox" class="marquee-box" :style="marqueeBox"></div>
|
||||||
|
|
||||||
|
<AnnotationLayer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="canvas-status">
|
<div class="canvas-footer">
|
||||||
<span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span>
|
<div class="canvas-status">
|
||||||
|
<span>{{ store.currentIndex.value + 1 }} / {{ store.count.value }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="note-toggle"
|
||||||
|
:class="{ active: noteOpen || !!slide?.note }"
|
||||||
|
:title="noteOpen ? '收起备注' : '演讲者备注'"
|
||||||
|
@click="noteOpen = !noteOpen"
|
||||||
|
>
|
||||||
|
<span class="note-toggle-icon">✎</span>
|
||||||
|
<span>备注</span>
|
||||||
|
<span v-if="slide?.note && !noteOpen" class="note-dot"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-show="noteOpen" class="canvas-note">
|
||||||
|
<textarea
|
||||||
|
class="canvas-note-input"
|
||||||
|
:value="slide?.note || ''"
|
||||||
|
placeholder="演讲者备注(不显示在幻灯片上)…"
|
||||||
|
rows="2"
|
||||||
|
@input="onNoteInput"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/* shape-render.test.ts — 形状元素渲染回归(细条/装饰形状不被文字层污染) */
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ElementView from '../src/components/editor/ElementView.vue'
|
||||||
|
import { store } from '../src/core/store'
|
||||||
|
import type { SlideElement } from '../src/core/types'
|
||||||
|
|
||||||
|
/** 目录页 t2:标题下方的细横条(rect 128×11.5px) */
|
||||||
|
const t2: SlideElement = {
|
||||||
|
id: 't2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '',
|
||||||
|
style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 步骤页 st4:步骤间的小三角 */
|
||||||
|
const st4: SlideElement = {
|
||||||
|
id: 'st4', type: 'shape', x: 27.5, y: 40, w: 5, h: 0.8, content: '',
|
||||||
|
style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('装饰形状渲染', () => {
|
||||||
|
it('细横条 rect:编辑态渲染图形 div + 空 contenteditable 文字层,无占位文字', () => {
|
||||||
|
const w = mount(ElementView, { props: { el: t2, bg: 'bg', edit: true }, attachTo: document.body })
|
||||||
|
const shape = w.find('.el-shape')
|
||||||
|
expect(shape.exists()).toBe(true)
|
||||||
|
expect(shape.attributes('style')).toContain('border-radius: 4px')
|
||||||
|
const text = w.find('.el-shape-text')
|
||||||
|
expect(text.exists()).toBe(true) // 编辑态保留可点击输入
|
||||||
|
expect(text.text()).toBe('') // 无占位文字
|
||||||
|
// 根节点暴露 data-shape 供 CSS 区分满幅/多边形安全区
|
||||||
|
expect(w.find('.el').attributes('data-shape')).toBe('rect')
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('小三角 triangle:走 clip-path 分支,非正比框渲染', () => {
|
||||||
|
const w = mount(ElementView, { props: { el: st4, bg: 'bg', edit: true }, attachTo: document.body })
|
||||||
|
const shape = w.find('.el-shape')
|
||||||
|
expect(shape.attributes('style') || '').toContain('clip-path')
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非编辑态(模板预览):空 content 不渲染文字层', () => {
|
||||||
|
const w = mount(ElementView, { props: { el: t2, bg: 'bg' }, attachTo: document.body })
|
||||||
|
expect(w.find('.el-shape-text').exists()).toBe(false)
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有文字的形状:非编辑态渲染 md 文字层且默认白字', () => {
|
||||||
|
void store
|
||||||
|
const el = { ...t2, h: 10, content: '**加粗**文字' }
|
||||||
|
const w = mount(ElementView, { props: { el, bg: 'bg' }, attachTo: document.body })
|
||||||
|
const text = w.find('.el-shape-text')
|
||||||
|
expect(text.exists()).toBe(true)
|
||||||
|
expect(text.html()).toContain('<strong>加粗</strong>')
|
||||||
|
expect(text.attributes('style') || '').toContain('rgb(255, 255, 255)')
|
||||||
|
w.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user