点击选择目录
@@ -350,6 +395,7 @@ function textPreview(data: string, maxLen = 80): string {
background: var(--panel, #f8fafc);
}
.dropzone:hover { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 5%, var(--panel, #f8fafc)); }
+.dropzone.drag-over { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 10%, var(--panel, #f8fafc)); }
.dropzone-icon { font-size: 44px; }
.dropzone-text { display: flex; flex-direction: column; gap: 4px; }
.dropzone-text strong { font-size: 16px; }
diff --git a/src/composables/useEditor.ts b/src/composables/useEditor.ts
index b618905..03c6c46 100644
--- a/src/composables/useEditor.ts
+++ b/src/composables/useEditor.ts
@@ -1,13 +1,14 @@
/* =====================================================================
- * useEditor.ts — 画布拖拽/缩放/选中逻辑(从 editor.js 提取)
+ * useEditor.ts — 画布拖拽/缩放/选中/框选逻辑
* 用 Vue ref 暴露拖拽会话状态,供 Canvas.vue 响应式绑定
* ===================================================================== */
import { ref, type Ref } from 'vue'
import { store } from '../core/store'
interface DragSession {
- mode: 'move' | 'resize'
+ mode: 'move' | 'resize' | 'multi-move'
id: string
+ ids?: string[] // multi-move 的元素集
ax?: number // resize 水平轴:-1/0/1
ay?: number // resize 垂直轴:-1/0/1
startX: number
@@ -19,21 +20,44 @@ interface DragSession {
rectW: number
rectH: number
result?: { x: number; y: number; w: number; h: number }
+ multiStart?: Array<{ id: string; x: number; y: number; w: number; h: number }> // multi-move 起点快照
+ multiResult?: Array<{ id: string; x: number; y: number }> // multi-move 各元素目标位
+}
+
+/** 框选会话(空白处按下拖动 → marquee) */
+interface MarqueeSession {
+ x0: number; y0: number; x1: number; y1: number // 百分比坐标
}
export function useEditor() {
const drag: Ref
= ref(null)
+ const marquee: Ref = ref(null)
const editing = ref(false) // 是否在 contenteditable 编辑中(暂停拖拽)
- /** 鼠标按下:空白取消选中 / 手柄缩放 / 元素拖拽 */
+ /** 鼠标按下:空白→框选起点 / 手柄缩放 / 元素拖拽(Shift 加选) */
function onCanvasMouseDown(e: MouseEvent, canvasEl: HTMLElement) {
if (editing.value) return
+ if (e.button !== 0) return // 仅左键
const target = (e.target as HTMLElement).closest('.el') as HTMLElement | null
const handle = (e.target as HTMLElement).classList && (e.target as HTMLElement).classList.contains('handle')
? (e.target as HTMLElement) : null
if (!target && !handle) {
+ const sel = store.getSelection()
+ if (!e.shiftKey && sel.length > 1) {
+ store.selectElement(null) // 多选态点空白:先只清多选
+ return
+ }
store.selectElement(null)
+ // 启动框选
+ const rect = canvasEl.getBoundingClientRect()
+ marquee.value = {
+ x0: (e.clientX - rect.left) / rect.width * 100,
+ y0: (e.clientY - rect.top) / rect.height * 100,
+ x1: (e.clientX - rect.left) / rect.width * 100,
+ y1: (e.clientY - rect.top) / rect.height * 100
+ }
+ e.preventDefault()
return
}
if (handle) {
@@ -43,7 +67,19 @@ export function useEditor() {
return
}
const id = target!.dataset.id!
- startDrag(id, e, canvasEl)
+ // Shift+点击:加选/减选,不进入拖拽
+ if (e.shiftKey) {
+ store.toggleSelect(id)
+ e.preventDefault()
+ return
+ }
+ // 点击已选多选集中的元素 → 拖动整组;否则正常单选拖动
+ const sel = store.getSelection()
+ if (sel.length > 1 && sel.includes(id)) {
+ startMultiDrag(sel, e, canvasEl)
+ } else {
+ startDrag(id, e, canvasEl)
+ }
e.preventDefault()
}
@@ -58,6 +94,23 @@ export function useEditor() {
}
}
+ /** 多选整组拖动:记录各元素起始位置(multiResult 始终 = 起点 + 累计位移,不从上次结果累加) */
+ function startMultiDrag(ids: string[], e: MouseEvent, canvasEl: HTMLElement) {
+ const starts = ids
+ .map(id => store.findElement(id))
+ .filter(Boolean)
+ .map(el => ({ id: el!.id, x: el!.x, y: el!.y, w: el!.w, h: el!.h }))
+ if (!starts.length) return
+ const rect = canvasEl.getBoundingClientRect()
+ drag.value = {
+ mode: 'multi-move', id: ids[ids.length - 1], ids,
+ startX: 0, startY: 0, startW: 0, startH: 0,
+ px0: e.clientX, py0: e.clientY, rectW: rect.width, rectH: rect.height,
+ multiStart: starts.map(s => ({ id: s.id, x: s.x, y: s.y, w: s.w, h: s.h })),
+ multiResult: starts.map(s => ({ id: s.id, x: s.x, y: s.y }))
+ }
+ }
+
const AXES: Record = {
tl: [-1, -1], tm: [0, -1], tr: [1, -1],
lm: [-1, 0], rm: [1, 0],
@@ -76,10 +129,35 @@ export function useEditor() {
}
function onMouseMove(e: MouseEvent) {
+ // 框选拖动:更新 marquee 矩形(Canvas 渲染选框)
+ const m = marquee.value
+ if (m) {
+ const canvas = document.querySelector('.canvas') as HTMLElement | null
+ if (canvas) {
+ const rect = canvas.getBoundingClientRect()
+ m.x1 = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100))
+ m.y1 = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100))
+ marquee.value = { ...m }
+ }
+ return
+ }
+
const d = drag.value
if (!d) return
const dx = (e.clientX - d.px0) / d.rectW * 100
const dy = (e.clientY - d.py0) / d.rectH * 100
+
+ if (d.mode === 'multi-move' && d.multiStart) {
+ // 关键:基于「起点 + 累计位移」计算,而非在上次结果上累加(否则位移重复叠加)
+ d.multiResult = d.multiStart.map(s => ({
+ id: s.id,
+ x: Math.max(0, Math.min(100 - s.w, s.x + dx)),
+ y: Math.max(0, Math.min(100 - s.h, s.y + dy))
+ }))
+ drag.value = { ...d }
+ return
+ }
+
let nx = d.startX, ny = d.startY, nw = d.startW, nh = d.startH
if (d.mode === 'move') {
@@ -96,11 +174,45 @@ export function useEditor() {
}
function onMouseUp() {
+ // 结束框选:计算与 marquee 相交的元素 → 多选
+ const m = marquee.value
+ if (m) {
+ marquee.value = null
+ const left = Math.min(m.x0, m.x1), right = Math.max(m.x0, m.x1)
+ const top = Math.min(m.y0, m.y1), bottom = Math.max(m.y0, m.y1)
+ // 过小的框(<1%)视为点击空白,仅清选
+ if (right - left > 1 || bottom - top > 1) {
+ const s = store.currentSlide.value
+ const hits = (s?.elements || [])
+ .filter(el => {
+ const ex = el.x, ey = el.y, ew = el.x + el.w, eh = el.y + el.h
+ return ex < right && ew > left && ey < bottom && eh > top // 矩形相交
+ })
+ .map(el => el.id)
+ if (hits.length) store.selectMany(hits)
+ }
+ return
+ }
+
const d = drag.value
if (!d) return
const r = d.result
+ const mr = d.multiResult
const id = d.id
drag.value = null
+ if (d.mode === 'multi-move' && mr) {
+ // 多选位移落盘(相对起点有变化的才提交)
+ const moved = mr.filter(r2 => {
+ const el = store.findElement(r2.id)
+ return el && (el.x !== r2.x || el.y !== r2.y)
+ })
+ if (moved.length) {
+ const group = store.beginBatch()
+ for (const t of moved) store.updateElement(t.id, { x: t.x, y: t.y })
+ void group
+ }
+ return
+ }
if (r) store.updateElement(id, r)
}
@@ -108,6 +220,7 @@ export function useEditor() {
return {
drag,
+ marquee,
editing,
onCanvasMouseDown,
onMouseMove,
diff --git a/src/core/importer.ts b/src/core/importer.ts
index 951c648..40b1957 100644
--- a/src/core/importer.ts
+++ b/src/core/importer.ts
@@ -12,10 +12,13 @@ import { createElement } from './sample'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
/** 允许的图片扩展名 */
-const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
+export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
/** 允许的文档扩展名(由 LLM 解析) */
-const DOC_EXTS = new Set(['.md', '.markdown', '.txt', '.text', '.pdf', '.docx', '.doc'])
+export const DOC_EXTS = new Set(['.md', '.markdown', '.txt', '.text', '.pdf', '.docx', '.doc'])
+
+/** 文件选择器 accept 属性(由支持清单派生,保持单一数据源) */
+export const ACCEPT_ATTR = [...IMAGE_EXTS, ...DOC_EXTS].join(',')
/** 文件分类结果 */
export interface FileEntry {
@@ -58,7 +61,7 @@ function readAsArrayBuffer(file: File): Promise {
}
/** 读取图片为 Data URL */
-function readAsDataURL(file: File): Promise {
+export function readAsDataURL(file: File): Promise {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
@@ -196,7 +199,8 @@ export async function scanFiles(fileList: FileList): Promise {
/** 读取文件内容(图片 → data URL,文档 → 原始文本),原位修改 entries */
export async function readEntries(entries: FileEntry[]): Promise {
- for (const entry of entries) {
+ // 并行读取:各文件互不依赖,错误就地记到 entry.error 不中断整体
+ await Promise.all(entries.map(async (entry) => {
try {
if (entry.kind === 'image') {
entry.data = await readAsDataURL(entry.file)
@@ -215,7 +219,7 @@ export async function readEntries(entries: FileEntry[]): Promise {
entry.error = e?.message || String(e)
console.warn(`读取失败 ${entry.name}:`, e)
}
- }
+ }))
}
/** 使用 AI 将一个文档的文本内容解析为幻灯片(异步,调用 LLM) */
diff --git a/src/core/store.ts b/src/core/store.ts
index 79bf303..5a82f95 100644
--- a/src/core/store.ts
+++ b/src/core/store.ts
@@ -15,21 +15,22 @@ const ACTIVE_KEY = 'u-ppt.activeLib.v1'
const CHAT_PREFIX = 'u-ppt.chat.'
const SAVE_DELAY = 400
-/* ---------- 内置页面模板(版式预设) ---------- */
+/* ---------- 内置页面模板(版式预设,按 group 分组展示) ---------- */
const BUILTIN_TEMPLATES: PageTemplate[] = [
+ /* ===== 基础 ===== */
{
- id: 'tpl-blank', name: '空白页', category: 'built-in', background: 'bg',
+ id: 'tpl-blank', name: '空白页', category: 'built-in', group: 'basic', background: 'bg',
elements: []
},
{
- id: 'tpl-title', name: '标题页', category: 'built-in', background: 'g-primary',
+ id: 'tpl-title', name: '标题页', category: 'built-in', group: 'basic', background: 'g-primary',
elements: [
{ id: 'b1', type: 'title', x: 10, y: 38, w: 80, h: 20, content: '标题', style: { fontSize: 64, color: 'text', align: 'center', anim: 'pop' } },
{ id: 'b2', type: 'text', x: 20, y: 60, w: 60, h: 8, content: '副标题', style: { fontSize: 24, color: 'muted', align: 'center', anim: 'fade-up' } }
]
},
{
- id: 'tpl-section', name: '章节页', category: 'built-in', background: 'g-deep',
+ id: 'tpl-section', name: '章节页', category: 'built-in', group: 'basic', background: 'g-deep',
elements: [
{ id: 's1', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
{ id: 's2', type: 'title', x: 12, y: 38, w: 70, h: 18, content: '章节标题', style: { fontSize: 56, color: 'text', align: 'left', anim: 'fade-up' } },
@@ -37,7 +38,19 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
]
},
{
- id: 'tpl-cards3', name: '三卡片', category: 'built-in', background: 'bg',
+ id: 'tpl-toc', name: '目录页', category: 'built-in', group: 'basic', background: 'bg',
+ elements: [
+ { id: 't1', type: 'title', x: 8, y: 8, w: 40, h: 12, content: '目录', style: { fontSize: 44, color: 'primary', align: 'left', anim: 'fade-up' } },
+ { id: 't2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } },
+ { id: 't3', type: 'list', x: 10, y: 32, w: 36, h: 50, content: '01 第一个主题\n02 第二个主题\n03 第三个主题\n04 第四个主题', style: { fontSize: 26, color: 'text', align: 'left', anim: 'fade-up' } },
+ { id: 't4', type: 'shape', x: 55, y: 0, w: 45, h: 100, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.15, anim: 'scale' } },
+ { id: 't5', type: 'stat', x: 58, y: 32, w: 36, h: 36, content: '04', style: { fontSize: 90, color: 'primary', label: '个章节', labelColor: 'muted', labelSize: 20, anim: 'scale' } }
+ ]
+ },
+
+ /* ===== 内容 ===== */
+ {
+ id: 'tpl-cards3', name: '三卡片', category: 'built-in', group: 'content', background: 'bg',
elements: [
{ id: 'c1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
{ id: 'c2', type: 'card', x: 6, y: 28, w: 28, h: 60, content: '卡片一\n要点描述', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
@@ -46,7 +59,7 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
]
},
{
- id: 'tpl-compare', name: '对比页', category: 'built-in', background: 'bg',
+ id: 'tpl-compare', name: '对比页', category: 'built-in', group: 'content', background: 'bg',
elements: [
{ id: 'p1', type: 'title', x: 10, y: 7, w: 80, h: 11, content: '对比标题', style: { fontSize: 36, color: 'primary', align: 'center', anim: 'fade-up' } },
{ id: 'p2', type: 'card', x: 7, y: 24, w: 40, h: 65, content: '左\n要点一\n要点二', style: { accent: 'muted', icon: '', anim: 'slide-l' } },
@@ -54,7 +67,78 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
]
},
{
- id: 'tpl-quote', name: '金句页', category: 'built-in', background: 'g-deep',
+ id: 'tpl-bullets', name: '要点列表', category: 'built-in', group: 'content', background: 'bg',
+ elements: [
+ { id: 'l1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '要点标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
+ { id: 'l2', type: 'shape', x: 8, y: 23, w: 10, h: 1.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'fade-up' } },
+ { id: 'l3', type: 'list', x: 10, y: 30, w: 80, h: 55, content: '第一个要点:具体说明\n第二个要点:具体说明\n第三个要点:具体说明\n第四个要点:具体说明', style: { fontSize: 28, color: 'text', align: 'left', anim: 'fade-up' } }
+ ]
+ },
+ {
+ id: 'tpl-image-text', name: '图文混排', category: 'built-in', group: 'content', background: 'bg',
+ elements: [
+ { id: 'it1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '图文标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
+ { id: 'it2', type: 'image', x: 8, y: 26, w: 42, h: 60, content: '', style: { fit: 'cover', anim: 'fade-up' } },
+ { id: 'it3', type: 'list', x: 56, y: 28, w: 38, h: 55, content: '要点一:具体说明\n要点二:具体说明\n要点三:具体说明', style: { fontSize: 26, color: 'text', align: 'left', anim: 'fade-up' } }
+ ]
+ },
+ {
+ id: 'tpl-timeline', name: '时间线', category: 'built-in', group: 'content', background: 'bg',
+ elements: [
+ { id: 'tl1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '发展历程', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
+ { id: 'tl2', type: 'shape', x: 10, y: 52, w: 80, h: 0.6, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, opacity: 0.6, anim: 'fade' } },
+ { id: 'tl3', type: 'shape', x: 18, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
+ { id: 'tl4', type: 'shape', x: 48, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
+ { id: 'tl5', type: 'shape', x: 78, y: 48.5, w: 2.4, h: 2.4, content: '', style: { shapeType: 'circle', fill: 'primary', anim: 'pop' } },
+ { id: 'tl6', type: 'stat', x: 8, y: 26, w: 22, h: 20, content: '2023', style: { fontSize: 40, color: 'primary', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
+ { id: 'tl7', type: 'stat', x: 38, y: 26, w: 22, h: 20, content: '2024', style: { fontSize: 40, color: 'primary', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
+ { id: 'tl8', type: 'stat', x: 68, y: 26, w: 22, h: 20, content: '2025', style: { fontSize: 40, color: 'accent', label: '里程碑说明', labelColor: 'muted', labelSize: 16, anim: 'fade-up' } },
+ { id: 'tl9', type: 'text', x: 8, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } },
+ { id: 'tl10', type: 'text', x: 38, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } },
+ { id: 'tl11', type: 'text', x: 68, y: 60, w: 22, h: 25, content: '这一年的关键事件与成果描述', style: { fontSize: 20, color: 'muted', align: 'center', anim: 'fade-up' } }
+ ]
+ },
+ {
+ id: 'tpl-steps', name: '步骤流程', category: 'built-in', group: 'content', background: 'bg',
+ elements: [
+ { id: 'st1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '实施步骤', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
+ { id: 'st2', type: 'stat', x: 8, y: 30, w: 18, h: 26, content: '1', style: { fontSize: 64, color: 'primary', label: '第一步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
+ { id: 'st3', type: 'text', x: 8, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
+ { id: 'st4', type: 'shape', x: 27.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
+ { id: 'st5', type: 'stat', x: 33, y: 30, w: 18, h: 26, content: '2', style: { fontSize: 64, color: 'primary', label: '第二步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
+ { id: 'st6', type: 'text', x: 33, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
+ { id: 'st7', type: 'shape', x: 52.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
+ { id: 'st8', type: 'stat', x: 58, y: 30, w: 18, h: 26, content: '3', style: { fontSize: 64, color: 'primary', label: '第三步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
+ { id: 'st9', type: 'text', x: 58, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } },
+ { id: 'st10', type: 'shape', x: 77.5, y: 40, w: 5, h: 0.8, content: '', style: { shapeType: 'triangle', fill: 'accent', anim: 'fade' } },
+ { id: 'st11', type: 'stat', x: 83, y: 30, w: 18, h: 26, content: '4', style: { fontSize: 64, color: 'accent', label: '第四步', labelColor: 'text', labelSize: 22, anim: 'scale' } },
+ { id: 'st12', type: 'text', x: 83, y: 60, w: 18, h: 25, content: '步骤说明文字', style: { fontSize: 19, color: 'muted', align: 'center', anim: 'fade-up' } }
+ ]
+ },
+
+ /* ===== 数据 ===== */
+ {
+ id: 'tpl-data', name: '数据页', category: 'built-in', group: 'data', background: 'panel',
+ elements: [
+ { id: 'd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '数据标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
+ { id: 'd2', type: 'stat', x: 8, y: 28, w: 28, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '说明', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
+ { id: 'd3', type: 'chart', x: 42, y: 26, w: 52, h: 40, content: '[{"label":"A","value":65},{"label":"B","value":45},{"label":"C","value":30}]', style: { color: 'primary', max: 100, chartType: 'bar', anim: 'fade-up' } }
+ ]
+ },
+ {
+ id: 'tpl-data3', name: '三数据页', category: 'built-in', group: 'data', background: 'bg',
+ elements: [
+ { id: 'dd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '关键数据', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
+ { id: 'dd2', type: 'stat', x: 8, y: 30, w: 27, h: 30, content: '2.4x', style: { fontSize: 68, color: 'primary', label: '指标说明一', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
+ { id: 'dd3', type: 'stat', x: 37, y: 30, w: 27, h: 30, content: '87%', style: { fontSize: 68, color: 'accent', label: '指标说明二', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
+ { id: 'dd4', type: 'stat', x: 66, y: 30, w: 27, h: 30, content: '120k', style: { fontSize: 68, color: 'primary', label: '指标说明三', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
+ { id: 'dd5', type: 'chart', x: 15, y: 62, w: 70, h: 30, content: '{"series":["本季","上季"],"items":[{"label":"华东","values":[120,95]},{"label":"华南","values":[100,88]},{"label":"华北","values":[86,70]}]}', style: { color: 'primary', chartType: 'bar', legend: true, grid: true, anim: 'fade-up' } }
+ ]
+ },
+
+ /* ===== 收尾 ===== */
+ {
+ id: 'tpl-quote', name: '金句页', category: 'built-in', group: 'ending', background: 'g-deep',
elements: [
{ id: 'q1', type: 'shape', x: 8, y: 24, w: 4, h: 52, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 4, anim: 'slide-l' } },
{ id: 'q2', type: 'quote', x: 16, y: 28, w: 76, h: 34, content: '金句内容', style: { fontSize: 48, color: 'text', italic: true, align: 'left', anim: 'scale' } },
@@ -62,11 +146,22 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
]
},
{
- id: 'tpl-data', name: '数据页', category: 'built-in', background: 'panel',
+ id: 'tpl-team', name: '团队页', category: 'built-in', group: 'ending', background: 'bg',
elements: [
- { id: 'd1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '数据标题', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
- { id: 'd2', type: 'stat', x: 8, y: 28, w: 28, h: 30, content: '65%', style: { fontSize: 76, color: 'primary', label: '说明', labelColor: 'muted', labelSize: 18, anim: 'scale' } },
- { id: 'd3', type: 'chart', x: 42, y: 26, w: 52, h: 40, content: '[{"label":"A","value":65},{"label":"B","value":45},{"label":"C","value":30}]', style: { color: 'primary', max: 100, chartType: 'bar', anim: 'fade-up' } }
+ { id: 'tm1', type: 'title', x: 8, y: 8, w: 80, h: 12, content: '核心团队', style: { fontSize: 40, color: 'primary', align: 'left', anim: 'fade-up' } },
+ { id: 'tm2', type: 'card', x: 8, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'primary', icon: '', anim: 'fade-up' } },
+ { id: 'tm3', type: 'card', x: 37, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'accent', icon: '', anim: 'fade-up' } },
+ { id: 'tm4', type: 'card', x: 66, y: 28, w: 27, h: 55, content: '姓名\n职位\n一句话介绍', style: { accent: 'primary', icon: '', anim: 'fade-up' } }
+ ]
+ },
+ {
+ id: 'tpl-thanks', name: '感谢页', category: 'built-in', group: 'ending', background: 'g-primary',
+ elements: [
+ { id: 'th1', type: 'shape', x: 55, y: -25, w: 65, h: 100, content: '', style: { shapeType: 'circle', fill: 'accent', gradient: true, opacity: 0.3, anim: 'scale' } },
+ { id: 'th2', type: 'shape', x: 0, y: 0, w: 6, h: 100, content: '', style: { shapeType: 'rect', fill: 'accent', radius: 0, anim: 'slide-l' } },
+ { id: 'th3', type: 'title', x: 10, y: 34, w: 70, h: 18, content: '感谢观看', style: { fontSize: 62, color: 'text', align: 'left', anim: 'pop' } },
+ { id: 'th4', type: 'text', x: 10, y: 56, w: 60, h: 10, content: '欢迎交流与讨论', style: { fontSize: 26, color: 'muted', align: 'left', anim: 'fade-up' } },
+ { id: 'th5', type: 'text', x: 10, y: 82, w: 50, h: 7, content: '联系方式 / 二维码占位', style: { fontSize: 18, color: 'muted', align: 'left', anim: 'fade' } }
]
}
]
@@ -87,11 +182,14 @@ const state = reactive<{
deck: Deck
currentIndex: number
selectedId: string | null
+ /** 多选集(框选/Shift加选时非空;selectedId 始终为锚点元素) */
+ selectedIds: string[]
activeLibId: string | null
}>({
deck: JSON.parse(JSON.stringify(SAMPLE_DECK)),
currentIndex: 0,
selectedId: null,
+ selectedIds: [],
activeLibId: null
})
@@ -119,9 +217,21 @@ function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
safeSet(LS_KEY, JSON.stringify(state.deck))
+ syncActiveLibItem()
}, SAVE_DELAY)
}
+/** 自动暂存:deck 有活跃文库条目时,改动自动回写文库(不点保存也不丢,刷新/首页重开均为最新) */
+function syncActiveLibItem() {
+ if (!state.activeLibId) return
+ const lib = getLibrary()
+ const item = lib.find(x => x.id === state.activeLibId)
+ if (!item) return
+ item.deck = clone(state.deck)
+ item.updatedAt = Date.now()
+ setLibrary(lib)
+}
+
/* ---------- 颜色/背景解析(纯函数,供组件调用) ---------- */
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const c = String(hex).replace('#', '')
@@ -194,7 +304,7 @@ export function resolveBg(key: string): string {
/* ---------- 内部:执行 Op 并推入历史 ---------- */
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
-function execOp(op: Op, opts?: { coalesceKey?: string }): void {
+function execOp(op: Op, opts?: { coalesceKey?: string; group?: string }): void {
if (suppressHistory) {
state.deck = applyOp(state.deck, op)
scheduleSave()
@@ -214,7 +324,7 @@ function execOp(op: Op, opts?: { coalesceKey?: string }): void {
// 执行正向
state.deck = applyOp(state.deck, op)
// 推入历史
- const entry: HistoryEntry = { forward: op, backward }
+ const entry: HistoryEntry = { forward: op, backward, group: opts?.group }
hist.push(entry)
if (hist.length > HIST_MAX) hist.shift()
future.length = 0
@@ -244,6 +354,18 @@ function migrateDeck(d: any): Deck {
}
/* ---------- 初始化 ---------- */
+
+/** 工作区 deck 是否有用户内容(区别于示例/空白):非示例且有元素,或页数>1 */
+function hasWorkDeck(): boolean {
+ const d = state.deck
+ if (!d || !d.slides?.length) return false
+ const sampleTitle = SAMPLE_DECK.slides[0]?.elements.find(e => e.type === 'title')?.content
+ const firstTitle = d.slides[0]?.elements.find(e => e.type === 'title')?.content
+ const isSample = d.slides.length === SAMPLE_DECK.slides.length && sampleTitle && firstTitle === sampleTitle
+ if (isSample) return false
+ return d.slides.length > 1 || d.slides.some(s => s.elements.length > 0)
+}
+
function init() {
let loaded: Deck | null = null
try { loaded = JSON.parse(localStorage.getItem(LS_KEY) || 'null') } catch (e) { /* ignore */ }
@@ -300,10 +422,63 @@ function setCurrentIndex(i: number) {
i = Math.max(0, Math.min(state.deck.slides.length - 1, i))
if (i === state.currentIndex) return
state.currentIndex = i
- state.selectedId = null
+ state.selectedId = null; state.selectedIds = []
scheduleSave()
}
-function selectElement(id: string | null) { state.selectedId = id || null }
+function selectElement(id: string | null) { state.selectedId = id || null; state.selectedIds = [] }
+
+/* ---------- 多选(框选 / Shift 加选 / Ctrl+A) ---------- */
+const selectedIds = computed(() => state.selectedIds)
+
+/** 有效多选集:过滤掉已不存在的元素,非空(length>1)时表示多选态 */
+function getSelection(): string[] {
+ const s = currentSlide.value
+ if (!s || !state.selectedIds.length) return []
+ return state.selectedIds.filter(id => s.elements.some(e => e.id === id))
+}
+
+function selectMany(ids: string[]) {
+ state.selectedIds = [...ids]
+ state.selectedId = ids.length ? ids[ids.length - 1] : null
+}
+
+/** Shift+点击:在多选集中加入/移除某元素 */
+function toggleSelect(id: string) {
+ const cur = [...state.selectedIds]
+ const i = cur.indexOf(id)
+ if (i >= 0) cur.splice(i, 1)
+ else cur.push(id)
+ state.selectedIds = cur
+ state.selectedId = cur.length ? cur[cur.length - 1] : null
+}
+
+function selectAllElements() {
+ const s = currentSlide.value
+ if (!s) return
+ state.selectedIds = s.elements.map(e => e.id)
+ state.selectedId = state.selectedIds.length ? state.selectedIds[0] : null
+}
+
+/** 多元素整体位移(框选拖动);每个元素独立 op 但同批 undo */
+function moveElementsBy(ids: string[], dx: number, dy: number) {
+ const group = beginBatch()
+ for (const id of ids) {
+ const el = findElement(id)
+ if (!el) continue
+ const x = Math.max(0, Math.min(100 - el.w, el.x + dx))
+ const y = Math.max(0, Math.min(100 - el.h, el.y + dy))
+ execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch: { x, y }, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
+ }
+}
+
+/** 批量删除(同批 undo) */
+function delElements(ids: string[]) {
+ const group = beginBatch()
+ for (const id of ids) {
+ if (findElement(id)) delElement(id)
+ }
+ void group
+}
/* ---------- 主题 ---------- */
function setTheme(name: string) {
@@ -317,7 +492,7 @@ function addSlide(atIndex?: number) {
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
- state.selectedId = null
+ state.selectedId = null; state.selectedIds = []
return s
}
@@ -328,7 +503,7 @@ function dupSlide() {
const idx = state.currentIndex + 1
execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
- state.selectedId = null
+ state.selectedId = null; state.selectedIds = []
}
function delSlide(i?: number): boolean {
@@ -337,7 +512,7 @@ function delSlide(i?: number): boolean {
const deletedSlide = clone(state.deck.slides[idx])
execOp({ type: 'del_slide', index: idx, deletedSlide, clientId: CLIENT_ID, timestamp: Date.now() })
if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1
- state.selectedId = null
+ state.selectedId = null; state.selectedIds = []
return true
}
@@ -353,9 +528,9 @@ function setSlideBackground(bg: string) {
}
/* ---------- 元素 CRUD ---------- */
-function addElement(type: SlideElement['type'], over?: Parameters[1]) {
+function addElement(type: SlideElement['type'], over?: Parameters[1], group?: string) {
const el = createElement(type, over)
- execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() })
+ execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
state.selectedId = el.id
return el
}
@@ -369,37 +544,124 @@ function delElement(id: string) {
const el = findElement(id)
const deletedElement = el ? clone(el) : undefined
execOp({ type: 'del_element', slideIdx: state.currentIndex, elementId: id, deletedElement, clientId: CLIENT_ID, timestamp: Date.now() })
- if (state.selectedId === id) state.selectedId = null
+ if (state.selectedId === id) state.selectedId = null; state.selectedIds = []
}
function moveElementZ(id: string, dir: number) {
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
}
+/* ---------- 元素剪贴板(内存级,跨页可用,不持久化;支持多元素) ---------- */
+let clipboard: SlideElement[] = []
+
+function copyElements(ids: string[]): boolean {
+ const els = ids.map(findElement).filter(Boolean) as SlideElement[]
+ if (!els.length) return false
+ clipboard = clone(els)
+ pasteCount = 0
+ return true
+}
+
+function copyElement(id: string): boolean {
+ return copyElements([id])
+}
+
+/** 剪切 = 复制 + 删除(删除走 op,可撤销;撤销后剪贴板仍保留内容) */
+function cutElements(ids: string[]): boolean {
+ if (!copyElements(ids)) return false
+ delElements(ids)
+ return true
+}
+
+function cutElement(id: string): boolean {
+ return cutElements([id])
+}
+
+/** 粘贴到当前页:新 id、位置阶梯偏移错开原位;连续粘贴合并为一条 undo */
+let pasteCount = 0
+let pasteTimer: ReturnType | null = null
+let pasteGroup: string | null = null
+
+function pasteElements(): boolean {
+ if (!clipboard.length) return false
+ pasteCount++
+ const off = (pasteCount - 1) * 3
+ // 连续粘贴(2s 内)共用同一 group → 一次 Ctrl+Z 整批撤销
+ if (!pasteTimer) pasteGroup = beginBatch()
+ else { clearTimeout(pasteTimer) }
+ pasteTimer = setTimeout(() => { pasteTimer = null; pasteGroup = null }, 2000)
+
+ const newIds: string[] = []
+ for (const src of clipboard) {
+ const el = createElement(src.type, {
+ ...src,
+ id: uid('el'),
+ x: Math.min(97, Math.max(0, src.x + off)),
+ y: Math.min(97, Math.max(0, src.y + off)),
+ content: src.content,
+ segments: src.segments ? clone(src.segments) : undefined,
+ style: { ...src.style }
+ })
+ execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() }, { group: pasteGroup! })
+ newIds.push(el.id)
+ }
+ state.selectedIds = newIds
+ state.selectedId = newIds.length ? newIds[newIds.length - 1] : null
+ return true
+}
+
+function pasteElement(): boolean {
+ return pasteElements()
+}
+
+function hasClipboard(): boolean { return clipboard.length > 0 }
+
function findElement(id: string): SlideElement | null {
const s = currentSlide.value
return s.elements.find(e => e.id === id) || null
}
-/* ---------- undo / redo(基于 Op 回滚) ---------- */
+/* ---------- undo / redo(基于 Op 回滚,同 group 批次整体回滚) ---------- */
+let batchCounter = 0
+
+/** 开始一个操作批次:期间所有 execOp 记录同 group 标记,undo 时整组回滚 */
+function beginBatch(): string {
+ return 'batch-' + (++batchCounter) + '-' + Date.now()
+}
+
function undo(): boolean {
if (!hist.length) return false
- const entry = hist.pop()!
+ // 弹出栈顶;若带 group 标记,同组的全部一起弹出(整批回滚)
+ const popped: HistoryEntry[] = []
+ do {
+ popped.push(hist.pop()!)
+ } while (hist.length > 0 && hist[hist.length - 1].group != null && hist[hist.length - 1].group === popped[0].group)
suppressHistory = true
- state.deck = applyOp(state.deck, entry.backward)
+ // popped 按执行顺序倒排(栈顶=最后执行在最前),撤回时后执行的先撤
+ for (const e of popped) {
+ state.deck = applyOp(state.deck, e.backward)
+ }
suppressHistory = false
- future.push(entry)
+ for (let i = popped.length - 1; i >= 0; i--) future.push(popped[i])
scheduleSave()
return true
}
function redo(): boolean {
if (!future.length) return false
- const entry = future.pop()!
+ // 同组的一起重做
+ const popped: HistoryEntry[] = []
+ const headGroup = future[future.length - 1].group
+ do {
+ popped.push(future.pop()!)
+ } while (future.length > 0 && headGroup != null && future[future.length - 1].group === headGroup)
suppressHistory = true
- state.deck = applyOp(state.deck, entry.forward)
+ // popped[0] 是最先执行的(undo 时最后压入 future),重做按原顺序:倒序遍历
+ for (let i = popped.length - 1; i >= 0; i--) {
+ state.deck = applyOp(state.deck, popped[i].forward)
+ }
suppressHistory = false
- hist.push(entry)
+ for (let i = popped.length - 1; i >= 0; i--) hist.push(popped[i])
scheduleSave()
return true
}
@@ -430,7 +692,7 @@ function replaceDeck(newDeck: Deck | Record, opts?: { keepHisto
state.activeLibId = null
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
if (state.currentIndex < 0) state.currentIndex = 0
- state.selectedId = null
+ state.selectedId = null; state.selectedIds = []
}
function replaceSlide(index: number, newSlide: Slide) {
@@ -438,17 +700,17 @@ function replaceSlide(index: number, newSlide: Slide) {
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
const oldSlide = state.deck.slides[index] ? clone(state.deck.slides[index]) : undefined
execOp({ type: 'replace_slide', index, slide: newSlide, oldSlide, clientId: CLIENT_ID, timestamp: Date.now() })
- state.selectedId = null
+ state.selectedId = null; state.selectedIds = []
}
-function appendSlide(newSlide: Slide) {
+function appendSlide(newSlide: Slide, group?: string) {
newSlide.id = uid('s')
newSlide.elements = newSlide.elements || []
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
const idx = state.deck.slides.length
- execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
+ execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() }, { group })
state.currentIndex = idx
- state.selectedId = null
+ state.selectedId = null; state.selectedIds = []
}
function insertSlideAt(index: number, newSlide: Slide) {
@@ -457,7 +719,7 @@ function insertSlideAt(index: number, newSlide: Slide) {
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = index
- state.selectedId = null
+ state.selectedId = null; state.selectedIds = []
}
function reset() {
@@ -577,7 +839,7 @@ function addSlideFromTemplate(tplId: string): boolean {
const idx = state.currentIndex + 1
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
state.currentIndex = idx
- state.selectedId = null
+ state.selectedId = null; state.selectedIds = []
return true
}
@@ -679,9 +941,15 @@ function getChat(chatId: string): ChatMessage[] {
try { return JSON.parse(localStorage.getItem(CHAT_PREFIX + chatId) || '[]') || [] } catch (e) { return [] }
}
-/** 写入某个 chatId 的会话 */
+/** 写入某个 chatId 的会话(超 1.5MB 时从最旧开始丢弃,防写满 localStorage) */
function setChat(chatId: string, msgs: ChatMessage[]) {
- safeSet(CHAT_PREFIX + chatId, JSON.stringify(msgs))
+ let list = msgs
+ while (list.length > 2) {
+ const size = JSON.stringify(list).length
+ if (size <= 1_500_000) break
+ list = list.slice(2) // 成对丢弃(user+assistant)
+ }
+ safeSet(CHAT_PREFIX + chatId, JSON.stringify(list))
}
/** 清除某个 chatId 的会话 */
@@ -723,8 +991,13 @@ export const store = {
addSlide, dupSlide, delSlide, moveSlide, setSlideBackground,
// 元素 CRUD
addElement, updateElement, delElement, moveElementZ, findElement,
+ // 元素剪贴板
+ copyElement, copyElements, cutElement, cutElements, pasteElement, pasteElements, hasClipboard,
+ // 多选
+ selectedIds, getSelection, selectMany, toggleSelect, selectAllElements,
+ moveElementsBy, delElements,
// undo/redo
- undo, redo,
+ undo, redo, beginBatch,
// 整体替换
replaceDeck, replaceSlide, appendSlide, insertSlideAt,
reset, exportJSON, importJSON,
@@ -739,6 +1012,8 @@ export const store = {
getTemplates, getUserTemplates, saveCurrentAsTemplate, addSlideFromTemplate, deleteTemplate,
// 会话管理
getChatId, getChat, setChat, clearChat, newChatId,
+ // 暂存检测
+ hasWorkDeck,
// 备份恢复
recoverBackup,
}
diff --git a/src/core/types.ts b/src/core/types.ts
index 50809c3..0f4fbf4 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -137,6 +137,8 @@ export interface PageTemplate {
name: string
/** 模板分类:built-in / user */
category: 'built-in' | 'user'
+ /** 展示分组:basic 基础 / content 内容 / data 数据 / ending 收尾(缺省按内容归组) */
+ group?: 'basic' | 'content' | 'data' | 'ending'
background: BgKey
/** 元素结构(内容可为占位符如「标题」「正文」) */
elements: SlideElement[]
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 9ac1543..d8856c4 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -108,6 +108,16 @@
.el-stat .label { margin-top: .35em; text-align: center; }
.el-shape { width: 100%; height: 100%; }
.el-image { width: 100%; height: 100%; object-fit: cover; }
+/* 空图片元素占位(未填 content 时不渲染裂图) */
+.el-image-empty {
+ width: 100%; height: 100%;
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
+ border: 2px dashed rgba(100, 116, 139, .45); border-radius: 10px;
+ background: rgba(148, 163, 184, .08); color: var(--ui-muted, #64748b);
+ text-align: center; padding: 8px;
+}
+.el-image-empty-icon { font-size: 28px; opacity: .7; }
+.el-image-empty-text { font-size: 12px; line-height: 1.5; }
.el-chart { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; gap: .5em; }
.el-chart .bar-row { display: flex; align-items: center; gap: .6em; font-size: 14px; }
.el-chart .bar-label { width: 26%; flex-shrink: 0; }
@@ -182,6 +192,17 @@
/* 选中态与手柄 */
.el.selected { outline: 2px solid var(--ui-primary); outline-offset: 0; }
.el.dragging { opacity: .85; }
+/* 多选态:次级高亮(锚点仍用 .selected 实线) */
+.el.multi-selected { outline: 1.5px dashed color-mix(in srgb, var(--ui-primary) 70%, transparent); outline-offset: 1px; }
+
+/* 框选矩形 */
+.marquee-box {
+ position: absolute;
+ border: 1.5px dashed var(--ui-primary);
+ background: color-mix(in srgb, var(--ui-primary) 8%, transparent);
+ pointer-events: none;
+ z-index: 999;
+}
.handle {
position: absolute; width: 10px; height: 10px;
background: #fff; border: 1.5px solid var(--ui-primary);