Files
u-ppt/src/components/editor/ElementView.vue
T

396 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =====================================================================
ElementView.vue 单元素渲染替代 editor.js renderElement
editor / present / thumb 三处复用此组件
===================================================================== -->
<script setup lang="ts">
import { computed } from 'vue'
import type { SlideElement, BgKey } from '../../core/types'
import { store, resolveColor, isDarkBg } from '../../core/store'
import { resolveRef } from '../../core/assets'
import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext'
import ChartView from './ChartView.vue'
/* ---------- LaTeX 子集渲染(公式元素) ---------- */
const LATEX_SYMBOLS: Record<string, string> = {
alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', theta: 'θ', lambda: 'λ', mu: 'μ', pi: 'π', sigma: 'σ', omega: 'ω', phi: 'φ',
sum: '∑', prod: '∏', int: '∫', infty: '∞',
leq: '≤', geq: '≥', neq: '≠', times: '×', pm: '±', cdot: '·', div: '÷',
rightarrow: '→', leftarrow: '←', Rightarrow: '⇒', in: '∈', notin: '∉', subset: '⊂', supset: '⊃', cup: '', cap: '∩',
forall: '∀', exists: '∃', partial: '∂', nabla: '∇'
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function renderLatex(src: string): string {
let s = escapeHtml(src)
// 1. \frac{a}{b}
s = s.replace(/\\frac\{([^{}]*)\}\{([^{}]*)\}/g, (_m, a: string, b: string) =>
`<span class="frac"><span class="num">${a}</span><span class="den">${b}</span></span>`)
// 2. \sqrt[n]{x} 和 \sqrt{x}
s = s.replace(/\\sqrt\[([^\[\]]+)\]\{([^{}]*)\}/g, (_m, n: string, x: string) =>
`<span class="sqrt"><span class="rad">${n}</span><span class="overline">${x}</span></span>`)
s = s.replace(/\\sqrt\{([^{}]*)\}/g, (_m, x: string) =>
`<span class="sqrt"><span class="overline">${x}</span></span>`)
// 3. \symbol → Unicode
s = s.replace(/\\([a-zA-Z]+)/g, (_m, name: string) => {
if (Object.prototype.hasOwnProperty.call(LATEX_SYMBOLS, name)) return LATEX_SYMBOLS[name]
return name
})
// 4. ^{...} 和 ^x
s = s.replace(/\^\{([^{}]*)\}/g, (_m, x: string) => `<sup>${x}</sup>`)
s = s.replace(/\^([0-9a-zA-Z])/g, (_m, x: string) => `<sup>${x}</sup>`)
// 5. _{...} 和 _x
s = s.replace(/_\{([^{}]*)\}/g, (_m, x: string) => `<sub>${x}</sub>`)
s = s.replace(/_([0-9a-zA-Z])/g, (_m, x: string) => `<sub>${x}</sub>`)
return s
}
const props = defineProps<{
el: SlideElement
bg: BgKey | string
/** 是否为编辑态(启用 contenteditable */
edit?: boolean
/** 是否显示八向缩放手柄 */
showHandles?: boolean
/** 是否为缩略图端(视频等重元素降级为静态渲染) */
thumb?: boolean
}>()
const emit = defineEmits<{
(e: 'blur', id: string, field: string, value: string): void
}>()
/** 当前页背景是否深色 → 文字是否需要反相(isDarkBg 复用 store 导出的统一实现) */
const dark = computed(() => {
const d = store.state.deck ? (props.el.type === 'card' ? false : isDarkBg(props.bg)) : false
return d
})
const s = computed(() => props.el.style || {})
const boxStyle = computed(() => {
const st = s.value
const css: Record<string, string> = {}
if (st.opacity != null) css.opacity = String(st.opacity)
if (st.fontSize) css.fontSize = st.fontSize + 'px'
if (st.align) css.textAlign = st.align
if (st.bold === true) css.fontWeight = '700'
if (st.bold === false) css.fontWeight = '400'
if (st.italic === true) css.fontStyle = 'italic'
const color = resolveColor(st.color, dark.value)
if (color) css.color = color
return css
})
const dataList = computed(() => (props.el.content || '').split('\n'))
/** 媒体源解析:把 "asset:<id>" 引用解析为可渲染 URL(本地 ObjectURL 或云端 URL),其余原样 */
const mediaSrc = computed(() => resolveRef(props.el.content))
/** 表格解析 */
const tableRows = computed(() => {
const lines = (props.el.content || '').split('\n').map(l => l.trim()).filter(Boolean)
const rows: string[][] = []
for (const line of lines) {
if (/^\|?[\s:-]+\|[\s:-|]+$/.test(line)) continue
const cells = line.replace(/^\||\|$/g, '').split('|').map(c => c.trim())
rows.push(cells)
}
return rows
})
/** 公式渲染 */
const renderedFormula = computed(() => renderLatex(props.el.content || ''))
/** 形状背景 */
const shapeBg = computed(() => {
const st = s.value
const fill = resolveColor(st.fill, false)
if (st.gradient) {
return 'linear-gradient(135deg, ' + fill + ' 0%, ' + resolveColor('accent', false) + ' 100%)'
}
return fill
})
/** 各形状 clip-pathrect/circle 走 CSS 圆角,不走 clip */
const SHAPE_CLIPS: Partial<Record<string, string>> = {
triangle: 'polygon(50% 0, 100% 100%, 0 100%)',
diamond: 'polygon(50% 0, 100% 50%, 50% 100%, 0 50%)',
pentagon: 'polygon(50% 0, 100% 38%, 82% 100%, 18% 100%, 0 38%)',
hexagon: 'polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%)',
star: 'polygon(50% 0, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
arrow: 'polygon(0 20%, 75% 20%, 75% 0, 100% 50%, 75% 100%, 75% 80%, 0 80%)',
chevron: 'polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%, 25% 50%)',
bubble: 'polygon(0 0, 100% 0, 100% 75%, 25% 75%, 10% 100%, 15% 75%, 0 75%)'
}
const shapeClip = computed(() => SHAPE_CLIPS[s.value.shapeType || 'rect'] || null)
/** 形状文字(md 渲染;segments 存在时优先) */
const renderedShapeText = computed(() => {
if (props.el.segments && props.el.segments.length && hasFormatting(props.el.segments)) {
return segmentsToHtml(props.el.segments)
}
return segmentsToHtml(markdownToSegments(props.el.content || ''))
})
/** 形状文字层样式:对齐 + 未设色时默认白(填充色底) */
const shapeTextStyle = computed(() => {
const css: Record<string, string> = { textAlign: s.value.align || 'center' }
if (!s.value.color) css.color = '#fff'
return css
})
const cardAccentColor = computed(() => resolveColor(s.value.accent, false))
/** 卡片:content 第一行=标题,其余=正文 */
const cardParts = computed(() => {
const lines = (props.el.content || '').split('\n')
return { title: lines[0] || '', body: lines.slice(1).join('\n') }
})
/** 卡片标题/正文失焦:从 DOM 读两块文字拼回 content(标题在前的兄弟节点)。
* 标题空但正文非空时保留空行占位,避免正文首行漂移成标题。 */
function onBlurCard(e: Event) {
const node = e.target as HTMLElement
const card = node.closest('.el-card')
if (!card) return
const title = (card.querySelector('.card-title')?.textContent || '').trim()
const body = (card.querySelector('.card-body')?.textContent || '').trim()
if (!title && !body) { emit('blur', props.el.id, 'content', ''); return }
if (body) emit('blur', props.el.id, 'content', title + '\n' + body)
else emit('blur', props.el.id, 'content', title)
}
/* ---------- Rich textsegments 结构化富文本)---------- */
/** 是否有 segments(结构化富文本),优先于 content */
const hasSegments = computed(() => {
return !!(props.el.segments && props.el.segments.length && hasFormatting(props.el.segments))
})
/** title/text/quote 的渲染 HTML */
const renderedContent = computed(() => {
if (hasSegments.value) return segmentsToHtml(props.el.segments!)
// 降级:检查 content 是否含 Markdown 语法(兼容旧数据)
return null
})
/** list 每行的渲染 HTMLsegments 或纯文本) */
const renderedListItems = computed(() => {
if (hasSegments.value) {
return props.el.segments!.map(line => segmentsToHtml([line]))
}
return null
})
/** contenteditable 失焦回调 */
function onBlur(e: Event, field: string) {
const node = e.target as HTMLElement
let val: string
if (field === 'label') {
val = node.textContent || ''
} else if (props.el.type === 'list') {
val = node.innerText.replace(/\r/g, '').trim()
} else {
val = node.textContent || ''
}
emit('blur', props.el.id, field, val)
}
</script>
<template>
<div
class="el"
:data-id="el.id"
:data-type="el.type"
:data-anim="s.anim"
:data-shape="el.type === 'shape' ? (s.shapeType || 'rect') : undefined"
:style="{
left: el.x + '%',
top: el.y + '%',
width: el.w + '%',
height: el.h + '%',
...boxStyle
}"
>
<!-- 标题 / 正文 / 金句 -->
<template v-if="el.type === 'title' || el.type === 'text' || el.type === 'quote'">
<!-- 编辑态纯文本 contenteditable -->
<div
v-if="edit"
class="el-text"
style="white-space: pre-wrap; width: 100%"
contenteditable="true"
data-edit="content"
@blur="onBlur($event, 'content')"
>{{ el.content }}</div>
<!-- 非编辑态 + segments渲染结构化富文本 -->
<div
v-else-if="renderedContent"
class="el-text el-text-rich"
v-html="renderedContent"
></div>
<!-- 非编辑态 + 纯文本 -->
<div v-else class="el-text" style="white-space: pre-wrap; width: 100%">{{ el.content }}</div>
</template>
<!-- 列表 -->
<template v-else-if="el.type === 'list'">
<!-- 编辑态 -->
<div v-if="edit" class="el-list" contenteditable="true" data-edit="content" @blur="onBlur($event, 'content')">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div>
</div>
<!-- 非编辑态 + segments -->
<div v-else-if="renderedListItems" class="el-list">
<div v-for="(html, i) in renderedListItems" :key="i" class="li" v-html="html"></div>
</div>
<!-- 非编辑态 + 纯文本 -->
<div v-else class="el-list">
<div v-for="(line, i) in dataList" :key="i" class="li">{{ line }}</div>
</div>
</template>
<!-- 数据 -->
<template v-else-if="el.type === 'stat'">
<div class="el-stat">
<div
class="num"
:contenteditable="edit"
data-edit="content"
@blur="edit && onBlur($event, 'content')"
>{{ el.content }}</div>
<div
class="label"
:style="{ fontSize: (s.labelSize || 16) + 'px', color: resolveColor(s.labelColor, dark) }"
:contenteditable="edit"
data-edit="label"
@blur="edit && onBlur($event, 'label')"
>{{ s.label }}</div>
</div>
</template>
<!-- 图片无内容时显示占位提示不渲染空 src 的裂图 -->
<template v-else-if="el.type === 'image'">
<div v-if="!el.content" class="el-image-empty">
<span class="el-image-empty-icon">🖼</span>
<span class="el-image-empty-text">拖入图片 · 属性面板本地图片 AI 配图</span>
</div>
<img v-else class="el-image" :src="mediaSrc" draggable="false" />
</template>
<!-- 视频缩略图端降级静态preload=metadata 控制加载开销 -->
<template v-else-if="el.type === 'video'">
<div v-if="!el.content" class="el-image-empty">
<span class="el-image-empty-icon">🎬</span>
<span class="el-image-empty-text">属性面板选择本地视频或填入 URL</span>
</div>
<!-- 缩略图poster 静态图 poster 黑底▶),不加载视频 -->
<div v-else-if="thumb" class="el-video-thumb">
<img v-if="s.poster" :src="s.poster" draggable="false" />
<span v-else class="el-video-thumb-play"></span>
</div>
<!-- 编辑/演示可播放点选拖动交给画布 mousedown 冒泡不阻断 -->
<video
v-else
class="el-video"
:src="mediaSrc"
:poster="s.poster || undefined"
controls
preload="metadata"
:autoplay="!!s.autoplay"
:loop="!!s.loop"
:muted="!!s.muted"
:style="{ objectFit: s.fit || 'contain' }"
draggable="false"
@click.stop
></video>
</template>
<!-- 形状 -->
<template v-else-if="el.type === 'shape'">
<div v-if="s.shapeType === 'circle' || s.shapeType === 'ellipse'" class="el-shape" :class="{ gradient: s.gradient }" :style="{ borderRadius: '50%', background: shapeBg }"></div>
<div v-else-if="shapeClip" class="el-shape" :class="{ gradient: s.gradient }" :style="{ background: shapeBg, clipPath: shapeClip }"></div>
<div v-else class="el-shape" :class="{ gradient: s.gradient }" :style="{ background: shapeBg, borderRadius: (s.radius != null ? s.radius : 12) + 'px' }"></div>
<!-- 形状文字层编辑态 contenteditable非编辑态渲染 md未设色时默认白填充色底 -->
<div
v-if="edit"
class="el-shape-text"
:style="!s.color ? { color: '#fff' } : {}"
contenteditable="true"
data-edit="content"
@blur="onBlur($event, 'content')"
>{{ el.content }}</div>
<div
v-else-if="el.content"
class="el-shape-text"
:style="shapeTextStyle"
v-html="renderedShapeText"
></div>
</template>
<!-- 图表 -->
<template v-else-if="el.type === 'chart'">
<ChartView :content="el.content" :style="s" :dark="dark" />
</template>
<!-- 卡片 -->
<template v-else-if="el.type === 'card'">
<div class="card-bar" :style="{ background: cardAccentColor }"></div>
<div class="el-card">
<div v-if="s.icon" class="card-icon">{{ s.icon }}</div>
<div
class="card-title"
:contenteditable="edit"
data-edit="content"
@blur="edit && onBlurCard($event)"
>{{ cardParts.title }}</div>
<div
class="card-body"
:contenteditable="edit"
data-edit="content"
@blur="edit && onBlurCard($event)"
>{{ cardParts.body }}</div>
</div>
</template>
<!-- 表格 -->
<template v-else-if="el.type === 'table'">
<table class="el-table">
<thead v-if="s.header !== false && tableRows.length">
<tr><th v-for="(c, i) in tableRows[0]" :key="i">{{ c }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, ri) in tableRows.slice(s.header !== false ? 1 : 0)" :key="ri">
<td v-for="(c, ci) in row" :key="ci">{{ c }}</td>
</tr>
</tbody>
</table>
</template>
<!-- 代码 -->
<template v-else-if="el.type === 'code'">
<pre class="el-code">
<code>{{ el.content }}</code>
<span v-if="s.lang" class="code-lang">{{ s.lang }}</span>
</pre>
</template>
<!-- 公式 -->
<template v-else-if="el.type === 'formula'">
<div class="el-formula" v-html="renderedFormula"></div>
</template>
<!-- 八向缩放手柄仅编辑态选中时 -->
<template v-if="showHandles">
<div
v-for="h in ['tl','tm','tr','lm','rm','bl','bm','br']"
:key="h"
class="handle"
:class="h"
:data-handle="h"
></div>
</template>
</div>
</template>