新增: 批注/备注/对齐分布/形状扩充/video 元素——编辑器能力增强
This commit is contained in:
Generated
+1168
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^4.1.11",
|
||||
|
||||
+7
-1
@@ -6,6 +6,7 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { store } from './core/store'
|
||||
import { readAsDataURL, fileToImageElement } from './core/importer'
|
||||
import { checkImageQuota } from './core/ai'
|
||||
import AppDialog from './components/common/AppDialog.vue'
|
||||
import Toolbar from './components/editor/Toolbar.vue'
|
||||
import ThumbBar from './components/editor/ThumbBar.vue'
|
||||
import Canvas from './components/editor/Canvas.vue'
|
||||
@@ -195,7 +196,9 @@ function onKey(e: KeyboardEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' && !inField) {
|
||||
/* Delete/Backspace:批注选中时让位给 AnnotationLayer 删批注,否则删元素 */
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && !inField) {
|
||||
if (store.selectedAnnoId.value) return
|
||||
const sel = store.getSelection()
|
||||
if (sel.length > 1) { e.preventDefault(); store.delElements(sel); return }
|
||||
const id = store.getSelectedId()
|
||||
@@ -436,4 +439,7 @@ onUnmounted(() => {
|
||||
<span class="global-drop-hint">.json 直接导入 · 图片/文档进入资料导入</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全局统一对话框(appAlert/appConfirm/appPrompt) -->
|
||||
<AppDialog />
|
||||
</template>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import type { ElementType } from '../../core/types'
|
||||
import { elementTypes } from '../../core/sample'
|
||||
|
||||
const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula']
|
||||
const TYPES: ElementType[] = ['title', 'text', 'list', 'stat', 'quote', 'image', 'video', 'shape', 'chart', 'card', 'table', 'code', 'formula']
|
||||
const ICONS: Record<string, string> = {
|
||||
title: 'T', text: '¶', list: '☰', stat: '#', quote: '“”',
|
||||
image: '🖼', shape: '▭', chart: '📊', card: '◰',
|
||||
image: '🖼', video: '🎬', shape: '▭', chart: '📊', card: '◰',
|
||||
table: '▦', code: '</>', formula: '∑'
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ const props = defineProps<{
|
||||
edit?: boolean
|
||||
/** 是否显示八向缩放手柄 */
|
||||
showHandles?: boolean
|
||||
/** 是否为缩略图端(视频等重元素降级为静态渲染) */
|
||||
thumb?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -108,8 +110,33 @@ const shapeBg = computed(() => {
|
||||
return fill
|
||||
})
|
||||
|
||||
const isCircle = computed(() => s.value.shapeType === 'circle')
|
||||
const isTriangle = computed(() => s.value.shapeType === 'triangle')
|
||||
/** 各形状 clip-path(rect/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))
|
||||
|
||||
@@ -119,6 +146,19 @@ const cardParts = computed(() => {
|
||||
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 text(segments 结构化富文本)---------- */
|
||||
/** 是否有 segments(结构化富文本),优先于 content */
|
||||
const hasSegments = computed(() => {
|
||||
@@ -161,6 +201,7 @@ function onBlur(e: Event, field: string) {
|
||||
: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 + '%',
|
||||
@@ -234,15 +275,54 @@ function onBlur(e: Event, field: string) {
|
||||
<img v-else class="el-image" :src="el.content" 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="el.content"
|
||||
: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="isCircle" class="el-shape" :class="{ gradient: s.gradient }" :style="{ borderRadius: '50%', background: shapeBg }"></div>
|
||||
<div v-else-if="isTriangle" class="el-shape">
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:100%;display:block">
|
||||
<polygon points="50,5 95,95 5,95" :fill="shapeBg" />
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- 图表 -->
|
||||
@@ -255,8 +335,18 @@ function onBlur(e: Event, field: string) {
|
||||
<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">{{ cardParts.title }}</div>
|
||||
<div class="card-body">{{ cardParts.body }}</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>
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ import { store } from '../../core/store'
|
||||
import { elementTypes } from '../../core/sample'
|
||||
import { generateImage, isImageConfigured, checkImageQuota } from '../../core/ai'
|
||||
import { readAsDataURL } from '../../core/importer'
|
||||
import { appAlert, appConfirm, appPrompt } from '../../core/dialog'
|
||||
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
|
||||
import AddGrid from './AddGrid.vue'
|
||||
import type { ElementType, ChartType } from '../../core/types'
|
||||
import type { ElementType, ChartType, ShapeType } from '../../core/types'
|
||||
|
||||
const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
|
||||
{ k: 'bar', label: '柱状图', icon: '📊' },
|
||||
@@ -31,7 +32,7 @@ const content = ref('')
|
||||
const fontSize = ref(24)
|
||||
const colorSel = ref('primary')
|
||||
const colorPicker = ref('#000000')
|
||||
const shape = ref<'rect' | 'circle' | 'triangle'>('rect')
|
||||
const shape = ref<ShapeType>('rect')
|
||||
const chartType = ref<ChartType>('bar')
|
||||
const codeLang = ref('')
|
||||
const imgBusy = ref(false)
|
||||
@@ -66,11 +67,11 @@ watch(selected, (el) => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const hasText = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
|
||||
const canFont = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
|
||||
const canAlign = computed(() => selected.value && ['title', 'text', 'quote', 'stat', 'table', 'formula'].includes(selected.value.type))
|
||||
const canBI = computed(() => selected.value && ['title', 'text', 'quote', 'list'].includes(selected.value.type))
|
||||
const canColor = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula'].includes(selected.value.type))
|
||||
const hasText = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape', 'card'].includes(selected.value.type))
|
||||
const canFont = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape', 'card'].includes(selected.value.type))
|
||||
const canAlign = computed(() => selected.value && ['title', 'text', 'quote', 'stat', 'table', 'formula', 'shape'].includes(selected.value.type))
|
||||
const canBI = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'shape'].includes(selected.value.type))
|
||||
const canColor = computed(() => selected.value && ['title', 'text', 'quote', 'list', 'stat', 'table', 'code', 'formula', 'shape'].includes(selected.value.type))
|
||||
const isShape = computed(() => selected.value?.type === 'shape')
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
@@ -87,9 +88,20 @@ function onMultiCopy() {
|
||||
const sel = store.getSelection()
|
||||
if (sel.length) store.copyElements(sel)
|
||||
}
|
||||
function onMultiDel() {
|
||||
async function onMultiDel() {
|
||||
const sel = store.getSelection()
|
||||
if (sel.length) store.delElements(sel)
|
||||
if (!sel.length) return
|
||||
if (await appConfirm('删除选中的 ' + sel.length + ' 个元素?', '可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||||
store.delElements(sel)
|
||||
}
|
||||
}
|
||||
function onAlignOp(edge: 'left' | 'hcenter' | 'right' | 'top' | 'vmiddle' | 'bottom') {
|
||||
const sel = store.getSelection()
|
||||
if (sel.length > 1) store.alignElements(sel, edge)
|
||||
}
|
||||
function onDistribute(axis: 'h' | 'v') {
|
||||
const sel = store.getSelection()
|
||||
if (sel.length > 2) store.distributeElements(sel, axis)
|
||||
}
|
||||
|
||||
function onContentInput() {
|
||||
@@ -152,9 +164,12 @@ function onZ(dir: number) {
|
||||
if (!selected.value) return
|
||||
store.moveElementZ(selected.value.id, dir)
|
||||
}
|
||||
function onDel() {
|
||||
async function onDel() {
|
||||
if (!selected.value) return
|
||||
store.delElement(selected.value.id)
|
||||
const label = elementTypes[selected.value.type]?.label || '元素'
|
||||
if (await appConfirm('删除这个' + label + '?', '可用 Ctrl+Z 撤销', { danger: true, okText: '删除' })) {
|
||||
store.delElement(selected.value.id)
|
||||
}
|
||||
}
|
||||
function onBg(k: string) {
|
||||
store.setSlideBackground(k)
|
||||
@@ -189,23 +204,62 @@ async function onAiImage() {
|
||||
if (imgBusy.value) return
|
||||
const el = selected.value
|
||||
if (!el || el.type !== 'image') return
|
||||
if (!isImageConfigured()) { alert('请先在「AI 设置」中配置 API Key'); return }
|
||||
const promptText = window.prompt('描述你想要的图片,例如「现代办公室协作场景,俯拍,柔和光线」')
|
||||
if (!isImageConfigured()) { appAlert('需要 API Key', '请先在「AI 设置」中配置'); return }
|
||||
const promptText = await appPrompt('AI 配图', { placeholder: '描述你想要的图片,如「现代办公室协作场景,俯拍,柔和光线」' })
|
||||
if (!promptText) return
|
||||
imgBusy.value = true
|
||||
imgAbort = new AbortController()
|
||||
try {
|
||||
const r = await generateImage({ prompt: promptText, signal: imgAbort.signal })
|
||||
const quotaErr = checkImageQuota(r.url)
|
||||
if (quotaErr) { alert(quotaErr); return }
|
||||
if (quotaErr) { appAlert('配图受限', quotaErr); return }
|
||||
store.updateElement(el.id, { content: r.url })
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') alert('配图失败:' + (e?.message || String(e)))
|
||||
if (e?.name !== 'AbortError') appAlert('配图失败', e?.message || String(e))
|
||||
} finally {
|
||||
imgBusy.value = false; imgAbort = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 批注(仅图片元素) ---------- */
|
||||
/** 复用文件内颜色选项集合(与颜色 select 的 option values 同款) */
|
||||
const ANNO_COLORS: Array<{ k: string; label: string }> = [
|
||||
{ k: 'primary', label: '主色' },
|
||||
{ k: 'accent', label: '强调色' },
|
||||
{ k: 'text', label: '正文色' },
|
||||
{ k: 'muted', label: '次要色' },
|
||||
{ k: '#ffffff', label: '白色' }
|
||||
]
|
||||
|
||||
/** 当前图片的批注列表 */
|
||||
const annotations = computed<any[]>(() => selected.value?.style?.annotations || [])
|
||||
|
||||
/** 折叠状态:记录「已展开」的批注 id(默认折叠,仅展开正在编辑的一条) */
|
||||
const expandedAnno = ref<string | null>(null)
|
||||
function toggleAnno(annoId: string) {
|
||||
expandedAnno.value = expandedAnno.value === annoId ? null : annoId
|
||||
}
|
||||
|
||||
function addAnno() {
|
||||
if (!selected.value) return
|
||||
const id = store.addAnnotation(selected.value.id)
|
||||
if (id) expandedAnno.value = id // 新增自动展开
|
||||
}
|
||||
function delAnno(annoId: string) {
|
||||
if (selected.value) store.delAnnotation(selected.value.id, annoId)
|
||||
if (expandedAnno.value === annoId) expandedAnno.value = null
|
||||
}
|
||||
function patchAnno(annoId: string, patch: Record<string, any>) {
|
||||
if (!selected.value) return
|
||||
store.updateAnnotation(selected.value.id, annoId, patch)
|
||||
}
|
||||
|
||||
/** 批注文字摘要(折叠态卡片头显示) */
|
||||
function annoSummary(anno: any): string {
|
||||
const t = (anno.text || '').trim()
|
||||
return t ? (t.length > 12 ? t.slice(0, 12) + '…' : t) : '(空批注)'
|
||||
}
|
||||
|
||||
/** 本地上传图片替换当前图片元素内容 */
|
||||
function onLocalImage() {
|
||||
const el = selected.value
|
||||
@@ -219,14 +273,43 @@ function onLocalImage() {
|
||||
try {
|
||||
const dataUrl = await readAsDataURL(file)
|
||||
const quotaErr = checkImageQuota(dataUrl)
|
||||
if (quotaErr) { alert(quotaErr); return }
|
||||
if (quotaErr) { appAlert('图片受限', quotaErr); return }
|
||||
store.updateElement(el.id, { content: dataUrl })
|
||||
} catch (e: any) {
|
||||
alert('读取图片失败:' + (e?.message || String(e)))
|
||||
appAlert('读取图片失败', e?.message || String(e))
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
/* ---------- 视频元素 ---------- */
|
||||
function onLocalVideo() {
|
||||
const el = selected.value
|
||||
if (!el) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'video/*'
|
||||
input.onchange = async () => {
|
||||
const f = input.files?.[0]
|
||||
if (!f) return
|
||||
// 注意:大视频会整体读入内存(dataURL),本地工具首版接受
|
||||
const dataUrl = await readAsDataURL(f)
|
||||
store.updateElement(el.id, { content: dataUrl })
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
function onVideoUrl(e: Event) {
|
||||
const el = selected.value
|
||||
if (!el) return
|
||||
const v = (e.target as HTMLInputElement).value.trim()
|
||||
if (v) store.updateElement(el.id, { content: v })
|
||||
}
|
||||
function toggleVideoOpt(key: 'autoplay' | 'loop' | 'muted') {
|
||||
const el = selected.value
|
||||
if (!el) return
|
||||
const cur = (el.style as any)[key]
|
||||
store.updateElement(el.id, { style: { [key]: !cur } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -247,6 +330,24 @@ function onLocalImage() {
|
||||
<button class="danger" @click="onMultiDel" title="Delete">🗑 删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>对齐</label>
|
||||
<div class="seg">
|
||||
<button @click="onAlignOp('left')" title="左对齐">⇤</button>
|
||||
<button @click="onAlignOp('hcenter')" title="水平居中">↔</button>
|
||||
<button @click="onAlignOp('right')" title="右对齐">⇥</button>
|
||||
<button @click="onAlignOp('top')" title="顶对齐">⇧</button>
|
||||
<button @click="onAlignOp('vmiddle')" title="垂直居中">↕</button>
|
||||
<button @click="onAlignOp('bottom')" title="底对齐">⇩</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>分布</label>
|
||||
<div class="seg">
|
||||
<button :disabled="multiCount < 3" @click="onDistribute('h')" :title="multiCount < 3 ? '需要选中 3 个以上' : '水平等间距'">⋯</button>
|
||||
<button :disabled="multiCount < 3" @click="onDistribute('v')" :title="multiCount < 3 ? '需要选中 3 个以上' : '垂直等间距'">⋮</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prop-row">
|
||||
<label>提示</label>
|
||||
<div style="font-size:12px;color:var(--ui-muted);line-height:1.6">
|
||||
@@ -262,7 +363,12 @@ function onLocalImage() {
|
||||
<!-- 内容 -->
|
||||
<div v-if="hasText && selected.type !== 'stat'" class="prop-row">
|
||||
<label>内容</label>
|
||||
<textarea rows="3" placeholder="输入文字(列表用换行分隔)" v-model="content" @input="onContentInput"></textarea>
|
||||
<textarea
|
||||
rows="3"
|
||||
:placeholder="selected.type === 'card' ? '第一行=标题,其余行=正文' : selected.type === 'shape' ? '形状内文字(支持 **加粗** 等 md 语法)' : '输入文字(列表用换行分隔)'"
|
||||
v-model="content"
|
||||
@input="onContentInput"
|
||||
></textarea>
|
||||
<!-- 富文本格式提示 -->
|
||||
<div v-if="['title','text','quote','list'].includes(selected.type)" class="rich-hint">
|
||||
<div class="rich-syntax">
|
||||
@@ -322,7 +428,15 @@ function onLocalImage() {
|
||||
<select v-model="shape" @change="onShapeChange">
|
||||
<option value="rect">矩形</option>
|
||||
<option value="circle">圆形</option>
|
||||
<option value="ellipse">椭圆</option>
|
||||
<option value="triangle">三角</option>
|
||||
<option value="diamond">菱形</option>
|
||||
<option value="pentagon">五边形</option>
|
||||
<option value="hexagon">六边形</option>
|
||||
<option value="star">五角星</option>
|
||||
<option value="arrow">箭头</option>
|
||||
<option value="chevron">折角</option>
|
||||
<option value="bubble">气泡</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -384,6 +498,31 @@ function onLocalImage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频来源(仅视频元素) -->
|
||||
<div v-if="selected.type === 'video'" class="prop-row">
|
||||
<label>视频来源</label>
|
||||
<div class="seg">
|
||||
<button @click="onLocalVideo" title="从本地选择视频文件">📁 本地视频</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="video-url"
|
||||
placeholder="或粘贴视频 URL (https://…)"
|
||||
style="margin-top:6px;width:100%;box-sizing:border-box"
|
||||
:value="selected.content"
|
||||
@change="onVideoUrl($event)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 视频播放选项 -->
|
||||
<div v-if="selected.type === 'video'" class="prop-row">
|
||||
<label>播放选项</label>
|
||||
<div class="seg">
|
||||
<button :class="{ active: !!selected.style.autoplay }" @click="toggleVideoOpt('autoplay')" title="自动播放">自动</button>
|
||||
<button :class="{ active: !!selected.style.loop }" @click="toggleVideoOpt('loop')" title="循环">循环</button>
|
||||
<button :class="{ active: !!selected.style.muted }" @click="toggleVideoOpt('muted')" title="静音">静音</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 层级 -->
|
||||
<div class="prop-row">
|
||||
<label>层级</label>
|
||||
@@ -393,6 +532,108 @@ function onLocalImage() {
|
||||
<button class="danger" @click="onDel" title="删除">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批注(仅图片元素) -->
|
||||
<div v-if="selected.type === 'image'" class="prop-row">
|
||||
<label>批注</label>
|
||||
<div class="anno-head">
|
||||
<button @click="addAnno" title="添加一条批注">+ 添加批注</button>
|
||||
</div>
|
||||
<div class="anno-list">
|
||||
<div v-for="(anno, i) in annotations" :key="anno.id" class="anno-card" :class="{ open: expandedAnno === anno.id }">
|
||||
<div class="anno-card-head" @click="toggleAnno(anno.id)">
|
||||
<span class="anno-caret">{{ expandedAnno === anno.id ? '▾' : '▸' }}</span>
|
||||
<span class="anno-card-title">批注 {{ i + 1 }}</span>
|
||||
<span v-if="expandedAnno !== anno.id" class="anno-card-summary">{{ annoSummary(anno) }}</span>
|
||||
<button class="anno-del" @click.stop="delAnno(anno.id)" title="删除批注">×</button>
|
||||
</div>
|
||||
|
||||
<div v-show="expandedAnno === anno.id" class="anno-body">
|
||||
<textarea
|
||||
class="anno-text"
|
||||
rows="2"
|
||||
placeholder="批注文字"
|
||||
:value="anno.text"
|
||||
@input="patchAnno(anno.id, { text: ($event.target as HTMLTextAreaElement).value })"
|
||||
></textarea>
|
||||
|
||||
<!-- 气泡文字样式 -->
|
||||
<div class="anno-sub">气泡文字</div>
|
||||
<div class="anno-line">
|
||||
<label class="anno-lbl">字号 {{ anno.fontSize || 16 }}px</label>
|
||||
<input
|
||||
type="range" min="12" max="48"
|
||||
:value="anno.fontSize || 16"
|
||||
@input="patchAnno(anno.id, { fontSize: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</div>
|
||||
<div class="anno-line">
|
||||
<select
|
||||
:value="anno.color || 'text'"
|
||||
@change="patchAnno(anno.id, { color: ($event.target as HTMLSelectElement).value })"
|
||||
>
|
||||
<option v-for="c in ANNO_COLORS" :key="c.k" :value="c.k">{{ c.label }}</option>
|
||||
</select>
|
||||
<div class="seg">
|
||||
<button :class="{ active: !!anno.bold }" @click="patchAnno(anno.id, { bold: !anno.bold })" title="加粗"><b>B</b></button>
|
||||
<button :class="{ active: !!anno.italic }" @click="patchAnno(anno.id, { italic: !anno.italic })" title="斜体"><i>I</i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="anno-line">
|
||||
<div class="seg">
|
||||
<button :class="{ active: anno.align === 'left' }" @click="patchAnno(anno.id, { align: 'left' })">⬅</button>
|
||||
<button :class="{ active: anno.align === 'center' || !anno.align }" @click="patchAnno(anno.id, { align: 'center' })">⬌</button>
|
||||
<button :class="{ active: anno.align === 'right' }" @click="patchAnno(anno.id, { align: 'right' })">➡</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 连线样式 -->
|
||||
<div class="anno-sub">连线</div>
|
||||
<div class="anno-line">
|
||||
<select
|
||||
:value="anno.line?.style || 'solid'"
|
||||
@change="patchAnno(anno.id, { line: { style: ($event.target as HTMLSelectElement).value } })"
|
||||
>
|
||||
<option value="solid">实线</option>
|
||||
<option value="dashed">虚线</option>
|
||||
</select>
|
||||
<select
|
||||
:value="anno.line?.color || 'muted'"
|
||||
@change="patchAnno(anno.id, { line: { color: ($event.target as HTMLSelectElement).value } })"
|
||||
>
|
||||
<option v-for="c in ANNO_COLORS" :key="c.k" :value="c.k">{{ c.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="anno-line">
|
||||
<label class="anno-lbl">线宽 {{ anno.line?.width ?? 0.5 }}px</label>
|
||||
<input
|
||||
type="range" min="0.5" max="8" step="0.5"
|
||||
:value="anno.line?.width ?? 0.5"
|
||||
@input="patchAnno(anno.id, { line: { width: Number(($event.target as HTMLInputElement).value) } })"
|
||||
/>
|
||||
</div>
|
||||
<div class="anno-line">
|
||||
<select
|
||||
:value="anno.line?.startCap || 'none'"
|
||||
@change="patchAnno(anno.id, { line: { startCap: ($event.target as HTMLSelectElement).value } })"
|
||||
>
|
||||
<option value="none">起点·无</option>
|
||||
<option value="arrow">起点·箭头</option>
|
||||
<option value="dot">起点·圆点</option>
|
||||
</select>
|
||||
<select
|
||||
:value="anno.line?.endCap || 'arrow'"
|
||||
@change="patchAnno(anno.id, { line: { endCap: ($event.target as HTMLSelectElement).value } })"
|
||||
>
|
||||
<option value="none">终点·无</option>
|
||||
<option value="arrow">终点·箭头</option>
|
||||
<option value="dot">终点·圆点</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 当前页背景 -->
|
||||
@@ -409,3 +650,126 @@ function onLocalImage() {
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.anno-head {
|
||||
display: flex;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.anno-head button {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border: 1px solid var(--ui-border, #e2e8f0);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: #fff;
|
||||
color: var(--ui-text, #1e293b);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.anno-head button:hover {
|
||||
background: var(--ui-primary-soft, #eef2ff);
|
||||
border-color: var(--ui-primary, #4f46e5);
|
||||
color: var(--ui-primary, #4f46e5);
|
||||
}
|
||||
.anno-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.anno-card {
|
||||
border: 0.5px solid var(--ui-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--ui-panel, #f8fafc);
|
||||
}
|
||||
.anno-card.open {
|
||||
border-color: var(--ui-primary, #4f46e5);
|
||||
}
|
||||
.anno-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.anno-card-head:hover {
|
||||
background: var(--ui-primary-soft, #eef2ff);
|
||||
}
|
||||
.anno-caret {
|
||||
font-size: 10px;
|
||||
color: var(--ui-muted, #64748b);
|
||||
width: 12px;
|
||||
flex: none;
|
||||
}
|
||||
.anno-card-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ui-text, #1e293b);
|
||||
flex: none;
|
||||
}
|
||||
.anno-card-summary {
|
||||
font-size: 12px;
|
||||
color: var(--ui-muted, #64748b);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.anno-del {
|
||||
margin-left: auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--ui-border, #e2e8f0);
|
||||
background: #fff;
|
||||
color: var(--ui-muted, #64748b);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
.anno-del:hover {
|
||||
background: #fff1f2;
|
||||
border-color: var(--ui-danger, #ef4444);
|
||||
color: var(--ui-danger, #ef4444);
|
||||
}
|
||||
.anno-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
.anno-text {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
.anno-sub {
|
||||
font-size: 11px;
|
||||
color: var(--ui-muted, #64748b);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.anno-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.anno-line > select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.anno-lbl {
|
||||
font-size: 12px;
|
||||
color: var(--ui-muted, #64748b);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.anno-line > input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
scanFiles, readEntries,
|
||||
aiAnalyzeDocument,
|
||||
fileToImageElement, imagesToSlideElements,
|
||||
fileToVideoElement,
|
||||
ACCEPT_ATTR,
|
||||
type FileEntry, type ImportReport,
|
||||
describeReport
|
||||
@@ -31,18 +32,21 @@ const analyzeDone = ref(false) // AI 分析完成
|
||||
const analyzeProgress = ref('') // AI 分析进度文案
|
||||
const analyzeError = ref('') // AI 分析错误
|
||||
const showImages = ref(true)
|
||||
const showVideos = ref(true)
|
||||
const showDocs = ref(true)
|
||||
|
||||
/* ---------- 过滤后的文件 ---------- */
|
||||
const filteredEntries = computed(() => {
|
||||
return entries.value.filter(e => {
|
||||
if (e.kind === 'image' && !showImages.value) return false
|
||||
if (e.kind === 'video' && !showVideos.value) return false
|
||||
if (e.kind === 'document' && !showDocs.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const imageEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'image'))
|
||||
const videoEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'video'))
|
||||
const docEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'document'))
|
||||
const unsupportedCount = computed(() => entries.value.filter(e => e.kind === 'unsupported').length)
|
||||
|
||||
@@ -115,7 +119,7 @@ async function handleFiles(fl: FileList | null) {
|
||||
const scanned = await scanFiles(fl)
|
||||
entries.value = scanned
|
||||
// 读取文件原始内容
|
||||
if (scanned.some(e => e.kind === 'image' || e.kind === 'document')) {
|
||||
if (scanned.some(e => e.kind === 'image' || e.kind === 'video' || e.kind === 'document')) {
|
||||
await readEntries(scanned)
|
||||
}
|
||||
loaded.value = true
|
||||
@@ -165,9 +169,10 @@ async function runAiAnalysis() {
|
||||
/* ---------- 执行导入 ---------- */
|
||||
function doImport() {
|
||||
const images = imageEntries.value.filter(e => e.data)
|
||||
const videos = videoEntries.value.filter(e => e.data)
|
||||
const docs = docEntries.value.filter(e => e.slides && e.slides.length > 0)
|
||||
|
||||
if (images.length === 0 && docs.length === 0) {
|
||||
if (images.length === 0 && videos.length === 0 && docs.length === 0) {
|
||||
// 如果有文档但还没 AI 分析,先分析
|
||||
if (docEntries.value.filter(d => d.data && !d.slides).length > 0) {
|
||||
void runAiAnalysis() // fire-and-forget:分析完成后 UI 自动更新按钮
|
||||
@@ -209,6 +214,21 @@ function doImport() {
|
||||
}
|
||||
}
|
||||
|
||||
// 1.5 视频 → 当前幻灯片(每个视频一页,元素铺满合理区域)
|
||||
if (videos.length > 0) {
|
||||
for (const v of videos) {
|
||||
const el = fileToVideoElement(v.file, v.data!)
|
||||
if (videos.length === 1) {
|
||||
store.addElement('video', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }, batch)
|
||||
insertedImages++
|
||||
} else {
|
||||
// 多视频:一视频一页
|
||||
store.appendSlide({ id: `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, background: 'bg', elements: [el] }, batch)
|
||||
insertedSlides++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AI 生成的幻灯片 → 追加到 deck
|
||||
if (docs.length > 0) {
|
||||
for (const d of docs) {
|
||||
@@ -313,6 +333,7 @@ function textPreview(data: string, maxLen = 80): string {
|
||||
<!-- 过滤 -->
|
||||
<div class="filter-bar">
|
||||
<label class="chk"><input type="checkbox" v-model="showImages" /> 🖼 图片 ({{ imageEntries.length }})</label>
|
||||
<label class="chk"><input type="checkbox" v-model="showVideos" /> 🎬 视频 ({{ videoEntries.length }})</label>
|
||||
<label class="chk"><input type="checkbox" v-model="showDocs" /> 📄 文档 ({{ docEntries.length }})</label>
|
||||
</div>
|
||||
|
||||
@@ -332,12 +353,14 @@ function textPreview(data: string, maxLen = 80): string {
|
||||
<!-- 文件列表 -->
|
||||
<div class="file-list">
|
||||
<div v-for="(entry, i) in filteredEntries" :key="i" class="file-item" :class="entry.kind">
|
||||
<span class="file-icon">{{ entry.kind === 'image' ? '🖼' : '📄' }}</span>
|
||||
<span class="file-icon">{{ entry.kind === 'image' ? '🖼' : entry.kind === 'video' ? '🎬' : '📄' }}</span>
|
||||
<span class="file-name" :title="entry.name">{{ entry.name }}</span>
|
||||
<span class="file-size">{{ fmtSize(entry.file.size) }}</span>
|
||||
<span class="file-status">
|
||||
<!-- 图片 -->
|
||||
<template v-if="entry.kind === 'image' && entry.data">✅ 图片就绪</template>
|
||||
<!-- 视频 -->
|
||||
<template v-else-if="entry.kind === 'video' && entry.data">✅ 视频就绪</template>
|
||||
<!-- 文档:AI 分析结果 -->
|
||||
<template v-else-if="entry.kind === 'document' && entry.slides">✅ AI → {{ entry.slides.length }} 页</template>
|
||||
<template v-else-if="entry.kind === 'document' && entry.data && analyzeDone">⏳ 待分析</template>
|
||||
|
||||
+12
-7
@@ -13,7 +13,7 @@ import { normSegments } from './richtext'
|
||||
import { isTauri, aiProxy, aiProxyStream } from './bridge'
|
||||
|
||||
const SEP = '%%PPT_JSON%%' // 对话模式中,自然语言回复与结构化操作的分隔标记
|
||||
const VALID_TYPES = ['title', 'text', 'list', 'stat', 'quote', 'image', 'shape', 'chart', 'card', 'table', 'code', 'formula'] as const
|
||||
const VALID_TYPES = ['title', 'text', 'list', 'stat', 'quote', 'image', 'video', 'shape', 'chart', 'card', 'table', 'code', 'formula'] as const
|
||||
|
||||
/* 智谱/通用错误码 → 可操作提示 */
|
||||
const ERROR_HINTS: Record<string, string> = {
|
||||
@@ -40,12 +40,12 @@ const SYS_BASE =
|
||||
'幻灯片模型:\n' +
|
||||
'{ "slides": [ { "background": "bg|panel|primary|accent|g-primary|g-deep|g-soft", "elements": [ 元素, ... ] } ] }\n' +
|
||||
'背景:bg/panel=浅底;primary/accent=纯色深底;g-primary=主→强调渐变(深);g-deep=深色渐变;g-soft=浅色渐变。\n\n' +
|
||||
'元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式\n' +
|
||||
'元素类型 type:title 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | video 视频 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式\n' +
|
||||
'元素:{ "type":..., "x":数字,"y":数字,"w":数字,"h":数字 (0-100), "content":字符串, "style":{...} }\n' +
|
||||
' - title/text/list/quote:content 为文字,list 用 \\n 分多行\n' +
|
||||
' - stat:content 为大数字(如 "65%"),style.label 为说明\n' +
|
||||
' - card:content 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
|
||||
' - shape:style.shapeType=rect|circle|triangle,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
|
||||
' - shape:style.shapeType=rect|circle|ellipse|triangle|diamond|pentagon|hexagon|star|arrow|chevron|bubble,style.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
|
||||
' - chart:content 为 JSON,两种格式:\n' +
|
||||
' 单系列:[{"label":"","value":数字}, ...]\n' +
|
||||
' 多系列:{"series":["Q1","Q2"], "items":[{"label":"华东","values":[120,150]}, ...]}\n' +
|
||||
@@ -56,7 +56,8 @@ const SYS_BASE =
|
||||
' - table:content 为 Markdown 管道表格字符串(首行表头,用 | 分列,换行分行),style.header=true 首行加粗\n' +
|
||||
' - code:content 为代码文本(保留符原样,不要转义),style.lang=语言如 js/python(可选)\n' +
|
||||
' - formula:content 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' +
|
||||
' - image:content 留空\n\n' +
|
||||
' - image:content 留空\n' +
|
||||
' - video:content 填视频 URL\n\n' +
|
||||
'可选 segments 字段(结构化富文本,同一行内不同片段可有不同样式):\n' +
|
||||
' segments: [[ {"text":"华东 "}, {"text":"增长 23%","bold":true,"color":"accent"} ], ...]\n' +
|
||||
' 每个 segment 支持:bold/italic/underline/strike/color(主题键或#hex)/highlight(黄底)/code/sup/sub/fontSize/link\n' +
|
||||
@@ -106,11 +107,12 @@ function deckContext(currentIdx: number, selectedEl?: SlideElement | null): stri
|
||||
const deck: Deck = store.getDeck()
|
||||
/** 图片/超长 content 脱敏:base64 会撑爆上下文,替换为占位说明 */
|
||||
const sanitizeEl = (el: SlideElement): SlideElement => {
|
||||
if (el.type === 'image') {
|
||||
if (el.type === 'image' || el.type === 'video') {
|
||||
const src = el.content || ''
|
||||
const label = el.type === 'video' ? '视频' : '图片'
|
||||
const desc = src.startsWith('data:')
|
||||
? `[图片 base64 ${Math.round(src.length / 1024)}KB]`
|
||||
: (src ? `[图片URL: ${src.slice(0, 80)}${src.length > 80 ? '…' : ''}]` : '[空图片]')
|
||||
? `[${label} base64 ${Math.round(src.length / 1024)}KB]`
|
||||
: (src ? `[${label}URL: ${src.slice(0, 80)}${src.length > 80 ? '…' : ''}]` : `[空${label}]`)
|
||||
return { ...el, content: desc }
|
||||
}
|
||||
if (el.content && el.content.length > 2000) {
|
||||
@@ -415,6 +417,9 @@ function normStyle(st: any): ElementStyle {
|
||||
if (out.grid != null) out.grid = !!out.grid
|
||||
if (out.header != null) out.header = !!out.header
|
||||
if (out.inline != null) out.inline = !!out.inline
|
||||
if (out.autoplay != null) out.autoplay = !!out.autoplay
|
||||
if (out.loop != null) out.loop = !!out.loop
|
||||
if (out.muted != null) out.muted = !!out.muted
|
||||
if (typeof out.lang === 'string' && out.lang.length > 16) out.lang = out.lang.slice(0, 16)
|
||||
;['color', 'fill', 'accent', 'labelColor'].forEach(k => {
|
||||
if (validColor(out[k]) === undefined && out[k] != null) delete out[k]
|
||||
|
||||
+24
-4
@@ -17,15 +17,18 @@ export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.s
|
||||
/** 允许的文档扩展名(由 LLM 解析) */
|
||||
export const DOC_EXTS = new Set(['.md', '.markdown', '.txt', '.text', '.pdf', '.docx', '.doc'])
|
||||
|
||||
/** 允许的视频扩展名(实际判定以 MIME video/* 为准,此处用于 accept 与无 MIME 环境兜底) */
|
||||
export const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv'])
|
||||
|
||||
/** 文件选择器 accept 属性(由支持清单派生,保持单一数据源) */
|
||||
export const ACCEPT_ATTR = [...IMAGE_EXTS, ...DOC_EXTS].join(',')
|
||||
export const ACCEPT_ATTR = [...IMAGE_EXTS, ...VIDEO_EXTS, ...DOC_EXTS].join(',')
|
||||
|
||||
/** 文件分类结果 */
|
||||
export interface FileEntry {
|
||||
file: File
|
||||
name: string
|
||||
ext: string
|
||||
kind: 'image' | 'document' | 'unsupported'
|
||||
kind: 'image' | 'video' | 'document' | 'unsupported'
|
||||
/** 图片的 data URL,或文档的原始文本 */
|
||||
data?: string
|
||||
/** AI 解析出的幻灯片(文档读取 + LLM 分析后填充) */
|
||||
@@ -46,6 +49,8 @@ export function getFileKind(file: File): FileEntry['kind'] {
|
||||
const dot = name.lastIndexOf('.')
|
||||
const ext = dot >= 0 ? name.slice(dot) : ''
|
||||
if (IMAGE_EXTS.has(ext)) return 'image'
|
||||
// 视频按 MIME 判定(扩展名太杂),无 MIME 时扩展名兜底
|
||||
if (file.type.startsWith('video/') || VIDEO_EXTS.has(ext)) return 'video'
|
||||
if (DOC_EXTS.has(ext)) return 'document'
|
||||
return 'unsupported'
|
||||
}
|
||||
@@ -163,8 +168,7 @@ export function fileToImageElement(file: File, dataUrl: string): SlideElement {
|
||||
export function imagesToSlideElements(files: Array<{ file: File; dataUrl: string }>): SlideElement[] {
|
||||
if (files.length === 1) {
|
||||
return [fileToImageElement(files[0].file, files[0].dataUrl)]
|
||||
}
|
||||
const cols = Math.ceil(Math.sqrt(files.length))
|
||||
} const cols = Math.ceil(Math.sqrt(files.length))
|
||||
const rows = Math.ceil(files.length / cols)
|
||||
const cellW = Math.floor(80 / cols)
|
||||
const cellH = Math.floor(60 / rows)
|
||||
@@ -180,6 +184,19 @@ export function imagesToSlideElements(files: Array<{ file: File; dataUrl: string
|
||||
})
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 视频导入 → SlideElement(video 类型)
|
||||
* 注意:dataURL 会把整个视频读入内存,大视频占内存较高(本地工具首版接受)
|
||||
* ============================================================ */
|
||||
|
||||
export function fileToVideoElement(file: File, dataUrl: string): SlideElement {
|
||||
return createElement('video', {
|
||||
content: dataUrl,
|
||||
x: 20, y: 15, w: 60, h: 45,
|
||||
style: { anim: 'fade' }
|
||||
})
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 批量文件扫描与读取
|
||||
* ============================================================ */
|
||||
@@ -204,6 +221,9 @@ export async function readEntries(entries: FileEntry[]): Promise<void> {
|
||||
try {
|
||||
if (entry.kind === 'image') {
|
||||
entry.data = await readAsDataURL(entry.file)
|
||||
} else if (entry.kind === 'video') {
|
||||
// 视频 → data URL(与图片同管线,播放元素直接消费)
|
||||
entry.data = await readAsDataURL(entry.file)
|
||||
} else if (entry.kind === 'document') {
|
||||
if (entry.ext === '.pdf') {
|
||||
entry.data = await extractPdfText(entry.file)
|
||||
|
||||
@@ -20,6 +20,7 @@ export type OpType =
|
||||
| 'move_slide'
|
||||
| 'replace_slide'
|
||||
| 'set_slide_bg'
|
||||
| 'set_slide_note'
|
||||
| 'add_element'
|
||||
| 'update_element'
|
||||
| 'del_element'
|
||||
@@ -106,6 +107,15 @@ export function applyOp(inputDeck: Deck, op: Op): Deck {
|
||||
break
|
||||
}
|
||||
|
||||
case 'set_slide_note': {
|
||||
const index = op.index as number
|
||||
const note = op.note as string
|
||||
if (deck.slides[index]) {
|
||||
deck.slides[index].note = note
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'add_element': {
|
||||
const slideIdx = op.slideIdx as number
|
||||
const element = op.element as SlideElement
|
||||
@@ -221,6 +231,12 @@ export function invertOp(inputDeck: Deck, op: Op): Op {
|
||||
return { type: 'set_slide_bg', index, bg: oldBg }
|
||||
}
|
||||
|
||||
case 'set_slide_note': {
|
||||
const index = op.index as number
|
||||
const oldNote = deck.slides[index]?.note ?? ''
|
||||
return { type: 'set_slide_note', index, note: oldNote }
|
||||
}
|
||||
|
||||
case 'add_element': {
|
||||
// 逆:删除加的元素
|
||||
return { type: 'del_element', slideIdx: op.slideIdx, elementId: (op.element as SlideElement).id }
|
||||
|
||||
+4
-2
@@ -30,7 +30,8 @@ export const elementTypes: Record<ElementType, { label: string; defaults: Elemen
|
||||
stat: { label: '数据', defaults: { fontSize: 64, color: 'primary', label: '说明文字', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
|
||||
quote: { label: '金句', defaults: { fontSize: 40, color: 'text', italic: true, align: 'center', anim: 'scale' } },
|
||||
image: { label: '图片', defaults: { fit: 'cover', anim: 'fade' } },
|
||||
shape: { label: '形状', defaults: { shapeType: 'rect', fill: 'accent', radius: 12, anim: 'fade' } },
|
||||
video: { label: '视频', defaults: { fit: 'contain', anim: 'fade' } },
|
||||
shape: { label: '形状', defaults: { shapeType: 'rect', fill: 'accent', radius: 12, fontSize: 20, anim: 'fade' } },
|
||||
chart: { label: '图表', defaults: { color: 'primary', max: 100, chartType: 'bar', legend: true, grid: true, anim: 'fade-up' } },
|
||||
card: { label: '卡片', defaults: { fontSize: 26, accent: 'primary', icon: '', anim: 'fade-up' } },
|
||||
table: { label: '表格', defaults: { fontSize: 16, color: 'text', header: true, anim: 'fade-up' } },
|
||||
@@ -48,7 +49,8 @@ export function createElement(type: ElementType, over?: Partial<SlideElement> &
|
||||
stat: { w: 30, h: 35 },
|
||||
quote: { w: 70, h: 25 },
|
||||
image: { w: 40, h: 40 },
|
||||
shape: { w: 25, h: 25 },
|
||||
video: { w: 50, h: 32 },
|
||||
shape: { w: 30, h: 15 },
|
||||
chart: { w: 45, h: 35 },
|
||||
card: { w: 35, h: 35 },
|
||||
table: { w: 60, h: 35 },
|
||||
|
||||
+102
-4
@@ -3,7 +3,7 @@
|
||||
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
|
||||
* ===================================================================== */
|
||||
import { reactive, computed } from 'vue'
|
||||
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey, ChatMessage } from './types'
|
||||
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey, ChatMessage, Annotation } from './types'
|
||||
import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample'
|
||||
import { applyOp, invertOp, type Op, type HistoryEntry } from './op'
|
||||
|
||||
@@ -185,12 +185,15 @@ const state = reactive<{
|
||||
/** 多选集(框选/Shift加选时非空;selectedId 始终为锚点元素) */
|
||||
selectedIds: string[]
|
||||
activeLibId: string | null
|
||||
/** 当前选中的批注 id(纯 UI 态,不持久化;Delete 删除优先级高于宿主元素) */
|
||||
selectedAnnoId: string | null
|
||||
}>({
|
||||
deck: JSON.parse(JSON.stringify(SAMPLE_DECK)),
|
||||
currentIndex: 0,
|
||||
selectedId: null,
|
||||
selectedIds: [],
|
||||
activeLibId: null
|
||||
activeLibId: null,
|
||||
selectedAnnoId: null
|
||||
})
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -430,6 +433,10 @@ function selectElement(id: string | null) { state.selectedId = id || null; state
|
||||
/* ---------- 多选(框选 / Shift 加选 / Ctrl+A) ---------- */
|
||||
const selectedIds = computed(() => state.selectedIds)
|
||||
|
||||
/* ---------- 批注选中(纯 UI 态) ---------- */
|
||||
const selectedAnnoId = computed(() => state.selectedAnnoId)
|
||||
function setSelectedAnnoId(id: string | null) { state.selectedAnnoId = id }
|
||||
|
||||
/** 有效多选集:过滤掉已不存在的元素,非空(length>1)时表示多选态 */
|
||||
function getSelection(): string[] {
|
||||
const s = currentSlide.value
|
||||
@@ -471,6 +478,53 @@ function moveElementsBy(ids: string[], dx: number, dy: number) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 多选对齐:left左对齐 hcenter水平居中 right右对齐 top顶对齐 vmiddle垂直居中 bottom底对齐 */
|
||||
function alignElements(ids: string[], edge: 'left' | 'hcenter' | 'right' | 'top' | 'vmiddle' | 'bottom') {
|
||||
const els = ids.map(findElement).filter(Boolean) as SlideElement[]
|
||||
if (els.length < 2) return
|
||||
const group = beginBatch()
|
||||
const minX = Math.min(...els.map(e => e.x))
|
||||
const maxX = Math.max(...els.map(e => e.x + e.w))
|
||||
const minY = Math.min(...els.map(e => e.y))
|
||||
const maxY = Math.max(...els.map(e => e.y + e.h))
|
||||
for (const el of els) {
|
||||
let x = el.x, y = el.y
|
||||
if (edge === 'left') x = minX
|
||||
if (edge === 'right') x = maxX - el.w
|
||||
if (edge === 'hcenter') x = (minX + maxX) / 2 - el.w / 2
|
||||
if (edge === 'top') y = minY
|
||||
if (edge === 'bottom') y = maxY - el.h
|
||||
if (edge === 'vmiddle') y = (minY + maxY) / 2 - el.h / 2
|
||||
if (x !== el.x || y !== el.y) {
|
||||
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: el.id, patch: { x, y }, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 多选分布:h 水平等间距分布(x 方向)v 垂直等间距分布(y 方向);首尾不动,中间均匀排布 */
|
||||
function distributeElements(ids: string[], axis: 'h' | 'v') {
|
||||
const els = ids.map(findElement).filter(Boolean) as SlideElement[]
|
||||
if (els.length < 3) return
|
||||
const group = beginBatch()
|
||||
const sorted = axis === 'h' ? [...els].sort((a, b) => a.x - b.x) : [...els].sort((a, b) => a.y - b.y)
|
||||
const first = sorted[0], last = sorted[sorted.length - 1]
|
||||
let gapTotal: number
|
||||
if (axis === 'h') {
|
||||
// last.x - first.x - first.w 为首尾间总空隙(含中间元素占的宽),再减中间元素总宽 = 纯空隙
|
||||
gapTotal = (last.x - first.x - first.w) - sorted.slice(1, -1).reduce((s, e) => s + e.w, 0)
|
||||
} else {
|
||||
gapTotal = (last.y - first.y - first.h) - sorted.slice(1, -1).reduce((s, e) => s + e.h, 0)
|
||||
}
|
||||
const gap = gapTotal / (sorted.length - 1)
|
||||
let cur = axis === 'h' ? first.x + first.w + gap : first.y + first.h + gap
|
||||
for (let i = 1; i < sorted.length - 1; i++) {
|
||||
const el = sorted[i]
|
||||
const patch = axis === 'h' ? { x: cur, y: el.y } : { x: el.x, y: cur }
|
||||
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: el.id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
|
||||
cur += (axis === 'h' ? el.w : el.h) + gap
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量删除(同批 undo) */
|
||||
function delElements(ids: string[]) {
|
||||
const group = beginBatch()
|
||||
@@ -527,6 +581,13 @@ function setSlideBackground(bg: string) {
|
||||
execOp({ type: 'set_slide_bg', index: state.currentIndex, bg: bg as Slide['background'], clientId: CLIENT_ID, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
function setSlideNote(note: string) {
|
||||
execOp(
|
||||
{ type: 'set_slide_note', index: state.currentIndex, note, clientId: CLIENT_ID, timestamp: Date.now() },
|
||||
{ coalesceKey: 'slide-note:' + state.currentIndex }
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- 元素 CRUD ---------- */
|
||||
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1], group?: string) {
|
||||
const el = createElement(type, over)
|
||||
@@ -540,6 +601,40 @@ function updateElement(id: string, patch: Partial<SlideElement> & { style?: Reco
|
||||
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { coalesceKey })
|
||||
}
|
||||
|
||||
/* ---------- 批注(annotation):挂在 element.style.annotations,走 updateElement ---------- */
|
||||
function addAnnotation(elId: string) {
|
||||
const el = findElement(elId)
|
||||
if (!el) return
|
||||
const list: Annotation[] = [...(el.style.annotations || [])]
|
||||
const anno: Annotation = {
|
||||
id: uid('an'),
|
||||
text: '',
|
||||
bx: Math.min(90, (el.x || 0) + (el.w || 20) + 4), by: Math.max(2, el.y || 10),
|
||||
bw: 22, bh: 12,
|
||||
ax: 50, ay: 50,
|
||||
}
|
||||
list.push(anno)
|
||||
updateElement(elId, { style: { annotations: list } })
|
||||
return anno.id
|
||||
}
|
||||
function updateAnnotation(elId: string, annoId: string, patch: Partial<Annotation>) {
|
||||
const el = findElement(elId)
|
||||
if (!el) return
|
||||
const list = (el.style.annotations || []).map(a => {
|
||||
if (a.id !== annoId) return a
|
||||
const next: Annotation = { ...a, ...patch }
|
||||
if (patch.line) next.line = { ...(a.line || {}), ...patch.line }
|
||||
return next
|
||||
})
|
||||
updateElement(elId, { style: { annotations: list } })
|
||||
}
|
||||
function delAnnotation(elId: string, annoId: string) {
|
||||
const el = findElement(elId)
|
||||
if (!el) return
|
||||
const list = (el.style.annotations || []).filter(a => a.id !== annoId)
|
||||
updateElement(elId, { style: { annotations: list } })
|
||||
}
|
||||
|
||||
function delElement(id: string) {
|
||||
const el = findElement(id)
|
||||
const deletedElement = el ? clone(el) : undefined
|
||||
@@ -988,14 +1083,17 @@ export const store = {
|
||||
// 主题
|
||||
setTheme,
|
||||
// 幻灯片 CRUD
|
||||
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground,
|
||||
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground, setSlideNote,
|
||||
// 元素 CRUD
|
||||
addElement, updateElement, delElement, moveElementZ, findElement,
|
||||
// 批注
|
||||
addAnnotation, updateAnnotation, delAnnotation,
|
||||
// 元素剪贴板
|
||||
copyElement, copyElements, cutElement, cutElements, pasteElement, pasteElements, hasClipboard,
|
||||
// 多选
|
||||
selectedIds, getSelection, selectMany, toggleSelect, selectAllElements,
|
||||
moveElementsBy, delElements,
|
||||
selectedAnnoId, setSelectedAnnoId,
|
||||
moveElementsBy, delElements, alignElements, distributeElements,
|
||||
// undo/redo
|
||||
undo, redo, beginBatch,
|
||||
// 整体替换
|
||||
|
||||
+32
-2
@@ -17,7 +17,7 @@ export type ColorKey = 'primary' | 'accent' | 'text' | 'muted' | (string & {})
|
||||
/** 元素类型 */
|
||||
export type ElementType =
|
||||
| 'title' | 'text' | 'list' | 'stat' | 'quote'
|
||||
| 'image' | 'shape' | 'chart' | 'card'
|
||||
| 'image' | 'video' | 'shape' | 'chart' | 'card'
|
||||
| 'table' | 'code' | 'formula'
|
||||
|
||||
/** 图表子类型 */
|
||||
@@ -31,7 +31,9 @@ export type AnimType =
|
||||
| 'pop' | 'rotate' | 'bounce' | 'flip' | 'blur'
|
||||
|
||||
/** 形状类型 */
|
||||
export type ShapeType = 'rect' | 'circle' | 'triangle'
|
||||
export type ShapeType =
|
||||
| 'rect' | 'circle' | 'ellipse' | 'triangle' | 'diamond'
|
||||
| 'pentagon' | 'hexagon' | 'star' | 'arrow' | 'chevron' | 'bubble'
|
||||
|
||||
/** 主题对象 */
|
||||
export interface Theme {
|
||||
@@ -44,6 +46,26 @@ export interface Theme {
|
||||
muted: string
|
||||
}
|
||||
|
||||
/** 批注(连线 + 气泡) */
|
||||
export interface Annotation {
|
||||
id: string
|
||||
text: string
|
||||
/** 气泡框:百分比坐标,与元素同坐标系(画布 1280×720) */
|
||||
bx: number; by: number; bw: number; bh: number
|
||||
/** 图片侧连线锚点(相对图片框的百分比 0~100),默认 50/50 */
|
||||
ax?: number; ay?: number
|
||||
/** 气泡文字整体样式 */
|
||||
fontSize?: number; color?: ColorKey; bold?: boolean; italic?: boolean; align?: 'left' | 'center' | 'right'
|
||||
/** 连线样式 */
|
||||
line?: {
|
||||
style?: 'solid' | 'dashed'
|
||||
width?: number
|
||||
color?: ColorKey
|
||||
startCap?: 'none' | 'arrow' | 'dot'
|
||||
endCap?: 'none' | 'arrow' | 'dot'
|
||||
}
|
||||
}
|
||||
|
||||
/** 元素样式(所有字段可选,按 type 决定哪些有效) */
|
||||
export interface ElementStyle {
|
||||
fontSize?: number
|
||||
@@ -58,6 +80,11 @@ export interface ElementStyle {
|
||||
labelSize?: number
|
||||
// image
|
||||
fit?: 'contain' | 'cover'
|
||||
// video
|
||||
poster?: string
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
muted?: boolean
|
||||
// shape
|
||||
shapeType?: ShapeType
|
||||
fill?: ColorKey
|
||||
@@ -85,6 +112,8 @@ export interface ElementStyle {
|
||||
// card
|
||||
icon?: string
|
||||
accent?: ColorKey
|
||||
// annotation(批注气泡,image 起步,未来任意元素)
|
||||
annotations?: Annotation[]
|
||||
}
|
||||
|
||||
/** 幻灯片元素 */
|
||||
@@ -106,6 +135,7 @@ export interface Slide {
|
||||
id: string
|
||||
background: BgKey
|
||||
elements: SlideElement[]
|
||||
note?: string // 演讲者备注/页脚批注(不渲染到幻灯片)
|
||||
}
|
||||
|
||||
/** 一份演示文稿 */
|
||||
|
||||
+107
-2
@@ -72,7 +72,7 @@
|
||||
}
|
||||
.canvas-frame {
|
||||
position: relative;
|
||||
width: min(100%, calc((100vh - 54px - 56px) * 16 / 9));
|
||||
width: min(100%, calc((100vh - 54px - 116px) * 16 / 9));
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
box-shadow: var(--shadow-md); border-radius: 4px; overflow: hidden;
|
||||
@@ -83,7 +83,78 @@
|
||||
width: 1280px; height: 720px;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.canvas-status { margin-top: 10px; font-size: 12px; color: var(--ui-muted); }
|
||||
.canvas-footer {
|
||||
margin-top: 10px;
|
||||
flex: none;
|
||||
width: min(100%, calc((100vh - 54px - 116px) * 16 / 9));
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.canvas-status { font-size: 12px; color: var(--ui-muted); flex: none; }
|
||||
.note-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 13px;
|
||||
background: transparent;
|
||||
color: var(--ui-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s, border-color .15s;
|
||||
}
|
||||
.note-toggle:hover {
|
||||
background: var(--ui-primary-soft);
|
||||
color: var(--ui-primary);
|
||||
}
|
||||
.note-toggle.active {
|
||||
color: var(--ui-primary);
|
||||
border-color: var(--ui-border);
|
||||
background: var(--ui-panel);
|
||||
}
|
||||
.note-toggle-icon { font-size: 13px; line-height: 1; }
|
||||
.note-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--ui-primary);
|
||||
}
|
||||
.canvas-note {
|
||||
margin-top: 8px;
|
||||
flex: none;
|
||||
/* 与 .canvas-frame 同宽,保持对齐 */
|
||||
width: min(100%, calc((100vh - 54px - 116px) * 16 / 9));
|
||||
max-width: 100%;
|
||||
}
|
||||
.canvas-note-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-panel);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.canvas-note-input:focus {
|
||||
border-color: var(--ui-primary);
|
||||
box-shadow: 0 0 0 3px var(--ui-primary-soft);
|
||||
}
|
||||
.canvas-note-input::placeholder {
|
||||
color: var(--ui-muted);
|
||||
}
|
||||
|
||||
/* ---------- 画布元素 ---------- */
|
||||
.el { position: absolute; overflow: hidden; }
|
||||
@@ -107,7 +178,41 @@
|
||||
.el-stat .num { font-weight: 800; line-height: 1; }
|
||||
.el-stat .label { margin-top: .35em; text-align: center; }
|
||||
.el-shape { width: 100%; height: 100%; }
|
||||
/* 形状文字层:覆盖在图形上方、居中;颜色默认白(形状为填充色底),字号/粗斜继承 style。
|
||||
padding 稍大:clip-path 多边形(箭头/星形等)边缘收窄,文字留出安全区 */
|
||||
.el-shape-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8% 12%;
|
||||
box-sizing: border-box;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
/* rect/椭圆这类满幅形状,文字安全区可以小 */
|
||||
.el[data-type="shape"][data-shape="rect"] > .el-shape-text,
|
||||
.el[data-type="shape"][data-shape="circle"] > .el-shape-text,
|
||||
.el[data-type="shape"][data-shape="ellipse"] > .el-shape-text {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
/* 空形状不显示文字占位:装饰形状(分隔条/小三角)保持纯净,
|
||||
加文字的引导由属性面板「内容」输入框承担 */
|
||||
.el-image { width: 100%; height: 100%; object-fit: cover; }
|
||||
.el-video { width: 100%; height: 100%; background: #000; border-radius: 8px; display: block; }
|
||||
/* 视频缩略图降级:不加载视频,poster 静态图 / 黑底▶占位 */
|
||||
.el-video-thumb {
|
||||
width: 100%; height: 100%;
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.el-video-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.el-video-thumb-play { color: rgba(255,255,255,.75); font-size: 28px; }
|
||||
/* 空图片元素占位(未填 content 时不渲染裂图) */
|
||||
.el-image-empty {
|
||||
width: 100%; height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user