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