返回 oh-my-ppt
useHtmlElementInsertion.ts
根目录 / src / renderer / src / components / html-editor / useHtmlElementInsertion.ts
1 import { nanoid } from 'nanoid'
2 import { escapeHtmlText } from '../../lib/utils'
3 import { buildSelectedElementFromSnapshot } from '../session-detail/element-inspector/elementEditUtils'
4 import { buildArtTextHtmlFragment, type ArtTextTemplateId } from '../../lib/artTextTemplates'
5 import {
6 buildShapeElementHtml,
7 buildIconElementHtml,
8 getShapeDefinition,
9 type InsertShapeType
10 } from '../session-detail/workspace/insert-shapes'
11 import {
12 buildChartElementHtml,
13 DEFAULT_CHART_DATA,
14 type InsertChartType
15 } from '../session-detail/workspace/insert-charts'
16 import type { EditableElementSnapshot } from '@arcsin1/presentation-editor-runtime'
17 import { useT } from '../../i18n'
18 import { useHtmlEditStore } from '../../store/htmlEditStore'
19 import { useHtmlEditHistoryStore } from '../../store/htmlEditHistoryStore'
20
21 /**
22 * HTML 编辑器的「插入元素」hook(复制自 session-detail.tsx 的 handleAdd*Element,独立实现)。
23 * 复用 session-edit 的纯插入片段构造器(shapes/charts/artText/formula),但接 html-edit store,
24 * 且画布为 document 模式(按 designWidth 居中、自顶向下堆叠,不依赖固定高度)。
25 * 媒体文件由 html-editor IPC 复制到当前文档的 assets 目录,再插入其 file:// URL。
26 */
27
28 const ADDED_ELEMENT_EDGE_PADDING = 20
29 const ADDED_TEXT_WIDTH = 420
30 const ADDED_TEXT_MIN_HEIGHT = 96
31 const ADDED_TEXT_OFFSET_STEP = 28
32 const ADDED_ART_TEXT_WIDTH = 560
33 const ADDED_ART_TEXT_MIN_HEIGHT = 130
34 const ADDED_ICON_SIZE = 96
35 const ADDED_CHART_WIDTH = 520
36 const ADDED_CHART_HEIGHT = 300
37 const ADDED_IMAGE_WIDTH = 480
38 const ADDED_IMAGE_HEIGHT = 320
39 const ADDED_VIDEO_WIDTH = 640
40 const ADDED_VIDEO_HEIGHT = 360
41 const DEFAULT_NEW_ELEMENT_Z_INDEX = 20
42
43 export interface UseHtmlElementInsertionOptions {
44 designWidth: number
45 t: ReturnType<typeof useT>
46 }
47
48 async function readSnapshotWithRetry(
49 read: (selector: string) => Promise<EditableElementSnapshot | null> | undefined,
50 selector: string
51 ): Promise<EditableElementSnapshot | null> {
52 for (let attempt = 0; attempt < 8; attempt += 1) {
53 if (attempt > 0) await new Promise<void>((resolve) => window.setTimeout(resolve, 50))
54 const snapshot = await read(selector)
55 if (snapshot) return snapshot
56 }
57 return null
58 }
59
60 export function useHtmlElementInsertion(opts: UseHtmlElementInsertionOptions): {
61 addText: () => Promise<void>
62 addArtText: (templateId: ArtTextTemplateId) => Promise<void>
63 addShape: (type: InsertShapeType) => Promise<void>
64 addIcon: (iconId: string) => Promise<void>
65 addChart: (type: InsertChartType) => Promise<void>
66 addImage: (src: string) => Promise<boolean>
67 addVideo: (src: string) => Promise<boolean>
68 copyElement: () => Promise<void>
69 } {
70 const { designWidth, t } = opts
71
72 const addAndSelect = async (blockId: string, htmlFragment: string): Promise<boolean> => {
73 const edit = useHtmlEditStore.getState()
74 const pc = edit.ctx?.getPageContext()
75 const iframe = edit.iframeHandle
76 if (!pc || !iframe) return false
77 const parentSelector = `body[data-page-id="${pc.pageId}"] [data-ppt-guard-root="1"]`
78 edit.commitCurrentDraft()
79 const injected = await iframe.injectElement(parentSelector, htmlFragment)
80 if (!injected) return false
81 useHtmlEditHistoryStore.getState().addElement({
82 pageId: pc.pageId,
83 htmlPath: pc.htmlPath,
84 parentSelector,
85 htmlFragment,
86 assignedBlockId: blockId,
87 insertIndex: -1
88 })
89 const selector = `body[data-page-id="${pc.pageId}"] [data-block-id="${blockId}"]`
90 const snapshot = await readSnapshotWithRetry((s) => iframe.readElementSnapshot(s), selector)
91 if (!snapshot) return true
92 useHtmlEditStore
93 .getState()
94 .selectElement(buildSelectedElementFromSnapshot({ selector, blockId, snapshot }))
95 return true
96 }
97
98 // document 模式定位:按 designWidth 水平居中,纵向自顶向下堆叠(不依赖固定高度)
99 const place = (w: number): { left: number; top: number; zIdx: number } => {
100 const existingCount = useHtmlEditHistoryStore.getState().addElements.length
101 const offset = existingCount * ADDED_TEXT_OFFSET_STEP
102 const left = Math.min(
103 Math.max(ADDED_ELEMENT_EDGE_PADDING, (designWidth - w) / 2) + offset,
104 Math.max(ADDED_ELEMENT_EDGE_PADDING, designWidth - w - ADDED_ELEMENT_EDGE_PADDING)
105 )
106 return {
107 left,
108 top: ADDED_ELEMENT_EDGE_PADDING + offset,
109 zIdx: DEFAULT_NEW_ELEMENT_Z_INDEX
110 }
111 }
112
113 const addText = async (): Promise<void> => {
114 const blockId = 'select-arcsin1-' + nanoid(8)
115 const { left, top, zIdx } = place(ADDED_TEXT_WIDTH)
116 const textStyle = [
117 'position:absolute',
118 `left:${left}px`,
119 `top:${top}px`,
120 `width:${ADDED_TEXT_WIDTH}px`,
121 `min-height:${ADDED_TEXT_MIN_HEIGHT}px`,
122 'margin:0',
123 'padding:0',
124 `z-index:${zIdx}`,
125 'color:#34402c',
126 'font-size:40px',
127 'font-weight:700',
128 'line-height:1.18',
129 'letter-spacing:0',
130 'white-space:pre-wrap',
131 'overflow-wrap:anywhere',
132 'font-family:inherit'
133 ].join('; ')
134 const htmlFragment = `<p data-block-id="${blockId}" style="${textStyle};">${escapeHtmlText(
135 t('editMode.defaultText')
136 )}</p>`
137 await addAndSelect(blockId, htmlFragment)
138 }
139
140 const addArtText = async (templateId: ArtTextTemplateId): Promise<void> => {
141 const blockId = 'select-arcsin1-' + nanoid(8)
142 const { left, top, zIdx } = place(ADDED_ART_TEXT_WIDTH)
143 const htmlFragment = buildArtTextHtmlFragment(templateId, {
144 blockId,
145 left,
146 top,
147 width: ADDED_ART_TEXT_WIDTH,
148 minHeight: ADDED_ART_TEXT_MIN_HEIGHT,
149 zIndex: zIdx
150 })
151 await addAndSelect(blockId, htmlFragment)
152 }
153
154 const addShape = async (type: InsertShapeType): Promise<void> => {
155 const def = getShapeDefinition(type)
156 if (!def) return
157 const blockId = 'select-arcsin1-' + nanoid(8)
158 const { left, top, zIdx } = place(def.defaultWidth)
159 const htmlFragment = buildShapeElementHtml({
160 blockId,
161 type,
162 left,
163 top,
164 width: def.defaultWidth,
165 height: def.defaultHeight,
166 zIndex: zIdx
167 })
168 await addAndSelect(blockId, htmlFragment)
169 }
170
171 const addIcon = async (iconId: string): Promise<void> => {
172 const blockId = 'select-arcsin1-' + nanoid(8)
173 const { left, top, zIdx } = place(ADDED_ICON_SIZE)
174 const htmlFragment = buildIconElementHtml({
175 blockId,
176 iconId,
177 left,
178 top,
179 width: ADDED_ICON_SIZE,
180 height: ADDED_ICON_SIZE,
181 zIndex: zIdx
182 })
183 await addAndSelect(blockId, htmlFragment)
184 }
185
186 const addChart = async (type: InsertChartType): Promise<void> => {
187 // 确保 Chart.js 已在 webview 中加载(只加载一次,executeJavaScript 绕过 CORS)
188 const iframe = useHtmlEditStore.getState().iframeHandle
189 if (iframe) await iframe.ensureChartJs()
190 const blockId = 'select-arcsin1-' + nanoid(8)
191 const { left, top, zIdx } = place(ADDED_CHART_WIDTH)
192 const htmlFragment = buildChartElementHtml(
193 { blockId, left, top, width: ADDED_CHART_WIDTH, height: ADDED_CHART_HEIGHT, zIndex: zIdx },
194 DEFAULT_CHART_DATA[type] || DEFAULT_CHART_DATA.bar
195 )
196 await addAndSelect(blockId, htmlFragment)
197 }
198
199 const addMedia = async (mediaType: 'image' | 'video', src: string): Promise<boolean> => {
200 const normalizedSrc = src.trim()
201 if (!normalizedSrc) return false
202 const blockId = 'select-arcsin1-' + nanoid(8)
203 const width = mediaType === 'video' ? ADDED_VIDEO_WIDTH : ADDED_IMAGE_WIDTH
204 const height = mediaType === 'video' ? ADDED_VIDEO_HEIGHT : ADDED_IMAGE_HEIGHT
205 const { left, top, zIdx } = place(width)
206 const style = [
207 'position:absolute',
208 `left:${left}px`,
209 `top:${top}px`,
210 `width:${width}px`,
211 `height:${height}px`,
212 `z-index:${zIdx}`,
213 'object-fit:contain',
214 'box-sizing:border-box'
215 ].join('; ')
216 const safeSrc = escapeHtmlText(normalizedSrc)
217 const htmlFragment =
218 mediaType === 'video'
219 ? `<video src="${safeSrc}" data-block-id="${blockId}" data-ppt-edit-kind="media" style="${style};" controls playsinline preload="metadata"></video>`
220 : `<img src="${safeSrc}" alt="" data-block-id="${blockId}" data-ppt-edit-kind="media" style="${style};" />`
221 return addAndSelect(blockId, htmlFragment)
222 }
223
224 const addImage = (src: string): Promise<boolean> => addMedia('image', src)
225
226 const addVideo = (src: string): Promise<boolean> => addMedia('video', src)
227
228 const copyElement = async (): Promise<void> => {
229 const edit = useHtmlEditStore.getState()
230 const pc = edit.ctx?.getPageContext()
231 const iframe = edit.iframeHandle
232 const selection = edit.selection
233 if (!pc || !iframe || !selection) return
234 const blockId = 'select-arcsin1-' + nanoid(8)
235 let copyResult: { selector: string; htmlFragment: string } | null = null
236 try {
237 copyResult = await iframe.copyElement(selection.selector, blockId)
238 } catch {
239 return
240 }
241 if (!copyResult) return
242 const parentSelector = `body[data-page-id="${pc.pageId}"] [data-ppt-guard-root="1"]`
243 edit.commitCurrentDraft()
244 useHtmlEditHistoryStore.getState().addElement({
245 pageId: pc.pageId,
246 htmlPath: pc.htmlPath,
247 parentSelector,
248 htmlFragment: copyResult.htmlFragment,
249 assignedBlockId: blockId,
250 insertIndex: -1
251 })
252 await iframe.injectElement(parentSelector, copyResult.htmlFragment)
253 const snapshot = await readSnapshotWithRetry(
254 (s) => iframe.readElementSnapshot(s),
255 copyResult.selector
256 )
257 if (!snapshot) return
258 useHtmlEditStore
259 .getState()
260 .selectElement(
261 buildSelectedElementFromSnapshot({ selector: copyResult.selector, blockId, snapshot })
262 )
263 }
264
265 return { addText, addArtText, addShape, addIcon, addChart, addImage, addVideo, copyElement }
266 }
267
267 lines TYPESCRIPT