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