| 1 | import { escapeHtmlText } from '@renderer/lib/utils' |
| 2 | import { |
| 3 | ICON_VIEWBOX, |
| 4 | getIconDefinition, |
| 5 | iconOuterSvgAttrs, |
| 6 | isRegisteredIconId, |
| 7 | serializeIconInner |
| 8 | } from './iconRegistry' |
| 9 | import { getShapeDefinition } from './shapeRegistry' |
| 10 | |
| 11 | export type InsertShapeType = |
| 12 | | 'rect' |
| 13 | | 'rounded-rect' |
| 14 | | 'ellipse' |
| 15 | | 'triangle' |
| 16 | | 'diamond' |
| 17 | | 'pentagon' |
| 18 | | 'hexagon' |
| 19 | | 'parallelogram' |
| 20 | | 'trapezoid' |
| 21 | | 'star-5' |
| 22 | | 'line' |
| 23 | | 'arrow-right' |
| 24 | | 'chevron-right' |
| 25 | |
| 26 | export interface InsertElementLayout { |
| 27 | blockId: string |
| 28 | left: number |
| 29 | top: number |
| 30 | width: number |
| 31 | height: number |
| 32 | zIndex: number |
| 33 | } |
| 34 | |
| 35 | export interface BuildShapeOptions extends InsertElementLayout { |
| 36 | type: InsertShapeType |
| 37 | fill?: string |
| 38 | stroke?: string |
| 39 | } |
| 40 | |
| 41 | export interface BuildIconOptions extends InsertElementLayout { |
| 42 | iconId: string |
| 43 | color?: string |
| 44 | } |
| 45 | |
| 46 | // blockId is generated as `select-arcsin1-` + nanoid(8); validate strictly so |
| 47 | // we never splice an arbitrary string into an HTML attribute. |
| 48 | const BLOCK_ID_RE = /^select-arcsin1-[A-Za-z0-9_-]{4,32}$/ |
| 49 | // Accept #hex (3/4/6/8 digits) and CSS named colors; reject everything else |
| 50 | // (no url(), no expression(), no var() with user input). |
| 51 | const COLOR_RE = |
| 52 | /^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8}|transparent|currentColor|none|[a-zA-Z]+)$/ |
| 53 | |
| 54 | export function isValidBlockId(value: string): boolean { |
| 55 | return BLOCK_ID_RE.test(value) |
| 56 | } |
| 57 | |
| 58 | export function isValidColor(value: string): boolean { |
| 59 | return COLOR_RE.test(value) |
| 60 | } |
| 61 | |
| 62 | function assertBlockId(blockId: string): void { |
| 63 | if (!isValidBlockId(blockId)) { |
| 64 | throw new Error(`buildInsertElementHtml: invalid blockId "${blockId}"`) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | function resolveColor(value: string | undefined, fallback: string): string { |
| 69 | if (!value) return fallback |
| 70 | if (!isValidColor(value)) { |
| 71 | throw new Error(`buildInsertElementHtml: invalid color "${value}"`) |
| 72 | } |
| 73 | return value |
| 74 | } |
| 75 | |
| 76 | function outerStyle(layout: InsertElementLayout, extra: string[]): string { |
| 77 | return escapeHtmlText( |
| 78 | [ |
| 79 | 'position:absolute', |
| 80 | `left:${layout.left}px`, |
| 81 | `top:${layout.top}px`, |
| 82 | `width:${layout.width}px`, |
| 83 | `height:${layout.height}px`, |
| 84 | `z-index:${layout.zIndex}`, |
| 85 | ...extra |
| 86 | ].join('; ') |
| 87 | ) |
| 88 | } |
| 89 | |
| 90 | export function buildShapeElementHtml(options: BuildShapeOptions): string { |
| 91 | assertBlockId(options.blockId) |
| 92 | const def = getShapeDefinition(options.type) |
| 93 | if (!def) { |
| 94 | throw new Error(`buildInsertElementHtml: unknown shape type "${options.type}"`) |
| 95 | } |
| 96 | const fill = resolveColor(options.fill, def.defaultFill) |
| 97 | const stroke = resolveColor(options.stroke, def.defaultStroke) |
| 98 | const strokeWidth = def.strokeWidth |
| 99 | // Use the default viewBox so the shape's intrinsic proportions are stable; |
| 100 | // the outer svg scales to the div via width/height 100%. |
| 101 | const vbW = def.defaultWidth |
| 102 | const vbH = def.defaultHeight |
| 103 | const inner = def.renderInner(vbW, vbH, { fill, stroke, strokeWidth }) |
| 104 | const style = outerStyle(options, []) |
| 105 | return `<div data-block-id="${options.blockId}" data-ppt-edit-kind="shape" data-ppt-shape-type="${options.type}" style="${style}"><svg viewBox="0 0 ${vbW} ${vbH}" width="100%" height="100%" preserveAspectRatio="none">${inner}</svg></div>` |
| 106 | } |
| 107 | |
| 108 | export function buildIconElementHtml(options: BuildIconOptions): string { |
| 109 | assertBlockId(options.blockId) |
| 110 | if (!isRegisteredIconId(options.iconId)) { |
| 111 | throw new Error(`buildInsertElementHtml: unknown iconId "${options.iconId}"`) |
| 112 | } |
| 113 | const def = getIconDefinition(options.iconId)! |
| 114 | const color = resolveColor(options.color, '#3f4b35') |
| 115 | // color lives on the outer div so currentColor (used by the svg stroke / badge fill) can |
| 116 | // be updated later by editing the div's inline style only. |
| 117 | const style = outerStyle(options, [`color:${color}`]) |
| 118 | const inner = serializeIconInner(def) |
| 119 | const svgAttrs = iconOuterSvgAttrs(def) |
| 120 | return `<div data-block-id="${options.blockId}" data-ppt-edit-kind="icon" data-ppt-icon-id="${options.iconId}" style="${style}"><svg viewBox="0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}" width="100%" height="100%" ${svgAttrs}>${inner}</svg></div>` |
| 121 | } |
| 122 |