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