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

794 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- =====================================================================
PropsPanel.vue 右侧属性面板元素样式 + 页面背景
===================================================================== -->
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { store } from '../../core/store'
import { elementTypes } from '../../core/sample'
import { generateImage, isImageConfigured, checkImageQuota } from '../../core/ai'
import { readAsDataURL } from '../../core/importer'
import { putAsset, isOssEnabled } from '../../core/assets'
import { appAlert, appConfirm, appPrompt } from '../../core/dialog'
import { markdownToSegments, segmentsToPlain, hasFormatting } from '../../core/richtext'
import AddGrid from './AddGrid.vue'
import Icon from '../common/Icon.vue'
import type { ElementType, ChartType, ShapeType } from '../../core/types'
const CHART_TYPES: Array<{ k: ChartType; label: string; icon: string }> = [
{ k: 'bar', label: '柱状图', icon: 'bar-chart' },
{ k: 'hbar', label: '条形图', icon: 'bar-chart-h' },
{ k: 'line', label: '折线图', icon: 'line-chart' },
{ k: 'area', label: '面积图', icon: 'area-chart' },
{ k: 'pie', label: '饼图', icon: 'pie' },
{ k: 'doughnut', label: '环形图', icon: 'doughnut' },
{ k: 'radar', label: '雷达图', icon: 'radar' },
{ k: 'progress', label: '进度图', icon: 'progress' }
]
const selected = computed(() => store.getSelected())
const slide = computed(() => store.currentSlide.value)
const multiCount = computed(() => store.getSelection().length)
/** 临时输入态(v-model 绑定) */
const content = ref('')
const fontSize = ref(24)
const colorSel = ref('primary')
const colorPicker = ref('#000000')
const shape = ref<ShapeType>('rect')
const chartType = ref<ChartType>('bar')
const codeLang = ref('')
const imgBusy = ref(false)
let imgAbort: AbortController | null = null
const BG_OPTIONS = [
{ k: 'bg', label: '白底' },
{ k: 'panel', label: '浅底' },
{ k: 'primary', label: '主色' },
{ k: 'accent', label: '强调' }
]
/** 选中变化时同步输入控件 */
watch(selected, (el) => {
if (!el) return
const s = el.style || {}
if (el.type !== 'stat') content.value = el.content || ''
fontSize.value = s.fontSize || 24
shape.value = (s.shapeType as any) || 'rect'
chartType.value = (s.chartType as ChartType) || 'bar'
codeLang.value = s.lang || ''
// 颜色下拉/picker 同步
const presets = ['primary', 'accent', 'text', 'muted', '#ffffff']
if (presets.includes(s.color || '')) {
colorSel.value = s.color!
colorPicker.value = '#000000'
} else if (s.color && s.color.charAt(0) === '#') {
colorSel.value = 'custom'
colorPicker.value = /^#[0-9a-f]{6}$/i.test(s.color) ? s.color : '#000000'
} else {
colorSel.value = 'primary'
}
}, { immediate: true })
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(() => {
const el = selected.value
return el ? (elementTypes[el.type]?.label || el.type) : ''
})
function onAdd(type: ElementType) {
store.addElement(type)
}
/* 多选批量操作 */
function onMultiCopy() {
const sel = store.getSelection()
if (sel.length) store.copyElements(sel)
}
async function onMultiDel() {
const sel = store.getSelection()
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() {
if (!selected.value) return
store.updateElement(selected.value.id, { content: content.value })
}
function onFontSizeInput() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { fontSize: Number(fontSize.value) } })
}
function onColorChange() {
if (!selected.value) return
if (colorSel.value === 'custom') return
store.updateElement(selected.value.id, { style: { color: colorSel.value } })
}
function onColorPickerInput() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { color: colorPicker.value } })
}
function onShapeChange() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { shapeType: shape.value } })
}
function onChartTypeChange() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { chartType: chartType.value } })
}
function toggleLegend() {
if (!selected.value) return
const cur = selected.value.style.legend !== false
store.updateElement(selected.value.id, { style: { legend: !cur } })
}
function toggleGrid() {
if (!selected.value) return
const cur = selected.value.style.grid !== false
store.updateElement(selected.value.id, { style: { grid: !cur } })
}
function toggleHeader() {
if (!selected.value) return
const cur = selected.value.style.header !== false
store.updateElement(selected.value.id, { style: { header: !cur } })
}
function onCodeLangInput() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { lang: codeLang.value } })
}
function onAlign(a: 'left' | 'center' | 'right') {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { align: a } })
}
function toggleBold() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { bold: !selected.value.style.bold } })
}
function toggleItalic() {
if (!selected.value) return
store.updateElement(selected.value.id, { style: { italic: !selected.value.style.italic } })
}
function onZ(dir: number) {
if (!selected.value) return
store.moveElementZ(selected.value.id, dir)
}
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)
}
/* ---------- 富文本格式 ---------- */
/** 当前选中元素是否有 segments */
const hasRich = computed(() => {
const el = selected.value
return !!(el?.segments && el.segments.length && hasFormatting(el.segments))
})
/** 从 Markdown 语法生成 segments */
function applyMarkdown() {
const el = selected.value
if (!el) return
const segs = markdownToSegments(content.value)
if (segs.length && hasFormatting(segs)) {
store.updateElement(el.id, { segments: segs })
}
}
/** 清除 segments(降级为纯文本) */
function clearRich() {
const el = selected.value
if (!el) return
store.updateElement(el.id, { segments: undefined })
}
/* ---------- AI 配图 / 本地换图 ---------- */
async function onAiImage() {
if (imgBusy.value) return
const el = selected.value
if (!el || el.type !== 'image') return
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) { appAlert('配图受限', quotaErr); return }
store.updateElement(el.id, { content: r.url })
} catch (e: any) {
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
if (!el || el.type !== 'image') return
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
try {
// OSS 启用:走资产库(上云或离线暂存),content 存引用/URL,绕过 5MB 墙
if (isOssEnabled()) {
const ref = await putAsset(file)
store.updateElement(el.id, { content: ref })
return
}
const dataUrl = await readAsDataURL(file)
const quotaErr = checkImageQuota(dataUrl)
if (quotaErr) { appAlert('图片受限', quotaErr); return }
store.updateElement(el.id, { content: dataUrl })
} catch (e: any) {
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
// OSS 启用:视频走资产库(大文件尤其受益,避免撑爆本地存储)
if (isOssEnabled()) {
try {
const ref = await putAsset(f)
store.updateElement(el.id, { content: ref })
} catch (e: any) {
appAlert('读取视频失败', e?.message || String(e))
}
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>
<div class="panel-pane props-pane" id="panelProps">
<!-- 空态 -->
<section v-if="!selected" class="prop-section" id="propEmpty">
<p class="prop-hint">点击画布元素以编辑样式<br />或在下方添加新元素</p>
<AddGrid @add="onAdd" />
</section>
<!-- 多选态批量操作 -->
<section v-else-if="multiCount > 1" class="prop-section" id="propMulti">
<h4 class="prop-title">多选 · <span>{{ multiCount }} 个元素</span></h4>
<div class="prop-row">
<label>批量操作</label>
<div class="seg">
<button @click="onMultiCopy" title="Ctrl+C"> 复制</button>
<button class="danger" @click="onMultiDel" title="Delete"><Icon name="trash" :size="13" /> 删除</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">
拖动整组移动 · Shift+点击加选/减选<br />空白处拖动框选 · Esc 取消多选
</div>
</div>
</section>
<!-- 选中元素 -->
<section v-else class="prop-section" id="propElement">
<h4 class="prop-title">元素 · <span>{{ typeLabel }}</span></h4>
<!-- 内容 -->
<div v-if="hasText && selected.type !== 'stat'" class="prop-row">
<label>内容</label>
<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">
**加粗** · *斜体* · ==高亮== · ~~删除线~~ · `代码` · ^上标^ · ~下标~
</div>
<div class="rich-actions">
<button v-if="hasRich" class="rich-btn danger" @click="clearRich">清除格式</button>
<button class="rich-btn" @click="applyMarkdown" title="把上面的 Markdown 语法转为富文本">转换格式</button>
</div>
</div>
</div>
<!-- 字号 -->
<div v-if="canFont" class="prop-row">
<label>字号 <span>{{ fontSize }}px</span></label>
<input type="range" min="12" max="120" v-model.number="fontSize" @input="onFontSizeInput" />
</div>
<!-- 颜色 -->
<div v-if="canColor" class="prop-row">
<label>颜色</label>
<div class="color-row">
<select v-model="colorSel" @change="onColorChange">
<option value="primary">主色</option>
<option value="accent">强调色</option>
<option value="text">正文色</option>
<option value="muted">次要色</option>
<option value="#ffffff">白色</option>
<option value="custom">自定义</option>
</select>
<input type="color" v-model="colorPicker" v-show="colorSel === 'custom'" @input="onColorPickerInput" />
</div>
</div>
<!-- 对齐 -->
<div v-if="canAlign" class="prop-row">
<label>对齐</label>
<div class="seg">
<button :class="{ active: selected.style.align === 'left' }" @click="onAlign('left')" title="左对齐"><Icon name="align-left" :size="14" /></button>
<button :class="{ active: selected.style.align === 'center' || !selected.style.align }" @click="onAlign('center')" title="居中"><Icon name="align-center" :size="14" /></button>
<button :class="{ active: selected.style.align === 'right' }" @click="onAlign('right')" title="右对齐"><Icon name="align-right" :size="14" /></button>
</div>
</div>
<!-- 加粗/斜体 -->
<div v-if="canBI" class="prop-row">
<label>样式</label>
<div class="seg">
<button :class="{ active: !!selected.style.bold }" @click="toggleBold" title="加粗"><b>B</b></button>
<button :class="{ active: !!selected.style.italic }" @click="toggleItalic" title="斜体"><i>I</i></button>
</div>
</div>
<!-- 形状 -->
<div v-if="isShape" class="prop-row">
<label>形状</label>
<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>
<!-- 图表配置 -->
<template v-if="selected.type === 'chart'">
<div class="prop-row">
<label>图表类型</label>
<select v-model="chartType" @change="onChartTypeChange">
<option v-for="t in CHART_TYPES" :key="t.k" :value="t.k">{{ t.label }}</option>
</select>
</div>
<div class="prop-row">
<label>显示选项</label>
<div class="seg">
<button :class="{ active: selected.style.legend !== false }" @click="toggleLegend" title="图例">图例</button>
<button :class="{ active: selected.style.grid !== false }" @click="toggleGrid" title="网格线">网格</button>
</div>
</div>
<div class="prop-row">
<label>数据格式</label>
<div style="font-size:11px;color:var(--ui-muted,#64748b);line-height:1.6;padding:.3em 0">
单系列[{"label":"A","value":65}]<br />
多系列{"series":["Q1","Q2"],"items":[{"label":"华东","values":[120,150]}]}
</div>
</div>
</template>
<!-- 表格选项 -->
<div v-if="selected.type === 'table'" class="prop-row">
<label>首行表头</label>
<div class="seg">
<button :class="{ active: selected.style.header !== false }" @click="toggleHeader">表头</button>
</div>
</div>
<!-- 代码语言 -->
<div v-if="selected.type === 'code'" class="prop-row">
<label>语言</label>
<input type="text" v-model="codeLang" @input="onCodeLangInput" placeholder="js / python / ..." />
</div>
<!-- 公式提示 -->
<div v-if="selected.type === 'formula'" class="prop-row">
<label>提示</label>
<div style="font-size:12px;color:var(--ui-muted);line-height:1.6">
LaTeX 语法示例<br />
E = mc^2<br />
\frac{a}{b}<br />
\sum_{i=1}^n x_i
</div>
</div>
<!-- 图片来源仅图片元素 -->
<div v-if="selected.type === 'image'" class="prop-row">
<label>图片来源</label>
<div class="seg">
<button @click="onLocalImage" title="从本地选择图片"><Icon name="folder" :size="13" /> 本地图片</button>
<button :disabled="imgBusy" @click="onAiImage"><template v-if="imgBusy">生成中…</template><template v-else><Icon name="palette" :size="13" /> AI 配图</template></button>
</div>
</div>
<!-- 视频来源仅视频元素 -->
<div v-if="selected.type === 'video'" class="prop-row">
<label>视频来源</label>
<div class="seg">
<button @click="onLocalVideo" title="从本地选择视频文件"><Icon name="folder" :size="13" /> 本地视频</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>
<div class="seg">
<button @click="onZ(1)" title="上移"></button>
<button @click="onZ(-1)" title="下移"></button>
<button class="danger" @click="onDel" title="删除"><Icon name="trash" :size="13" /></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' })" title="左对齐"><Icon name="align-left" :size="14" /></button>
<button :class="{ active: anno.align === 'center' || !anno.align }" @click="patchAnno(anno.id, { align: 'center' })" title="居中"><Icon name="align-center" :size="14" /></button>
<button :class="{ active: anno.align === 'right' }" @click="patchAnno(anno.id, { align: 'right' })" title="右对齐"><Icon name="align-right" :size="14" /></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>
<!-- 当前页背景 -->
<section class="prop-section">
<h4 class="prop-title">当前页背景</h4>
<div class="bg-grid">
<button
v-for="b in BG_OPTIONS"
:key="b.k"
:class="{ active: slide.background === b.k }"
@click="onBg(b.k)"
>{{ b.label }}</button>
</div>
</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, #eeeefc);
border-color: var(--ui-primary, #5b5bd6);
color: var(--ui-primary, #5b5bd6);
}
.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, #5b5bd6);
}
.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, #eeeefc);
}
.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>