新增: 批注/备注/对齐分布/形状扩充/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
+382 -18
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,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>