draply-dev 1.1.1 → 1.2.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/bin/cli.js CHANGED
@@ -63,13 +63,28 @@ const server = http.createServer((req, res) => {
63
63
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
64
64
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
65
65
 
66
- // ── Draply: Save endpoint ──────────────────────────────────────────────────
66
+ // ── Draply: Save endpoint — AUTO-APPLY to source files ─────────────────────
67
67
  if (req.url === '/draply-save' && req.method === 'POST') {
68
68
  let body = '';
69
69
  req.on('data', c => body += c);
70
70
  req.on('end', () => {
71
71
  try {
72
72
  const { changes } = JSON.parse(body);
73
+ const projectInfo = detectProject(projectRoot);
74
+ const results = [];
75
+
76
+ for (const ch of (changes || [])) {
77
+ if (!ch.selector || !ch.props) continue;
78
+ const result = autoApplyChange(projectRoot, ch.selector, ch.props, projectInfo);
79
+ results.push(result);
80
+ if (result.applied) {
81
+ console.log(` \x1b[32m✓\x1b[0m Applied: ${result.file} (${result.strategy})`);
82
+ } else {
83
+ console.log(` \x1b[33m⚠\x1b[0m Fallback CSS: ${ch.selector.split('>').pop().trim()}`);
84
+ }
85
+ }
86
+
87
+ // Also save CSS override as backup
73
88
  const lines = [];
74
89
  for (const ch of (changes || [])) {
75
90
  if (!ch.selector) continue;
@@ -77,15 +92,15 @@ const server = http.createServer((req, res) => {
77
92
  .map(([k, v]) => ` ${k}: ${v};`)
78
93
  .join('\n');
79
94
  const label = ch.selector.split('>').pop().trim();
80
- lines.push(`/* ${label} */
81
- ${ch.selector} {
82
- ${props}
83
- }`);
95
+ lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
84
96
  }
85
97
  const css = '/* draply — ' + new Date().toLocaleString('ru-RU') + ' */\n\n' + lines.join('\n\n') + '\n';
86
98
  fs.writeFileSync(overridesPath, css, 'utf8');
99
+
100
+ const applied = results.filter(r => r.applied).length;
101
+ const total = results.length;
87
102
  res.writeHead(200, { 'Content-Type': 'application/json' });
88
- res.end(JSON.stringify({ ok: true }));
103
+ res.end(JSON.stringify({ ok: true, applied, total, results }));
89
104
  } catch (e) {
90
105
  res.writeHead(500, { 'Content-Type': 'application/json' });
91
106
  res.end(JSON.stringify({ ok: false, error: e.message }));
@@ -300,3 +315,326 @@ function walkDir(dir, extensions, results = []) {
300
315
  } catch { /* ignore */ }
301
316
  return results;
302
317
  }
318
+
319
+ // ══════════════════════════════════════════════════════════════════════════
320
+ // AUTO-APPLY CHANGES TO SOURCE FILES
321
+ // ══════════════════════════════════════════════════════════════════════════
322
+
323
+ function autoApplyChange(root, selector, props, projectInfo) {
324
+ const result = { selector, applied: false, file: null, strategy: null, reason: '' };
325
+
326
+ // 1. Extract class name from selector
327
+ const className = extractClassName(selector);
328
+ if (!className) {
329
+ result.reason = 'No class name in selector';
330
+ return result;
331
+ }
332
+
333
+ // 2. Try: find existing CSS rule and modify it
334
+ const cssResult = findAndModifyCSSRule(root, className, props);
335
+ if (cssResult.applied) {
336
+ result.applied = true;
337
+ result.file = path.relative(root, cssResult.file);
338
+ result.strategy = 'css-modify';
339
+ return result;
340
+ }
341
+
342
+ // 3. Try: find JSX component using this class → find its CSS import → add rule there
343
+ const importResult = findCSSImportAndAppend(root, className, props);
344
+ if (importResult.applied) {
345
+ result.applied = true;
346
+ result.file = path.relative(root, importResult.file);
347
+ result.strategy = 'css-append';
348
+ return result;
349
+ }
350
+
351
+ // 4. Try: modify inline style in JSX
352
+ const inlineResult = modifyInlineStyle(root, className, props);
353
+ if (inlineResult.applied) {
354
+ result.applied = true;
355
+ result.file = path.relative(root, inlineResult.file);
356
+ result.strategy = 'inline-style';
357
+ return result;
358
+ }
359
+
360
+ // 5. Fallback: find any CSS file in project and append
361
+ const fallbackResult = appendToAnyCSSFile(root, className, props);
362
+ if (fallbackResult.applied) {
363
+ result.applied = true;
364
+ result.file = path.relative(root, fallbackResult.file);
365
+ result.strategy = 'css-fallback';
366
+ return result;
367
+ }
368
+
369
+ result.reason = 'Could not find target file for ' + className;
370
+ return result;
371
+ }
372
+
373
+ // Extract the most specific class name from a CSS selector
374
+ function extractClassName(selector) {
375
+ // Selector like: #root > div > section.nexus-hero > h1.title
376
+ const parts = selector.split('>').map(s => s.trim());
377
+ // Try from the end — most specific first
378
+ for (let i = parts.length - 1; i >= 0; i--) {
379
+ const classMatch = parts[i].match(/\.([a-zA-Z][a-zA-Z0-9_-]*)/);
380
+ if (classMatch) return classMatch[1];
381
+ }
382
+ return null;
383
+ }
384
+
385
+ // Strategy 1: Find existing CSS rule in .css/.scss files and modify it
386
+ function findAndModifyCSSRule(root, className, props) {
387
+ const cssFiles = walkDir(root, ['.css', '.scss', '.sass', '.less']);
388
+ const ruleRegex = new RegExp(
389
+ `(\\.${escapeRegex(className)}\\s*\\{)([^}]*)(\\})`, 's'
390
+ );
391
+
392
+ for (const file of cssFiles) {
393
+ // Skip node_modules, .draply, etc
394
+ const rel = path.relative(root, file);
395
+ if (rel.includes('node_modules') || rel.includes('.draply')) continue;
396
+
397
+ try {
398
+ let content = fs.readFileSync(file, 'utf8');
399
+ const match = content.match(ruleRegex);
400
+ if (!match) continue;
401
+
402
+ // Found the rule — modify it
403
+ const existingBlock = match[2];
404
+ let newBlock = existingBlock;
405
+
406
+ for (const [prop, val] of Object.entries(props)) {
407
+ const propRegex = new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g');
408
+ if (propRegex.test(newBlock)) {
409
+ // Property exists — update value
410
+ newBlock = newBlock.replace(
411
+ new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g'),
412
+ `$1${val}$3`
413
+ );
414
+ } else {
415
+ // Property doesn't exist — add it
416
+ newBlock = newBlock.trimEnd() + `\n ${prop}: ${val};\n`;
417
+ }
418
+ }
419
+
420
+ content = content.replace(ruleRegex, `$1${newBlock}$3`);
421
+ fs.writeFileSync(file, content, 'utf8');
422
+
423
+ return { applied: true, file };
424
+ } catch { /* skip */ }
425
+ }
426
+
427
+ return { applied: false };
428
+ }
429
+
430
+ // Strategy 2: Find the JSX component using className, find its CSS import, append rule
431
+ function findCSSImportAndAppend(root, className, props) {
432
+ const jsxFiles = walkDir(root, ['.jsx', '.tsx', '.js', '.ts']);
433
+
434
+ for (const jsxFile of jsxFiles) {
435
+ const rel = path.relative(root, jsxFile);
436
+ if (rel.includes('node_modules')) continue;
437
+
438
+ try {
439
+ const content = fs.readFileSync(jsxFile, 'utf8');
440
+
441
+ // Check if this component uses the className
442
+ if (!content.includes(className)) continue;
443
+
444
+ // Find CSS imports in this file
445
+ // Matches: import './styles.css' or import styles from './styles.module.css' or require('./styles.css')
446
+ const importRegex = /(?:import\s+(?:\w+\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less))['"]|require\(['"]([^'"]+\.(?:css|scss|sass|less))['"]\))/g;
447
+ let importMatch;
448
+ const cssImports = [];
449
+
450
+ while ((importMatch = importRegex.exec(content)) !== null) {
451
+ const importPath = importMatch[1] || importMatch[2];
452
+ const fullCSSPath = path.resolve(path.dirname(jsxFile), importPath);
453
+ if (fs.existsSync(fullCSSPath)) {
454
+ cssImports.push(fullCSSPath);
455
+ }
456
+ }
457
+
458
+ if (cssImports.length === 0) continue;
459
+
460
+ // Append rule to the first CSS import
461
+ const targetCSS = cssImports[0];
462
+ const newRule = `\n/* Draply: .${className} */\n.${className} {\n${formatProps(props)}\n}\n`;
463
+
464
+ let cssContent = fs.readFileSync(targetCSS, 'utf8');
465
+
466
+ // Check if rule already exists (might have been added before)
467
+ if (cssContent.includes(`.${className}`)) {
468
+ // Modify existing rule instead
469
+ return findAndModifyCSSRuleSingle(targetCSS, className, props);
470
+ }
471
+
472
+ cssContent += newRule;
473
+ fs.writeFileSync(targetCSS, cssContent, 'utf8');
474
+
475
+ return { applied: true, file: targetCSS };
476
+ } catch { /* skip */ }
477
+ }
478
+
479
+ return { applied: false };
480
+ }
481
+
482
+ // Helper: modify rule in a specific file
483
+ function findAndModifyCSSRuleSingle(file, className, props) {
484
+ const ruleRegex = new RegExp(
485
+ `(\\.${escapeRegex(className)}\\s*\\{)([^}]*)(\\})`, 's'
486
+ );
487
+ try {
488
+ let content = fs.readFileSync(file, 'utf8');
489
+ const match = content.match(ruleRegex);
490
+ if (!match) return { applied: false };
491
+
492
+ let newBlock = match[2];
493
+ for (const [prop, val] of Object.entries(props)) {
494
+ const propRegex = new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g');
495
+ if (propRegex.test(newBlock)) {
496
+ newBlock = newBlock.replace(
497
+ new RegExp(`(${escapeRegex(prop)}\\s*:\\s*)([^;]+)(;)`, 'g'),
498
+ `$1${val}$3`
499
+ );
500
+ } else {
501
+ newBlock = newBlock.trimEnd() + `\n ${prop}: ${val};\n`;
502
+ }
503
+ }
504
+ content = content.replace(ruleRegex, `$1${newBlock}$3`);
505
+ fs.writeFileSync(file, content, 'utf8');
506
+ return { applied: true, file };
507
+ } catch { return { applied: false }; }
508
+ }
509
+
510
+ // Strategy 3: Add inline style to JSX element
511
+ function modifyInlineStyle(root, className, props) {
512
+ const jsxFiles = walkDir(root, ['.jsx', '.tsx']);
513
+
514
+ for (const file of jsxFiles) {
515
+ const rel = path.relative(root, file);
516
+ if (rel.includes('node_modules')) continue;
517
+
518
+ try {
519
+ let content = fs.readFileSync(file, 'utf8');
520
+ if (!content.includes(className)) continue;
521
+
522
+ // Find element with this className
523
+ // Pattern: className="...nexus-hero..." or className={'...nexus-hero...'}
524
+ const elementRegex = new RegExp(
525
+ `(<[a-zA-Z][a-zA-Z0-9]*[^>]*className=["'{][^"'}]*${escapeRegex(className)}[^"'}]*["'}])([^>]*>|\\s*/>)`,
526
+ 's'
527
+ );
528
+
529
+ const match = content.match(elementRegex);
530
+ if (!match) continue;
531
+
532
+ const fullTag = match[0];
533
+ const jsxProps = propsToJSXStyle(props);
534
+
535
+ // Check if element already has a style prop
536
+ if (fullTag.includes('style=')) {
537
+ // Modify existing style prop — add/update properties
538
+ const styleRegex = /style=\{\{([^}]*)\}\}/;
539
+ const styleMatch = fullTag.match(styleRegex);
540
+ if (styleMatch) {
541
+ let existingStyles = styleMatch[1];
542
+ for (const [camelProp, val] of Object.entries(jsxProps)) {
543
+ const propRegex = new RegExp(`${camelProp}\\s*:\\s*['"][^'"]*['"]`, 'g');
544
+ if (propRegex.test(existingStyles)) {
545
+ existingStyles = existingStyles.replace(propRegex, `${camelProp}: '${val}'`);
546
+ } else {
547
+ existingStyles = existingStyles.trim();
548
+ if (existingStyles && !existingStyles.endsWith(',')) existingStyles += ',';
549
+ existingStyles += ` ${camelProp}: '${val}'`;
550
+ }
551
+ }
552
+ const newTag = fullTag.replace(styleRegex, `style={{${existingStyles}}}`);
553
+ content = content.replace(fullTag, newTag);
554
+ }
555
+ } else {
556
+ // Add new style prop
557
+ const styleStr = Object.entries(jsxProps)
558
+ .map(([k, v]) => `${k}: '${v}'`)
559
+ .join(', ');
560
+ const insertion = ` style={{${styleStr}}}`;
561
+ // Insert before the closing > or />
562
+ const newTag = fullTag.replace(match[2], insertion + match[2]);
563
+ content = content.replace(fullTag, newTag);
564
+ }
565
+
566
+ fs.writeFileSync(file, content, 'utf8');
567
+ return { applied: true, file };
568
+ } catch { /* skip */ }
569
+ }
570
+
571
+ return { applied: false };
572
+ }
573
+
574
+ // Strategy 4: Find any CSS file in the project and append the rule
575
+ function appendToAnyCSSFile(root, className, props) {
576
+ // Priority: index.css, App.css, globals.css, main.css, styles.css
577
+ const candidates = [
578
+ 'src/index.css', 'src/App.css', 'src/app/globals.css', 'src/globals.css',
579
+ 'src/main.css', 'src/styles.css', 'styles/globals.css', 'app/globals.css',
580
+ 'src/styles/globals.css'
581
+ ];
582
+
583
+ for (const candidate of candidates) {
584
+ const fullPath = path.join(root, candidate);
585
+ if (fs.existsSync(fullPath)) {
586
+ try {
587
+ let content = fs.readFileSync(fullPath, 'utf8');
588
+ // Check if rule already exists
589
+ if (content.includes(`.${className}`)) {
590
+ return findAndModifyCSSRuleSingle(fullPath, className, props);
591
+ }
592
+ const newRule = `\n/* Draply: .${className} */\n.${className} {\n${formatProps(props)}\n}\n`;
593
+ content += newRule;
594
+ fs.writeFileSync(fullPath, content, 'utf8');
595
+ return { applied: true, file: fullPath };
596
+ } catch { /* skip */ }
597
+ }
598
+ }
599
+
600
+ // Last resort: find ANY css file
601
+ const allCSS = walkDir(root, ['.css']);
602
+ for (const file of allCSS) {
603
+ const rel = path.relative(root, file);
604
+ if (rel.includes('node_modules') || rel.includes('.draply') || rel.includes('.next')) continue;
605
+ try {
606
+ let content = fs.readFileSync(file, 'utf8');
607
+ if (content.includes(`.${className}`)) {
608
+ return findAndModifyCSSRuleSingle(file, className, props);
609
+ }
610
+ const newRule = `\n/* Draply: .${className} */\n.${className} {\n${formatProps(props)}\n}\n`;
611
+ content += newRule;
612
+ fs.writeFileSync(file, content, 'utf8');
613
+ return { applied: true, file };
614
+ } catch { /* skip */ }
615
+ }
616
+
617
+ return { applied: false };
618
+ }
619
+
620
+ // ── Helpers ──────────────────────────────────────────────────────────────────
621
+
622
+ function escapeRegex(str) {
623
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
624
+ }
625
+
626
+ function formatProps(props) {
627
+ return Object.entries(props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
628
+ }
629
+
630
+ function camelCase(str) {
631
+ return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
632
+ }
633
+
634
+ function propsToJSXStyle(props) {
635
+ const result = {};
636
+ for (const [prop, val] of Object.entries(props)) {
637
+ result[camelCase(prop)] = val;
638
+ }
639
+ return result;
640
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "draply-dev",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Visual overlay for any frontend project — move, resize, restyle live in the browser, save to CSS",
5
5
  "author": "Arman",
6
6
  "type": "commonjs",
@@ -56,7 +56,12 @@
56
56
  flex-direction: column;
57
57
  gap: 8px;
58
58
  padding: 8px 0;
59
+ max-height: 250px;
60
+ overflow-y: auto;
59
61
  }
62
+ #__ps_diff_panel__::-webkit-scrollbar { width: 4px; }
63
+ #__ps_diff_panel__::-webkit-scrollbar-track { background: transparent; }
64
+ #__ps_diff_panel__::-webkit-scrollbar-thumb { background: #2a2a44; border-radius: 2px; }
60
65
  #__ps_diff_panel__.v { display: flex; }
61
66
 
62
67
  .ps-diff-empty {
@@ -184,7 +189,12 @@
184
189
  flex-direction: column;
185
190
  gap: 8px;
186
191
  padding: 8px 0;
192
+ max-height: 250px;
193
+ overflow-y: auto;
187
194
  }
195
+ #__ps_export_panel__::-webkit-scrollbar { width: 4px; }
196
+ #__ps_export_panel__::-webkit-scrollbar-track { background: transparent; }
197
+ #__ps_export_panel__::-webkit-scrollbar-thumb { background: #2a2a44; border-radius: 2px; }
188
198
  #__ps_export_panel__.v { display: flex; }
189
199
 
190
200
  .ps-export-format-tabs {
package/src/overlay.js CHANGED
@@ -1531,14 +1531,27 @@
1531
1531
  }
1532
1532
 
1533
1533
  sv.addEventListener('click', () => {
1534
+ toast('⏳ Applying changes to source...');
1534
1535
  fetch('/draply-save', {
1535
1536
  method: 'POST',
1536
1537
  headers: { 'Content-Type': 'application/json' },
1537
1538
  body: JSON.stringify({ changes: state.changes })
1538
1539
  }).then(r => r.json()).then(d => {
1539
- if (d.ok) toast('Сохранено в draply.css');
1540
- else toast('⚠ Ошибка: ' + (d.error || 'unknown'));
1541
- }).catch(() => toast('⚠ Сервер недоступен'));
1540
+ if (d.ok) {
1541
+ if (d.applied > 0) {
1542
+ toast(`✅ Applied to ${d.applied}/${d.total} source files!`);
1543
+ // Log details to console
1544
+ (d.results || []).forEach(r => {
1545
+ if (r.applied) console.log(`[Draply] ✓ ${r.file} (${r.strategy})`);
1546
+ else console.log(`[Draply] ⚠ ${r.selector}: ${r.reason}`);
1547
+ });
1548
+ } else {
1549
+ toast('💾 Saved to CSS override (no source files found)');
1550
+ }
1551
+ } else {
1552
+ toast('⚠ Error: ' + (d.error || 'unknown'));
1553
+ }
1554
+ }).catch(() => toast('⚠ Server unavailable'));
1542
1555
  state.changes = []; history.length = 0; sv.disabled = true; updateUnsUI();
1543
1556
  });
1544
1557