重构: Op Log 数据模型,为协同预留基础(P2)
- 新增 op.ts: Op 类型定义 + 纯函数 reducer(applyOp) + 逆操作计算(invertOp) - 支持的 Op 类型: add_slide/del_slide/move_slide/replace_slide/set_slide_bg/ add_element/update_element/del_element/move_element_z/set_theme/replace_deck - undo/redo 从快照栈改为 Op 回滚(forward/backward 双向 Op) - store 所有写操作内部改为 execOp,对外接口完全不变(88 处调用零改动) - update_element 保留连续操作合并(拖滑块/连续输入不膨胀历史) - 每个 Op 带 clientId + timestamp,为未来协同预留 - replace_deck 作为特殊 Op 进 undo 历史
This commit is contained in:
274
src/core/op.ts
Normal file
274
src/core/op.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
* op.ts — 操作日志(Op Log)数据模型
|
||||||
|
*
|
||||||
|
* 所有对 deck 的变更都表达为 Op,通过纯函数 reducer 应用。
|
||||||
|
* Op 是可序列化、可回放、可逆的——为未来协同预留基础。
|
||||||
|
*
|
||||||
|
* 设计:
|
||||||
|
* - 元素级粒度:update_element 带字段级 patch
|
||||||
|
* - replace_deck 作为特殊 Op,进 undo 历史
|
||||||
|
* - 每个 Op 可计算逆 Op(用于 undo)
|
||||||
|
* - reducer 是纯函数:(deck, op) → newDeck
|
||||||
|
* ===================================================================== */
|
||||||
|
import type { Deck, Slide, SlideElement, ElementStyle, BgKey } from './types'
|
||||||
|
|
||||||
|
/* ---------- Op 类型定义 ---------- */
|
||||||
|
|
||||||
|
export type OpType =
|
||||||
|
| 'add_slide'
|
||||||
|
| 'del_slide'
|
||||||
|
| 'move_slide'
|
||||||
|
| 'replace_slide'
|
||||||
|
| 'set_slide_bg'
|
||||||
|
| 'add_element'
|
||||||
|
| 'update_element'
|
||||||
|
| 'del_element'
|
||||||
|
| 'move_element_z'
|
||||||
|
| 'set_theme'
|
||||||
|
| 'replace_deck'
|
||||||
|
|
||||||
|
export interface Op {
|
||||||
|
type: OpType
|
||||||
|
// 通用元数据(为协同预留)
|
||||||
|
clientId?: string
|
||||||
|
timestamp?: number
|
||||||
|
// 各类型的 payload(按 type 不同,只填对应字段)
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带逆操作的历史条目 */
|
||||||
|
export interface HistoryEntry {
|
||||||
|
forward: Op
|
||||||
|
backward: Op
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 工具 ---------- */
|
||||||
|
|
||||||
|
function clone<T>(o: T): T { return JSON.parse(JSON.stringify(o)) }
|
||||||
|
|
||||||
|
/** 在 deck 的某页里找元素 */
|
||||||
|
function findEl(deck: Deck, slideIdx: number, elId: string): SlideElement | null {
|
||||||
|
const s = deck.slides[slideIdx]
|
||||||
|
if (!s) return null
|
||||||
|
return s.elements.find(e => e.id === elId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 纯函数 reducer:applyOp(deck, op) → newDeck
|
||||||
|
* 不 mutate 输入,返回新 deck
|
||||||
|
* ============================================================ */
|
||||||
|
export function applyOp(inputDeck: Deck, op: Op): Deck {
|
||||||
|
const deck = clone(inputDeck) // 深拷贝,不 mutate 原数据
|
||||||
|
|
||||||
|
switch (op.type) {
|
||||||
|
case 'add_slide': {
|
||||||
|
const atIndex = op.atIndex as number | undefined
|
||||||
|
const slide = op.slide as Slide
|
||||||
|
const idx = atIndex != null ? atIndex : deck.slides.length
|
||||||
|
deck.slides.splice(idx, 0, slide)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'del_slide': {
|
||||||
|
const index = op.index as number
|
||||||
|
if (deck.slides.length <= 1) break
|
||||||
|
deck.slides.splice(index, 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'move_slide': {
|
||||||
|
const from = op.from as number
|
||||||
|
const to = op.to as number
|
||||||
|
if (from === to || from < 0 || to < 0 || to >= deck.slides.length) break
|
||||||
|
const item = deck.slides.splice(from, 1)[0]
|
||||||
|
deck.slides.splice(to, 0, item)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'replace_slide': {
|
||||||
|
const index = op.index as number
|
||||||
|
const slide = op.slide as Slide
|
||||||
|
if (deck.slides[index]) {
|
||||||
|
slide.id = deck.slides[index].id // 保持 id 不变
|
||||||
|
}
|
||||||
|
deck.slides[index] = slide
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'set_slide_bg': {
|
||||||
|
const index = op.index as number
|
||||||
|
const bg = op.bg as BgKey
|
||||||
|
if (deck.slides[index]) {
|
||||||
|
deck.slides[index].background = bg
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'add_element': {
|
||||||
|
const slideIdx = op.slideIdx as number
|
||||||
|
const element = op.element as SlideElement
|
||||||
|
const s = deck.slides[slideIdx]
|
||||||
|
if (s) s.elements.push(element)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update_element': {
|
||||||
|
const slideIdx = op.slideIdx as number
|
||||||
|
const elementId = op.elementId as string
|
||||||
|
const patch = op.patch as Partial<SlideElement> & { style?: Record<string, unknown> }
|
||||||
|
const el = findEl(deck, slideIdx, elementId)
|
||||||
|
if (!el) break
|
||||||
|
if (patch.style) { Object.assign(el.style, patch.style) }
|
||||||
|
const rest = { ...patch }; delete (rest as Record<string, unknown>).style
|
||||||
|
Object.assign(el, rest)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'del_element': {
|
||||||
|
const slideIdx = op.slideIdx as number
|
||||||
|
const elementId = op.elementId as string
|
||||||
|
const s = deck.slides[slideIdx]
|
||||||
|
if (s) s.elements = s.elements.filter(e => e.id !== elementId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'move_element_z': {
|
||||||
|
const slideIdx = op.slideIdx as number
|
||||||
|
const elementId = op.elementId as string
|
||||||
|
const dir = op.dir as number
|
||||||
|
const s = deck.slides[slideIdx]
|
||||||
|
if (!s) break
|
||||||
|
const i = s.elements.findIndex(e => e.id === elementId)
|
||||||
|
if (i < 0) break
|
||||||
|
const j = dir > 0 ? i + 1 : i - 1
|
||||||
|
if (j < 0 || j >= s.elements.length) break
|
||||||
|
const tmp = s.elements[i]; s.elements[i] = s.elements[j]; s.elements[j] = tmp
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'set_theme': {
|
||||||
|
const theme = op.theme as string
|
||||||
|
deck.theme = theme
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'replace_deck': {
|
||||||
|
const newDeck = op.deck as Deck
|
||||||
|
// 全量替换,但保留版本号
|
||||||
|
newDeck.v = deck.v
|
||||||
|
return newDeck
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 未知 Op 类型,不修改
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return deck
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 逆操作计算:invertOp(deck, op) → Op
|
||||||
|
* 根据当前 deck 状态,计算能撤销 op 的逆 Op
|
||||||
|
* ============================================================ */
|
||||||
|
export function invertOp(inputDeck: Deck, op: Op): Op {
|
||||||
|
const deck = inputDeck // 只读,不 clone
|
||||||
|
|
||||||
|
switch (op.type) {
|
||||||
|
case 'add_slide': {
|
||||||
|
// 逆:删除加的那页
|
||||||
|
const slide = op.slide as Slide
|
||||||
|
const idx = deck.slides.findIndex(s => s.id === slide.id)
|
||||||
|
return { type: 'del_slide', index: idx >= 0 ? idx : (op.atIndex as number || deck.slides.length - 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'del_slide': {
|
||||||
|
// 逆:把删掉的页插回原位
|
||||||
|
const index = op.index as number
|
||||||
|
const deletedSlide = op.deletedSlide as Slide | undefined
|
||||||
|
if (deletedSlide) {
|
||||||
|
return { type: 'add_slide', atIndex: index, slide: deletedSlide }
|
||||||
|
}
|
||||||
|
// 没有 deletedSlide 信息(兼容),返回 no-op
|
||||||
|
return { type: 'replace_deck', deck: clone(deck) }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'move_slide': {
|
||||||
|
// 逆:from/to 反过来
|
||||||
|
return { type: 'move_slide', from: op.to as number, to: op.from as number }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'replace_slide': {
|
||||||
|
// 逆:换回原来的 slide
|
||||||
|
const index = op.index as number
|
||||||
|
const oldSlide = deck.slides[index] ? clone(deck.slides[index]) : undefined
|
||||||
|
if (oldSlide) {
|
||||||
|
return { type: 'replace_slide', index, slide: (op.oldSlide as Slide) || oldSlide }
|
||||||
|
}
|
||||||
|
return { type: 'replace_deck', deck: clone(deck) }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'set_slide_bg': {
|
||||||
|
// 逆:换回原来的背景
|
||||||
|
const index = op.index as number
|
||||||
|
const oldBg = deck.slides[index]?.background || 'bg'
|
||||||
|
return { type: 'set_slide_bg', index, bg: oldBg }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'add_element': {
|
||||||
|
// 逆:删除加的元素
|
||||||
|
return { type: 'del_element', slideIdx: op.slideIdx, elementId: (op.element as SlideElement).id }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'update_element': {
|
||||||
|
// 逆:把被改的字段恢复原值
|
||||||
|
const slideIdx = op.slideIdx as number
|
||||||
|
const elementId = op.elementId as string
|
||||||
|
const patch = op.patch as Record<string, unknown>
|
||||||
|
const el = findEl(deck, slideIdx, elementId)
|
||||||
|
if (!el) return { type: 'replace_deck', deck: clone(deck) }
|
||||||
|
// 构造反向 patch:取当前值
|
||||||
|
const backwardPatch: Record<string, unknown> = {}
|
||||||
|
if (patch.style) {
|
||||||
|
const oldStyle: Record<string, unknown> = {}
|
||||||
|
for (const k of Object.keys(patch.style as object)) {
|
||||||
|
oldStyle[k] = (el.style as Record<string, unknown>)[k]
|
||||||
|
}
|
||||||
|
backwardPatch.style = oldStyle
|
||||||
|
}
|
||||||
|
for (const k of Object.keys(patch)) {
|
||||||
|
if (k === 'style') continue
|
||||||
|
backwardPatch[k] = (el as unknown as Record<string, unknown>)[k]
|
||||||
|
}
|
||||||
|
return { type: 'update_element', slideIdx, elementId, patch: backwardPatch }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'del_element': {
|
||||||
|
// 逆:把删掉的元素加回去
|
||||||
|
const slideIdx = op.slideIdx as number
|
||||||
|
const deletedElement = op.deletedElement as SlideElement | undefined
|
||||||
|
if (deletedElement) {
|
||||||
|
return { type: 'add_element', slideIdx, element: deletedElement }
|
||||||
|
}
|
||||||
|
return { type: 'replace_deck', deck: clone(deck) }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'move_element_z': {
|
||||||
|
// 逆:方向反过来
|
||||||
|
return { type: 'move_element_z', slideIdx: op.slideIdx, elementId: op.elementId, dir: -(op.dir as number) }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'set_theme': {
|
||||||
|
// 逆:换回原来的主题
|
||||||
|
return { type: 'set_theme', theme: deck.theme }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'replace_deck': {
|
||||||
|
// 逆:换回整个旧 deck
|
||||||
|
return { type: 'replace_deck', deck: clone(deck) }
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { type: 'replace_deck', deck: clone(deck) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
|
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
|
||||||
* ===================================================================== */
|
* ===================================================================== */
|
||||||
import { reactive, computed } from 'vue'
|
import { reactive, computed } from 'vue'
|
||||||
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey } from './types'
|
import type { AiCfg, Deck, LibItem, PageTemplate, Slide, SlideElement, ThemeKey, ChatMessage } from './types'
|
||||||
import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample'
|
import { DECK_VERSION, SAMPLE_DECK, createElement, themes, uid } from './sample'
|
||||||
|
import { applyOp, invertOp, type Op, type HistoryEntry } from './op'
|
||||||
|
|
||||||
/* ---------- localStorage 键 ---------- */
|
/* ---------- localStorage 键 ---------- */
|
||||||
const LS_KEY = 'u-ppt.deck.v1'
|
const LS_KEY = 'u-ppt.deck.v1'
|
||||||
@@ -70,15 +71,17 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
/* ---------- 历史快照 ---------- */
|
/* ---------- Op 历史栈(undo/redo 基于 Op 回滚) ---------- */
|
||||||
interface Snapshot { deck: Deck; index: number; sel: string | null }
|
|
||||||
const HIST_MAX = 40
|
const HIST_MAX = 40
|
||||||
let hist: Snapshot[] = []
|
let hist: HistoryEntry[] = [] // 过去的操作
|
||||||
let future: Snapshot[] = []
|
let future: HistoryEntry[] = [] // redo 栈
|
||||||
let suppressHistory = false
|
let suppressHistory = false
|
||||||
let touchKey: string | null = null
|
let touchKey: string | null = null
|
||||||
let touchTimer: ReturnType<typeof setTimeout> | null = null
|
let touchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
/** 当前端的 client ID(为协同预留) */
|
||||||
|
const CLIENT_ID = 'client-' + Math.random().toString(36).slice(2, 10)
|
||||||
|
|
||||||
/* ---------- 响应式状态(单一数据源) ---------- */
|
/* ---------- 响应式状态(单一数据源) ---------- */
|
||||||
const state = reactive<{
|
const state = reactive<{
|
||||||
deck: Deck
|
deck: Deck
|
||||||
@@ -173,20 +176,40 @@ export function resolveBg(key: string): string {
|
|||||||
return t[key] || '#ffffff'
|
return t[key] || '#ffffff'
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 历史快照 ---------- */
|
/* ---------- 内部:执行 Op 并推入历史 ---------- */
|
||||||
function pushHistory() {
|
|
||||||
if (suppressHistory) return
|
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
|
||||||
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
function execOp(op: Op, opts?: { coalesceKey?: string }): void {
|
||||||
|
if (suppressHistory) {
|
||||||
|
state.deck = applyOp(state.deck, op)
|
||||||
|
scheduleSave()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 连续同类操作合并(如拖字号滑块)
|
||||||
|
if (opts?.coalesceKey && touchKey === opts.coalesceKey && hist.length > 0) {
|
||||||
|
// 合并:不推新历史,只执行 forward(已有的 backward 保持不变)
|
||||||
|
state.deck = applyOp(state.deck, op)
|
||||||
|
scheduleSave()
|
||||||
|
if (touchTimer) clearTimeout(touchTimer)
|
||||||
|
touchTimer = setTimeout(() => { touchKey = null }, 900)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 计算逆 Op(在执行前)
|
||||||
|
const backward = invertOp(state.deck, op)
|
||||||
|
// 执行正向
|
||||||
|
state.deck = applyOp(state.deck, op)
|
||||||
|
// 推入历史
|
||||||
|
const entry: HistoryEntry = { forward: op, backward }
|
||||||
|
hist.push(entry)
|
||||||
if (hist.length > HIST_MAX) hist.shift()
|
if (hist.length > HIST_MAX) hist.shift()
|
||||||
future.length = 0
|
future.length = 0
|
||||||
}
|
// coalesce 逻辑
|
||||||
|
if (opts?.coalesceKey) {
|
||||||
/** 连续「同一元素同一字段」的操作合并为一条历史(如拖字号滑块、连续敲字) */
|
touchKey = opts.coalesceKey
|
||||||
function touchHistory(key: string) {
|
if (touchTimer) clearTimeout(touchTimer)
|
||||||
if (suppressHistory) return
|
touchTimer = setTimeout(() => { touchKey = null }, 900)
|
||||||
if (touchKey !== key) { pushHistory(); touchKey = key }
|
}
|
||||||
if (touchTimer) clearTimeout(touchTimer)
|
scheduleSave()
|
||||||
touchTimer = setTimeout(() => { touchKey = null }, 900)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 数据版本迁移(旧版数据补字段升级,不丢弃) ---------- */
|
/* ---------- 数据版本迁移(旧版数据补字段升级,不丢弃) ---------- */
|
||||||
@@ -269,101 +292,73 @@ function selectElement(id: string | null) { state.selectedId = id || null }
|
|||||||
|
|
||||||
/* ---------- 主题 ---------- */
|
/* ---------- 主题 ---------- */
|
||||||
function setTheme(name: string) {
|
function setTheme(name: string) {
|
||||||
// 允许内置主题键与已存的自定义主题键
|
|
||||||
if (!themes[name] && !getCustomThemes()[name]) return
|
if (!themes[name] && !getCustomThemes()[name]) return
|
||||||
pushHistory()
|
execOp({ type: 'set_theme', theme: name, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.deck.theme = name
|
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 幻灯片 CRUD ---------- */
|
/* ---------- 幻灯片 CRUD ---------- */
|
||||||
function addSlide(atIndex?: number) {
|
function addSlide(atIndex?: number) {
|
||||||
pushHistory()
|
|
||||||
const s: Slide = { id: uid('s'), background: 'bg', elements: [] }
|
const s: Slide = { id: uid('s'), background: 'bg', elements: [] }
|
||||||
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
|
const idx = atIndex != null ? atIndex + 1 : state.deck.slides.length
|
||||||
state.deck.slides.splice(idx, 0, s)
|
execOp({ type: 'add_slide', atIndex: idx, slide: s, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
function dupSlide() {
|
function dupSlide() {
|
||||||
pushHistory()
|
|
||||||
const cur = currentSlide.value; if (!cur) return
|
const cur = currentSlide.value; if (!cur) return
|
||||||
const copy = clone(cur); copy.id = uid('s')
|
const copy = clone(cur); copy.id = uid('s')
|
||||||
copy.elements.forEach(e => { e.id = uid('el') })
|
copy.elements.forEach(e => { e.id = uid('el') })
|
||||||
state.deck.slides.splice(state.currentIndex + 1, 0, copy)
|
const idx = state.currentIndex + 1
|
||||||
state.currentIndex++
|
execOp({ type: 'add_slide', atIndex: idx, slide: copy, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delSlide(i?: number): boolean {
|
function delSlide(i?: number): boolean {
|
||||||
if (state.deck.slides.length <= 1) return false
|
if (state.deck.slides.length <= 1) return false
|
||||||
pushHistory()
|
|
||||||
const idx = i != null ? i : state.currentIndex
|
const idx = i != null ? i : state.currentIndex
|
||||||
state.deck.slides.splice(idx, 1)
|
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
|
if (state.currentIndex >= state.deck.slides.length) state.currentIndex = state.deck.slides.length - 1
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveSlide(from: number, to: number) {
|
function moveSlide(from: number, to: number) {
|
||||||
if (from === to || from < 0 || to < 0) return
|
if (from === to || from < 0 || to < 0) return
|
||||||
const arr = state.deck.slides
|
if (to >= state.deck.slides.length) return
|
||||||
if (to >= arr.length) return
|
execOp({ type: 'move_slide', from, to, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
pushHistory()
|
|
||||||
const item = arr.splice(from, 1)[0]
|
|
||||||
arr.splice(to, 0, item)
|
|
||||||
state.currentIndex = to
|
state.currentIndex = to
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSlideBackground(bg: string) {
|
function setSlideBackground(bg: string) {
|
||||||
pushHistory()
|
execOp({ type: 'set_slide_bg', index: state.currentIndex, bg: bg as Slide['background'], clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
currentSlide.value.background = bg as Slide['background']
|
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 元素 CRUD ---------- */
|
/* ---------- 元素 CRUD ---------- */
|
||||||
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
|
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
|
||||||
pushHistory()
|
|
||||||
const el = createElement(type, over)
|
const el = createElement(type, over)
|
||||||
currentSlide.value.elements.push(el)
|
execOp({ type: 'add_element', slideIdx: state.currentIndex, element: el, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.selectedId = el.id
|
state.selectedId = el.id
|
||||||
scheduleSave()
|
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateElement(id: string, patch: Partial<SlideElement> & { style?: Record<string, unknown> }) {
|
function updateElement(id: string, patch: Partial<SlideElement> & { style?: Record<string, unknown> }) {
|
||||||
const key = id + ':' + (patch.style ? Object.keys(patch.style).sort().join(',') : Object.keys(patch).sort().join(','))
|
const coalesceKey = id + ':' + (patch.style ? Object.keys(patch.style).sort().join(',') : Object.keys(patch).sort().join(','))
|
||||||
touchHistory(key)
|
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { coalesceKey })
|
||||||
const el = findElement(id); if (!el) return
|
|
||||||
if (patch.style) { Object.assign(el.style, patch.style) }
|
|
||||||
const rest = { ...patch }; delete (rest as Record<string, unknown>).style
|
|
||||||
Object.assign(el, rest)
|
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delElement(id: string) {
|
function delElement(id: string) {
|
||||||
pushHistory()
|
const el = findElement(id)
|
||||||
const s = currentSlide.value
|
const deletedElement = el ? clone(el) : undefined
|
||||||
s.elements = s.elements.filter(e => e.id !== id)
|
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
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveElementZ(id: string, dir: number) {
|
function moveElementZ(id: string, dir: number) {
|
||||||
pushHistory()
|
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
const s = currentSlide.value
|
|
||||||
const i = s.elements.findIndex(e => e.id === id)
|
|
||||||
if (i < 0) return
|
|
||||||
const j = dir > 0 ? i + 1 : i - 1
|
|
||||||
if (j < 0 || j >= s.elements.length) return
|
|
||||||
const tmp = s.elements[i]; s.elements[i] = s.elements[j]; s.elements[j] = tmp
|
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findElement(id: string): SlideElement | null {
|
function findElement(id: string): SlideElement | null {
|
||||||
@@ -371,91 +366,83 @@ function findElement(id: string): SlideElement | null {
|
|||||||
return s.elements.find(e => e.id === id) || null
|
return s.elements.find(e => e.id === id) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- undo / redo ---------- */
|
/* ---------- undo / redo(基于 Op 回滚) ---------- */
|
||||||
function undo(): boolean {
|
function undo(): boolean {
|
||||||
if (!hist.length) return false
|
if (!hist.length) return false
|
||||||
future.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
const entry = hist.pop()!
|
||||||
const prev = hist.pop()!
|
|
||||||
suppressHistory = true
|
suppressHistory = true
|
||||||
state.deck = prev.deck
|
state.deck = applyOp(state.deck, entry.backward)
|
||||||
state.currentIndex = Math.min(prev.index, state.deck.slides.length - 1)
|
|
||||||
state.selectedId = prev.sel
|
|
||||||
suppressHistory = false
|
suppressHistory = false
|
||||||
|
future.push(entry)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function redo(): boolean {
|
function redo(): boolean {
|
||||||
if (!future.length) return false
|
if (!future.length) return false
|
||||||
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
const entry = future.pop()!
|
||||||
const nxt = future.pop()!
|
|
||||||
suppressHistory = true
|
suppressHistory = true
|
||||||
state.deck = nxt.deck
|
state.deck = applyOp(state.deck, entry.forward)
|
||||||
state.currentIndex = Math.min(nxt.index, state.deck.slides.length - 1)
|
|
||||||
state.selectedId = nxt.sel
|
|
||||||
suppressHistory = false
|
suppressHistory = false
|
||||||
|
hist.push(entry)
|
||||||
scheduleSave()
|
scheduleSave()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 整体替换(AI 生成 / 导入 / 重置用) ---------- */
|
/* ---------- 整体替换(AI 生成 / 导入 / 重置用) ---------- */
|
||||||
function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHistory?: boolean; newChat?: boolean }) {
|
function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHistory?: boolean; newChat?: boolean }) {
|
||||||
if (!opts?.keepHistory) pushHistory()
|
|
||||||
// 自动备份:newChat=true 时说明是生成整套/重置等“覆盖性操作”,把当前 deck 存入 .bak
|
// 自动备份:newChat=true 时说明是生成整套/重置等“覆盖性操作”,把当前 deck 存入 .bak
|
||||||
if (opts?.newChat && state.deck?.slides?.length) {
|
if (opts?.newChat && state.deck?.slides?.length) {
|
||||||
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(state.deck)) } catch (e) {}
|
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(state.deck)) } catch (e) {}
|
||||||
}
|
}
|
||||||
suppressHistory = true
|
|
||||||
let d = newDeck as Deck
|
let d = newDeck as Deck
|
||||||
if (!d || typeof d !== 'object') d = {} as Deck
|
if (!d || typeof d !== 'object') d = {} as Deck
|
||||||
d.v = DECK_VERSION // 维护版本号
|
d.v = DECK_VERSION
|
||||||
// 健壮性:损坏/空数据兜底为一页空白,避免下游 forEach 崩溃
|
|
||||||
if (!Array.isArray(d.slides) || !d.slides.length) {
|
if (!Array.isArray(d.slides) || !d.slides.length) {
|
||||||
d.slides = [{ id: uid('s'), background: 'bg', elements: [] }]
|
d.slides = [{ id: uid('s'), background: 'bg', elements: [] }]
|
||||||
}
|
}
|
||||||
// 会话 ID 绑定:newChat=true 开新会话;否则沿用 deck 自带的 chatId(文库加载时);都没有则生成新的
|
|
||||||
if (opts?.newChat || !d.chatId) {
|
if (opts?.newChat || !d.chatId) {
|
||||||
d.chatId = newChatId()
|
d.chatId = newChatId()
|
||||||
}
|
}
|
||||||
state.deck = d
|
if (opts?.keepHistory) {
|
||||||
|
suppressHistory = true
|
||||||
|
state.deck = d
|
||||||
|
suppressHistory = false
|
||||||
|
scheduleSave()
|
||||||
|
} else {
|
||||||
|
execOp({ type: 'replace_deck', deck: d, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
|
}
|
||||||
state.activeLibId = null
|
state.activeLibId = null
|
||||||
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
state.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
||||||
if (state.currentIndex < 0) state.currentIndex = 0
|
if (state.currentIndex < 0) state.currentIndex = 0
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
suppressHistory = false
|
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceSlide(index: number, newSlide: Slide) {
|
function replaceSlide(index: number, newSlide: Slide) {
|
||||||
pushHistory()
|
|
||||||
newSlide.id = state.deck.slides[index] ? state.deck.slides[index].id : uid('s')
|
|
||||||
newSlide.elements = newSlide.elements || []
|
newSlide.elements = newSlide.elements || []
|
||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
state.deck.slides[index] = newSlide
|
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
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendSlide(newSlide: Slide) {
|
function appendSlide(newSlide: Slide) {
|
||||||
pushHistory()
|
|
||||||
newSlide.id = uid('s')
|
newSlide.id = uid('s')
|
||||||
newSlide.elements = newSlide.elements || []
|
newSlide.elements = newSlide.elements || []
|
||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
state.deck.slides.push(newSlide)
|
const idx = state.deck.slides.length
|
||||||
state.currentIndex = state.deck.slides.length - 1
|
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertSlideAt(index: number, newSlide: Slide) {
|
function insertSlideAt(index: number, newSlide: Slide) {
|
||||||
pushHistory()
|
|
||||||
newSlide.id = uid('s')
|
newSlide.id = uid('s')
|
||||||
newSlide.elements = newSlide.elements || []
|
newSlide.elements = newSlide.elements || []
|
||||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||||
state.deck.slides.splice(index, 0, newSlide)
|
execOp({ type: 'add_slide', atIndex: index, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = index
|
state.currentIndex = index
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -517,14 +504,12 @@ function saveCustomTheme(t: import('./types').Theme): string {
|
|||||||
|
|
||||||
/** 应用一个主题(内置 key 或自定义对象)到当前 deck */
|
/** 应用一个主题(内置 key 或自定义对象)到当前 deck */
|
||||||
function applyTheme(nameOrObj: string | import('./types').Theme) {
|
function applyTheme(nameOrObj: string | import('./types').Theme) {
|
||||||
pushHistory()
|
|
||||||
if (typeof nameOrObj === 'string') {
|
if (typeof nameOrObj === 'string') {
|
||||||
state.deck.theme = nameOrObj
|
execOp({ type: 'set_theme', theme: nameOrObj, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
} else {
|
} else {
|
||||||
const key = saveCustomTheme(nameOrObj)
|
const key = saveCustomTheme(nameOrObj)
|
||||||
state.deck.theme = key
|
execOp({ type: 'set_theme', theme: key, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
}
|
}
|
||||||
scheduleSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 页面模板(版式复用) ---------- */
|
/* ---------- 页面模板(版式复用) ---------- */
|
||||||
@@ -569,20 +554,19 @@ function saveCurrentAsTemplate(name: string): PageTemplate {
|
|||||||
function addSlideFromTemplate(tplId: string): boolean {
|
function addSlideFromTemplate(tplId: string): boolean {
|
||||||
const tpl = getTemplates().find(t => t.id === tplId)
|
const tpl = getTemplates().find(t => t.id === tplId)
|
||||||
if (!tpl) return false
|
if (!tpl) return false
|
||||||
pushHistory()
|
|
||||||
const newSlide: Slide = {
|
const newSlide: Slide = {
|
||||||
id: uid('s'),
|
id: uid('s'),
|
||||||
background: tpl.background,
|
background: tpl.background,
|
||||||
elements: clone(tpl.elements).map(e => ({ ...e, id: uid('el') }))
|
elements: clone(tpl.elements).map(e => ({ ...e, id: uid('el') }))
|
||||||
}
|
}
|
||||||
const idx = state.currentIndex + 1
|
const idx = state.currentIndex + 1
|
||||||
state.deck.slides.splice(idx, 0, newSlide)
|
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||||
state.currentIndex = idx
|
state.currentIndex = idx
|
||||||
state.selectedId = null
|
state.selectedId = null
|
||||||
scheduleSave()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 删除用户模板 ---------- */
|
||||||
/** 删除用户模板 */
|
/** 删除用户模板 */
|
||||||
function deleteTemplate(tplId: string) {
|
function deleteTemplate(tplId: string) {
|
||||||
const user = getUserTemplates().filter(t => t.id !== tplId)
|
const user = getUserTemplates().filter(t => t.id !== tplId)
|
||||||
@@ -661,7 +645,6 @@ function newBlankDeck() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 会话管理(按 chatId 隔离) ---------- */
|
/* ---------- 会话管理(按 chatId 隔离) ---------- */
|
||||||
import type { ChatMessage } from './types'
|
|
||||||
|
|
||||||
function newChatId(): string {
|
function newChatId(): string {
|
||||||
return 'chat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
|
return 'chat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
|
||||||
|
|||||||
Reference in New Issue
Block a user