新增: 批注/备注/对齐分布/形状扩充/video 元素——编辑器能力增强

This commit is contained in:
lxy
2026-08-25 12:30:39 +08:00
parent af5211d46d
commit 5d3046b653
14 changed files with 1984 additions and 55 deletions
+1168
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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: '∑'
}
+100 -10
View File
@@ -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-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))
@@ -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 textsegments 结构化富文本)---------- */
/** 是否有 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>
+381 -17
View File
@@ -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,10 +164,13 @@ function onZ(dir: number) {
if (!selected.value) return
store.moveElementZ(selected.value.id, dir)
}
function onDel() {
async function onDel() {
if (!selected.value) return
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>
+26 -3
View File
@@ -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
View File
@@ -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' +
'元素类型 typetitle 标题 | text 正文 | list 列表 | stat 数据 | quote 金句 | image 图片 | shape 形状 | chart 图表 | card 卡片 | table 表格 | code 代码 | formula 公式\n' +
'元素类型 typetitle 标题 | 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/quotecontent 为文字,list 用 \\n 分多行\n' +
' - statcontent 为大数字(如 "65%")style.label 为说明\n' +
' - cardcontent 第一行=标题、其余行=正文;style.accent=顶部色条键,style.icon=emoji 图标\n' +
' - shapestyle.shapeType=rect|circle|trianglestyle.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
' - shapestyle.shapeType=rect|circle|ellipse|triangle|diamond|pentagon|hexagon|star|arrow|chevron|bubblestyle.fill=颜色键,style.gradient=true 渐变,style.opacity=0~1\n' +
' - chartcontent 为 JSON,两种格式:\n' +
' 单系列:[{"label":"","value":数字}, ...]\n' +
' 多系列:{"series":["Q1","Q2"], "items":[{"label":"华东","values":[120,150]}, ...]}\n' +
@@ -56,7 +56,8 @@ const SYS_BASE =
' - tablecontent 为 Markdown 管道表格字符串(首行表头,用 | 分列,换行分行),style.header=true 首行加粗\n' +
' - codecontent 为代码文本(保留符原样,不要转义),style.lang=语言如 js/python(可选)\n' +
' - formulacontent 为 LaTeX 公式字符串(不含 $$ 分隔符),如 "E = mc^2"、"\\sum_{i=1}^n x_i"\n' +
' - imagecontent 留空\n\n' +
' - imagecontent 留空\n' +
' - videocontent 填视频 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
View File
@@ -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
})
}
/* ============================================================
* 视频导入 → SlideElementvideo 类型)
* 注意: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)
+16
View 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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%;