重构: 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:
@@ -3,8 +3,9 @@
|
||||
* 由 store.js 迁移:用 Vue reactive 替代手写 pub/sub,组件自动追踪
|
||||
* ===================================================================== */
|
||||
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 { applyOp, invertOp, type Op, type HistoryEntry } from './op'
|
||||
|
||||
/* ---------- localStorage 键 ---------- */
|
||||
const LS_KEY = 'u-ppt.deck.v1'
|
||||
@@ -70,15 +71,17 @@ const BUILTIN_TEMPLATES: PageTemplate[] = [
|
||||
}
|
||||
]
|
||||
|
||||
/* ---------- 历史快照 ---------- */
|
||||
interface Snapshot { deck: Deck; index: number; sel: string | null }
|
||||
/* ---------- Op 历史栈(undo/redo 基于 Op 回滚) ---------- */
|
||||
const HIST_MAX = 40
|
||||
let hist: Snapshot[] = []
|
||||
let future: Snapshot[] = []
|
||||
let hist: HistoryEntry[] = [] // 过去的操作
|
||||
let future: HistoryEntry[] = [] // redo 栈
|
||||
let suppressHistory = false
|
||||
let touchKey: string | 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<{
|
||||
deck: Deck
|
||||
@@ -173,20 +176,40 @@ export function resolveBg(key: string): string {
|
||||
return t[key] || '#ffffff'
|
||||
}
|
||||
|
||||
/* ---------- 历史快照 ---------- */
|
||||
function pushHistory() {
|
||||
if (suppressHistory) return
|
||||
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
||||
/* ---------- 内部:执行 Op 并推入历史 ---------- */
|
||||
|
||||
/** 执行一个 Op,计算逆 Op,推入历史栈。返回是否执行成功 */
|
||||
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()
|
||||
future.length = 0
|
||||
}
|
||||
|
||||
/** 连续「同一元素同一字段」的操作合并为一条历史(如拖字号滑块、连续敲字) */
|
||||
function touchHistory(key: string) {
|
||||
if (suppressHistory) return
|
||||
if (touchKey !== key) { pushHistory(); touchKey = key }
|
||||
if (touchTimer) clearTimeout(touchTimer)
|
||||
touchTimer = setTimeout(() => { touchKey = null }, 900)
|
||||
// coalesce 逻辑
|
||||
if (opts?.coalesceKey) {
|
||||
touchKey = opts.coalesceKey
|
||||
if (touchTimer) clearTimeout(touchTimer)
|
||||
touchTimer = setTimeout(() => { touchKey = null }, 900)
|
||||
}
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
/* ---------- 数据版本迁移(旧版数据补字段升级,不丢弃) ---------- */
|
||||
@@ -269,101 +292,73 @@ function selectElement(id: string | null) { state.selectedId = id || null }
|
||||
|
||||
/* ---------- 主题 ---------- */
|
||||
function setTheme(name: string) {
|
||||
// 允许内置主题键与已存的自定义主题键
|
||||
if (!themes[name] && !getCustomThemes()[name]) return
|
||||
pushHistory()
|
||||
state.deck.theme = name
|
||||
scheduleSave()
|
||||
execOp({ type: 'set_theme', theme: name, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/* ---------- 幻灯片 CRUD ---------- */
|
||||
function addSlide(atIndex?: number) {
|
||||
pushHistory()
|
||||
const s: Slide = { id: uid('s'), background: 'bg', elements: [] }
|
||||
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.selectedId = null
|
||||
scheduleSave()
|
||||
return s
|
||||
}
|
||||
|
||||
function dupSlide() {
|
||||
pushHistory()
|
||||
const cur = currentSlide.value; if (!cur) return
|
||||
const copy = clone(cur); copy.id = uid('s')
|
||||
copy.elements.forEach(e => { e.id = uid('el') })
|
||||
state.deck.slides.splice(state.currentIndex + 1, 0, copy)
|
||||
state.currentIndex++
|
||||
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
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function delSlide(i?: number): boolean {
|
||||
if (state.deck.slides.length <= 1) return false
|
||||
pushHistory()
|
||||
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
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
function moveSlide(from: number, to: number) {
|
||||
if (from === to || from < 0 || to < 0) return
|
||||
const arr = state.deck.slides
|
||||
if (to >= arr.length) return
|
||||
pushHistory()
|
||||
const item = arr.splice(from, 1)[0]
|
||||
arr.splice(to, 0, item)
|
||||
if (to >= state.deck.slides.length) return
|
||||
execOp({ type: 'move_slide', from, to, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||
state.currentIndex = to
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function setSlideBackground(bg: string) {
|
||||
pushHistory()
|
||||
currentSlide.value.background = bg as Slide['background']
|
||||
scheduleSave()
|
||||
execOp({ type: 'set_slide_bg', index: state.currentIndex, bg: bg as Slide['background'], clientId: CLIENT_ID, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
/* ---------- 元素 CRUD ---------- */
|
||||
function addElement(type: SlideElement['type'], over?: Parameters<typeof createElement>[1]) {
|
||||
pushHistory()
|
||||
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
|
||||
scheduleSave()
|
||||
return el
|
||||
}
|
||||
|
||||
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(','))
|
||||
touchHistory(key)
|
||||
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()
|
||||
const coalesceKey = id + ':' + (patch.style ? Object.keys(patch.style).sort().join(',') : Object.keys(patch).sort().join(','))
|
||||
execOp({ type: 'update_element', slideIdx: state.currentIndex, elementId: id, patch, clientId: CLIENT_ID, timestamp: Date.now() }, { coalesceKey })
|
||||
}
|
||||
|
||||
function delElement(id: string) {
|
||||
pushHistory()
|
||||
const s = currentSlide.value
|
||||
s.elements = s.elements.filter(e => e.id !== id)
|
||||
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
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function moveElementZ(id: string, dir: number) {
|
||||
pushHistory()
|
||||
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()
|
||||
execOp({ type: 'move_element_z', slideIdx: state.currentIndex, elementId: id, dir, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/* ---------- undo / redo ---------- */
|
||||
/* ---------- undo / redo(基于 Op 回滚) ---------- */
|
||||
function undo(): boolean {
|
||||
if (!hist.length) return false
|
||||
future.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
||||
const prev = hist.pop()!
|
||||
const entry = hist.pop()!
|
||||
suppressHistory = true
|
||||
state.deck = prev.deck
|
||||
state.currentIndex = Math.min(prev.index, state.deck.slides.length - 1)
|
||||
state.selectedId = prev.sel
|
||||
state.deck = applyOp(state.deck, entry.backward)
|
||||
suppressHistory = false
|
||||
future.push(entry)
|
||||
scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
function redo(): boolean {
|
||||
if (!future.length) return false
|
||||
hist.push({ deck: clone(state.deck), index: state.currentIndex, sel: state.selectedId })
|
||||
const nxt = future.pop()!
|
||||
const entry = future.pop()!
|
||||
suppressHistory = true
|
||||
state.deck = nxt.deck
|
||||
state.currentIndex = Math.min(nxt.index, state.deck.slides.length - 1)
|
||||
state.selectedId = nxt.sel
|
||||
state.deck = applyOp(state.deck, entry.forward)
|
||||
suppressHistory = false
|
||||
hist.push(entry)
|
||||
scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
/* ---------- 整体替换(AI 生成 / 导入 / 重置用) ---------- */
|
||||
function replaceDeck(newDeck: Deck | Record<string, unknown>, opts?: { keepHistory?: boolean; newChat?: boolean }) {
|
||||
if (!opts?.keepHistory) pushHistory()
|
||||
// 自动备份:newChat=true 时说明是生成整套/重置等“覆盖性操作”,把当前 deck 存入 .bak
|
||||
if (opts?.newChat && state.deck?.slides?.length) {
|
||||
try { localStorage.setItem(LS_KEY + '.bak', JSON.stringify(state.deck)) } catch (e) {}
|
||||
}
|
||||
suppressHistory = true
|
||||
let d = newDeck as Deck
|
||||
if (!d || typeof d !== 'object') d = {} as Deck
|
||||
d.v = DECK_VERSION // 维护版本号
|
||||
// 健壮性:损坏/空数据兜底为一页空白,避免下游 forEach 崩溃
|
||||
d.v = DECK_VERSION
|
||||
if (!Array.isArray(d.slides) || !d.slides.length) {
|
||||
d.slides = [{ id: uid('s'), background: 'bg', elements: [] }]
|
||||
}
|
||||
// 会话 ID 绑定:newChat=true 开新会话;否则沿用 deck 自带的 chatId(文库加载时);都没有则生成新的
|
||||
if (opts?.newChat || !d.chatId) {
|
||||
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.currentIndex = Math.min(state.currentIndex, d.slides.length - 1)
|
||||
if (state.currentIndex < 0) state.currentIndex = 0
|
||||
state.selectedId = null
|
||||
suppressHistory = false
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
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.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
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function appendSlide(newSlide: Slide) {
|
||||
pushHistory()
|
||||
newSlide.id = uid('s')
|
||||
newSlide.elements = newSlide.elements || []
|
||||
newSlide.elements.forEach(e => { if (!e.id) e.id = uid('el') })
|
||||
state.deck.slides.push(newSlide)
|
||||
state.currentIndex = state.deck.slides.length - 1
|
||||
const idx = state.deck.slides.length
|
||||
execOp({ type: 'add_slide', atIndex: idx, slide: newSlide, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||
state.currentIndex = idx
|
||||
state.selectedId = null
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function insertSlideAt(index: number, newSlide: Slide) {
|
||||
pushHistory()
|
||||
newSlide.id = uid('s')
|
||||
newSlide.elements = newSlide.elements || []
|
||||
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.selectedId = null
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
@@ -517,14 +504,12 @@ function saveCustomTheme(t: import('./types').Theme): string {
|
||||
|
||||
/** 应用一个主题(内置 key 或自定义对象)到当前 deck */
|
||||
function applyTheme(nameOrObj: string | import('./types').Theme) {
|
||||
pushHistory()
|
||||
if (typeof nameOrObj === 'string') {
|
||||
state.deck.theme = nameOrObj
|
||||
execOp({ type: 'set_theme', theme: nameOrObj, clientId: CLIENT_ID, timestamp: Date.now() })
|
||||
} else {
|
||||
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 {
|
||||
const tpl = getTemplates().find(t => t.id === tplId)
|
||||
if (!tpl) return false
|
||||
pushHistory()
|
||||
const newSlide: Slide = {
|
||||
id: uid('s'),
|
||||
background: tpl.background,
|
||||
elements: clone(tpl.elements).map(e => ({ ...e, id: uid('el') }))
|
||||
}
|
||||
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.selectedId = null
|
||||
scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
/* ---------- 删除用户模板 ---------- */
|
||||
/** 删除用户模板 */
|
||||
function deleteTemplate(tplId: string) {
|
||||
const user = getUserTemplates().filter(t => t.id !== tplId)
|
||||
@@ -661,7 +645,6 @@ function newBlankDeck() {
|
||||
}
|
||||
|
||||
/* ---------- 会话管理(按 chatId 隔离) ---------- */
|
||||
import type { ChatMessage } from './types'
|
||||
|
||||
function newChatId(): string {
|
||||
return 'chat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
|
||||
|
||||
Reference in New Issue
Block a user