@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.
- package/LICENSE +21 -0
- package/dist/assets/index-DTShI_jq.js +553 -0
- package/dist/index.html +12 -0
- package/lib/index.d.ts +427 -0
- package/lib/index.js +2550 -0
- package/package.json +48 -0
- package/src/App.tsx +101 -0
- package/src/api/component-gen-client.ts +225 -0
- package/src/components/GeometryEditor.tsx +418 -0
- package/src/components/HistoryPanel.tsx +119 -0
- package/src/components/PreviewStage.tsx +67 -0
- package/src/components/ResultStage.tsx +92 -0
- package/src/components/UploadInput.tsx +121 -0
- package/src/copy/en.ts +150 -0
- package/src/copy/index.ts +50 -0
- package/src/copy/zh.ts +154 -0
- package/src/hooks/useAuthGate.ts +51 -0
- package/src/hooks/useJobRunner.ts +127 -0
- package/src/index.ts +37 -0
- package/src/main.tsx +85 -0
- package/src/pages/FootprintGenPage.tsx +185 -0
- package/src/pages/SymbolGenPage.tsx +136 -0
- package/src/ports.ts +149 -0
- package/src/styles/inject.ts +124 -0
- package/src/utils/dims.ts +266 -0
- package/src/utils/ecad.ts +91 -0
- package/src/utils/labels.ts +76 -0
|
@@ -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
|
+
}
|