返回 oh-my-ppt
editSessionStore.ts
根目录 / src / renderer / src / store / editSessionStore.ts
1 import { create } from 'zustand'
2 import { ipc } from '@renderer/lib/ipc'
3 import type { I18nKey, TranslationParams } from '../i18n'
4 import type { EditModeMovePayload, EditSelectionPayload } from '@arcsin1/presentation-editor-runtime'
5 import type { PreviewIframeHandle } from '../components/preview/PreviewIframe'
6 import {
7 EMPTY_ELEMENT_DRAFT,
8 fontSizeToNumber,
9 normalizeFontWeight,
10 normalizeTextAlign,
11 opacityToInput,
12 rgbToHex
13 } from '../components/session-detail/element-inspector/elementEditUtils'
14 import type { ElementEditDraft } from '../components/session-detail/element-inspector'
15 import { hasCapability } from '../components/session-detail/element-inspector/types'
16 import {
17 buildChartJsConfig,
18 normalizeChartData,
19 type InsertChartSeries,
20 type InsertChartType
21 } from '../components/session-detail/workspace/insert-charts'
22 import { editTargetMatchesDeletedSelector, useEditHistoryStore } from './editHistoryStore'
23 import { useGenerateStore } from './generateStore'
24 import { useSessionDetailUiStore } from './sessionDetailStore'
25 import { useToastStore } from './toastStore'
26
27 type ElementPropertyStylePatch = {
28 zIndex?: number
29 opacity?: number
30 backgroundColor?: string
31 color?: string
32 fontSize?: string
33 fontWeight?: string
34 textAlign?: string
35 objectFit?: string
36 }
37
38 type ElementPropertyAttrsPatch = {
39 alt?: string
40 poster?: string
41 controls?: boolean
42 muted?: boolean
43 loop?: boolean
44 autoplay?: boolean
45 playsInline?: boolean
46 preload?: string
47 }
48
49 type ElementPropertyPatch = {
50 html?: string
51 text?: string
52 textTarget?: EditSelectionPayload['textTarget']
53 formula?: {
54 latex: string
55 html: string
56 displayMode: boolean
57 originalLatex?: string
58 }
59 chart?: {
60 type: string
61 title: string
62 labels: string[]
63 values: number[]
64 series: InsertChartSeries[]
65 primaryColor: string
66 accentColor: string
67 textColor: string
68 smooth: boolean
69 horizontal: boolean
70 stacked: boolean
71 areaFill: boolean
72 showPoints: boolean
73 showLegend: boolean
74 doughnutCutout: number
75 radarFill: boolean
76 configJson: string
77 }
78 style?: ElementPropertyStylePatch
79 attrs?: ElementPropertyAttrsPatch
80 }
81
82 export interface EditSessionContext {
83 t: (key: I18nKey, params?: TranslationParams) => string
84 requestRefresh: () => void
85 bumpThumbnail: (pageId: string) => void
86 getPageContext: () => { pageId: string; htmlPath: string; sessionId: string } | null
87 }
88
89 function getCommitFieldsForSelection(selection: EditSelectionPayload): Set<keyof ElementEditDraft> {
90 const fields = new Set<keyof ElementEditDraft>()
91 const capabilities = selection.capabilities || []
92 if (capabilities.includes('layer')) fields.add('layoutZIndex')
93 if (hasCapability(selection, 'appearance')) {
94 fields.add('opacity')
95 fields.add('backgroundColor')
96 }
97 if (hasCapability(selection, 'media')) {
98 fields.add('objectFit')
99 fields.add('alt')
100 fields.add('poster')
101 fields.add('controls')
102 fields.add('muted')
103 fields.add('loop')
104 fields.add('autoplay')
105 fields.add('playsInline')
106 fields.add('preload')
107 }
108 if (capabilities.includes('text')) {
109 fields.add('html')
110 fields.add('text')
111 fields.add('color')
112 fields.add('fontSize')
113 fields.add('fontWeight')
114 fields.add('textAlign')
115 }
116 if (capabilities.includes('formula')) {
117 fields.add('formulaLatex')
118 fields.add('formulaHtml')
119 fields.add('formulaDisplayMode')
120 }
121 if (capabilities.includes('chart')) {
122 fields.add('chartTitle')
123 fields.add('chartDataJson')
124 fields.add('chartPrimaryColor')
125 fields.add('chartAccentColor')
126 fields.add('chartTextColor')
127 fields.add('chartSmooth')
128 fields.add('chartHorizontal')
129 fields.add('chartStacked')
130 fields.add('chartAreaFill')
131 fields.add('chartShowPoints')
132 fields.add('chartShowLegend')
133 fields.add('chartDoughnutCutout')
134 fields.add('chartRadarFill')
135 fields.add('chartConfigJson')
136 }
137 return fields
138 }
139
140 function parseCsvList(value: string): string[] {
141 return String(value || '')
142 .split(',')
143 .map((item) => item.trim())
144 .filter(Boolean)
145 }
146
147 function parseNumberCsv(value: string): number[] {
148 return parseCsvList(value)
149 .map((item) => Number(item))
150 .filter((item) => Number.isFinite(item))
151 }
152
153 const CHART_DATA_X_KEYS = ['x', 'label', 'category', 'name']
154 const MAX_CHART_IMPORT_ROWS = 200
155 const MAX_CHART_IMPORT_SERIES = 8
156
157 function toFiniteNumber(value: unknown): number | null {
158 if (typeof value === 'number') return Number.isFinite(value) ? value : null
159 const text = String(value ?? '').trim().replace(/,/g, '')
160 if (!text) return null
161 const parsed = Number(text)
162 return Number.isFinite(parsed) ? parsed : null
163 }
164
165 function formatChartDataJson(
166 labels: string[],
167 series: InsertChartSeries[] | undefined,
168 values: number[]
169 ): string {
170 const safeSeries =
171 series && series.length > 0
172 ? series
173 : [
174 {
175 name: 'Value',
176 values
177 }
178 ]
179 return JSON.stringify(
180 labels.map((label, index) => ({
181 x: label,
182 ...safeSeries.reduce<Record<string, number>>((record, item, seriesIndex) => {
183 const name = item.name || (seriesIndex === 0 ? 'Value' : `Series ${seriesIndex + 1}`)
184 const value = Number(item.values[index])
185 record[name] = Number.isFinite(value) ? value : 0
186 return record
187 }, {})
188 })),
189 null,
190 2
191 )
192 }
193
194 function parseChartDataJson(
195 value: string
196 ): { labels: string[]; values: number[]; series: InsertChartSeries[] } | null {
197 const text = String(value || '').trim()
198 if (!text) return null
199 try {
200 const parsed = JSON.parse(text)
201 if (!Array.isArray(parsed)) return null
202 const labels: string[] = []
203 const normalizedRows: Array<Record<string, unknown>> = []
204 parsed.slice(0, MAX_CHART_IMPORT_ROWS).forEach((item) => {
205 if (Array.isArray(item)) {
206 const label = String(item[0] ?? '').trim()
207 if (!label) return
208 labels.push(label)
209 normalizedRows.push(
210 item.slice(1, MAX_CHART_IMPORT_SERIES + 1).reduce<Record<string, unknown>>(
211 (record, cell, index) => {
212 record[index === 0 ? 'Value' : `Series ${index + 1}`] = cell
213 return record
214 },
215 {}
216 )
217 )
218 } else if (item && typeof item === 'object') {
219 const record = item as Record<string, unknown>
220 const keys = Object.keys(record)
221 const xKey =
222 CHART_DATA_X_KEYS.find((key) => key in record) ??
223 keys.find((key) => toFiniteNumber(record[key]) === null) ??
224 keys[0]
225 const label = String(record[xKey] ?? '').trim()
226 if (!label) return
227 labels.push(label)
228 normalizedRows.push(
229 keys.reduce<Record<string, unknown>>((row, key) => {
230 if (key !== xKey) row[key] = record[key]
231 return row
232 }, {})
233 )
234 }
235 })
236 if (labels.length === 0 || normalizedRows.length === 0) return null
237 const seriesKeys = Array.from(
238 new Set(
239 normalizedRows.flatMap((row) =>
240 Object.keys(row).filter((key) => normalizedRows.some((item) => key in item))
241 )
242 )
243 )
244 .filter((key) => key.trim() && normalizedRows.some((row) => toFiniteNumber(row[key]) !== null))
245 .slice(0, MAX_CHART_IMPORT_SERIES)
246 const safeSeriesKeys = seriesKeys.length > 0 ? seriesKeys : ['Value']
247 const series = safeSeriesKeys.map((key, index) => ({
248 name: key || (index === 0 ? 'Value' : `Series ${index + 1}`),
249 values: normalizedRows.map((row) => toFiniteNumber(row[key]) ?? 0)
250 }))
251 return labels.length > 0 ? { labels, values: series[0]?.values ?? [], series } : null
252 } catch {
253 return null
254 }
255 }
256
257 function buildChartPatchFromDraft(draft: ElementEditDraft): ElementPropertyPatch['chart'] {
258 const chartData = parseChartDataJson(draft.chartDataJson)
259 const chart = normalizeChartData({
260 type: draft.chartType as InsertChartType,
261 title: draft.chartTitle,
262 labels: chartData?.labels ?? parseCsvList(draft.chartLabels),
263 values: chartData?.values ?? parseNumberCsv(draft.chartValues),
264 series: chartData?.series,
265 primaryColor: draft.chartPrimaryColor,
266 accentColor: draft.chartAccentColor,
267 textColor: draft.chartTextColor,
268 smooth: draft.chartSmooth,
269 horizontal: draft.chartHorizontal,
270 stacked: draft.chartStacked,
271 areaFill: draft.chartAreaFill,
272 showPoints: draft.chartShowPoints,
273 showLegend: draft.chartShowLegend,
274 doughnutCutout: Number(draft.chartDoughnutCutout),
275 radarFill: draft.chartRadarFill
276 })
277 return {
278 ...chart,
279 configJson: JSON.stringify(buildChartJsConfig(chart))
280 }
281 }
282
283 function buildElementPropertyPatch(
284 selection: EditSelectionPayload,
285 draft: ElementEditDraft,
286 fields?: Array<keyof ElementEditDraft>
287 ): ElementPropertyPatch | null {
288 if (!selection.snapshot) return null
289
290 const commitFields =
291 fields && fields.length > 0 ? new Set(fields) : getCommitFieldsForSelection(selection)
292 const initial = selection.snapshot
293 const style: ElementPropertyStylePatch = {}
294 const attrs: ElementPropertyAttrsPatch = {}
295 let text: string | undefined
296 let html: string | undefined
297 let formula: ElementPropertyPatch['formula'] | undefined
298 let chart: ElementPropertyPatch['chart'] | undefined
299
300 if (commitFields.has('layoutZIndex')) {
301 const value = parseInt(draft.layoutZIndex, 10)
302 const initialValue = selection.zIndex ?? 10
303 if (Number.isFinite(value) && value !== initialValue) style.zIndex = value
304 }
305 if (commitFields.has('opacity')) {
306 const value = Number(draft.opacity)
307 const initialValue = Number(opacityToInput(initial.computed.opacity))
308 if (Number.isFinite(value) && value !== initialValue) style.opacity = value
309 }
310 if (
311 commitFields.has('backgroundColor') &&
312 draft.backgroundColor !== rgbToHex(initial.computed.svgPaintColor || initial.computed.backgroundColor)
313 ) {
314 style.backgroundColor = draft.backgroundColor
315 }
316 if (
317 commitFields.has('objectFit') &&
318 draft.objectFit !== (initial.computed.objectFit || 'contain')
319 ) {
320 style.objectFit = draft.objectFit
321 }
322 const initialHtml = initial.text?.html || ''
323 if (commitFields.has('html') && draft.html.trim() && draft.html.trim() !== initialHtml.trim()) {
324 html = draft.html.trim()
325 }
326 const initialText = selection.textTarget?.text ?? initial.text?.value ?? ''
327 if (!html && commitFields.has('text') && draft.text.trim() && draft.text.trim() !== initialText) {
328 text = draft.text.trim()
329 }
330 if (
331 (commitFields.has('formulaLatex') ||
332 commitFields.has('formulaHtml') ||
333 commitFields.has('formulaDisplayMode')) &&
334 draft.formulaLatex.trim() &&
335 draft.formulaHtml.trim()
336 ) {
337 const initialFormula = initial.formula
338 const nextLatex = draft.formulaLatex.trim()
339 const nextHtml = draft.formulaHtml.trim()
340 const nextDisplayMode = draft.formulaDisplayMode
341 if (
342 nextLatex !== (initialFormula?.latex || '') ||
343 nextHtml !== (initialFormula?.html || '') ||
344 nextDisplayMode !== Boolean(initialFormula?.displayMode)
345 ) {
346 formula = {
347 latex: nextLatex,
348 html: nextHtml,
349 displayMode: nextDisplayMode,
350 originalLatex: initialFormula?.latex || ''
351 }
352 }
353 }
354 if (
355 commitFields.has('chartTitle') ||
356 commitFields.has('chartDataJson') ||
357 commitFields.has('chartPrimaryColor') ||
358 commitFields.has('chartAccentColor') ||
359 commitFields.has('chartTextColor') ||
360 commitFields.has('chartSmooth') ||
361 commitFields.has('chartHorizontal') ||
362 commitFields.has('chartStacked') ||
363 commitFields.has('chartAreaFill') ||
364 commitFields.has('chartShowPoints') ||
365 commitFields.has('chartShowLegend') ||
366 commitFields.has('chartDoughnutCutout') ||
367 commitFields.has('chartRadarFill') ||
368 commitFields.has('chartConfigJson')
369 ) {
370 const nextChart = buildChartPatchFromDraft(draft)
371 const initialChart = initial.chart
372 ? {
373 type: initial.chart.type,
374 title: initial.chart.title,
375 labels: initial.chart.labels,
376 values: initial.chart.values,
377 series: initial.chart.series || [
378 {
379 name: initial.chart.title || 'Value',
380 values: initial.chart.values
381 }
382 ],
383 primaryColor: initial.chart.primaryColor,
384 accentColor: initial.chart.accentColor,
385 textColor: initial.chart.textColor,
386 smooth: initial.chart.smooth,
387 horizontal: initial.chart.horizontal,
388 stacked: initial.chart.stacked,
389 areaFill: initial.chart.areaFill,
390 showPoints: initial.chart.showPoints,
391 showLegend: initial.chart.showLegend,
392 doughnutCutout: initial.chart.doughnutCutout,
393 radarFill: initial.chart.radarFill,
394 configJson: initial.chart.configJson
395 }
396 : null
397 if (JSON.stringify(nextChart) !== JSON.stringify(initialChart)) {
398 chart = nextChart
399 }
400 }
401 if (commitFields.has('color') && draft.color !== rgbToHex(initial.computed.color)) {
402 style.color = draft.color
403 }
404 if (
405 commitFields.has('fontSize') &&
406 draft.fontSize !== fontSizeToNumber(initial.computed.fontSize)
407 ) {
408 style.fontSize = draft.fontSize ? `${draft.fontSize}px` : undefined
409 }
410 if (
411 commitFields.has('fontWeight') &&
412 draft.fontWeight !== normalizeFontWeight(initial.computed.fontWeight)
413 ) {
414 style.fontWeight = draft.fontWeight
415 }
416 if (
417 commitFields.has('textAlign') &&
418 draft.textAlign !== normalizeTextAlign(initial.computed.textAlign)
419 ) {
420 style.textAlign = draft.textAlign
421 }
422 if (commitFields.has('alt') && draft.alt !== (initial.attrs.alt || '')) attrs.alt = draft.alt
423 if (commitFields.has('poster') && draft.poster !== (initial.attrs.poster || '')) {
424 attrs.poster = draft.poster
425 }
426 if (commitFields.has('controls') && draft.controls !== Boolean(initial.attrs.controls)) {
427 attrs.controls = draft.controls
428 }
429 if (commitFields.has('muted') && draft.muted !== Boolean(initial.attrs.muted)) {
430 attrs.muted = draft.muted
431 }
432 if (commitFields.has('loop') && draft.loop !== Boolean(initial.attrs.loop)) {
433 attrs.loop = draft.loop
434 }
435 if (commitFields.has('autoplay') && draft.autoplay !== Boolean(initial.attrs.autoplay)) {
436 attrs.autoplay = draft.autoplay
437 }
438 if (
439 commitFields.has('playsInline') &&
440 draft.playsInline !== (initial.attrs.playsInline !== false)
441 ) {
442 attrs.playsInline = draft.playsInline
443 }
444 if (commitFields.has('preload') && draft.preload !== (initial.attrs.preload || 'metadata')) {
445 attrs.preload = draft.preload
446 }
447
448 if (
449 html === undefined &&
450 text === undefined &&
451 formula === undefined &&
452 chart === undefined &&
453 Object.keys(style).length === 0 &&
454 Object.keys(attrs).length === 0
455 ) {
456 return null
457 }
458
459 return {
460 html,
461 text,
462 formula,
463 chart,
464 textTarget: text !== undefined ? selection.textTarget : undefined,
465 style: Object.keys(style).length > 0 ? style : undefined,
466 attrs: Object.keys(attrs).length > 0 ? attrs : undefined
467 }
468 }
469
470 interface EditSessionState {
471 iframeHandle: PreviewIframeHandle | null
472 selection: EditSelectionPayload | null
473 draft: ElementEditDraft
474 isSavingEdits: boolean
475 isApplyingSyncElement: boolean
476 ctx: EditSessionContext | null
477
478 attach: (ctx: EditSessionContext) => void
479 setIframeHandle: (handle: PreviewIframeHandle | null) => void
480 resetForPage: () => void
481 reset: () => void
482 selectElement: (payload: EditSelectionPayload) => void
483 handleMoved: (payload: EditModeMovePayload) => void
484 updateDraft: (
485 draft: ElementEditDraft,
486 options?: { commit?: boolean; fields?: Array<keyof ElementEditDraft> }
487 ) => void
488 cancelEdit: () => void
489 deleteSelected: () => void
490 deleteBySelector: (selector: string) => void
491 discardAll: () => void
492 undo: () => void
493 redo: () => void
494 replayPending: () => void
495 commitDraft: (draft: ElementEditDraft, fields?: Array<keyof ElementEditDraft>) => boolean
496 commitCurrentDraft: () => boolean
497 flushPendingDrags: () => Promise<void>
498 save: () => Promise<{ saved: boolean; error?: string }>
499 applySelectedToAllPages: () => Promise<{ applied: boolean; error?: string }>
500 }
501
502 export const useEditSessionStore = create<EditSessionState>((set, get) => ({
503 iframeHandle: null,
504 selection: null,
505 draft: EMPTY_ELEMENT_DRAFT,
506 isSavingEdits: false,
507 isApplyingSyncElement: false,
508 ctx: null,
509
510 attach: (ctx) => set({ ctx }),
511 setIframeHandle: (iframeHandle) => set({ iframeHandle }),
512 resetForPage: () => set({ selection: null, draft: EMPTY_ELEMENT_DRAFT }),
513 reset: () =>
514 set({
515 iframeHandle: null,
516 selection: null,
517 draft: EMPTY_ELEMENT_DRAFT,
518 isSavingEdits: false,
519 ctx: null
520 }),
521
522 commitDraft: (draft, fields) => {
523 const selection = get().selection
524 const pc = get().ctx?.getPageContext()
525 if (!selection || !pc) return false
526 const patch = buildElementPropertyPatch(selection, draft, fields)
527 if (!patch) return false
528 useEditHistoryStore.getState().upsertPropertyEdit({
529 pageId: pc.pageId,
530 htmlPath: pc.htmlPath,
531 selector: selection.selector,
532 blockId: selection.blockId,
533 patch
534 })
535 return true
536 },
537 commitCurrentDraft: () => get().commitDraft(get().draft),
538
539 selectElement: (payload) => {
540 get().commitCurrentDraft()
541 if (!payload.snapshot) {
542 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
543 useSessionDetailUiStore.getState().clearEditSelectedElement()
544 return
545 }
546 set({ selection: payload })
547 useSessionDetailUiStore.getState().setEditSelectedElement(payload.selector)
548 const zValue = payload.zIndex !== undefined ? String(payload.zIndex) : '10'
549 const bounds = payload.snapshot.metrics.page
550 const computed = payload.snapshot.computed
551 const attrs = payload.snapshot.attrs
552 const formula = payload.snapshot.formula
553 const chart = payload.snapshot.chart
554 if (payload.isText) {
555 set({
556 draft: {
557 text: payload.textTarget?.text ?? payload.text,
558 html: payload.html || payload.snapshot.text?.html || '',
559 color: rgbToHex(computed.color),
560 fontSize: fontSizeToNumber(computed.fontSize),
561 fontWeight: normalizeFontWeight(computed.fontWeight),
562 textAlign: normalizeTextAlign(computed.textAlign),
563 layoutX: String(Math.round(bounds.x)),
564 layoutY: String(Math.round(bounds.y)),
565 layoutWidth: String(Math.round(bounds.width)),
566 layoutHeight: String(Math.round(bounds.height)),
567 layoutZIndex: zValue,
568 opacity: opacityToInput(computed.opacity),
569 backgroundColor: rgbToHex(computed.svgPaintColor || computed.backgroundColor),
570 objectFit: computed.objectFit || 'contain',
571 alt: attrs.alt || '',
572 poster: attrs.poster || '',
573 controls: Boolean(attrs.controls),
574 muted: Boolean(attrs.muted),
575 loop: Boolean(attrs.loop),
576 autoplay: Boolean(attrs.autoplay),
577 playsInline: attrs.playsInline !== false,
578 preload: attrs.preload || 'metadata',
579 artTextTemplateId: attrs.artTextTemplate || '',
580 formulaLatex: formula?.latex || '',
581 formulaHtml: formula?.html || '',
582 formulaDisplayMode: Boolean(formula?.displayMode),
583 chartType: chart?.type || 'bar',
584 chartTitle: chart?.title || '',
585 chartLabels: chart?.labels.join(', ') || '',
586 chartValues: chart?.values.join(', ') || '',
587 chartDataJson: chart ? formatChartDataJson(chart.labels, chart.series, chart.values) : '',
588 chartPrimaryColor: chart?.primaryColor || '#5d6b4d',
589 chartAccentColor: chart?.accentColor || '#8fbc8f',
590 chartTextColor: chart?.textColor || '#2f3b28',
591 chartSmooth: chart?.smooth !== false,
592 chartHorizontal: Boolean(chart?.horizontal),
593 chartStacked: Boolean(chart?.stacked),
594 chartAreaFill: chart?.areaFill !== false,
595 chartShowPoints: chart?.showPoints !== false,
596 chartShowLegend: Boolean(chart?.showLegend),
597 chartDoughnutCutout: String(chart?.doughnutCutout ?? 58),
598 chartRadarFill: chart?.radarFill !== false,
599 chartConfigJson: chart?.configJson || ''
600 }
601 })
602 } else {
603 set({
604 draft: {
605 ...EMPTY_ELEMENT_DRAFT,
606 layoutX: String(Math.round(bounds.x)),
607 layoutY: String(Math.round(bounds.y)),
608 layoutWidth: String(Math.round(bounds.width)),
609 layoutHeight: String(Math.round(bounds.height)),
610 layoutZIndex: zValue,
611 opacity: opacityToInput(computed.opacity),
612 backgroundColor: rgbToHex(computed.svgPaintColor || computed.backgroundColor),
613 objectFit: computed.objectFit || 'contain',
614 alt: attrs.alt || '',
615 poster: attrs.poster || '',
616 controls: Boolean(attrs.controls),
617 muted: Boolean(attrs.muted),
618 loop: Boolean(attrs.loop),
619 autoplay: Boolean(attrs.autoplay),
620 playsInline: attrs.playsInline !== false,
621 preload: attrs.preload || 'metadata',
622 artTextTemplateId: attrs.artTextTemplate || '',
623 formulaLatex: formula?.latex || '',
624 formulaHtml: formula?.html || '',
625 formulaDisplayMode: Boolean(formula?.displayMode),
626 chartType: chart?.type || 'bar',
627 chartTitle: chart?.title || '',
628 chartLabels: chart?.labels.join(', ') || '',
629 chartValues: chart?.values.join(', ') || '',
630 chartDataJson: chart ? formatChartDataJson(chart.labels, chart.series, chart.values) : '',
631 chartPrimaryColor: chart?.primaryColor || '#5d6b4d',
632 chartAccentColor: chart?.accentColor || '#8fbc8f',
633 chartTextColor: chart?.textColor || '#2f3b28',
634 chartSmooth: chart?.smooth !== false,
635 chartHorizontal: Boolean(chart?.horizontal),
636 chartStacked: Boolean(chart?.stacked),
637 chartAreaFill: chart?.areaFill !== false,
638 chartShowPoints: chart?.showPoints !== false,
639 chartShowLegend: Boolean(chart?.showLegend),
640 chartDoughnutCutout: String(chart?.doughnutCutout ?? 58),
641 chartRadarFill: chart?.radarFill !== false,
642 chartConfigJson: chart?.configJson || ''
643 }
644 })
645 }
646 },
647
648 handleMoved: (payload) => {
649 const pc = get().ctx?.getPageContext()
650 if (!pc) return
651 const selection = get().selection
652 const draftZIndex = parseInt(get().draft.layoutZIndex, 10)
653
654 if (selection && payload.selector === selection.selector) {
655 const visualX =
656 payload.visualX ??
657 (selection.pageBounds?.x ?? selection.bounds?.x ?? 0) +
658 (payload.layoutMode === 'translate' ? payload.x : payload.deltaX)
659 const visualY =
660 payload.visualY ??
661 (selection.pageBounds?.y ?? selection.bounds?.y ?? 0) +
662 (payload.layoutMode === 'translate' ? payload.y : payload.deltaY)
663 set((state) => ({
664 draft: {
665 ...state.draft,
666 layoutX: String(Math.round(visualX)),
667 layoutY: String(Math.round(visualY)),
668 ...(payload.width !== undefined
669 ? { layoutWidth: String(Math.round(payload.width)) }
670 : {}),
671 ...(payload.height !== undefined
672 ? { layoutHeight: String(Math.round(payload.height)) }
673 : {})
674 }
675 }))
676 }
677
678 useEditHistoryStore.getState().upsertDragEdit({
679 pageId: pc.pageId,
680 htmlPath: pc.htmlPath,
681 selector: payload.selector,
682 x: payload.x,
683 y: payload.y,
684 width: payload.width ?? null,
685 height: payload.height ?? null,
686 layoutIsland: payload.layoutIsland,
687 childUpdates: payload.childUpdates ?? [],
688 isAbsoluteMode: payload.layoutMode === 'absolute',
689 zIndex: Number.isFinite(draftZIndex) ? draftZIndex : undefined
690 })
691 },
692
693 updateDraft: (draft, options) => {
694 const selection = get().selection
695 const prevDraft = get().draft
696 const pc = get().ctx?.getPageContext()
697 const liveStyle: ElementPropertyStylePatch = {}
698 const liveAttrs: ElementPropertyAttrsPatch = {}
699
700 if (selection && pc && draft.layoutZIndex !== prevDraft.layoutZIndex) {
701 const zNum = parseInt(draft.layoutZIndex, 10)
702 if (Number.isFinite(zNum)) liveStyle.zIndex = zNum
703 }
704 if (draft.opacity !== prevDraft.opacity) {
705 const opacity = Number(draft.opacity)
706 if (Number.isFinite(opacity)) liveStyle.opacity = opacity
707 }
708 if (draft.backgroundColor !== prevDraft.backgroundColor)
709 liveStyle.backgroundColor = draft.backgroundColor
710 if (draft.objectFit !== prevDraft.objectFit) liveStyle.objectFit = draft.objectFit
711 if (draft.textAlign !== prevDraft.textAlign) liveStyle.textAlign = draft.textAlign
712 if (draft.alt !== prevDraft.alt) liveAttrs.alt = draft.alt
713 if (draft.poster !== prevDraft.poster) liveAttrs.poster = draft.poster
714 if (draft.controls !== prevDraft.controls) liveAttrs.controls = draft.controls
715 if (draft.muted !== prevDraft.muted) liveAttrs.muted = draft.muted
716 if (draft.loop !== prevDraft.loop) liveAttrs.loop = draft.loop
717 if (draft.autoplay !== prevDraft.autoplay) liveAttrs.autoplay = draft.autoplay
718 if (draft.playsInline !== prevDraft.playsInline) liveAttrs.playsInline = draft.playsInline
719 if (draft.preload !== prevDraft.preload) liveAttrs.preload = draft.preload
720 const formulaChanged =
721 draft.formulaLatex !== prevDraft.formulaLatex ||
722 draft.formulaHtml !== prevDraft.formulaHtml ||
723 draft.formulaDisplayMode !== prevDraft.formulaDisplayMode
724 const chartChanged =
725 draft.chartTitle !== prevDraft.chartTitle ||
726 draft.chartDataJson !== prevDraft.chartDataJson ||
727 draft.chartPrimaryColor !== prevDraft.chartPrimaryColor ||
728 draft.chartAccentColor !== prevDraft.chartAccentColor ||
729 draft.chartTextColor !== prevDraft.chartTextColor ||
730 draft.chartSmooth !== prevDraft.chartSmooth ||
731 draft.chartHorizontal !== prevDraft.chartHorizontal ||
732 draft.chartStacked !== prevDraft.chartStacked ||
733 draft.chartAreaFill !== prevDraft.chartAreaFill ||
734 draft.chartShowPoints !== prevDraft.chartShowPoints ||
735 draft.chartShowLegend !== prevDraft.chartShowLegend ||
736 draft.chartDoughnutCutout !== prevDraft.chartDoughnutCutout ||
737 draft.chartRadarFill !== prevDraft.chartRadarFill
738
739 set({ draft })
740
741 if (selection && pc) {
742 const iframe = get().iframeHandle
743 const zNum = parseInt(draft.layoutZIndex, 10)
744 if (Number.isFinite(zNum) && draft.layoutZIndex !== prevDraft.layoutZIndex) {
745 iframe?.applyZIndex(selection.selector, zNum)
746 }
747 if (Object.keys(liveStyle).length > 0 || Object.keys(liveAttrs).length > 0) {
748 iframe?.applyElementProperties(selection.selector, {
749 style: liveStyle,
750 attrs: liveAttrs
751 })
752 }
753 if (selection.isText) {
754 iframe?.liveUpdateElement(selection.selector, {
755 html: draft.html,
756 text: draft.text,
757 textTarget: selection.textTarget,
758 style: {
759 color: draft.color,
760 fontSize: draft.fontSize ? `${draft.fontSize}px` : undefined,
761 fontWeight: draft.fontWeight
762 }
763 })
764 }
765 if (selection.capabilities?.includes('formula') && formulaChanged && draft.formulaHtml) {
766 iframe?.liveUpdateElement(selection.selector, {
767 formula: {
768 latex: draft.formulaLatex.trim(),
769 html: draft.formulaHtml,
770 displayMode: draft.formulaDisplayMode
771 }
772 })
773 }
774 if (selection.capabilities?.includes('chart') && chartChanged) {
775 iframe?.liveUpdateElement(selection.selector, {
776 chart: buildChartPatchFromDraft(draft)
777 })
778 }
779 if (options?.commit) get().commitDraft(draft, options.fields)
780 }
781 },
782
783 cancelEdit: () => {
784 get().commitCurrentDraft()
785 get().iframeHandle?.clearEditModeSelection()
786 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
787 useSessionDetailUiStore.getState().clearEditSelectedElement()
788 },
789
790 deleteSelected: () => {
791 const selection = get().selection
792 const pc = get().ctx?.getPageContext()
793 if (!selection || !pc) return
794 const selector = selection.selector
795 useEditHistoryStore.getState().addDelete({
796 pageId: pc.pageId,
797 htmlPath: pc.htmlPath,
798 selector
799 })
800 get().iframeHandle?.hideElement(selector)
801 get().iframeHandle?.clearEditModeSelection()
802 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
803 useSessionDetailUiStore.getState().clearEditSelectedElement()
804 },
805
806 deleteBySelector: (selector) => {
807 const pc = get().ctx?.getPageContext()
808 if (!pc || !selector) return
809 const selection = get().selection
810 if (selection && selection.selector === selector) get().commitCurrentDraft()
811 useEditHistoryStore.getState().addDelete({
812 pageId: pc.pageId,
813 htmlPath: pc.htmlPath,
814 selector
815 })
816 get().iframeHandle?.hideElement(selector)
817 get().iframeHandle?.clearEditModeSelection()
818 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
819 useSessionDetailUiStore.getState().clearEditSelectedElement()
820 },
821
822 discardAll: () => {
823 const ctx = get().ctx
824 const pc = ctx?.getPageContext()
825 if (!ctx || !pc) return
826 const editHistory = useEditHistoryStore.getState()
827 const snapshot = editHistory.getSnapshotForPage(pc.pageId)
828 const hadPending =
829 snapshot.dragEdits.length > 0 ||
830 snapshot.textEdits.length > 0 ||
831 snapshot.propertyEdits.length > 0 ||
832 snapshot.deletes.length > 0 ||
833 snapshot.addElements.length > 0
834 editHistory.clearPage(pc.pageId)
835 get().iframeHandle?.clearEditModeSelection()
836 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
837 useSessionDetailUiStore.getState().clearEditSelectedElement()
838 if (hadPending) ctx.requestRefresh()
839 if (hadPending) useToastStore.getState().info(ctx.t('sessionDetail.discardedAdjustments'))
840 },
841
842 undo: () => {
843 const ctx = get().ctx
844 const pc = ctx?.getPageContext()
845 if (!ctx || !pc) return
846 get().commitCurrentDraft()
847 if (!useEditHistoryStore.getState().undo(pc.pageId)) return
848 get().iframeHandle?.clearEditModeSelection()
849 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
850 useSessionDetailUiStore.getState().clearEditSelectedElement()
851 ctx.requestRefresh()
852 },
853
854 redo: () => {
855 const ctx = get().ctx
856 const pc = ctx?.getPageContext()
857 if (!ctx || !pc) return
858 if (!useEditHistoryStore.getState().redo(pc.pageId)) return
859 get().iframeHandle?.clearEditModeSelection()
860 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
861 useSessionDetailUiStore.getState().clearEditSelectedElement()
862 ctx.requestRefresh()
863 },
864
865 replayPending: () => {
866 const pc = get().ctx?.getPageContext()
867 const iframe = get().iframeHandle
868 if (!pc || !iframe) return
869 const snapshot = useEditHistoryStore.getState().getSnapshotForPage(pc.pageId)
870 for (const d of snapshot.deletes) iframe.hideElement(d.selector)
871 for (const a of snapshot.addElements)
872 iframe.injectElement(a.parentSelector, a.htmlFragment, a.insertIndex)
873 for (const d of snapshot.dragEdits) {
874 if (d.layoutIsland) iframe.applyLayoutIsland(d.layoutIsland)
875 iframe.applyDragStyle(d.selector, {
876 x: d.x,
877 y: d.y,
878 width: d.width ?? undefined,
879 height: d.height ?? undefined,
880 isAbsoluteMode: d.isAbsoluteMode
881 })
882 if (d.zIndex !== undefined) iframe.applyZIndex(d.selector, d.zIndex)
883 if (d.childUpdates.length > 0) iframe.applyChildUpdates(d.selector, d.childUpdates)
884 }
885 for (const t of snapshot.textEdits) {
886 iframe.liveUpdateElement(t.selector, {
887 text: t.patch.text,
888 textTarget: undefined,
889 style: t.patch.style
890 })
891 }
892 for (const p of snapshot.propertyEdits) {
893 iframe.applyElementProperties(p.selector, {
894 style: p.patch.style,
895 attrs: p.patch.attrs
896 })
897 if (
898 p.patch.formula ||
899 p.patch.chart ||
900 p.patch.html ||
901 p.patch.text ||
902 p.patch.style?.color ||
903 p.patch.style?.fontSize ||
904 p.patch.style?.fontWeight
905 ) {
906 iframe.liveUpdateElement(p.selector, {
907 text: p.patch.text,
908 html: p.patch.html,
909 formula: p.patch.formula,
910 chart: p.patch.chart,
911 textTarget: p.patch.textTarget,
912 style: {
913 color: p.patch.style?.color,
914 fontSize: p.patch.style?.fontSize,
915 fontWeight: p.patch.style?.fontWeight
916 }
917 })
918 }
919 }
920 const selection = get().selection
921 const selectedDeleted = selection
922 ? snapshot.deletes.some((d) =>
923 editTargetMatchesDeletedSelector(selection.selector, d.selector, selection.blockId)
924 )
925 : false
926 if (selection?.selector && !selectedDeleted) {
927 void iframe.restoreEditModeSelection?.(selection.selector)
928 }
929 },
930
931 flushPendingDrags: async () => {
932 const pc = get().ctx?.getPageContext()
933 const iframe = get().iframeHandle
934 if (!pc || !iframe) return
935 const editHistory = useEditHistoryStore.getState()
936 const snap = editHistory.getSnapshotForPage(pc.pageId)
937 const deletedSelectors = new Set(snap.deletes.map((d) => d.selector))
938 const covered = new Set<string>()
939 for (const d of snap.dragEdits) {
940 if (deletedSelectors.has(d.selector)) continue
941 covered.add(d.selector)
942 const layout = await iframe.readElementLayout(d.selector)
943 if (!layout) continue
944 editHistory.upsertDragEdit({
945 pageId: d.pageId,
946 htmlPath: d.htmlPath,
947 selector: d.selector,
948 x: layout.x,
949 y: layout.y,
950 isAbsoluteMode: layout.isAbsoluteMode,
951 width: d.width != null ? (layout.width > 0 ? layout.width : d.width) : null,
952 height: d.height != null ? (layout.height > 0 ? layout.height : d.height) : null,
953 layoutIsland: layout.layoutIsland ?? d.layoutIsland,
954 childUpdates: d.childUpdates ?? [],
955 zIndex: d.zIndex
956 })
957 }
958 // Capture an in-flight first move/resize: a `moved` whose async `ensureAnchoredAnchor`
959 // is still straddling the save has not upserted a dragEdit yet, so the loop
960 // above skipped it. If the currently selected element has actually moved from
961 // its selection-time position or size, read its current DOM layout and persist it now
962 // (mirroring what that late `moved` would have produced). Without this, an
963 // empty save + refresh would silently drop the edit. The stale `moved` itself
964 // is dropped by the PreviewIframe page-instance guard after the refresh.
965 const selection = get().selection
966 if (
967 selection?.selector &&
968 selection.snapshot &&
969 !covered.has(selection.selector) &&
970 !deletedSelectors.has(selection.selector)
971 ) {
972 const layout = await iframe.readElementLayout(selection.selector)
973 if (layout) {
974 const base = selection.snapshot.metrics.page
975 const movedX = Math.abs((layout.visualX ?? 0) - base.x)
976 const movedY = Math.abs((layout.visualY ?? 0) - base.y)
977 const resizedWidth = layout.width > 0 && Math.abs(layout.width - base.width) >= 0.5
978 const resizedHeight = layout.height > 0 && Math.abs(layout.height - base.height) >= 0.5
979 const resized = resizedWidth || resizedHeight
980 if (movedX >= 0.5 || movedY >= 0.5 || resized) {
981 const draftZIndex = parseInt(get().draft.layoutZIndex, 10)
982 editHistory.upsertDragEdit({
983 pageId: pc.pageId,
984 htmlPath: pc.htmlPath,
985 selector: selection.selector,
986 x: layout.x,
987 y: layout.y,
988 isAbsoluteMode: layout.isAbsoluteMode,
989 width: resized && layout.width > 0 ? layout.width : null,
990 height: resized && layout.height > 0 ? layout.height : null,
991 layoutIsland: layout.layoutIsland,
992 childUpdates: [],
993 zIndex: Number.isFinite(draftZIndex) ? draftZIndex : undefined
994 })
995 }
996 }
997 }
998 },
999
1000 save: async () => {
1001 if (get().isSavingEdits) return { saved: false }
1002 const ctx = get().ctx
1003 const iframe = get().iframeHandle
1004 const pc = ctx?.getPageContext()
1005 if (!ctx || !pc) return { saved: false }
1006 set({ isSavingEdits: true })
1007 try {
1008 get().commitCurrentDraft()
1009 await get().flushPendingDrags()
1010 const editHistory = useEditHistoryStore.getState()
1011 const snapshot = editHistory.getSnapshotForPage(pc.pageId)
1012 const hasEdits =
1013 snapshot.dragEdits.length > 0 ||
1014 snapshot.textEdits.length > 0 ||
1015 snapshot.propertyEdits.length > 0 ||
1016 snapshot.deletes.length > 0 ||
1017 snapshot.addElements.length > 0
1018 if (!hasEdits) {
1019 iframe?.clearEditModeSelection()
1020 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
1021 useSessionDetailUiStore.getState().clearEditSelectedElement()
1022 ctx.requestRefresh()
1023 return { saved: false }
1024 }
1025
1026 const filledAddElements = await Promise.all(
1027 snapshot.addElements.map(async (el) => {
1028 if (el.htmlFragment) return el
1029 const selector = el.assignedBlockId
1030 ? `body[data-page-id="${el.pageId}"] [data-block-id="${el.assignedBlockId}"]`
1031 : ''
1032 if (!selector || !iframe) return el
1033 try {
1034 const html = await iframe.readElementHtml?.(selector)
1035 return html ? { ...el, htmlFragment: html } : el
1036 } catch {
1037 return el
1038 }
1039 })
1040 )
1041 const isDeletedTarget = (selector: string, blockId?: string): boolean =>
1042 snapshot.deletes.some((d) =>
1043 editTargetMatchesDeletedSelector(selector, d.selector, blockId)
1044 )
1045 const safeDragEdits = snapshot.dragEdits.filter((e) => !isDeletedTarget(e.selector))
1046 const safeTextEdits = snapshot.textEdits.filter((e) => !isDeletedTarget(e.selector))
1047 const safePropertyEdits = snapshot.propertyEdits.filter(
1048 (e) => !isDeletedTarget(e.selector, e.blockId)
1049 )
1050 const parts: string[] = []
1051 const ac = snapshot.addElements.length
1052 const dc = snapshot.deletes.length
1053 const rc = safeDragEdits.length
1054 const tc = safeTextEdits.length
1055 const pcount = safePropertyEdits.length
1056 if (ac > 0) parts.push(`添加 ${ac} 个元素`)
1057 if (dc > 0) parts.push(`删除 ${dc} 个元素`)
1058 if (rc > 0) parts.push(`调整 ${rc} 个元素位置`)
1059 if (tc > 0) parts.push(`编辑 ${tc} 个元素文字`)
1060 if (pcount > 0) parts.push(`编辑 ${pcount} 个元素属性`)
1061 const prompt = parts.join('、') || '手动调整'
1062 const result = await ipc.saveEditBatch({
1063 sessionId: pc.sessionId,
1064 htmlPath: pc.htmlPath,
1065 pageId: pc.pageId,
1066 dragEdits: safeDragEdits,
1067 textEdits: safeTextEdits,
1068 propertyEdits: safePropertyEdits,
1069 deletes: snapshot.deletes,
1070 addElements: filledAddElements,
1071 prompt
1072 })
1073 if (!result.success) throw new Error(ctx.t('sessionDetail.layoutSaveFailed'))
1074 useEditHistoryStore.getState().markPageSaved(pc.pageId)
1075 iframe?.clearEditModeSelection()
1076 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
1077 useSessionDetailUiStore.getState().clearEditSelectedElement()
1078 ctx.bumpThumbnail(pc.pageId)
1079 ctx.requestRefresh()
1080 const totalCount =
1081 result.dragCount +
1082 result.textCount +
1083 (result.propertyCount || 0) +
1084 result.deleteCount +
1085 result.addCount
1086 useToastStore
1087 .getState()
1088 .success(ctx.t('sessionDetail.adjustmentsSaved', { count: totalCount }))
1089 return { saved: true }
1090 } catch (error) {
1091 const message =
1092 error instanceof Error ? error.message : ctx.t('sessionDetail.layoutSaveFailed')
1093 useToastStore.getState().error(message)
1094 return { saved: false, error: message }
1095 } finally {
1096 set({ isSavingEdits: false })
1097 }
1098 },
1099
1100 applySelectedToAllPages: async () => {
1101 if (get().isApplyingSyncElement || get().isSavingEdits) return { applied: false }
1102 const ctx = get().ctx
1103 const iframe = get().iframeHandle
1104 const pc = ctx?.getPageContext()
1105 const selection = get().selection
1106 if (!ctx || !pc || !iframe || !selection?.selector) return { applied: false }
1107
1108 set({ isApplyingSyncElement: true })
1109 try {
1110 get().commitCurrentDraft()
1111 await get().flushPendingDrags()
1112 const sourceHtmlFragment = await iframe.readElementHtml(selection.selector)
1113 if (!sourceHtmlFragment) throw new Error(ctx.t('sessionDetail.syncElementReadFailed'))
1114
1115 const snapshot = useEditHistoryStore.getState().getSnapshotForPage(pc.pageId)
1116 const hasPendingEdits =
1117 snapshot.dragEdits.length > 0 ||
1118 snapshot.textEdits.length > 0 ||
1119 snapshot.propertyEdits.length > 0 ||
1120 snapshot.deletes.length > 0 ||
1121 snapshot.addElements.length > 0
1122 if (hasPendingEdits) {
1123 const saved = await get().save()
1124 if (!saved.saved && saved.error) throw new Error(saved.error)
1125 }
1126
1127 const result = await ipc.applySyncElementToAllPages({
1128 sessionId: pc.sessionId,
1129 htmlPath: pc.htmlPath,
1130 pageId: pc.pageId,
1131 sourceHtmlFragment,
1132 sourceBlockId: selection.blockId
1133 })
1134 if (!result.success) throw new Error(ctx.t('sessionDetail.syncElementFailed'))
1135
1136 const pages = useGenerateStore.getState().currentPages
1137 for (const page of pages) {
1138 const pageId = page.pageId || page.id
1139 if (pageId) ctx.bumpThumbnail(pageId)
1140 }
1141 iframe.clearEditModeSelection()
1142 set({ selection: null, draft: EMPTY_ELEMENT_DRAFT })
1143 useSessionDetailUiStore.getState().clearEditSelectedElement()
1144 ctx.requestRefresh()
1145 useToastStore
1146 .getState()
1147 .success(ctx.t('sessionDetail.syncElementApplied', { count: result.changedCount }))
1148 return { applied: true }
1149 } catch (error) {
1150 const message =
1151 error instanceof Error ? error.message : ctx.t('sessionDetail.syncElementFailed')
1152 useToastStore.getState().error(message)
1153 return { applied: false, error: message }
1154 } finally {
1155 set({ isApplyingSyncElement: false })
1156 }
1157 }
1158 }))
1159
1159 lines TYPESCRIPT