| 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 |