@huaqiu/component-gen-app 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,418 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — interactive package-dimension editor.
3
+ *
4
+ * Faithful port of the `dsh-tool-symbol-footprint` rich-HIT geometry editor
5
+ * (packageSilhouette + pointerToViewBox + the two-way-bound W/H handle + field
6
+ * editor). DSH-agnostic: `onConfirm(values, edited)` / `onCancel()` are the
7
+ * only ways back to the caller — the app itself is the driver (single-HIL).
8
+ */
9
+ import { Fragment, memo, useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
10
+ import {
11
+ bgaGrid, classifyDimensions, clampDimension, dimensionBounds, formatDimension,
12
+ normalizeDimensions, numVal, parseDimension, pinCountOf, pkgFamilyLabel,
13
+ rectFromValues, summaryOf, toleranceOf, validateDimensions, type DimensionValues,
14
+ } from '../utils/dims.js'
15
+ import { fieldLabel } from '../utils/labels.js'
16
+ import type { Translate } from '../copy/index.js'
17
+
18
+ export interface GeometryEditorProps {
19
+ dimensions: Record<string, unknown>
20
+ pkgType?: string | null
21
+ fileName?: string | null
22
+ disabled?: boolean
23
+ t: Translate
24
+ onConfirm: (values: DimensionValues, edited: Record<string, boolean>) => void
25
+ onCancel: () => void
26
+ }
27
+
28
+ function pointerToViewBox(
29
+ svg: SVGSVGElement,
30
+ clientX: number,
31
+ clientY: number,
32
+ viewW: number,
33
+ viewH: number,
34
+ ): { x: number; y: number } {
35
+ const rect = svg.getBoundingClientRect()
36
+ const x = rect.width > 0 ? (clientX - rect.left) * (viewW / rect.width) : 0
37
+ const y = rect.height > 0 ? (clientY - rect.top) * (viewH / rect.height) : 0
38
+ return { x, y }
39
+ }
40
+
41
+ const COUNT_KEYS = new Set(['pin_count', 'pins', 'pinCount', 'rows', 'row', 'columns', 'column', 'cols', 'n_max', 'n', 'count', 'total_pins'])
42
+
43
+ function packageSilhouette(
44
+ pkgType: string | null,
45
+ values: DimensionValues,
46
+ geom: { x: number; y: number; w: number; h: number },
47
+ ): ReactElement[] {
48
+ const type = String(pkgType || '').toLowerCase()
49
+ const bodyX = geom.x
50
+ const bodyY = geom.y
51
+ const bodyW = geom.w
52
+ const bodyH = geom.h
53
+ const padL = Math.max(2, Math.min(7, Math.round(Math.min(bodyW, bodyH) * 0.07)))
54
+ const nodes: ReactElement[] = []
55
+ nodes.push(
56
+ <rect key="body" className="hq-genhit__body" x={bodyX} y={bodyY} width={bodyW} height={bodyH} rx={2} />,
57
+ )
58
+ const pins = pinCountOf(values, 8)
59
+ const perSide = Math.max(2, Math.ceil(pins / 2))
60
+
61
+ const side = (edge: 'L' | 'R' | 'T' | 'B'): ReactElement[] => {
62
+ const arr: ReactElement[] = []
63
+ if (edge === 'L' || edge === 'R') {
64
+ const len = Math.min(padL, bodyW * 0.22)
65
+ const x0 = edge === 'L' ? bodyX - len : bodyX + bodyW
66
+ for (let i = 0; i < perSide; i++) {
67
+ const y = bodyY + (i + 0.5) * (bodyH / perSide)
68
+ const ph = Math.max(1.6, (bodyH / perSide) * 0.55)
69
+ arr.push(<rect key={edge + i} className="hq-genhit__pad" x={x0} y={y - ph / 2} width={len} height={ph} rx={1} />)
70
+ }
71
+ } else {
72
+ const len2 = Math.min(padL, bodyH * 0.22)
73
+ const y0 = edge === 'T' ? bodyY - len2 : bodyY + bodyH
74
+ for (let j = 0; j < perSide; j++) {
75
+ const x = bodyX + (j + 0.5) * (bodyW / perSide)
76
+ const pw = Math.max(1.6, (bodyW / perSide) * 0.55)
77
+ arr.push(<rect key={edge + j} className="hq-genhit__pad" x={x - pw / 2} y={y0} width={pw} height={len2} rx={1} />)
78
+ }
79
+ }
80
+ return arr
81
+ }
82
+
83
+ if (type === 'bga') {
84
+ const g = bgaGrid(values, Math.max(2, Math.round(Math.sqrt(pins))))
85
+ const ballR = Math.max(0.9, Math.min(bodyW, bodyH) / (Math.max(g.rows, g.cols) * 3.4))
86
+ for (let r = 0; r < g.rows; r++) {
87
+ for (let c = 0; c < g.cols; c++) {
88
+ nodes.push(
89
+ <circle
90
+ key={`ball${r}_${c}`}
91
+ className="hq-genhit__ball"
92
+ cx={bodyX + (c + 0.5) * (bodyW / g.cols)}
93
+ cy={bodyY + (r + 0.5) * (bodyH / g.rows)}
94
+ r={ballR}
95
+ />,
96
+ )
97
+ }
98
+ }
99
+ } else if (type === 'qfn' || type === 'son') {
100
+ const ep = Math.min(bodyW * 0.55, bodyH * 0.55)
101
+ nodes.push(
102
+ <rect
103
+ key="epad"
104
+ className="hq-genhit__epad"
105
+ x={bodyX + (bodyW - ep) / 2}
106
+ y={bodyY + (bodyH - ep) / 2}
107
+ width={ep}
108
+ height={ep}
109
+ rx={1}
110
+ />,
111
+ )
112
+ const edges = type === 'son' ? (['L', 'R'] as const) : (['L', 'R', 'T', 'B'] as const)
113
+ for (const e of edges) nodes.push(...side(e))
114
+ } else if (type === 'qfp' || type === 'plcc') {
115
+ nodes.push(...side('L'), ...side('R'), ...side('T'), ...side('B'))
116
+ } else {
117
+ nodes.push(...side('L'), ...side('R'))
118
+ }
119
+ return nodes
120
+ }
121
+
122
+ /** dims.ts `summaryOf` addresses the plugin's `card.editor.*` keys — remap. */
123
+ function editorT(t: Translate): (key: string, params?: Record<string, unknown>) => string {
124
+ return (key, params) => t(key.replace(/^card\.editor\./, 'editor.'), params)
125
+ }
126
+
127
+ export const GeometryEditor = memo(function GeometryEditor(props: GeometryEditorProps): ReactElement {
128
+ const { dimensions, pkgType = null, fileName = null, disabled = false, t, onConfirm, onCancel } = props
129
+ const normalized = useMemo(() => normalizeDimensions(dimensions), [dimensions])
130
+ const { widthKey, heightKey } = normalized
131
+
132
+ const [values, setValues] = useState<DimensionValues>(() => normalized.values)
133
+ const [fieldText, setFieldText] = useState<Record<string, string>>(() => {
134
+ const m: Record<string, string> = {}
135
+ for (const k of normalized.numericKeys) m[k] = formatDimension(normalized.values[k])
136
+ return m
137
+ })
138
+ const [invalid, setInvalid] = useState<Record<string, boolean>>({})
139
+ const [edited, setEdited] = useState<Record<string, boolean>>({})
140
+ const [advancedOpen, setAdvancedOpen] = useState(false)
141
+ const [drag, setDrag] = useState<{
142
+ mode: 'W' | 'H' | 'WH'
143
+ startX: number
144
+ startY: number
145
+ startW: number
146
+ startH: number
147
+ rectW: number
148
+ rectH: number
149
+ } | null>(null)
150
+ const svgRef = useRef<SVGSVGElement | null>(null)
151
+
152
+ const VIEW_W = 360
153
+ const VIEW_H = 230
154
+ const PAD = 34
155
+ const geom = rectFromValues(values, widthKey, heightKey, VIEW_W, VIEW_H, PAD)
156
+ const labelFor = (key: string): string => fieldLabel(key, t)
157
+ const fieldUnit = (key: string): string => (COUNT_KEYS.has(key) ? '' : t('editor.unit'))
158
+
159
+ const withKey = (obj: Record<string, number>, key: string, value: number): Record<string, number> => {
160
+ const out = { ...obj }
161
+ out[key] = value
162
+ return out
163
+ }
164
+ const withKeyBool = (obj: Record<string, boolean>, key: string, value: boolean): Record<string, boolean> => {
165
+ const out = { ...obj }
166
+ out[key] = value
167
+ return out
168
+ }
169
+
170
+ function startDrag(mode: 'W' | 'H' | 'WH') {
171
+ return (ev: React.PointerEvent<SVGElement>): void => {
172
+ if (disabled || !svgRef.current) return
173
+ ev.preventDefault()
174
+ const view = pointerToViewBox(svgRef.current, ev.clientX, ev.clientY, VIEW_W, VIEW_H)
175
+ setDrag({
176
+ mode,
177
+ startX: view.x,
178
+ startY: view.y,
179
+ startW: widthKey && values[widthKey] != null ? values[widthKey] : 1,
180
+ startH: heightKey && values[heightKey] != null ? values[heightKey] : 1,
181
+ rectW: geom.w,
182
+ rectH: geom.h,
183
+ })
184
+ try { svgRef.current.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
185
+ }
186
+ }
187
+
188
+ function onPointerMove(ev: React.PointerEvent<SVGSVGElement>): void {
189
+ if (!drag || !svgRef.current) return
190
+ const view = pointerToViewBox(svgRef.current, ev.clientX, ev.clientY, VIEW_W, VIEW_H)
191
+ let next = values
192
+ let nextEdited = edited
193
+ if (drag.mode === 'W' || drag.mode === 'WH') {
194
+ if (widthKey) {
195
+ const bW = dimensionBounds(widthKey)
196
+ const newW = clampDimension(drag.startW + (view.x - drag.startX) * (drag.startW / drag.rectW), bW.min, bW.max)
197
+ next = withKey(next, widthKey, newW)
198
+ nextEdited = withKeyBool(nextEdited, widthKey, true)
199
+ }
200
+ }
201
+ if (drag.mode === 'H' || drag.mode === 'WH') {
202
+ if (heightKey) {
203
+ const bH = dimensionBounds(heightKey)
204
+ const newH = clampDimension(drag.startH + (view.y - drag.startY) * (drag.startH / drag.rectH), bH.min, bH.max)
205
+ next = withKey(next, heightKey, newH)
206
+ nextEdited = withKeyBool(nextEdited, heightKey, true)
207
+ }
208
+ }
209
+ if (next !== values) setValues(next)
210
+ if (nextEdited !== edited) setEdited(nextEdited)
211
+ }
212
+
213
+ function endDrag(): void {
214
+ setDrag(null)
215
+ }
216
+
217
+ function onFieldChange(key: string, ev: React.ChangeEvent<HTMLInputElement>): void {
218
+ const raw = ev.target.value
219
+ setFieldText((prev) => ({ ...prev, [key]: raw }))
220
+ const n = parseDimension(raw)
221
+ if (n == null) {
222
+ setInvalid((prev) => ({ ...prev, [key]: true }))
223
+ return
224
+ }
225
+ const bounds = dimensionBounds(key)
226
+ setInvalid((prev) => ({ ...prev, [key]: false }))
227
+ setValues((prev) => withKey(prev, key, clampDimension(n, bounds.min, bounds.max)))
228
+ setEdited((prev) => withKeyBool(prev, key, true))
229
+ }
230
+
231
+ function onFieldBlur(key: string): void {
232
+ setFieldText((prev) => ({ ...prev, [key]: formatDimension(values[key]) }))
233
+ setInvalid((prev) => ({ ...prev, [key]: false }))
234
+ }
235
+
236
+ function focusField(key: string): void {
237
+ if (disabled || typeof document === 'undefined') return
238
+ const el = document.querySelector(`.hq-genhit__field-input[data-field="${key}"]`) as HTMLInputElement | null
239
+ if (el && el.focus) {
240
+ el.focus()
241
+ el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
242
+ }
243
+ }
244
+
245
+ const hasInvalid = Object.values(invalid).some(Boolean)
246
+
247
+ const topY = geom.y - 16
248
+ const leftX = geom.x - 16
249
+ const wMid = geom.x + geom.w / 2
250
+ const hMid = geom.y + geom.h / 2
251
+ const wLabel = widthKey && values[widthKey] != null ? `${formatDimension(values[widthKey])} ${t('editor.unit')}` : ''
252
+ const hLabel = heightKey && values[heightKey] != null ? `${formatDimension(values[heightKey])} ${t('editor.unit')}` : ''
253
+ const arrow = (pts: string): ReactElement => <polygon className="hq-genhit__arrow" points={pts} />
254
+ const tolText = (key: string | null): string => {
255
+ const tol = toleranceOf(values, key)
256
+ if (!tol || tol.min == null || tol.max == null || tol.min === tol.max) return ''
257
+ return `${formatDimension(tol.min)}\u2013${formatDimension(tol.max)} ${t('editor.unit')}`
258
+ }
259
+ const wTolText = tolText(widthKey)
260
+ const hTolText = tolText(heightKey)
261
+
262
+ const groups = classifyDimensions(normalized.numericKeys, widthKey, heightKey)
263
+ const essentialKeys: string[] = []
264
+ if (widthKey) essentialKeys.push(widthKey)
265
+ if (heightKey) essentialKeys.push(heightKey)
266
+ for (const ek of groups.essential) {
267
+ if (ek !== widthKey && ek !== heightKey) essentialKeys.push(ek)
268
+ }
269
+ const advancedKeys = groups.advanced
270
+
271
+ const renderField = (key: string): ReactElement => {
272
+ const bounds = dimensionBounds(key)
273
+ return (
274
+ <div
275
+ className={`hq-genhit__field${invalid[key] ? ' hq-genhit__field--invalid' : ''}`}
276
+ key={key}
277
+ title={invalid[key] ? t('editor.validationInvalid') : undefined}
278
+ >
279
+ <label
280
+ className={`hq-genhit__field-label${edited[key] ? ' hq-genhit__field-label--edited' : ''}`}
281
+ onClick={() => focusField(key)}
282
+ >
283
+ {labelFor(key)}
284
+ </label>
285
+ {edited[key]
286
+ ? <span className="hq-genhit__field-tag hq-genhit__field-tag--edited">{t('editor.editedTag')}</span>
287
+ : <span className="hq-genhit__field-tag hq-genhit__field-tag--ai">{t('editor.aiTag')}</span>}
288
+ <input
289
+ className="hq-genhit__field-input"
290
+ data-field={key}
291
+ type="number"
292
+ inputMode="decimal"
293
+ min={bounds.min}
294
+ max={bounds.max}
295
+ step={0.1}
296
+ value={fieldText[key] == null ? '' : fieldText[key]}
297
+ disabled={disabled}
298
+ onChange={(ev) => onFieldChange(key, ev)}
299
+ onBlur={() => onFieldBlur(key)}
300
+ />
301
+ <span className="hq-genhit__field-unit">{fieldUnit(key)}</span>
302
+ </div>
303
+ )
304
+ }
305
+
306
+ const issues = validateDimensions(values)
307
+ const seen = new Set<string>()
308
+ const uniqueIssues = issues.filter((it) => {
309
+ if (seen.has(it.key)) return false
310
+ seen.add(it.key)
311
+ return true
312
+ })
313
+ const issueTexts = uniqueIssues.map((it) => {
314
+ const codeLabel = it.code === 'min_gt_max' ? t('editor.issueMinGtMax')
315
+ : it.code === 'out_of_range' ? t('editor.issueOutOfRange')
316
+ : t('editor.validationInvalid')
317
+ return `${labelFor(it.key)}: ${codeLabel}`
318
+ })
319
+ const totalIssues = issueTexts.length + (hasInvalid ? 1 : 0)
320
+
321
+ const canEditGeometry = !!(widthKey && heightKey)
322
+ const summaryParts = summaryOf(values, widthKey, heightKey, editorT(t))
323
+
324
+ return (
325
+ <div className="hq-genhit__editor">
326
+ {canEditGeometry
327
+ ? (
328
+ <svg
329
+ ref={svgRef}
330
+ className="hq-genhit__geom"
331
+ viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
332
+ onPointerMove={onPointerMove}
333
+ onPointerUp={endDrag}
334
+ onPointerCancel={endDrag}
335
+ >
336
+ <line className="hq-genhit__dimline" x1={geom.x} y1={topY} x2={geom.x + geom.w} y2={topY} />
337
+ {arrow(`${geom.x},${topY} ${geom.x + 7},${topY - 3} ${geom.x + 7},${topY + 3}`)}
338
+ {arrow(`${geom.x + geom.w},${topY} ${geom.x + geom.w - 7},${topY - 3} ${geom.x + geom.w - 7},${topY + 3}`)}
339
+ <text
340
+ className={`hq-genhit__dimlabel${widthKey ? ' hq-genhit__dimlabel--clickable' : ''}`}
341
+ x={wMid}
342
+ y={topY - 8}
343
+ textAnchor="middle"
344
+ onClick={widthKey ? () => focusField(widthKey) : undefined}
345
+ >
346
+ {wLabel}
347
+ </text>
348
+ {wTolText ? <text className="hq-genhit__tol" x={wMid} y={topY + 12} textAnchor="middle">{wTolText}</text> : null}
349
+ <line className="hq-genhit__dimline" x1={leftX} y1={geom.y} x2={leftX} y2={geom.y + geom.h} />
350
+ {arrow(`${leftX},${geom.y} ${leftX - 3},${geom.y + 7} ${leftX + 3},${geom.y + 7}`)}
351
+ {arrow(`${leftX},${geom.y + geom.h} ${leftX - 3},${geom.y + geom.h - 7} ${leftX + 3},${geom.y + geom.h - 7}`)}
352
+ <text
353
+ className={`hq-genhit__dimlabel${heightKey ? ' hq-genhit__dimlabel--clickable' : ''}`}
354
+ x={leftX - 8}
355
+ y={hMid}
356
+ textAnchor="middle"
357
+ transform={`rotate(-90 ${leftX - 8} ${hMid})`}
358
+ onClick={heightKey ? () => focusField(heightKey) : undefined}
359
+ >
360
+ {hLabel}
361
+ </text>
362
+ {hTolText
363
+ ? <text className="hq-genhit__tol" x={leftX + 12} y={hMid} textAnchor="middle" transform={`rotate(-90 ${leftX + 12} ${hMid})`}>{hTolText}</text>
364
+ : null}
365
+ {packageSilhouette(pkgType, values, geom).map((node, ni) => (
366
+ <Fragment key={node.key ?? `sil${ni}`}>{node}</Fragment>
367
+ ))}
368
+ <circle className="hq-genhit__handle" cx={geom.x + geom.w} cy={geom.y + geom.h / 2} r={6} onPointerDown={startDrag('W')} />
369
+ <circle className="hq-genhit__handle hq-genhit__handle--h" cx={geom.x + geom.w / 2} cy={geom.y + geom.h} r={6} onPointerDown={startDrag('H')} />
370
+ <circle className="hq-genhit__handle hq-genhit__handle--wh" cx={geom.x + geom.w} cy={geom.y + geom.h} r={7} onPointerDown={startDrag('WH')} />
371
+ </svg>
372
+ )
373
+ : null}
374
+ {canEditGeometry ? <div className="hq-genhit__drag-hint">{t('editor.dragHint')}</div> : null}
375
+ <div className="hq-genhit__pkg">
376
+ {pkgFamilyLabel(pkgType) ? <span className="hq-genhit__badge hq-genhit__badge--pkg">{pkgFamilyLabel(pkgType)}</span> : null}
377
+ <span className="hq-genhit__pkg-meta">{t('editor.pins', { count: pinCountOf(values, 0) })}</span>
378
+ {summaryParts.map((part, pi) => (
379
+ <span className="hq-genhit__pkg-meta hq-genhit__pkg-meta--sep" key={`sum${pi}`}>· {part}</span>
380
+ ))}
381
+ </div>
382
+ {essentialKeys.length > 0 ? <div className="hq-genhit__fields">{essentialKeys.map(renderField)}</div> : null}
383
+ {advancedKeys.length > 0
384
+ ? (
385
+ <div className="hq-genhit__adv">
386
+ <button
387
+ type="button"
388
+ className="hq-genhit__adv-toggle"
389
+ onClick={() => setAdvancedOpen(!advancedOpen)}
390
+ aria-expanded={advancedOpen ? 'true' : 'false'}
391
+ >
392
+ {(advancedOpen ? '\u25be ' : '\u25b8 ') + t('editor.advanced') + ` (${advancedKeys.length})`}
393
+ </button>
394
+ {advancedOpen ? <div className="hq-genhit__fields hq-genhit__fields--adv">{advancedKeys.map(renderField)}</div> : null}
395
+ </div>
396
+ )
397
+ : null}
398
+ <div className={`hq-genhit__validation${totalIssues > 0 ? ' hq-genhit__validation--warn' : ''}`}>
399
+ {totalIssues > 0
400
+ ? (
401
+ <span>
402
+ {t('editor.validationIssue', { n: totalIssues })}
403
+ {issueTexts.length > 0 ? <span className="hq-genhit__validation-detail">{issueTexts[0]}</span> : null}
404
+ </span>
405
+ )
406
+ : <span>{t('editor.validationOk')}</span>}
407
+ </div>
408
+ <div className="hq-genhit__actions">
409
+ <button type="button" className="hq-genhit__act" onClick={() => onConfirm(values, edited)} disabled={disabled || hasInvalid}>
410
+ ✓ {t('editor.confirmLabel')}
411
+ </button>
412
+ <button type="button" className="hq-genhit__act" onClick={onCancel} disabled={disabled}>
413
+ ✕ {t('editor.cancelLabel')}
414
+ </button>
415
+ </div>
416
+ </div>
417
+ )
418
+ })
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — history panel.
3
+ *
4
+ * Reads `ports.history()` with cursor pagination; renders entries with
5
+ * reopen / download / delete. Stays DSH-agnostic — actions resolve through
6
+ * the ports, and artifact text comes from `ports.artifactContent`.
7
+ */
8
+ import { useCallback, useEffect, useState, type ReactElement } from 'react'
9
+ import type { ComponentGenPorts, HistoryEntry } from '../ports.js'
10
+ import type { Translate } from '../copy/index.js'
11
+ import { triggerDownload } from '../utils/ecad.js'
12
+
13
+ export interface HistoryPanelProps {
14
+ ports: ComponentGenPorts
15
+ t: Translate
16
+ activeKind?: 'symbol' | 'footprint' | null
17
+ onReopen: (entry: HistoryEntry) => void
18
+ }
19
+
20
+ const PAGE = 12
21
+
22
+ export function HistoryPanel({ ports, t, activeKind = null, onReopen }: HistoryPanelProps): ReactElement {
23
+ const [entries, setEntries] = useState<HistoryEntry[]>([])
24
+ const [cursor, setCursor] = useState<string | null>(null)
25
+ const [loading, setLoading] = useState(false)
26
+ const [done, setDone] = useState(false)
27
+ const [busy, setBusy] = useState<string | null>(null)
28
+
29
+ const load = useCallback(async (nextCursor: string | null): Promise<void> => {
30
+ setLoading(true)
31
+ try {
32
+ const page = await ports.history({ limit: PAGE, cursor: nextCursor })
33
+ if (!nextCursor) setEntries(page.entries)
34
+ else setEntries((prev) => [...prev, ...page.entries])
35
+ setCursor(page.nextCursor ?? null)
36
+ if (!page.nextCursor) setDone(true)
37
+ } catch (e) {
38
+ console.warn('[hq-component-gen] history load failed', e)
39
+ setDone(true)
40
+ } finally {
41
+ setLoading(false)
42
+ }
43
+ }, [ports])
44
+
45
+ useEffect(() => { void load(null) }, [load])
46
+
47
+ const doDelete = useCallback(async (entry: HistoryEntry): Promise<void> => {
48
+ setBusy(entry.id)
49
+ try {
50
+ await ports.deleteHistory(entry.id)
51
+ setEntries((prev) => prev.filter((e) => e.id !== entry.id))
52
+ } finally {
53
+ setBusy(null)
54
+ }
55
+ }, [ports])
56
+
57
+ const doDownload = useCallback(async (entry: HistoryEntry): Promise<void> => {
58
+ if (!entry.result?.artifactId) return
59
+ setBusy(entry.id)
60
+ try {
61
+ const content = await ports.artifactContent(entry.result.artifactId)
62
+ const filename = entry.result.filename || `${entry.result.artifactId}.${entry.kind === 'symbol' ? 'kicad_sym' : 'kicad_mod'}`
63
+ triggerDownload(filename, content)
64
+ } catch (e) {
65
+ console.warn('[hq-component-gen] history download failed', e)
66
+ } finally {
67
+ setBusy(null)
68
+ }
69
+ }, [ports])
70
+
71
+ const visible = activeKind ? entries.filter((e) => e.kind === activeKind) : entries
72
+
73
+ return (
74
+ <div className="cga-history">
75
+ {visible.length === 0 && !loading
76
+ ? <div className="cga-upload__text">{t('history.empty')}</div>
77
+ : null}
78
+ {visible.map((entry) => (
79
+ <div className="cga-history__item" key={entry.id}>
80
+ <div className="cga-history__meta">
81
+ <span className="cga-history__title">
82
+ {entry.kind === 'symbol' ? t('history.symbol') : t('history.footprint')}
83
+ {' — '}
84
+ {entry.result?.filename ?? entry.id.slice(0, 8)}
85
+ </span>
86
+ <span className="cga-history__sub">
87
+ {new Date(entry.createdAt).toLocaleString()} · {t(`history.${entry.status}`)}
88
+ </span>
89
+ </div>
90
+ <div style={{ display: 'flex', gap: 4 }}>
91
+ {entry.status === 'generated' && entry.result?.artifactId
92
+ ? (
93
+ <>
94
+ <button type="button" className="cga-history__act" disabled={busy === entry.id} onClick={() => onReopen(entry)}>
95
+ {t('history.reopen')}
96
+ </button>
97
+ <button type="button" className="cga-history__act" disabled={busy === entry.id} onClick={() => void doDownload(entry)}>
98
+ {t('history.download')}
99
+ </button>
100
+ </>
101
+ )
102
+ : null}
103
+ <button type="button" className="cga-history__act" disabled={busy === entry.id} onClick={() => void doDelete(entry)}>
104
+ {t('history.delete')}
105
+ </button>
106
+ </div>
107
+ </div>
108
+ ))}
109
+ {!done && visible.length > 0
110
+ ? (
111
+ <button type="button" className="cga-btn" disabled={loading} onClick={() => void load(cursor)}>
112
+ {loading ? t('app.loading') : t('history.loadMore')}
113
+ </button>
114
+ )
115
+ : null}
116
+ {done && visible.length > 0 ? <div className="cga-upload__text">{t('history.noMore')}</div> : null}
117
+ </div>
118
+ )
119
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — ECAD canvas preview of a generated artifact.
3
+ *
4
+ * The host supplies the artifact text via `ports.artifactContent`; this
5
+ * component renders it with the bundled ecad-renderer and releases the viewer
6
+ * on unmount / src change.
7
+ */
8
+ import { useEffect, useRef, useState, type ReactElement } from 'react'
9
+ import { renderArtifactToCanvas, sizeCanvasFor } from '../utils/ecad.js'
10
+ import type { Translate } from '../copy/index.js'
11
+
12
+ export interface PreviewStageProps {
13
+ kind: string | null
14
+ content: string
15
+ srcKey: string | null
16
+ t: Translate
17
+ }
18
+
19
+ export function PreviewStage({ kind, content, srcKey, t }: PreviewStageProps): ReactElement {
20
+ const canvasRef = useRef<HTMLCanvasElement | null>(null)
21
+ const [view, setView] = useState<{ view: 'loading' | 'ready' | 'error'; message: string }>({ view: 'loading', message: '' })
22
+
23
+ useEffect(() => {
24
+ let cancelled = false
25
+ let disposeViewer: (() => void) | null = null
26
+ setView({ view: 'loading', message: '' })
27
+ ;(async () => {
28
+ try {
29
+ const canvas = canvasRef.current
30
+ if (!canvas) return
31
+ // Wait one layout frame so the canvas has its final CSS size.
32
+ await new Promise((resolve) => {
33
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(resolve)
34
+ else setTimeout(resolve, 16)
35
+ })
36
+ if (cancelled || canvas !== canvasRef.current) return
37
+ sizeCanvasFor(canvas)
38
+ disposeViewer = await renderArtifactToCanvas(kind ?? 'symbol', content, canvas)
39
+ if (cancelled || canvas !== canvasRef.current) return
40
+ setView({ view: 'ready', message: '' })
41
+ } catch (e) {
42
+ if (!cancelled) {
43
+ console.warn('[hq-component-gen] preview render failed', e)
44
+ setView({ view: 'error', message: String((e as Error)?.message || e) })
45
+ }
46
+ }
47
+ })()
48
+ return () => {
49
+ cancelled = true
50
+ try { disposeViewer?.() } catch { /* ignore */ }
51
+ }
52
+ }, [srcKey, kind, content])
53
+
54
+ const overlay =
55
+ view.view === 'loading'
56
+ ? <div className="hq-genhit__stage-msg">{t('app.loading')}</div>
57
+ : view.view === 'error'
58
+ ? <div className="hq-genhit__stage-msg hq-genhit__stage-msg--error">{t('app.error')}{view.message}</div>
59
+ : null
60
+
61
+ return (
62
+ <div className={`hq-genhit__stage hq-genhit__stage--${kind ?? 'symbol'}`}>
63
+ <canvas ref={canvasRef} className="hq-genhit__canvas" />
64
+ {overlay}
65
+ </div>
66
+ )
67
+ }