返回 oh-my-ppt
editHistoryStore.ts
根目录 / src / renderer / src / store / editHistoryStore.ts
1 import { create } from 'zustand'
2 import type { InsertChartSeries } from '../components/session-detail/workspace/insert-charts'
3 import type { EditModeLayoutIsland } from '../lib/presentation-layout-island'
4
5 // ─── Types ────────────────────────────────────────────
6
7 export interface DragEditItem {
8 pageId: string
9 htmlPath: string
10 selector: string
11 x: number
12 y: number
13 width: number | null
14 height: number | null
15 layoutIsland?: EditModeLayoutIsland
16 childUpdates: Array<{ path: number[]; width?: number; height?: number }>
17 isAbsoluteMode: boolean
18 zIndex?: number
19 zIndexOnly?: boolean
20 }
21
22 export interface TextEditItem {
23 pageId: string
24 htmlPath: string
25 selector: string
26 patch: {
27 text?: string
28 style: { color?: string; fontSize?: string; fontWeight?: string; textAlign?: string }
29 }
30 }
31
32 export interface PropertyEditItem {
33 pageId: string
34 htmlPath: string
35 selector: string
36 blockId?: string
37 patch: {
38 html?: string
39 text?: string
40 formula?: {
41 latex: string
42 html: string
43 displayMode: boolean
44 originalLatex?: string
45 }
46 chart?: {
47 type: string
48 title: string
49 labels: string[]
50 values: number[]
51 series: InsertChartSeries[]
52 primaryColor: string
53 accentColor: string
54 textColor: string
55 smooth: boolean
56 horizontal: boolean
57 stacked: boolean
58 areaFill: boolean
59 showPoints: boolean
60 showLegend: boolean
61 doughnutCutout: number
62 radarFill: boolean
63 configJson: string
64 }
65 textTarget?: {
66 type: 'text-node'
67 parentSelector: string
68 textNodeIndex: number
69 text: string
70 }
71 style?: {
72 zIndex?: number
73 opacity?: number
74 backgroundColor?: string
75 color?: string
76 fontSize?: string
77 fontWeight?: string
78 textAlign?: string
79 objectFit?: string
80 }
81 attrs?: {
82 alt?: string
83 poster?: string
84 controls?: boolean
85 muted?: boolean
86 loop?: boolean
87 autoplay?: boolean
88 playsInline?: boolean
89 preload?: string
90 }
91 }
92 }
93
94 export interface DeleteItem {
95 pageId: string
96 htmlPath: string
97 selector: string
98 }
99
100 export interface AddElementItem {
101 pageId: string
102 htmlPath: string
103 parentSelector: string
104 htmlFragment: string
105 assignedBlockId: string
106 insertIndex: number
107 }
108
109 export interface EditSnapshot {
110 dragEdits: DragEditItem[]
111 textEdits: TextEditItem[]
112 propertyEdits: PropertyEditItem[]
113 deletes: DeleteItem[]
114 addElements: AddElementItem[]
115 }
116
117 type PageEditStacks = Record<string, EditSnapshot[]>
118
119 // ─── Helpers ──────────────────────────────────────────
120
121 function cloneSnapshot(s: EditSnapshot): EditSnapshot {
122 return {
123 dragEdits: s.dragEdits.map((e) => ({
124 ...e,
125 layoutIsland: e.layoutIsland
126 ? { ...e.layoutIsland, children: e.layoutIsland.children.map((child) => ({ ...child })) }
127 : undefined,
128 childUpdates: e.childUpdates.map((c) => ({ ...c }))
129 })),
130 textEdits: s.textEdits.map((e) => ({
131 ...e,
132 patch: {
133 text: e.patch.text,
134 style: { ...e.patch.style }
135 }
136 })),
137 propertyEdits: s.propertyEdits.map((e) => ({
138 ...e,
139 patch: {
140 html: e.patch.html,
141 text: e.patch.text,
142 formula: e.patch.formula ? { ...e.patch.formula } : undefined,
143 chart: e.patch.chart
144 ? {
145 ...e.patch.chart,
146 labels: [...e.patch.chart.labels],
147 values: [...e.patch.chart.values],
148 series: (e.patch.chart.series || []).map((series) => ({
149 ...series,
150 values: [...series.values]
151 }))
152 }
153 : undefined,
154 textTarget: e.patch.textTarget ? { ...e.patch.textTarget } : undefined,
155 style: e.patch.style ? { ...e.patch.style } : undefined,
156 attrs: e.patch.attrs ? { ...e.patch.attrs } : undefined
157 }
158 })),
159 deletes: s.deletes.map((e) => ({ ...e })),
160 addElements: s.addElements.map((e) => ({ ...e }))
161 }
162 }
163
164 function emptySnapshot(): EditSnapshot {
165 return {
166 dragEdits: [],
167 textEdits: [],
168 propertyEdits: [],
169 deletes: [],
170 addElements: []
171 }
172 }
173
174 function getSnapshotForPageFromState(
175 state: Pick<
176 EditHistoryState,
177 'dragEdits' | 'textEdits' | 'propertyEdits' | 'deletes' | 'addElements'
178 >,
179 pageId: string
180 ): EditSnapshot {
181 return {
182 dragEdits: state.dragEdits.filter((e) => e.pageId === pageId),
183 textEdits: state.textEdits.filter((e) => e.pageId === pageId),
184 propertyEdits: state.propertyEdits.filter((e) => e.pageId === pageId),
185 deletes: state.deletes.filter((e) => e.pageId === pageId),
186 addElements: state.addElements.filter((e) => e.pageId === pageId)
187 }
188 }
189
190 function replacePageSnapshot<
191 T extends Pick<
192 EditHistoryState,
193 'dragEdits' | 'textEdits' | 'propertyEdits' | 'deletes' | 'addElements'
194 >
195 >(state: T, pageId: string, snapshot: EditSnapshot): Pick<
196 EditHistoryState,
197 'dragEdits' | 'textEdits' | 'propertyEdits' | 'deletes' | 'addElements'
198 > {
199 return {
200 dragEdits: [
201 ...state.dragEdits.filter((item) => item.pageId !== pageId),
202 ...snapshot.dragEdits
203 ],
204 textEdits: [
205 ...state.textEdits.filter((item) => item.pageId !== pageId),
206 ...snapshot.textEdits
207 ],
208 propertyEdits: [
209 ...state.propertyEdits.filter((item) => item.pageId !== pageId),
210 ...snapshot.propertyEdits
211 ],
212 deletes: [...state.deletes.filter((item) => item.pageId !== pageId), ...snapshot.deletes],
213 addElements: [
214 ...state.addElements.filter((item) => item.pageId !== pageId),
215 ...snapshot.addElements
216 ]
217 }
218 }
219
220 function pushPageStack(stacks: PageEditStacks, pageId: string, snapshot: EditSnapshot): PageEditStacks {
221 return {
222 ...stacks,
223 [pageId]: [...(stacks[pageId] || []), cloneSnapshot(snapshot)]
224 }
225 }
226
227 function clearPageStack(stacks: PageEditStacks, pageId: string): PageEditStacks {
228 const next = { ...stacks }
229 delete next[pageId]
230 return next
231 }
232
233 function compactPatchObject<T extends Record<string, unknown>>(value: T | undefined): Partial<T> {
234 if (!value) return {}
235 return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as Partial<T>
236 }
237
238 function propertyPatchEquals(a: PropertyEditItem['patch'], b: PropertyEditItem['patch']): boolean {
239 const aStyle = compactPatchObject(a.style)
240 const bStyle = compactPatchObject(b.style)
241 const aAttrs = compactPatchObject(a.attrs)
242 const bAttrs = compactPatchObject(b.attrs)
243 return (
244 a.text === b.text &&
245 a.html === b.html &&
246 JSON.stringify(a.formula || null) === JSON.stringify(b.formula || null) &&
247 JSON.stringify(a.chart || null) === JSON.stringify(b.chart || null) &&
248 JSON.stringify(a.textTarget || null) === JSON.stringify(b.textTarget || null) &&
249 JSON.stringify(aStyle) === JSON.stringify(bStyle) &&
250 JSON.stringify(aAttrs) === JSON.stringify(bAttrs)
251 )
252 }
253
254 export function extractSelectorBlockIds(selector: string): string[] {
255 if (!selector || !selector.includes('data-block-id')) return []
256 const pattern = /data-block-id\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\]\s]+))/g
257 const ids: string[] = []
258 let match: RegExpExecArray | null
259 while ((match = pattern.exec(selector)) !== null) {
260 const id = match[1] || match[2] || match[3]
261 if (id && !ids.includes(id)) ids.push(id)
262 }
263 return ids
264 }
265
266 export function selectorReferencesBlockId(selector: string, blockId: string): boolean {
267 return extractSelectorBlockIds(selector).includes(blockId)
268 }
269
270 export function selectorsTargetSameBlock(selectorA: string, selectorB: string): boolean {
271 if (!selectorA || !selectorB) return false
272 if (selectorA === selectorB) return true
273 const idsA = extractSelectorBlockIds(selectorA)
274 if (idsA.length === 0) return false
275 const idsB = extractSelectorBlockIds(selectorB)
276 return idsB.some((id) => idsA.includes(id))
277 }
278
279 export function editTargetMatchesDeletedSelector(
280 editSelector: string,
281 deletedSelector: string,
282 editBlockId?: string
283 ): boolean {
284 if (selectorsTargetSameBlock(editSelector, deletedSelector)) return true
285 return Boolean(editBlockId && selectorReferencesBlockId(deletedSelector, editBlockId))
286 }
287
288 function deleteItemsTargetSameElement(a: DeleteItem, b: DeleteItem): boolean {
289 return (
290 a.pageId === b.pageId &&
291 a.htmlPath === b.htmlPath &&
292 selectorsTargetSameBlock(a.selector, b.selector)
293 )
294 }
295
296 function isGeneratedBackgroundAdd(item: AddElementItem): boolean {
297 return (
298 item.htmlFragment.includes('data-ppt-generated-background="1"') ||
299 item.htmlFragment.includes("data-ppt-generated-background='1'")
300 )
301 }
302
303 function isGeneratedBackgroundDelete(item: DeleteItem): boolean {
304 return (
305 item.selector === '[data-ppt-generated-background="1"]' ||
306 item.selector === "[data-ppt-generated-background='1']" ||
307 item.selector === '[data-ppt-generated-background-style="1"]' ||
308 item.selector === "[data-ppt-generated-background-style='1']"
309 )
310 }
311
312 function appendUniqueDeletes(existing: DeleteItem[], incoming: DeleteItem[]): DeleteItem[] {
313 const next = [...existing]
314 for (const item of incoming) {
315 const duplicate = next.some((deleteItem) => deleteItemsTargetSameElement(deleteItem, item))
316 if (!duplicate) next.push(item)
317 }
318 return next
319 }
320
321 // ─── Store ────────────────────────────────────────────
322
323 interface EditHistoryState {
324 dragEdits: DragEditItem[]
325 textEdits: TextEditItem[]
326 propertyEdits: PropertyEditItem[]
327 deletes: DeleteItem[]
328 addElements: AddElementItem[]
329 undoStacks: PageEditStacks
330 redoStacks: PageEditStacks
331 savedCheckpoints: Record<string, EditSnapshot>
332
333 upsertDragEdit: (edit: DragEditItem) => void
334 upsertTextEdit: (edit: TextEditItem) => void
335 upsertPropertyEdit: (edit: PropertyEditItem) => void
336 addDelete: (item: DeleteItem) => void
337 addElement: (item: AddElementItem) => void
338 addElementWithDeletes: (item: AddElementItem, deletes: DeleteItem[]) => void
339 undo: (pageId: string) => EditSnapshot | null
340 redo: (pageId: string) => EditSnapshot | null
341 canUndo: (pageId?: string | null) => boolean
342 canRedo: (pageId?: string | null) => boolean
343 hasPendingEdits: (pageId?: string | null) => boolean
344 markPageSaved: (pageId: string) => void
345 clearPage: (pageId: string) => void
346 clear: () => void
347 getSnapshotForPage: (pageId: string) => EditSnapshot
348 }
349
350 export const useEditHistoryStore = create<EditHistoryState>((set, get) => ({
351 dragEdits: [],
352 textEdits: [],
353 propertyEdits: [],
354 deletes: [],
355 addElements: [],
356 undoStacks: {},
357 redoStacks: {},
358 savedCheckpoints: {},
359
360 upsertDragEdit: (edit) =>
361 set((state) => {
362 const snapshot = getSnapshotForPageFromState(state, edit.pageId)
363 const idx = state.dragEdits.findIndex(
364 (item) =>
365 item.pageId === edit.pageId &&
366 item.htmlPath === edit.htmlPath &&
367 item.selector === edit.selector
368 )
369 let next: DragEditItem[]
370 if (idx < 0) {
371 next = [...state.dragEdits, edit]
372 } else {
373 // Merge: zIndexOnly edits preserve existing position data;
374 // drag edits preserve existing zIndex if new edit has none
375 const existing = state.dragEdits[idx]
376 const merged: DragEditItem = {
377 ...edit,
378 zIndex: edit.zIndex ?? existing.zIndex,
379 layoutIsland: edit.layoutIsland ?? existing.layoutIsland
380 }
381 if (edit.zIndexOnly) {
382 // Z-index-only change: keep existing position data, preserve flag
383 merged.x = existing.x
384 merged.y = existing.y
385 merged.width = existing.width
386 merged.height = existing.height
387 merged.layoutIsland = existing.layoutIsland
388 merged.childUpdates = existing.childUpdates
389 merged.isAbsoluteMode = existing.isAbsoluteMode
390 merged.zIndexOnly = true
391 } else {
392 // Full drag edit: clear zIndexOnly flag since position is also being updated
393 merged.zIndexOnly = undefined
394 }
395 next = state.dragEdits.map((item, i) => (i === idx ? merged : item))
396 }
397 return {
398 undoStacks: pushPageStack(state.undoStacks, edit.pageId, snapshot),
399 redoStacks: clearPageStack(state.redoStacks, edit.pageId),
400 dragEdits: next
401 }
402 }),
403
404 upsertTextEdit: (edit) =>
405 set((state) => {
406 const snapshot = getSnapshotForPageFromState(state, edit.pageId)
407 const idx = state.textEdits.findIndex(
408 (item) =>
409 item.pageId === edit.pageId &&
410 item.selector === edit.selector
411 )
412 const next =
413 idx < 0
414 ? [...state.textEdits, edit]
415 : state.textEdits.map((item, i) => (i === idx ? edit : item))
416 return {
417 undoStacks: pushPageStack(state.undoStacks, edit.pageId, snapshot),
418 redoStacks: clearPageStack(state.redoStacks, edit.pageId),
419 textEdits: next
420 }
421 }),
422
423 upsertPropertyEdit: (edit) =>
424 set((state) => {
425 const snapshot = getSnapshotForPageFromState(state, edit.pageId)
426 const idx = state.propertyEdits.findIndex(
427 (item) =>
428 item.pageId === edit.pageId &&
429 item.htmlPath === edit.htmlPath &&
430 item.selector === edit.selector
431 )
432 const mergePatch = (prev: PropertyEditItem['patch'], next: PropertyEditItem['patch']): PropertyEditItem['patch'] => ({
433 html: next.html ?? prev.html,
434 text: next.text ?? prev.text,
435 formula: next.formula ?? prev.formula,
436 chart: next.chart ?? prev.chart,
437 textTarget: next.textTarget ?? prev.textTarget,
438 style: {
439 ...(prev.style || {}),
440 ...(next.style || {})
441 },
442 attrs: {
443 ...(prev.attrs || {}),
444 ...(next.attrs || {})
445 }
446 })
447 const next =
448 idx < 0
449 ? [...state.propertyEdits, edit]
450 : state.propertyEdits.map((item, i) =>
451 i === idx ? { ...item, ...edit, patch: mergePatch(item.patch, edit.patch) } : item
452 )
453 if (idx >= 0 && propertyPatchEquals(state.propertyEdits[idx].patch, next[idx].patch)) {
454 return state
455 }
456 return {
457 undoStacks: pushPageStack(state.undoStacks, edit.pageId, snapshot),
458 redoStacks: clearPageStack(state.redoStacks, edit.pageId),
459 propertyEdits: next
460 }
461 }),
462
463 addDelete: (item) =>
464 set((state) => {
465 const snapshot = getSnapshotForPageFromState(state, item.pageId)
466 const pendingAdd = state.addElements.find(
467 (add) =>
468 add.pageId === item.pageId &&
469 add.htmlPath === item.htmlPath &&
470 selectorReferencesBlockId(item.selector, add.assignedBlockId)
471 )
472 if (pendingAdd) {
473 const cancelGeneratedBackgroundReplacement = isGeneratedBackgroundAdd(pendingAdd)
474 return {
475 undoStacks: pushPageStack(state.undoStacks, item.pageId, snapshot),
476 redoStacks: clearPageStack(state.redoStacks, item.pageId),
477 dragEdits: state.dragEdits.filter(
478 (edit) =>
479 edit.pageId !== item.pageId ||
480 !editTargetMatchesDeletedSelector(edit.selector, item.selector)
481 ),
482 textEdits: state.textEdits.filter(
483 (edit) =>
484 edit.pageId !== item.pageId ||
485 !editTargetMatchesDeletedSelector(edit.selector, item.selector)
486 ),
487 propertyEdits: state.propertyEdits.filter(
488 (edit) =>
489 edit.pageId !== item.pageId ||
490 !editTargetMatchesDeletedSelector(edit.selector, item.selector, edit.blockId)
491 ),
492 deletes: cancelGeneratedBackgroundReplacement
493 ? state.deletes.filter(
494 (deleteItem) =>
495 deleteItem.pageId !== item.pageId ||
496 deleteItem.htmlPath !== item.htmlPath ||
497 !isGeneratedBackgroundDelete(deleteItem)
498 )
499 : state.deletes,
500 addElements: state.addElements.filter(
501 (add) =>
502 add !== pendingAdd &&
503 !(
504 cancelGeneratedBackgroundReplacement &&
505 add.pageId === item.pageId &&
506 add.htmlPath === item.htmlPath &&
507 isGeneratedBackgroundAdd(add)
508 )
509 )
510 }
511 }
512 const exists = state.deletes.some((deleteItem) => deleteItemsTargetSameElement(deleteItem, item))
513 if (exists) return state
514 return {
515 undoStacks: pushPageStack(state.undoStacks, item.pageId, snapshot),
516 redoStacks: clearPageStack(state.redoStacks, item.pageId),
517 deletes: [...state.deletes, item]
518 }
519 }),
520
521 addElement: (item) =>
522 set((state) => {
523 const snapshot = getSnapshotForPageFromState(state, item.pageId)
524 return {
525 undoStacks: pushPageStack(state.undoStacks, item.pageId, snapshot),
526 redoStacks: clearPageStack(state.redoStacks, item.pageId),
527 addElements: [...state.addElements, item]
528 }
529 }),
530
531 addElementWithDeletes: (item, deletes) =>
532 set((state) => {
533 const pageId = item.pageId
534 const snapshot = getSnapshotForPageFromState(state, pageId)
535 const generatedBackgroundAdd = isGeneratedBackgroundAdd(item)
536 const existingDeletes = generatedBackgroundAdd
537 ? state.deletes.filter(
538 (deleteItem) =>
539 deleteItem.pageId !== pageId ||
540 deleteItem.htmlPath !== item.htmlPath ||
541 !isGeneratedBackgroundDelete(deleteItem)
542 )
543 : state.deletes
544 const existingAddElements = generatedBackgroundAdd
545 ? state.addElements.filter(
546 (add) =>
547 add.pageId !== pageId ||
548 add.htmlPath !== item.htmlPath ||
549 !isGeneratedBackgroundAdd(add)
550 )
551 : state.addElements
552 return {
553 undoStacks: pushPageStack(state.undoStacks, pageId, snapshot),
554 redoStacks: clearPageStack(state.redoStacks, pageId),
555 deletes: appendUniqueDeletes(existingDeletes, deletes),
556 addElements: [...existingAddElements, item]
557 }
558 }),
559
560 undo: (pageId) => {
561 const state = get()
562 const pageUndoStack = state.undoStacks[pageId] || []
563 if (pageUndoStack.length === 0) return null
564 const prev = pageUndoStack[pageUndoStack.length - 1]
565 const current = getSnapshotForPageFromState(state, pageId)
566 set({
567 undoStacks: {
568 ...state.undoStacks,
569 [pageId]: pageUndoStack.slice(0, -1)
570 },
571 redoStacks: pushPageStack(state.redoStacks, pageId, current),
572 ...replacePageSnapshot(state, pageId, prev)
573 })
574 return cloneSnapshot(prev)
575 },
576
577 redo: (pageId) => {
578 const state = get()
579 const pageRedoStack = state.redoStacks[pageId] || []
580 if (pageRedoStack.length === 0) return null
581 const next = pageRedoStack[pageRedoStack.length - 1]
582 const current = getSnapshotForPageFromState(state, pageId)
583 set({
584 redoStacks: {
585 ...state.redoStacks,
586 [pageId]: pageRedoStack.slice(0, -1)
587 },
588 undoStacks: pushPageStack(state.undoStacks, pageId, current),
589 ...replacePageSnapshot(state, pageId, next)
590 })
591 return cloneSnapshot(next)
592 },
593
594 canUndo: (pageId) => Boolean(pageId && (get().undoStacks[pageId]?.length || 0) > 0),
595 canRedo: (pageId) => Boolean(pageId && (get().redoStacks[pageId]?.length || 0) > 0),
596 hasPendingEdits: (pageId) => {
597 if (!pageId) return false
598 const state = get()
599 const snapshot = getSnapshotForPageFromState(state, pageId)
600 return Boolean(
601 snapshot.dragEdits.length > 0 ||
602 snapshot.textEdits.length > 0 ||
603 snapshot.propertyEdits.length > 0 ||
604 snapshot.deletes.length > 0 ||
605 snapshot.addElements.length > 0
606 )
607 },
608
609 markPageSaved: (pageId) =>
610 set((state) => ({
611 ...replacePageSnapshot(state, pageId, emptySnapshot()),
612 undoStacks: clearPageStack(state.undoStacks, pageId),
613 redoStacks: clearPageStack(state.redoStacks, pageId),
614 savedCheckpoints: {
615 ...state.savedCheckpoints,
616 [pageId]: emptySnapshot()
617 }
618 })),
619
620 clearPage: (pageId) =>
621 set((state) => ({
622 dragEdits: state.dragEdits.filter((item) => item.pageId !== pageId),
623 textEdits: state.textEdits.filter((item) => item.pageId !== pageId),
624 propertyEdits: state.propertyEdits.filter((item) => item.pageId !== pageId),
625 deletes: state.deletes.filter((item) => item.pageId !== pageId),
626 addElements: state.addElements.filter((item) => item.pageId !== pageId),
627 undoStacks: clearPageStack(state.undoStacks, pageId),
628 redoStacks: clearPageStack(state.redoStacks, pageId),
629 savedCheckpoints: {
630 ...state.savedCheckpoints,
631 [pageId]: emptySnapshot()
632 }
633 })),
634
635 clear: () =>
636 set({
637 dragEdits: [],
638 textEdits: [],
639 propertyEdits: [],
640 deletes: [],
641 addElements: [],
642 undoStacks: {},
643 redoStacks: {},
644 savedCheckpoints: {}
645 }),
646
647 getSnapshotForPage: (pageId) => {
648 return cloneSnapshot(getSnapshotForPageFromState(get(), pageId))
649 }
650 }))
651
651 lines TYPESCRIPT