mini-figma-code-connect 0.1.0

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/src/ui/ui.ts ADDED
@@ -0,0 +1,271 @@
1
+ type Level = 'error' | 'warn'
2
+ type Finding = { level: Level; text: string }
3
+ type PropertyDef = { name: string; type: string; variantOptions?: string[] }
4
+ type Schema = {
5
+ componentId: string
6
+ componentKey: string
7
+ componentName: string
8
+ properties: PropertyDef[]
9
+ }
10
+ type AccessorCall = { depth: number; prop: string; method: string; ok: boolean; note?: string }
11
+ type Candidate = { id: string; name: string; mapped: boolean }
12
+ type Msg =
13
+ | {
14
+ type: 'state'
15
+ state: 'empty' | 'unmapped' | 'error'
16
+ message: string
17
+ schema?: Schema
18
+ figmaUrl?: string | null
19
+ candidates?: Candidate[]
20
+ }
21
+ | { type: 'state'; state: 'candidates'; message: string; candidates: Candidate[] }
22
+ | {
23
+ type: 'state'
24
+ state: 'scan'
25
+ message: string
26
+ groups: {
27
+ componentKey: string
28
+ componentName: string
29
+ count: number
30
+ sampleNodeId: string
31
+ mapped: boolean
32
+ codeComponent: string | null
33
+ }[]
34
+ }
35
+ | {
36
+ type: 'state'
37
+ state: 'ok'
38
+ schema: Schema
39
+ template: { id: string; meta: { url: string; source: string; component: string } }
40
+ snippet: string
41
+ imports: string[]
42
+ findings: Finding[]
43
+ calls: AccessorCall[]
44
+ candidates?: Candidate[]
45
+ }
46
+ | { type: 'download'; filename: string; json: string }
47
+
48
+ const body = document.getElementById('body') as HTMLElement
49
+ const send = (type: string, extra?: Record<string, unknown>) =>
50
+ parent.postMessage({ pluginMessage: { type, ...extra } }, '*')
51
+
52
+ const esc = (s: string) =>
53
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
54
+
55
+ function schemaTable(schema: Schema, calls: AccessorCall[]): string {
56
+ const consumed = new Set(calls.filter((c) => c.depth === 0).map((c) => c.prop))
57
+ const rows = schema.properties
58
+ .map((p) => {
59
+ const tag = consumed.has(p.name)
60
+ ? '<span class="tag ok">已映射</span>'
61
+ : '<span class="tag miss">未映射</span>'
62
+ const opts = p.variantOptions ? esc(p.variantOptions.join(' / ')) : '—'
63
+ return `<tr><td>${esc(p.name)}</td><td><code>${p.type}</code></td><td>${opts}</td><td>${tag}</td></tr>`
64
+ })
65
+ .join('')
66
+ return `<table><thead><tr><th>属性</th><th>类型</th><th>可选值</th><th></th></tr></thead><tbody>${rows}</tbody></table>`
67
+ }
68
+
69
+ function backBar(candidates?: Candidate[]): string {
70
+ if (!candidates || candidates.length === 0) return ''
71
+ return `<div class="row"><button class="ghost back" type="button">← 返回列表(${candidates.length} 个实例)</button></div>`
72
+ }
73
+
74
+ function wireBack(): void {
75
+ const btn = document.querySelector<HTMLButtonElement>('.back')
76
+ if (btn) btn.addEventListener('click', () => send('backToList'))
77
+ }
78
+
79
+ // 一个按钮身兼两职:候选列表状态(选中的是容器,没有单个实例)导出全部;其它状态导出当前选中的单个实例
80
+ const exportSchemaBtn = document.getElementById('export-schema') as HTMLButtonElement
81
+ let inCandidatesMode = false
82
+ function syncExportButton(state: string, count: number): void {
83
+ inCandidatesMode = state === 'candidates'
84
+ if (state === 'candidates') {
85
+ exportSchemaBtn.disabled = count === 0
86
+ exportSchemaBtn.textContent = count > 0 ? `导出全部 schema.json(${count} 个)` : '导出全部 schema.json'
87
+ } else {
88
+ exportSchemaBtn.disabled = false
89
+ exportSchemaBtn.textContent = '导出 schema.json'
90
+ }
91
+ }
92
+
93
+ function render(msg: Msg): void {
94
+ if (msg.type !== 'download') {
95
+ syncExportButton(msg.state, msg.state === 'candidates' ? msg.candidates.length : 0)
96
+ }
97
+
98
+ if (msg.type === 'download') {
99
+ const url = URL.createObjectURL(new Blob([msg.json], { type: 'application/json' }))
100
+ const a = document.createElement('a')
101
+ a.href = url
102
+ a.download = msg.filename
103
+ document.body.appendChild(a)
104
+ a.click()
105
+ a.remove()
106
+ URL.revokeObjectURL(url)
107
+ // 兜底:iframe 的 sandbox 若拦掉下载,至少把内容留在剪贴板里
108
+ void navigator.clipboard.writeText(msg.json).catch(() => undefined)
109
+ return
110
+ }
111
+
112
+ if (msg.state === 'scan') {
113
+ // 未映射排前面(跟 scanPage 排序一致),仿 Figma 官方 Code Connect UI 那张组件目录列表的样式:
114
+ // 一行一个组件,左边 Figma 组件名,右边状态 + 代码组件名,顶部一个搜索框过滤
115
+ const rows = msg.groups
116
+ .map(
117
+ (g) => `
118
+ <li class="component-row" data-search="${esc(g.componentName.toLowerCase())}">
119
+ <button class="row-name" type="button" data-id="${esc(g.sampleNodeId)}">
120
+ <span class="diamond">◆</span>
121
+ <span>${esc(g.componentName)}</span>
122
+ <span class="dim">${g.count} 处</span>
123
+ </button>
124
+ <span class="row-code">
125
+ ${
126
+ g.mapped
127
+ ? `<span class="status-dot ok">✓</span><code>&lt;${esc(g.codeComponent ?? '?')}&gt;</code>`
128
+ : `<span class="status-dot miss">×</span><span class="dim">未映射</span>`
129
+ }
130
+ </span>
131
+ </li>`,
132
+ )
133
+ .join('')
134
+
135
+ body.innerHTML = `
136
+ <section>
137
+ <p class="hint">${esc(msg.message)}</p>
138
+ <input class="scan-search" type="text" placeholder="搜索组件…" />
139
+ <div class="component-list">
140
+ <div class="list-header"><span>Figma 组件</span><span>代码组件</span></div>
141
+ <ul class="component-rows">${rows}</ul>
142
+ </div>
143
+ </section>
144
+ `
145
+ body.querySelectorAll<HTMLButtonElement>('.row-name').forEach((el) => {
146
+ el.addEventListener('click', () => send('pick', { id: el.dataset.id }))
147
+ })
148
+ const search = document.querySelector<HTMLInputElement>('.scan-search')
149
+ if (search) {
150
+ search.addEventListener('input', () => {
151
+ const q = search.value.trim().toLowerCase()
152
+ body.querySelectorAll<HTMLLIElement>('.component-row').forEach((row) => {
153
+ row.style.display = !q || (row.dataset.search ?? '').includes(q) ? '' : 'none'
154
+ })
155
+ })
156
+ }
157
+ return
158
+ }
159
+
160
+ if (msg.state === 'candidates') {
161
+ const items = msg.candidates
162
+ .map(
163
+ (c, i) =>
164
+ `<li><button class="ghost pick" type="button" data-id="${esc(c.id)}"><span>${i + 1}. ${esc(
165
+ c.name,
166
+ )}</span><span class="tag ${c.mapped ? 'ok' : 'miss'}">${c.mapped ? '已映射' : '未映射'}</span></button></li>`,
167
+ )
168
+ .join('')
169
+ body.innerHTML = `<section><p class="hint">${esc(msg.message)}</p><ul class="candidates">${items}</ul></section>`
170
+ body.querySelectorAll<HTMLButtonElement>('.pick').forEach((btn) => {
171
+ btn.addEventListener('click', () => send('pick', { id: btn.dataset.id }))
172
+ })
173
+ return
174
+ }
175
+
176
+ if (msg.state !== 'ok') {
177
+ const extra =
178
+ msg.schema && msg.schema.properties.length > 0
179
+ ? `<section><h2>读到的属性 schema</h2>${schemaTable(msg.schema, [])}</section>`
180
+ : ''
181
+ // 未映射可能是因为压根没有对应的代码组件——给一条能直接丢进 AI 工具的提示词,
182
+ // 先把组件生成出来(组件名跟 Figma 实例名保持一致,方便后面接 mini-code-connect 映射)
183
+ const genPrompt =
184
+ msg.state === 'unmapped' && msg.schema && msg.figmaUrl
185
+ ? `<section>
186
+ <div class="row"><h2>生成通用组件</h2><button class="ghost copy" id="copy-prompt" type="button">复制</button></div>
187
+ <pre id="gen-prompt">/figma-design-to-code ${esc(msg.figmaUrl)},生成通用组件"${esc(
188
+ msg.schema.componentName,
189
+ )}",组件名和实例名保持一致</pre>
190
+ </section>`
191
+ : ''
192
+ body.innerHTML = `${backBar(msg.candidates)}<section><p class="hint">${esc(
193
+ msg.message,
194
+ )}</p></section>${genPrompt}${extra}`
195
+ wireBack()
196
+ const copyPrompt = document.getElementById('copy-prompt')
197
+ if (copyPrompt) {
198
+ copyPrompt.addEventListener('click', () => {
199
+ const pre = document.getElementById('gen-prompt')
200
+ if (!pre || !pre.textContent) return
201
+ void navigator.clipboard.writeText(pre.textContent).then(() => {
202
+ copyPrompt.textContent = '已复制'
203
+ setTimeout(() => (copyPrompt.textContent = '复制'), 1200)
204
+ })
205
+ })
206
+ }
207
+ return
208
+ }
209
+
210
+ const { schema, template, snippet, imports, findings, calls } = msg
211
+
212
+ const bindingCard = `
213
+ <div class="card">
214
+ <div class="kv"><span>Figma 组件</span><span>${esc(schema.componentName)}</span></div>
215
+ <div class="kv"><span>source</span><span><code>${esc(template.meta.source)}</code></span></div>
216
+ <div class="kv"><span>component</span><span><code>${esc(template.meta.component)}</code></span></div>
217
+ <div class="kv"><span>template id</span><span><code>${esc(template.id)}</code></span></div>
218
+ </div>`
219
+
220
+ const findingList =
221
+ findings.length === 0
222
+ ? '<p class="hint">没有发现问题。</p>'
223
+ : `<ul class="findings">${findings
224
+ .map((f) => `<li class="${f.level}">${esc(f.text)}</li>`)
225
+ .join('')}</ul>`
226
+
227
+ const importLines = imports.length ? esc(imports.join('\n')) + '\n\n' : ''
228
+
229
+ body.innerHTML = `
230
+ ${backBar(msg.candidates)}
231
+ <section><h2>绑定</h2>${bindingCard}</section>
232
+ <section>
233
+ <div class="row"><h2>生成的代码片段</h2><button class="ghost copy" id="copy" type="button">复制</button></div>
234
+ <pre id="snippet">${importLines}${esc(snippet)}</pre>
235
+ </section>
236
+ <section><h2>属性 schema 与覆盖率</h2>${schemaTable(schema, calls)}</section>
237
+ <section><h2>自检(Step 6)</h2>${findingList}</section>
238
+ <section><h2>本次 accessor 调用</h2><pre>${esc(
239
+ calls
240
+ .map((c) => `${' '.repeat(c.depth)}${c.method}("${c.prop}")${c.ok ? '' : ` ⚠ ${c.note ?? ''}`}`)
241
+ .join('\n') || '(无)',
242
+ )}</pre></section>`
243
+
244
+ const copy = document.getElementById('copy')
245
+ if (copy) {
246
+ copy.addEventListener('click', () => {
247
+ const pre = document.getElementById('snippet')
248
+ if (!pre || !pre.textContent) return
249
+ void navigator.clipboard.writeText(pre.textContent).then(() => {
250
+ copy.textContent = '已复制'
251
+ setTimeout(() => (copy.textContent = '复制'), 1200)
252
+ })
253
+ })
254
+ }
255
+ wireBack()
256
+ }
257
+
258
+ ;(document.getElementById('refresh') as HTMLButtonElement).addEventListener('click', () => send('refresh'))
259
+ ;(document.getElementById('scan') as HTMLButtonElement).addEventListener('click', () => send('scan'))
260
+ exportSchemaBtn.addEventListener('click', () => send(inCandidatesMode ? 'exportAllSchemas' : 'exportSchema'))
261
+ ;(document.getElementById('export-mapping-table') as HTMLButtonElement).addEventListener('click', () =>
262
+ send('exportMappingTable'),
263
+ )
264
+
265
+ onmessage = (e: MessageEvent) => {
266
+ const msg = e.data && e.data.pluginMessage
267
+ if (msg) render(msg as Msg)
268
+ }
269
+
270
+ // UI 就绪后主动拉一次。主线程不做首次 post —— 那会早于这行代码执行,首帧会丢
271
+ send('refresh')