@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
package/src/ports.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/component-gen-app` — the whole contract between the portable app
|
|
3
|
+
* and its host (DSH adapter or standalone server).
|
|
4
|
+
*
|
|
5
|
+
* The app has ZERO DSH imports. It only knows this ports interface. Two
|
|
6
|
+
* adapters implement it with the same HTTP client against different origins:
|
|
7
|
+
*
|
|
8
|
+
* - DSH adapter → `createDshPorts()` in `dsh-tool-symbol-footprint`, fetch
|
|
9
|
+
* to `/api/v1/huaqiu/component-gen/*` (plugin-owned webServer route).
|
|
10
|
+
* - Standalone → `createHttpPorts()` in `api/component-gen-client.ts`,
|
|
11
|
+
* fetch to `http://localhost:<port>/api/v1/huaqiu/component-gen/*`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ── Domain types (shared with `@huaqiu/component-gen-server`) ────────────────
|
|
15
|
+
|
|
16
|
+
export type ComponentGenPage = 'symbol' | 'footprint'
|
|
17
|
+
|
|
18
|
+
export type JobKind = 'symbol' | 'extract-footprint' | 'generate-footprint'
|
|
19
|
+
|
|
20
|
+
export type JobStatus =
|
|
21
|
+
| 'queued'
|
|
22
|
+
| 'running'
|
|
23
|
+
| 'needs_confirmation'
|
|
24
|
+
| 'completed'
|
|
25
|
+
| 'failed'
|
|
26
|
+
| 'cancelled'
|
|
27
|
+
|
|
28
|
+
export interface JobInput {
|
|
29
|
+
/** data URL of the uploaded image (server stores a thumbnail into history). */
|
|
30
|
+
imageDataUrl?: string
|
|
31
|
+
instruction?: string
|
|
32
|
+
packageType?: string
|
|
33
|
+
dimensions?: Record<string, number>
|
|
34
|
+
fileName?: string
|
|
35
|
+
/** which dimensions the human edited (footprint confirmations). */
|
|
36
|
+
edited?: Record<string, boolean>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface JobState {
|
|
40
|
+
id: string
|
|
41
|
+
kind: JobKind
|
|
42
|
+
status: JobStatus
|
|
43
|
+
progress?: string
|
|
44
|
+
/** structured result of the generation function (status/kind/fileUrl/...). */
|
|
45
|
+
result?: Record<string, unknown>
|
|
46
|
+
/** extracted dimensions for the `needs_confirmation` phase. */
|
|
47
|
+
dimensions?: Record<string, unknown>
|
|
48
|
+
pkgType?: string | null
|
|
49
|
+
fileName?: string | null
|
|
50
|
+
error?: string
|
|
51
|
+
createdAt: string
|
|
52
|
+
updatedAt: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type JobEvent =
|
|
56
|
+
| { type: 'progress'; message: string; at: string }
|
|
57
|
+
| { type: 'needs_confirmation'; dimensions: Record<string, unknown>; pkgType?: string | null; fileName?: string | null; at: string }
|
|
58
|
+
| { type: 'completed'; job: JobState; at: string }
|
|
59
|
+
| { type: 'failed'; error: string; result?: Record<string, unknown>; at: string }
|
|
60
|
+
| { type: 'cancelled'; at: string }
|
|
61
|
+
|
|
62
|
+
export interface StartJobRequest {
|
|
63
|
+
kind: JobKind
|
|
64
|
+
input: JobInput
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface HistoryQuery {
|
|
68
|
+
limit?: number
|
|
69
|
+
cursor?: string | null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface HistoryPage {
|
|
73
|
+
entries: HistoryEntry[]
|
|
74
|
+
nextCursor?: string | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface HistoryEntry {
|
|
78
|
+
id: string
|
|
79
|
+
kind: 'symbol' | 'footprint'
|
|
80
|
+
createdAt: string
|
|
81
|
+
status: 'generated' | 'failed' | 'cancelled'
|
|
82
|
+
input: {
|
|
83
|
+
imageId?: string
|
|
84
|
+
instruction?: string
|
|
85
|
+
packageType?: string
|
|
86
|
+
dimensions?: Record<string, number>
|
|
87
|
+
}
|
|
88
|
+
/** which dimensions the human edited (footprint confirmations). */
|
|
89
|
+
edited?: Record<string, boolean>
|
|
90
|
+
result?: {
|
|
91
|
+
artifactId: string
|
|
92
|
+
filename: string
|
|
93
|
+
fileUrl?: string
|
|
94
|
+
size?: number
|
|
95
|
+
}
|
|
96
|
+
error?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface HistoryPatch {
|
|
100
|
+
status?: HistoryEntry['status']
|
|
101
|
+
result?: HistoryEntry['result']
|
|
102
|
+
error?: string
|
|
103
|
+
edited?: Record<string, boolean>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Request to reopen a generated history entry in the active generation page. */
|
|
107
|
+
export interface ReopenRequest {
|
|
108
|
+
/** monotonically increasing so re-clicking the same entry re-applies. */
|
|
109
|
+
n: number
|
|
110
|
+
entry: HistoryEntry
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ComponentGenConfig {
|
|
114
|
+
hostMode: boolean
|
|
115
|
+
capabilities: {
|
|
116
|
+
symbol: boolean
|
|
117
|
+
footprint: boolean
|
|
118
|
+
}
|
|
119
|
+
limits: {
|
|
120
|
+
/** max accepted input image bytes. */
|
|
121
|
+
imageBytes: number
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Auth capability consumed through the public `@huaqiu/dsh-auth` surface. */
|
|
126
|
+
export interface ComponentGenAuthPort {
|
|
127
|
+
isAuthenticated(): Promise<boolean>
|
|
128
|
+
getUserInfo(): Promise<{ nickname?: string } | null>
|
|
129
|
+
/** Trigger the existing dsh-auth login flow (host-owned). */
|
|
130
|
+
login(): Promise<void>
|
|
131
|
+
onAuthStateChanged(listener: (authenticated: boolean) => void): () => void
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The whole contract the app needs from its host. */
|
|
135
|
+
export interface ComponentGenPorts {
|
|
136
|
+
config(): Promise<ComponentGenConfig>
|
|
137
|
+
startJob(req: StartJobRequest, signal?: AbortSignal): Promise<JobState>
|
|
138
|
+
jobEvents(jobId: string, onEvent: (e: JobEvent) => void): () => void
|
|
139
|
+
abortJob(jobId: string): Promise<void>
|
|
140
|
+
history(query: HistoryQuery): Promise<HistoryPage>
|
|
141
|
+
historyEntry(id: string): Promise<HistoryEntry | null>
|
|
142
|
+
patchHistory(id: string, patch: HistoryPatch): Promise<HistoryEntry>
|
|
143
|
+
deleteHistory(id: string): Promise<void>
|
|
144
|
+
/** raw artifact text for preview (from `@huaqiu/dsh-artifacts` routes). */
|
|
145
|
+
artifactContent(artifactId: string): Promise<string>
|
|
146
|
+
/** data URL of a stored input thumbnail. */
|
|
147
|
+
inputImage(imageId: string): Promise<string>
|
|
148
|
+
auth: ComponentGenAuthPort
|
|
149
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/component-gen-app` — scoped stylesheet.
|
|
3
|
+
*
|
|
4
|
+
* Injected as a `<style>` tag (never a CSS import) so both build surfaces work:
|
|
5
|
+
* the standalone vite bundle AND the DSH client-module bundle (tsdown has no
|
|
6
|
+
* CSS pipeline). Reuses the `hq-genhit__*` class vocabulary (shared with
|
|
7
|
+
* `dsh-tool-symbol-footprint`) plus an `cga-*` app-shell layer. Uses the same
|
|
8
|
+
* DSW design tokens so the app follows the host palette in DSH.
|
|
9
|
+
*/
|
|
10
|
+
export const APP_STYLE_ID = 'hq-cga-styles'
|
|
11
|
+
export const APP_PLUGIN_ID = '@huaqiu/component-gen-app'
|
|
12
|
+
|
|
13
|
+
const CSS = `
|
|
14
|
+
/* ── shared with dsh-tool-symbol-footprint (editor + preview) ─────────────── */
|
|
15
|
+
.hq-genhit__stage { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; box-sizing: border-box; background: var(--dsw-alias-markdown-code-block, #0a1929); overflow: hidden; }
|
|
16
|
+
.hq-genhit__stage--symbol, .hq-genhit__stage--footprint { height: 300px; }
|
|
17
|
+
.hq-genhit__canvas { display: block; width: 100%; height: 100%; background: var(--dsw-alias-markdown-code-block, #0a1929); }
|
|
18
|
+
.hq-genhit__stage-msg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 10px 14px; box-sizing: border-box; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); text-align: center; }
|
|
19
|
+
.hq-genhit__editor { padding: 0 12px 4px; }
|
|
20
|
+
.hq-genhit__geom { display: block; width: 100%; height: auto; touch-action: none; }
|
|
21
|
+
.hq-genhit__body { fill: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); stroke: var(--dsw-alias-label-primary, currentColor); stroke-width: 1.5; }
|
|
22
|
+
.hq-genhit__pad { fill: var(--dsw-alias-accent-primary, currentColor); opacity: 0.85; }
|
|
23
|
+
.hq-genhit__ball { fill: var(--dsw-alias-accent-primary, currentColor); opacity: 0.85; }
|
|
24
|
+
.hq-genhit__epad { fill: var(--dsw-alias-accent-primary, currentColor); opacity: 0.25; stroke: var(--dsw-alias-accent-primary, currentColor); stroke-width: 1; stroke-dasharray: 2 2; }
|
|
25
|
+
.hq-genhit__pkg { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; padding: 0 0 8px; }
|
|
26
|
+
.hq-genhit__badge--pkg { border-color: var(--dsw-alias-accent-primary, currentColor); color: var(--dsw-alias-accent-primary, currentColor); }
|
|
27
|
+
.hq-genhit__pkg-meta { font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
|
|
28
|
+
.hq-genhit__pkg-meta--sep { opacity: 0.85; }
|
|
29
|
+
.hq-genhit__dimline { stroke: var(--dsw-alias-label-secondary, currentColor); stroke-width: 1; }
|
|
30
|
+
.hq-genhit__dimlabel { fill: var(--dsw-alias-label-secondary, currentColor); font: 11px/1 system-ui, sans-serif; }
|
|
31
|
+
.hq-genhit__dimlabel--clickable { cursor: pointer; }
|
|
32
|
+
.hq-genhit__dimlabel--clickable:hover { fill: var(--dsw-alias-accent-primary, currentColor); text-decoration: underline; }
|
|
33
|
+
.hq-genhit__tol { fill: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); font: 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
34
|
+
.hq-genhit__arrow { fill: var(--dsw-alias-label-secondary, currentColor); }
|
|
35
|
+
.hq-genhit__handle { fill: var(--dsw-alias-accent-primary, currentColor); stroke: var(--dsw-alias-bg-layer-1, #fff); stroke-width: 1.5; cursor: ew-resize; }
|
|
36
|
+
.hq-genhit__handle--h { cursor: ns-resize; }
|
|
37
|
+
.hq-genhit__handle--wh { cursor: nwse-resize; }
|
|
38
|
+
.hq-genhit__drag-hint { padding: 0 2px 6px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); }
|
|
39
|
+
.hq-genhit__fields { display: flex; flex-wrap: wrap; gap: 8px 12px; padding: 0 0 10px; }
|
|
40
|
+
.hq-genhit__field { display: inline-flex; align-items: center; gap: 6px; }
|
|
41
|
+
.hq-genhit__field-label { font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
|
|
42
|
+
.hq-genhit__field-label--edited { color: var(--dsw-alias-accent-primary, currentColor); }
|
|
43
|
+
.hq-genhit__field-tag { font: 10px/1.2 system-ui, sans-serif; padding: 1px 4px; border-radius: 4px; }
|
|
44
|
+
.hq-genhit__field-tag--ai { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); border: 1px solid var(--dsw-alias-border-l1, currentColor); }
|
|
45
|
+
.hq-genhit__field-tag--edited { color: var(--dsw-alias-accent-primary, currentColor); border: 1px solid var(--dsw-alias-accent-primary, currentColor); }
|
|
46
|
+
.hq-genhit__field-input { width: 72px; padding: 3px 6px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1, currentColor); background: var(--dsw-alias-bg-layer-1, transparent); color: var(--dsw-alias-label-primary, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace); }
|
|
47
|
+
.hq-genhit__field--invalid .hq-genhit__field-input { border-color: var(--dsw-alias-state-error-primary, currentColor); }
|
|
48
|
+
.hq-genhit__field-unit { font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
|
|
49
|
+
.hq-genhit__adv { padding: 0 0 8px; }
|
|
50
|
+
.hq-genhit__adv-toggle { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; padding: 3px 8px; background: var(--dsw-alias-bg-layer-1, transparent); color: var(--dsw-alias-label-secondary, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); cursor: pointer; }
|
|
51
|
+
.hq-genhit__fields--adv { padding-top: 8px; }
|
|
52
|
+
.hq-genhit__validation { display: flex; align-items: baseline; gap: 6px; padding: 6px 10px; margin: 0 0 10px; border-radius: 6px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); background: var(--dsw-alias-interactive-bg-hover, transparent); }
|
|
53
|
+
.hq-genhit__validation--warn { color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-error-primary, currentColor)); background: var(--dsw-alias-state-warning-bg, transparent); }
|
|
54
|
+
.hq-genhit__validation-detail { opacity: 0.85; }
|
|
55
|
+
|
|
56
|
+
/* ── app shell (cga-*) — tokens/geometry follow dsh's Modal + settings dialog
|
|
57
|
+
(mask-1/mask-blur, layer-2 + elevation, r24 dialog, 28x28 r8 close, 13px/500
|
|
58
|
+
labels, r8 inputs on bg-layer-3 with border-l4, capsule sm buttons) ─────── */
|
|
59
|
+
.cga-app { display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box; font: var(--dsw-font-s-14, 14px/1.5 system-ui, sans-serif); color: var(--dsw-alias-label-primary, currentColor); }
|
|
60
|
+
.cga-app__head { display: flex; align-items: center; gap: 8px; }
|
|
61
|
+
.cga-app__head-title { font-size: 16px; line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary, currentColor); }
|
|
62
|
+
.cga-app__head-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; margin-left: auto; padding: 0; border: none; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary, currentColor); cursor: pointer; }
|
|
63
|
+
.cga-app__head-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
|
|
64
|
+
.cga-panel { border: 0.5px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2)); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, transparent); overflow: hidden; }
|
|
65
|
+
.cga-panel__body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
|
66
|
+
.cga-upload { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 18px 12px; border: 1px dashed var(--dsw-alias-border-l3, currentColor); border-radius: 8px; cursor: pointer; }
|
|
67
|
+
.cga-upload--dragging { border-color: var(--dsw-alias-brand-primary, var(--dsw-alias-accent-primary, currentColor)); background: var(--dsw-alias-interactive-bg-hover, transparent); }
|
|
68
|
+
.cga-upload__thumb { max-width: 160px; max-height: 120px; border-radius: 6px; object-fit: contain; }
|
|
69
|
+
.cga-upload__text { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); text-align: center; }
|
|
70
|
+
.cga-upload__browse { margin-top: 2px; height: 28px; padding: 0 10px; border: 0.5px solid var(--dsw-alias-border-l3, currentColor); border-radius: 14px; background: transparent; color: var(--dsw-alias-label-primary, currentColor); font: inherit; font-size: 12px; line-height: 18px; cursor: pointer; }
|
|
71
|
+
.cga-upload__browse:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
|
|
72
|
+
.cga-field { display: flex; flex-direction: column; gap: 6px; }
|
|
73
|
+
.cga-field__label { font-size: 13px; font-weight: 500; line-height: 1.5; color: var(--dsw-alias-label-primary, currentColor); }
|
|
74
|
+
.cga-field__input, .cga-field__select { height: 34px; padding: 0 12px; border: 0.5px solid var(--dsw-alias-border-l4, currentColor); border-radius: 8px; background: var(--dsw-alias-bg-layer-3, transparent); color: var(--dsw-alias-label-primary, currentColor); font: inherit; font-size: 13px; line-height: 1.5; }
|
|
75
|
+
.cga-field__input:focus-visible, .cga-field__select:focus-visible { outline: none; border-color: var(--dsw-alias-brand-primary, var(--dsw-alias-accent-primary, currentColor)); }
|
|
76
|
+
.cga-field__input::placeholder { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-tertiary, currentColor)); }
|
|
77
|
+
.cga-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
78
|
+
.cga-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; height: 28px; padding: 0 10px; border: none; border-radius: 14px; background: transparent; color: var(--dsw-alias-label-primary, currentColor); font: inherit; font-size: 12px; line-height: 18px; cursor: pointer; }
|
|
79
|
+
.cga-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
|
|
80
|
+
.cga-btn:disabled { opacity: 0.4; cursor: default; }
|
|
81
|
+
.cga-btn--primary { background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-accent-primary, currentColor)); border-color: transparent; color: var(--dsw-alias-label-primary-foreground, #fff); }
|
|
82
|
+
.cga-btn--primary:hover { background: var(--dsw-alias-button-primary-hover, var(--dsw-alias-accent-primary, currentColor)); }
|
|
83
|
+
.cga-btn--primary:disabled { opacity: 0.6; }
|
|
84
|
+
.cga-btn--outline { border: 0.5px solid var(--dsw-alias-border-l3, currentColor); }
|
|
85
|
+
.cga-app__head-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
|
86
|
+
.cga-banner { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; font-size: 12px; line-height: 1.5; }
|
|
87
|
+
.cga-banner--info { color: var(--dsw-alias-label-secondary, currentColor); background: var(--dsw-alias-interactive-bg-hover, transparent); }
|
|
88
|
+
.cga-banner--warn { color: var(--dsw-alias-state-warning-primary, currentColor); background: var(--dsw-alias-state-warning-bg, transparent); }
|
|
89
|
+
.cga-banner--error { color: var(--dsw-alias-state-error-primary, currentColor); background: var(--dsw-alias-state-error-bg, transparent); }
|
|
90
|
+
.cga-progress { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary, currentColor); }
|
|
91
|
+
.cga-spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--dsw-alias-border-l3, currentColor); border-top-color: var(--dsw-alias-brand-primary, var(--dsw-alias-accent-primary, currentColor)); animation: cga-spin 0.8s linear infinite; flex: none; }
|
|
92
|
+
@keyframes cga-spin { to { transform: rotate(360deg); } }
|
|
93
|
+
.cga-history { display: flex; flex-direction: column; gap: 6px; }
|
|
94
|
+
.cga-history__item { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 0.5px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2)); border-radius: 8px; }
|
|
95
|
+
.cga-history__meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
|
|
96
|
+
.cga-history__title { font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-primary, currentColor); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
97
|
+
.cga-history__sub { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); }
|
|
98
|
+
.cga-history__act { border: 0; background: none; color: var(--dsw-alias-brand-primary, var(--dsw-alias-accent-primary, currentColor)); font: inherit; font-size: 12px; line-height: 1.5; cursor: pointer; padding: 2px 4px; }
|
|
99
|
+
.cga-history__act:hover { color: var(--dsw-alias-label-primary, currentColor); }
|
|
100
|
+
.cga-auth { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-interactive-bg-hover, transparent); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary, currentColor); }
|
|
101
|
+
|
|
102
|
+
/* History modal — same chrome as dsh's Modal/settings dialog. */
|
|
103
|
+
.cga-history-dialog { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; }
|
|
104
|
+
.cga-history-dialog__mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1); backdrop-filter: var(--dsw-mask-blur); }
|
|
105
|
+
.cga-history-dialog__panel { position: relative; z-index: 1; display: flex; flex-direction: column; width: min(480px, 100%); max-height: min(560px, calc(100vh - 48px)); overflow: hidden; border-radius: 24px; background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-elevation-prominent); }
|
|
106
|
+
.cga-history-dialog__head { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 20px 14px 8px 24px; }
|
|
107
|
+
.cga-history-dialog__title { font-size: 16px; line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary, currentColor); }
|
|
108
|
+
.cga-history-dialog__body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 24px 24px; }
|
|
109
|
+
`
|
|
110
|
+
|
|
111
|
+
export function injectAppStyles(): void {
|
|
112
|
+
if (typeof document === 'undefined') return
|
|
113
|
+
if (document.getElementById(APP_STYLE_ID)) return
|
|
114
|
+
const style = document.createElement('style')
|
|
115
|
+
style.id = APP_STYLE_ID
|
|
116
|
+
style.setAttribute('data-plugin', APP_PLUGIN_ID)
|
|
117
|
+
style.textContent = CSS
|
|
118
|
+
document.head.appendChild(style)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function removeAppStyles(): void {
|
|
122
|
+
if (typeof document === 'undefined') return
|
|
123
|
+
document.getElementById(APP_STYLE_ID)?.remove()
|
|
124
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure geometry/dimension model for the interactive footprint dimension
|
|
3
|
+
* editor (rich-hit). Two-way-bound: the SVG package silhouette and the numeric
|
|
4
|
+
* inputs are two views of the same `values` map. Drag a handle → values
|
|
5
|
+
* change; type in an input → geometry re-renders. Only "confirm"/"cancel" go
|
|
6
|
+
* back to the model.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Per-key mm bounds used to keep edited values sane. */
|
|
10
|
+
const DIM_BOUNDS: Record<string, { min: number; max: number }> = {
|
|
11
|
+
W: { min: 0.1, max: 500 },
|
|
12
|
+
L: { min: 0.1, max: 500 },
|
|
13
|
+
width: { min: 0.1, max: 500 },
|
|
14
|
+
height: { min: 0.1, max: 500 },
|
|
15
|
+
bodyWidth: { min: 0.1, max: 500 },
|
|
16
|
+
bodyLength: { min: 0.1, max: 500 },
|
|
17
|
+
pitch: { min: 0.05, max: 100 },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function dimensionBounds(key: string): { min: number; max: number } {
|
|
21
|
+
return DIM_BOUNDS[key] ?? { min: 0.01, max: 1000 }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clampDimension(value: number, min: number, max: number): number {
|
|
25
|
+
const n = Number(value)
|
|
26
|
+
if (!Number.isFinite(n)) return min
|
|
27
|
+
if (n < min) return min
|
|
28
|
+
if (n > max) return max
|
|
29
|
+
return n
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parse a dimension string (allows a trailing unit like "6.2mm"). Null when invalid. */
|
|
33
|
+
export function parseDimension(text: string): number | null {
|
|
34
|
+
const m = text.trim().match(/^([+-]?(\d+(\.\d+)?|\.\d+))([^0-9]*)$/)
|
|
35
|
+
if (!m) return null
|
|
36
|
+
const n = Number(m[1])
|
|
37
|
+
return Number.isFinite(n) ? n : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Format a number for display: max 2 decimals, no trailing zeros. */
|
|
41
|
+
export function formatDimension(value: number | null | undefined): string {
|
|
42
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return ''
|
|
43
|
+
return String(Math.round(value * 100) / 100)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A dimension map with all scalar values coerced to numbers. */
|
|
47
|
+
export type DimensionValues = Record<string, number>
|
|
48
|
+
|
|
49
|
+
/** Geometry key picks among a dimension map. */
|
|
50
|
+
export interface DimensionGeometry {
|
|
51
|
+
widthKey: string | null
|
|
52
|
+
heightKey: string | null
|
|
53
|
+
otherKeys: string[]
|
|
54
|
+
numericKeys: string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Pick the geometry keys among a dimension map: a width-like key and a
|
|
59
|
+
* height-like key (rendered as the draggable board outline) and the rest.
|
|
60
|
+
*/
|
|
61
|
+
export function pickGeometry(dimensions: DimensionValues): DimensionGeometry {
|
|
62
|
+
let widthKey: string | null = null
|
|
63
|
+
let heightKey: string | null = null
|
|
64
|
+
const numericKeys: string[] = []
|
|
65
|
+
const widthCandidates = ['W', 'width', 'Width', 'D', 'd_max', 'd_min', 'bodyWidth', 'body_width', 'bodyLength', 'body_length', 'boardWidth', 'E', 'e_max', 'e_min']
|
|
66
|
+
const heightCandidates = ['H', 'L', 'height', 'Height', 'Length', 'E', 'e_max', 'e_min', 'bodyLength', 'body_length', 'bodyWidth', 'body_width', 'boardHeight', 'D', 'd_max', 'd_min']
|
|
67
|
+
|
|
68
|
+
for (const k of Object.keys(dimensions)) {
|
|
69
|
+
const v = dimensions[k]
|
|
70
|
+
const n = typeof v === 'number' ? v : (typeof v === 'string' ? parseDimension(v) : null)
|
|
71
|
+
if (n == null) continue
|
|
72
|
+
numericKeys.push(k)
|
|
73
|
+
}
|
|
74
|
+
for (const c of widthCandidates) {
|
|
75
|
+
if (!widthKey && dimensions[c] != null) widthKey = c
|
|
76
|
+
}
|
|
77
|
+
for (const c of heightCandidates) {
|
|
78
|
+
if (c !== widthKey && dimensions[c] != null) { heightKey = c; break }
|
|
79
|
+
}
|
|
80
|
+
const otherKeys = numericKeys.filter((k) => k !== widthKey && k !== heightKey)
|
|
81
|
+
return { widthKey, heightKey, otherKeys, numericKeys }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Normalize a dimension map into numeric values + geometry key picks. */
|
|
85
|
+
export function normalizeDimensions(dimensions: Record<string, unknown> | null): DimensionGeometry & { values: DimensionValues } {
|
|
86
|
+
const values: DimensionValues = {}
|
|
87
|
+
if (dimensions && typeof dimensions === 'object' && !Array.isArray(dimensions)) {
|
|
88
|
+
for (const k of Object.keys(dimensions)) {
|
|
89
|
+
const v = dimensions[k]
|
|
90
|
+
const n = typeof v === 'number' ? v : (typeof v === 'string' ? parseDimension(v) : null)
|
|
91
|
+
if (n != null) values[k] = n
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const geom = pickGeometry(values)
|
|
95
|
+
return { values, ...geom }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fit a W×H rectangle into a viewBox, preserving aspect ratio and leaving
|
|
100
|
+
* padding for the dimension lines. Returns {x,y,w,h} in viewBox units.
|
|
101
|
+
*/
|
|
102
|
+
export function rectFromValues(
|
|
103
|
+
values: DimensionValues,
|
|
104
|
+
widthKey: string | null,
|
|
105
|
+
heightKey: string | null,
|
|
106
|
+
viewW: number,
|
|
107
|
+
viewH: number,
|
|
108
|
+
pad: number,
|
|
109
|
+
): { x: number; y: number; w: number; h: number } {
|
|
110
|
+
let W = widthKey && values[widthKey] != null ? values[widthKey] : 1
|
|
111
|
+
let H = heightKey && values[heightKey] != null ? values[heightKey] : 1
|
|
112
|
+
if (W <= 0) W = 1
|
|
113
|
+
if (H <= 0) H = 1
|
|
114
|
+
const availW = Math.max(1, viewW - pad * 2)
|
|
115
|
+
const availH = Math.max(1, viewH - pad * 2)
|
|
116
|
+
const scale = Math.min(availW / W, availH / H)
|
|
117
|
+
const w = W * scale
|
|
118
|
+
const h = H * scale
|
|
119
|
+
return { x: (viewW - w) / 2, y: (viewH - h) / 2, w, h }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Structured message sent back to the agent when the user confirms. */
|
|
123
|
+
export function dimensionConfirmMessage(
|
|
124
|
+
pkgType: string | null,
|
|
125
|
+
fileName: string | null,
|
|
126
|
+
values: DimensionValues,
|
|
127
|
+
edited: Record<string, boolean>,
|
|
128
|
+
): string {
|
|
129
|
+
const label = pkgType || fileName || 'the component'
|
|
130
|
+
const parts = Object.keys(values).map((k) => `${k}=${formatDimension(values[k])}`)
|
|
131
|
+
let msg = `The user confirmed the footprint dimensions for ${label}: ${parts.join(', ')}. ` +
|
|
132
|
+
'Call generate_footprint_from_dimensions with exactly these dimensions now.'
|
|
133
|
+
const editedKeys = Object.keys(edited).filter((k) => edited[k])
|
|
134
|
+
if (editedKeys.length > 0) {
|
|
135
|
+
msg += ` The user manually changed: ${editedKeys.join(', ')}.`
|
|
136
|
+
}
|
|
137
|
+
return msg
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Structured message sent back to the agent when the user declines. */
|
|
141
|
+
export function dimensionDeclineMessage(pkgType: string | null, fileName: string | null): string {
|
|
142
|
+
const label = pkgType || fileName || 'the component'
|
|
143
|
+
return `The user declined to generate a footprint from the extracted dimensions for ${label}. ` +
|
|
144
|
+
'Do not generate a footprint without new instructions.'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** First positive numeric value across alias keys, else the fallback. */
|
|
148
|
+
export function numVal(values: DimensionValues, keys: string[], fallback: number): number {
|
|
149
|
+
for (const key of keys) {
|
|
150
|
+
const v = values[key]
|
|
151
|
+
const n = typeof v === 'number' ? v : (typeof v === 'string' ? parseDimension(v) : null)
|
|
152
|
+
if (n != null && Number.isFinite(n) && n > 0) return n
|
|
153
|
+
}
|
|
154
|
+
return fallback
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Pin count from the dimension map (aliases: pin_count, pins, pinCount, n_max…). */
|
|
158
|
+
export function pinCountOf(values: DimensionValues, fallback: number): number {
|
|
159
|
+
const n = numVal(values, ['pin_count', 'pins', 'pinCount', 'n_max', 'n'], -1)
|
|
160
|
+
return n >= 0 ? Math.max(2, Math.round(n)) : fallback
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** BGA ball-grid rows/columns (aliases: rows, columns, cols). */
|
|
164
|
+
export function bgaGrid(values: DimensionValues, fallback: number): { rows: number; cols: number } {
|
|
165
|
+
let rows = Math.round(numVal(values, ['rows', 'row'], fallback))
|
|
166
|
+
let cols = Math.round(numVal(values, ['columns', 'col', 'cols'], fallback))
|
|
167
|
+
if (!(rows > 0)) rows = fallback
|
|
168
|
+
if (!(cols > 0)) cols = fallback
|
|
169
|
+
return { rows, cols }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Human-readable package-family label for the editor's info row. */
|
|
173
|
+
export function pkgFamilyLabel(pkgType: string | null): string {
|
|
174
|
+
return String(pkgType || '').toUpperCase()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const PITCH_KEYS = new Set(['pitch', 'pitch_d', 'pitch_e', 'lead_pitch', 'pitch_x', 'e'])
|
|
178
|
+
const PIN_KEYS = new Set(['pin_count', 'pins', 'pinCount', 'total_pins'])
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Split the numeric keys into the small "essential" set (body W/H, pitch,
|
|
182
|
+
* pin count) and the rest, which fold into a collapsible "Advanced" section.
|
|
183
|
+
*/
|
|
184
|
+
export function classifyDimensions(
|
|
185
|
+
numericKeys: string[],
|
|
186
|
+
widthKey: string | null,
|
|
187
|
+
heightKey: string | null,
|
|
188
|
+
): { essential: string[]; advanced: string[] } {
|
|
189
|
+
const essential: string[] = []
|
|
190
|
+
const advanced: string[] = []
|
|
191
|
+
for (const k of numericKeys) {
|
|
192
|
+
if (k === widthKey || k === heightKey || PITCH_KEYS.has(k) || PIN_KEYS.has(k)) {
|
|
193
|
+
if (!essential.includes(k)) essential.push(k)
|
|
194
|
+
} else if (!advanced.includes(k)) {
|
|
195
|
+
advanced.push(k)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { essential, advanced }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Find the min/max tolerance partner for a body dimension key, e.g.
|
|
203
|
+
* `d_max` → `d_min`. Returns { min, max } or null when no partner exists.
|
|
204
|
+
*/
|
|
205
|
+
export function toleranceOf(values: DimensionValues, key: string | null): { min: number; max: number } | null {
|
|
206
|
+
if (!key || !values || typeof values !== 'object') return null
|
|
207
|
+
let lo: number | null = null
|
|
208
|
+
let hi: number | null = null
|
|
209
|
+
if (/_(max|min)$/.test(key)) {
|
|
210
|
+
const base = key.replace(/_(max|min)$/, '')
|
|
211
|
+
const loKey = `${base}_min`
|
|
212
|
+
const hiKey = `${base}_max`
|
|
213
|
+
const loV = values[loKey]
|
|
214
|
+
const hiV = values[hiKey]
|
|
215
|
+
if (loV != null) lo = loV
|
|
216
|
+
if (hiV != null) hi = hiV
|
|
217
|
+
} else {
|
|
218
|
+
const loV = values[`${key}_min`]
|
|
219
|
+
const hiV = values[`${key}_max`]
|
|
220
|
+
if (loV != null) lo = loV
|
|
221
|
+
if (hiV != null) hi = hiV
|
|
222
|
+
}
|
|
223
|
+
if (lo == null && hi == null) return null
|
|
224
|
+
return { min: lo != null ? lo : (hi as number), max: hi != null ? hi : (lo as number) }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Structural validation of the dimension set. */
|
|
228
|
+
export function validateDimensions(values: DimensionValues): Array<{ key: string; code: 'out_of_range' | 'min_gt_max' | 'invalid' }> {
|
|
229
|
+
const issues: Array<{ key: string; code: 'out_of_range' | 'min_gt_max' | 'invalid' }> = []
|
|
230
|
+
for (const key of Object.keys(values)) {
|
|
231
|
+
const v = values[key]
|
|
232
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
233
|
+
issues.push({ key, code: 'invalid' })
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
const b = dimensionBounds(key)
|
|
237
|
+
if (v < b.min || v > b.max) issues.push({ key, code: 'out_of_range' })
|
|
238
|
+
}
|
|
239
|
+
const pairs: Array<[string, string]> = [['d_min', 'd_max'], ['e_min', 'e_max'], ['a_min', 'a_max'], ['b_min', 'b_max'], ['l_min', 'l_max']]
|
|
240
|
+
for (const [lo, hi] of pairs) {
|
|
241
|
+
const loV = values[lo]
|
|
242
|
+
const hiV = values[hi]
|
|
243
|
+
if (loV != null && hiV != null && loV > hiV) {
|
|
244
|
+
issues.push({ key: hi, code: 'min_gt_max' })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return issues
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Compact summary used in the package info row: body W×H, pitch and pins. */
|
|
251
|
+
export function summaryOf(
|
|
252
|
+
values: DimensionValues,
|
|
253
|
+
widthKey: string | null,
|
|
254
|
+
heightKey: string | null,
|
|
255
|
+
t: (key: string, params?: Record<string, unknown>) => string,
|
|
256
|
+
): string[] {
|
|
257
|
+
const parts: string[] = []
|
|
258
|
+
const bodyW = widthKey && values[widthKey] != null ? formatDimension(values[widthKey]) : null
|
|
259
|
+
const bodyH = heightKey && values[heightKey] != null ? formatDimension(values[heightKey]) : null
|
|
260
|
+
if (bodyW != null && bodyH != null) {
|
|
261
|
+
parts.push(`${t('card.editor.body')} ${bodyW} \u00d7 ${bodyH} ${t('card.editor.unit')}`)
|
|
262
|
+
}
|
|
263
|
+
const pitch = numVal(values, ['pitch', 'pitch_d', 'pitch_e', 'lead_pitch', 'pitch_x', 'e'], -1)
|
|
264
|
+
if (pitch >= 0) parts.push(`${t('card.editor.pitch')} ${formatDimension(pitch)} ${t('card.editor.unit')}`)
|
|
265
|
+
return parts
|
|
266
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/component-gen-app` — ECAD preview + download helpers.
|
|
3
|
+
*
|
|
4
|
+
* The preview pipeline is bundled (offline-friendly): `@huaqiu/ecad-renderer`
|
|
5
|
+
* (subpath entries, self-contained ESM) + `@huaqiu/kicad-sexpr-parser` are
|
|
6
|
+
* bundled by tsdown.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the plugin HIT card, artifact TEXT is not fetched from a hardcoded
|
|
9
|
+
* route here — the host supplies it through `ComponentGenPorts.artifactContent`
|
|
10
|
+
* (HTTP or standalone), so this module stays transport-agnostic.
|
|
11
|
+
*/
|
|
12
|
+
import type { schematicProto } from '@huaqiu/kicad-sexpr-parser'
|
|
13
|
+
import type { boardProto } from '@huaqiu/kicad-sexpr-parser'
|
|
14
|
+
import { BoardParser, SchematicParser } from '@huaqiu/kicad-sexpr-parser'
|
|
15
|
+
import { renderSymbol } from '@huaqiu/ecad-renderer/symbol'
|
|
16
|
+
import { renderFootprint } from '@huaqiu/ecad-renderer/footprint'
|
|
17
|
+
|
|
18
|
+
function wrapFootprintInBoard(src: string): string {
|
|
19
|
+
return `(kicad_pcb (version 20240108) (generator "huaqiu-dsh") ${src})`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseSymbol(source: string): schematicProto.I_LibSymbol {
|
|
23
|
+
const sp = new SchematicParser()
|
|
24
|
+
if (typeof sp.parseLibSymbols !== 'function') {
|
|
25
|
+
throw new Error('parseLibSymbols is not a method on SchematicParser')
|
|
26
|
+
}
|
|
27
|
+
const symbols = sp.parseLibSymbols(source)
|
|
28
|
+
if (!symbols || symbols.length === 0) throw new Error('no symbols found in .kicad_sym source')
|
|
29
|
+
return symbols[0]!
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseFootprint(source: string): boardProto.I_Footprint {
|
|
33
|
+
const bp = new BoardParser()
|
|
34
|
+
if (typeof bp.parse !== 'function') throw new Error('parse is not a method on BoardParser')
|
|
35
|
+
const toParse = /^\s*\(\s*kicad_pcb\b/.test(source) ? source : wrapFootprintInBoard(source)
|
|
36
|
+
const board = bp.parse(toParse)
|
|
37
|
+
if (!board || !Array.isArray(board.footprints) || board.footprints.length === 0) {
|
|
38
|
+
throw new Error('no footprints found in wrapped kicad_pcb source')
|
|
39
|
+
}
|
|
40
|
+
return board.footprints[0]!
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Render a single generated artifact (symbol/footprint) onto a canvas. Returns
|
|
45
|
+
* the renderer dispose handle so the caller can release the viewer.
|
|
46
|
+
*/
|
|
47
|
+
export async function renderArtifactToCanvas(
|
|
48
|
+
kind: string,
|
|
49
|
+
content: string,
|
|
50
|
+
canvas: HTMLCanvasElement,
|
|
51
|
+
): Promise<() => void> {
|
|
52
|
+
let dispose: (() => void) | undefined
|
|
53
|
+
if (kind === 'symbol') {
|
|
54
|
+
const sym = parseSymbol(content)
|
|
55
|
+
const r = await renderSymbol(sym, { canvas, interactive: true })
|
|
56
|
+
dispose = () => r.dispose()
|
|
57
|
+
} else if (kind === 'footprint') {
|
|
58
|
+
const fp = parseFootprint(content)
|
|
59
|
+
const r = await renderFootprint(fp, { canvas, interactive: true })
|
|
60
|
+
dispose = () => r.dispose()
|
|
61
|
+
} else {
|
|
62
|
+
throw new Error(`unsupported preview kind: ${kind}`)
|
|
63
|
+
}
|
|
64
|
+
return () => { try { dispose?.() } catch { /* ignore */ } }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Size a canvas to its CSS box (device-pixel-ratio aware). */
|
|
68
|
+
export function sizeCanvasFor(canvas: HTMLCanvasElement): void {
|
|
69
|
+
const dpr = window.devicePixelRatio || 1
|
|
70
|
+
const cssW = canvas.clientWidth || 720
|
|
71
|
+
const cssH = canvas.clientHeight || 320
|
|
72
|
+
canvas.width = Math.max(100, Math.floor(cssW * dpr))
|
|
73
|
+
canvas.height = Math.max(100, Math.floor(cssH * dpr))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Trigger a browser download of a text artifact. */
|
|
77
|
+
export function triggerDownload(filename: string, text: string, mime = 'text/plain;charset=utf-8'): void {
|
|
78
|
+
try {
|
|
79
|
+
const blob = new Blob([text], { type: mime })
|
|
80
|
+
const url = URL.createObjectURL(blob)
|
|
81
|
+
const a = document.createElement('a')
|
|
82
|
+
a.href = url
|
|
83
|
+
a.download = filename
|
|
84
|
+
document.body.appendChild(a)
|
|
85
|
+
a.click()
|
|
86
|
+
a.remove()
|
|
87
|
+
setTimeout(() => { try { URL.revokeObjectURL(url) } catch { /* ignore */ } }, 2000)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.warn('[hq-cga] download failed', err)
|
|
90
|
+
}
|
|
91
|
+
}
|