diff --git a/src/core/op.ts b/src/core/op.ts new file mode 100644 index 0000000..5574e6a --- /dev/null +++ b/src/core/op.ts @@ -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(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 & { style?: Record } + 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).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 + const el = findEl(deck, slideIdx, elementId) + if (!el) return { type: 'replace_deck', deck: clone(deck) } + // 构造反向 patch:取当前值 + const backwardPatch: Record = {} + if (patch.style) { + const oldStyle: Record = {} + for (const k of Object.keys(patch.style as object)) { + oldStyle[k] = (el.style as Record)[k] + } + backwardPatch.style = oldStyle + } + for (const k of Object.keys(patch)) { + if (k === 'style') continue + backwardPatch[k] = (el as unknown as Record)[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) } + } +} diff --git a/src/core/store.ts b/src/core/store.ts index a6f2d78..c992713 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -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 | 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[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 & { style?: Record }) { - 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).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, 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)