@fragmentsx/figma-converter 0.0.1

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.
Files changed (63) hide show
  1. package/package.json +31 -0
  2. package/playground/index.html +12 -0
  3. package/playground/node_modules/.bin/tsc +17 -0
  4. package/playground/node_modules/.bin/tsserver +17 -0
  5. package/playground/node_modules/.bin/vite +17 -0
  6. package/playground/package.json +25 -0
  7. package/playground/src/123.json +527 -0
  8. package/playground/src/App.tsx +534 -0
  9. package/playground/src/doc.json +4024 -0
  10. package/playground/src/main.tsx +4 -0
  11. package/playground/tsconfig.json +12 -0
  12. package/playground/vite.config.ts +59 -0
  13. package/src/__tests__/fixtures/simple-frame.json +70 -0
  14. package/src/__tests__/fixtures/with-effects.json +19 -0
  15. package/src/__tests__/fixtures/with-image.json +16 -0
  16. package/src/__tests__/integration.test.ts +113 -0
  17. package/src/__tests__/validation.test.ts +108 -0
  18. package/src/converter.test.ts +206 -0
  19. package/src/converter.ts +165 -0
  20. package/src/diff/__tests__/buildBreakpointFrame.test.ts +43 -0
  21. package/src/diff/__tests__/computeDelta.test.ts +33 -0
  22. package/src/diff/__tests__/matchNodes.test.ts +52 -0
  23. package/src/diff/buildBreakpointFrame.ts +99 -0
  24. package/src/diff/computeDelta.ts +40 -0
  25. package/src/diff/index.ts +3 -0
  26. package/src/diff/matchNodes.ts +46 -0
  27. package/src/images/__tests__/collectImages.test.ts +30 -0
  28. package/src/images/collectImages.ts +29 -0
  29. package/src/index.ts +16 -0
  30. package/src/mappers/__tests__/borderRadius.test.ts +28 -0
  31. package/src/mappers/__tests__/layout.test.ts +71 -0
  32. package/src/mappers/__tests__/nodeMapper.test.ts +394 -0
  33. package/src/mappers/__tests__/position.test.ts +67 -0
  34. package/src/mappers/__tests__/size.test.ts +69 -0
  35. package/src/mappers/borderRadius.ts +18 -0
  36. package/src/mappers/index.ts +5 -0
  37. package/src/mappers/layout.ts +58 -0
  38. package/src/mappers/nodeMapper.ts +330 -0
  39. package/src/mappers/position.ts +71 -0
  40. package/src/mappers/size.ts +39 -0
  41. package/src/styles/__tests__/border.test.ts +19 -0
  42. package/src/styles/__tests__/effects.test.ts +60 -0
  43. package/src/styles/__tests__/gradientFallback.test.ts +96 -0
  44. package/src/styles/__tests__/paint.test.ts +126 -0
  45. package/src/styles/border.ts +40 -0
  46. package/src/styles/effects.ts +40 -0
  47. package/src/styles/gradientFallback.ts +113 -0
  48. package/src/styles/index.ts +3 -0
  49. package/src/styles/paint.ts +164 -0
  50. package/src/text/__tests__/textToHtml.test.ts +66 -0
  51. package/src/text/textToHtml.ts +83 -0
  52. package/src/types/figma.ts +127 -0
  53. package/src/types/index.ts +2 -0
  54. package/src/types/result.ts +115 -0
  55. package/src/utils/__tests__/color.test.ts +15 -0
  56. package/src/utils/__tests__/id.test.ts +20 -0
  57. package/src/utils/color.ts +20 -0
  58. package/src/utils/id.ts +9 -0
  59. package/src/utils/index.ts +2 -0
  60. package/src/validation.ts +94 -0
  61. package/tsconfig.json +15 -0
  62. package/vite.config.ts +26 -0
  63. package/vitest.config.ts +14 -0
@@ -0,0 +1,534 @@
1
+ import { useState, useMemo } from "react";
2
+ import { convertFigmaToFragment } from "@fragmentsx/figma-converter";
3
+ import type { ConversionResult } from "@fragmentsx/figma-converter";
4
+ import { createTestFragmentsClient } from "@fragmentsx/client-core";
5
+ import { Instance } from "@fragmentsx/render-react";
6
+
7
+ const FRAGMENT_ID = 1;
8
+
9
+ type Tab =
10
+ | "render"
11
+ | "info"
12
+ | "document"
13
+ | "imageRefs"
14
+ | "svgRefs"
15
+ | "cssChunks"
16
+ | "warnings";
17
+
18
+ export function App() {
19
+ const [jsonInput, setJsonInput] = useState("");
20
+ const [result, setResult] = useState<ConversionResult | null>(null);
21
+ const [error, setError] = useState<string | null>(null);
22
+ const [renderKey, setRenderKey] = useState(0);
23
+ const [tab, setTab] = useState<Tab>("render");
24
+
25
+ const globalManager = useMemo(() => {
26
+ if (!result) return null;
27
+ const gm = createTestFragmentsClient({
28
+ fragments: { [FRAGMENT_ID]: result.document as any },
29
+ });
30
+ gm.$load.loadFragment(FRAGMENT_ID);
31
+
32
+ // Инжектируем cssChunks в <head>
33
+ const styleId = "figma-converter-playground-css-chunks";
34
+ const existing = document.getElementById(styleId);
35
+ if (existing) existing.remove();
36
+ if (result.cssChunks.length > 0) {
37
+ const style = document.createElement("style");
38
+ style.id = styleId;
39
+ style.textContent = result.cssChunks.map((c) => c.content).join("\n");
40
+ document.head.appendChild(style);
41
+ }
42
+
43
+ return gm;
44
+ }, [result, renderKey]);
45
+
46
+ const handleConvert = () => {
47
+ setError(null);
48
+ try {
49
+ const parsed = JSON.parse(jsonInput);
50
+ const frames = Array.isArray(parsed)
51
+ ? parsed.map((f: any) => ({
52
+ node: f.node || f,
53
+ width: (f.node || f).width,
54
+ }))
55
+ : [{ node: parsed, width: parsed.width }];
56
+
57
+ const conversionResult = convertFigmaToFragment(frames);
58
+ setResult(conversionResult);
59
+ setRenderKey((k) => k + 1);
60
+ setTab("render");
61
+ } catch (e: any) {
62
+ setError(e.message);
63
+ setResult(null);
64
+ }
65
+ };
66
+
67
+ const handleLoadSample = async () => {
68
+ try {
69
+ const res = await fetch("/src/doc.json");
70
+ const json = await res.text();
71
+ setJsonInput(json);
72
+ } catch {
73
+ setError("Не удалось загрузить doc.json");
74
+ }
75
+ };
76
+
77
+ const handleFetchFromPlugin = async () => {
78
+ try {
79
+ const res = await fetch("/api/doc");
80
+ if (res.status === 404) {
81
+ setError("Нет данных — нажмите '→ Playground' в Figma-плагине");
82
+ return;
83
+ }
84
+ const json = await res.text();
85
+ setJsonInput(json);
86
+ } catch {
87
+ setError("Не удалось получить данные из плагина");
88
+ }
89
+ };
90
+
91
+ return (
92
+ <div style={styles.root}>
93
+ <header style={styles.header}>
94
+ Converter Playground
95
+ {result && (
96
+ <span style={styles.headerStats}>
97
+ {" "}
98
+ — images: {result.imageRefs.length}, svgs: {result.svgRefs.length},
99
+ fonts: {result.fonts.length}, warnings: {result.warnings.length}
100
+ </span>
101
+ )}
102
+ </header>
103
+
104
+ <div style={styles.main}>
105
+ {/* Левая панель — ввод */}
106
+ <div style={styles.left}>
107
+ <textarea
108
+ style={styles.textarea}
109
+ value={jsonInput}
110
+ onChange={(e) => setJsonInput(e.target.value)}
111
+ placeholder="Вставьте FigmaNode JSON..."
112
+ spellCheck={false}
113
+ />
114
+ <div style={styles.buttons}>
115
+ <button style={styles.button} onClick={handleConvert}>
116
+ Конвертировать
117
+ </button>
118
+ <button
119
+ style={{ ...styles.button, background: "#555" }}
120
+ onClick={handleLoadSample}
121
+ >
122
+ doc.json
123
+ </button>
124
+ <button
125
+ style={{ ...styles.button, background: "#2e7d32" }}
126
+ onClick={handleFetchFromPlugin}
127
+ >
128
+ Из плагина
129
+ </button>
130
+ </div>
131
+ {error && <div style={styles.error}>{error}</div>}
132
+ </div>
133
+
134
+ {/* Правая панель — результат */}
135
+ <div style={styles.right}>
136
+ {result ? (
137
+ <>
138
+ <div style={styles.tabs}>
139
+ {(
140
+ [
141
+ "render",
142
+ "info",
143
+ "document",
144
+ "imageRefs",
145
+ "svgRefs",
146
+ "cssChunks",
147
+ "warnings",
148
+ ] as Tab[]
149
+ ).map((t) => (
150
+ <button
151
+ key={t}
152
+ style={tab === t ? styles.tabActive : styles.tab}
153
+ onClick={() => setTab(t)}
154
+ >
155
+ {t}
156
+ {t === "imageRefs" && ` (${result.imageRefs.length})`}
157
+ {t === "svgRefs" && ` (${result.svgRefs.length})`}
158
+ {t === "warnings" && ` (${result.warnings.length})`}
159
+ {t === "cssChunks" && ` (${result.cssChunks.length})`}
160
+ </button>
161
+ ))}
162
+ </div>
163
+ <div
164
+ style={
165
+ tab === "render" ? styles.tabContentRender : styles.tabContent
166
+ }
167
+ >
168
+ {tab === "render" && globalManager && (
169
+ <Instance
170
+ fragmentId={String(FRAGMENT_ID)}
171
+ globalManager={globalManager}
172
+ />
173
+ )}
174
+ {tab === "info" && <InfoTab result={result} />}
175
+ {tab === "document" && <JsonView data={result.document} />}
176
+ {tab === "imageRefs" && <ImageRefsTab result={result} />}
177
+ {tab === "svgRefs" && <SvgRefsTab result={result} />}
178
+ {tab === "cssChunks" && <CssChunksTab result={result} />}
179
+ {tab === "warnings" && <WarningsTab result={result} />}
180
+ </div>
181
+ </>
182
+ ) : (
183
+ <div style={styles.placeholder}>
184
+ Вставьте JSON и нажмите "Конвертировать"
185
+ </div>
186
+ )}
187
+ </div>
188
+ </div>
189
+ </div>
190
+ );
191
+ }
192
+
193
+ function InfoTab({ result }: { result: ConversionResult }) {
194
+ const doc = result.document;
195
+ const primaryFrame = doc.children[0];
196
+
197
+ function countNodes(node: any): number {
198
+ let count = 1;
199
+ if (node.children) {
200
+ for (const child of node.children) count += countNodes(child);
201
+ }
202
+ return count;
203
+ }
204
+
205
+ const totalNodes = countNodes(doc);
206
+
207
+ return (
208
+ <div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
209
+ <Section title="Документ">
210
+ <Row label="Fragment ID" value={doc._id} />
211
+ <Row label="Всего нод" value={totalNodes} />
212
+ <Row label="Breakpoints" value={doc.children.length} />
213
+ {primaryFrame && (
214
+ <>
215
+ <Row
216
+ label="Primary frame"
217
+ value={`${primaryFrame.width} x ${primaryFrame.height}`}
218
+ />
219
+ <Row
220
+ label="Primary children"
221
+ value={primaryFrame.children?.length ?? 0}
222
+ />
223
+ </>
224
+ )}
225
+ </Section>
226
+
227
+ <Section title="Ресурсы">
228
+ <Row
229
+ label="Растровые картинки (imageRefs)"
230
+ value={result.imageRefs.length}
231
+ />
232
+ <Row label="Векторы → SVG (svgRefs)" value={result.svgRefs.length} />
233
+ <Row label="CSS chunks" value={result.cssChunks.length} />
234
+ <Row label="Шрифты" value={result.fonts.length} />
235
+ </Section>
236
+
237
+ {result.fonts.length > 0 && (
238
+ <Section title="Шрифты">
239
+ {result.fonts.map((f, i) => (
240
+ <Row key={i} label={f.family} value={f.style} />
241
+ ))}
242
+ </Section>
243
+ )}
244
+
245
+ <Section title="Предупреждения">
246
+ <Row label="Количество" value={result.warnings.length} />
247
+ </Section>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ function ImageRefsTab({ result }: { result: ConversionResult }) {
253
+ if (result.imageRefs.length === 0)
254
+ return <Empty text="Нет растровых изображений" />;
255
+ return (
256
+ <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
257
+ {result.imageRefs.map((ref, i) => (
258
+ <div key={i} style={styles.card}>
259
+ <div style={styles.cardTitle}>Image #{i + 1}</div>
260
+ <Row label="Figma imageHash" value={ref.figmaImageHash} />
261
+ <Row label="Fragment nodeId" value={ref.nodeId} />
262
+ <Row label="paintIndex" value={ref.paintIndex} />
263
+ <div style={styles.pipelineNote}>
264
+ Pipeline: figma.getImageByHash("{ref.figmaImageHash}") → upload →
265
+ paint.image.src
266
+ </div>
267
+ </div>
268
+ ))}
269
+ </div>
270
+ );
271
+ }
272
+
273
+ function SvgRefsTab({ result }: { result: ConversionResult }) {
274
+ if (result.svgRefs.length === 0) return <Empty text="Нет векторов" />;
275
+ return (
276
+ <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
277
+ {result.svgRefs.map((ref, i) => (
278
+ <div key={i} style={styles.card}>
279
+ <div style={styles.cardTitle}>SVG #{i + 1}</div>
280
+ <Row label="Figma nodeId" value={ref.figmaNodeId} />
281
+ <Row label="Fragment nodeId" value={ref.nodeId} />
282
+ <div style={styles.pipelineNote}>
283
+ Pipeline: node.exportAsync(SVG) → upload .svg → paint.image.src
284
+ </div>
285
+ </div>
286
+ ))}
287
+ </div>
288
+ );
289
+ }
290
+
291
+ function CssChunksTab({ result }: { result: ConversionResult }) {
292
+ if (result.cssChunks.length === 0) return <Empty text="Нет CSS chunks" />;
293
+ return (
294
+ <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
295
+ {result.cssChunks.map((chunk, i) => (
296
+ <div key={i} style={styles.card}>
297
+ <div style={styles.cardTitle}>{chunk.name}</div>
298
+ <pre style={styles.pre}>{chunk.content}</pre>
299
+ </div>
300
+ ))}
301
+ </div>
302
+ );
303
+ }
304
+
305
+ function WarningsTab({ result }: { result: ConversionResult }) {
306
+ if (result.warnings.length === 0) return <Empty text="Нет предупреждений" />;
307
+ return (
308
+ <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
309
+ {result.warnings.map((w, i) => (
310
+ <div key={i} style={styles.warning}>
311
+ <strong>
312
+ {w.nodeType}:{w.nodeId}
313
+ </strong>{" "}
314
+ — {w.field}: {w.message}
315
+ </div>
316
+ ))}
317
+ </div>
318
+ );
319
+ }
320
+
321
+ function JsonView({ data }: { data: any }) {
322
+ return <pre style={styles.pre}>{JSON.stringify(data, null, 2)}</pre>;
323
+ }
324
+
325
+ function Section({
326
+ title,
327
+ children,
328
+ }: {
329
+ title: string;
330
+ children: React.ReactNode;
331
+ }) {
332
+ return (
333
+ <div style={styles.section}>
334
+ <div style={styles.sectionTitle}>{title}</div>
335
+ {children}
336
+ </div>
337
+ );
338
+ }
339
+
340
+ function Row({ label, value }: { label: string; value: any }) {
341
+ return (
342
+ <div style={styles.row}>
343
+ <span style={styles.rowLabel}>{label}</span>
344
+ <span style={styles.rowValue}>{String(value)}</span>
345
+ </div>
346
+ );
347
+ }
348
+
349
+ function Empty({ text }: { text: string }) {
350
+ return <div style={styles.placeholder}>{text}</div>;
351
+ }
352
+
353
+ const styles: Record<string, React.CSSProperties> = {
354
+ root: {
355
+ display: "flex",
356
+ flexDirection: "column",
357
+ height: "100vh",
358
+ fontFamily: "system-ui, sans-serif",
359
+ margin: 0,
360
+ background: "#f8f9fa",
361
+ },
362
+ header: {
363
+ background: "#1a1a2e",
364
+ color: "#fff",
365
+ padding: "12px 20px",
366
+ fontSize: "16px",
367
+ fontWeight: 600,
368
+ flexShrink: 0,
369
+ },
370
+ headerStats: {
371
+ fontWeight: 400,
372
+ fontSize: "13px",
373
+ opacity: 0.7,
374
+ },
375
+ main: {
376
+ display: "flex",
377
+ flex: 1,
378
+ overflow: "hidden",
379
+ },
380
+ left: {
381
+ width: "40%",
382
+ display: "flex",
383
+ flexDirection: "column",
384
+ padding: "12px",
385
+ gap: "8px",
386
+ borderRight: "1px solid #ddd",
387
+ },
388
+ textarea: {
389
+ flex: 1,
390
+ minHeight: "200px",
391
+ fontFamily: "monospace",
392
+ fontSize: "12px",
393
+ padding: "8px",
394
+ border: "1px solid #ccc",
395
+ borderRadius: "6px",
396
+ resize: "none",
397
+ background: "#fff",
398
+ },
399
+ buttons: {
400
+ display: "flex",
401
+ gap: "8px",
402
+ },
403
+ button: {
404
+ background: "#1a1a2e",
405
+ color: "#fff",
406
+ border: "none",
407
+ borderRadius: "6px",
408
+ padding: "10px 16px",
409
+ cursor: "pointer",
410
+ fontSize: "13px",
411
+ fontWeight: 600,
412
+ flex: 1,
413
+ },
414
+ error: {
415
+ background: "#fee",
416
+ color: "#c00",
417
+ padding: "8px",
418
+ borderRadius: "6px",
419
+ fontSize: "13px",
420
+ },
421
+ right: {
422
+ width: "60%",
423
+ background: "#fff",
424
+ overflow: "auto",
425
+ display: "flex",
426
+ flexDirection: "column",
427
+ },
428
+ tabs: {
429
+ display: "flex",
430
+ borderBottom: "1px solid #ddd",
431
+ flexShrink: 0,
432
+ overflowX: "auto",
433
+ },
434
+ tab: {
435
+ padding: "8px 14px",
436
+ fontSize: "12px",
437
+ fontWeight: 500,
438
+ border: "none",
439
+ background: "transparent",
440
+ cursor: "pointer",
441
+ color: "#666",
442
+ borderBottom: "2px solid transparent",
443
+ whiteSpace: "nowrap",
444
+ },
445
+ tabActive: {
446
+ padding: "8px 14px",
447
+ fontSize: "12px",
448
+ fontWeight: 600,
449
+ border: "none",
450
+ background: "transparent",
451
+ cursor: "pointer",
452
+ color: "#1a1a2e",
453
+ borderBottom: "2px solid #1a1a2e",
454
+ whiteSpace: "nowrap",
455
+ },
456
+ tabContent: {
457
+ flex: 1,
458
+ overflow: "auto",
459
+ padding: "12px",
460
+ },
461
+ tabContentRender: {
462
+ flex: 1,
463
+ overflow: "auto",
464
+ position: "relative",
465
+ },
466
+ section: {
467
+ background: "#f5f5f5",
468
+ borderRadius: "8px",
469
+ padding: "12px",
470
+ },
471
+ sectionTitle: {
472
+ fontSize: "13px",
473
+ fontWeight: 600,
474
+ marginBottom: "8px",
475
+ color: "#333",
476
+ },
477
+ row: {
478
+ display: "flex",
479
+ justifyContent: "space-between",
480
+ padding: "3px 0",
481
+ fontSize: "12px",
482
+ },
483
+ rowLabel: {
484
+ color: "#666",
485
+ },
486
+ rowValue: {
487
+ fontFamily: "monospace",
488
+ color: "#333",
489
+ fontWeight: 500,
490
+ },
491
+ card: {
492
+ background: "#f9f9f9",
493
+ border: "1px solid #eee",
494
+ borderRadius: "8px",
495
+ padding: "12px",
496
+ },
497
+ cardTitle: {
498
+ fontSize: "13px",
499
+ fontWeight: 600,
500
+ marginBottom: "6px",
501
+ },
502
+ pipelineNote: {
503
+ marginTop: "8px",
504
+ padding: "6px 8px",
505
+ background: "#eef6ff",
506
+ borderRadius: "4px",
507
+ fontSize: "11px",
508
+ fontFamily: "monospace",
509
+ color: "#1565c0",
510
+ },
511
+ pre: {
512
+ margin: 0,
513
+ fontSize: "11px",
514
+ fontFamily: "monospace",
515
+ whiteSpace: "pre-wrap",
516
+ wordBreak: "break-all",
517
+ lineHeight: 1.5,
518
+ },
519
+ warning: {
520
+ background: "#fff8e1",
521
+ padding: "6px 8px",
522
+ borderRadius: "4px",
523
+ fontSize: "12px",
524
+ borderLeft: "3px solid #f9a825",
525
+ },
526
+ placeholder: {
527
+ display: "flex",
528
+ alignItems: "center",
529
+ justifyContent: "center",
530
+ height: "100%",
531
+ color: "#999",
532
+ fontSize: "14px",
533
+ },
534
+ };