draply-dev 1.4.5 → 1.5.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 +146 -48
- package/package.json +1 -1
- package/src/overlay.js +287 -42
package/bin/cli.js
CHANGED
|
@@ -7,11 +7,32 @@ const path = require('path');
|
|
|
7
7
|
const zlib = require('zlib');
|
|
8
8
|
|
|
9
9
|
const args = process.argv.slice(2);
|
|
10
|
-
const targetPort = parseInt(args[0]) || 3000;
|
|
11
|
-
const proxyPort = parseInt(args[1]) || 4000;
|
|
12
|
-
const targetHost = args[2] || 'localhost';
|
|
13
|
-
|
|
14
10
|
const pkg = require('../package.json');
|
|
11
|
+
|
|
12
|
+
// --help / --version (#18)
|
|
13
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
14
|
+
console.log(`\n \x1b[36mDraply v${pkg.version}\x1b[0m — Visual overlay for any frontend project\n`);
|
|
15
|
+
console.log(` Usage: npx draply <target-port> [proxy-port] [target-host]\n`);
|
|
16
|
+
console.log(` Arguments:`);
|
|
17
|
+
console.log(` target-port Port your dev server runs on (default: 3000)`);
|
|
18
|
+
console.log(` proxy-port Port for Draply proxy (default: 4000)`);
|
|
19
|
+
console.log(` target-host Hostname of dev server (default: localhost)\n`);
|
|
20
|
+
console.log(` Examples:`);
|
|
21
|
+
console.log(` npx draply 3000 Proxy localhost:3000 → localhost:4000`);
|
|
22
|
+
console.log(` npx draply 5500 4001 Proxy localhost:5500 → localhost:4001\n`);
|
|
23
|
+
console.log(` Options:`);
|
|
24
|
+
console.log(` --help, -h Show this help`);
|
|
25
|
+
console.log(` --version, -v Show version\n`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
29
|
+
console.log(pkg.version);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const targetPort = parseInt(args.filter(a => !a.startsWith('-'))[0]) || 3000;
|
|
34
|
+
const proxyPort = parseInt(args.filter(a => !a.startsWith('-'))[1]) || 4000;
|
|
35
|
+
const targetHost = args.filter(a => !a.startsWith('-'))[2] || 'localhost';
|
|
15
36
|
const OVERLAY_JS = fs.readFileSync(path.join(__dirname, '../src/overlay.js'), 'utf8');
|
|
16
37
|
// Unique marker that does NOT appear inside overlay.js itself
|
|
17
38
|
const MARKER = 'data-ps-done="1"';
|
|
@@ -42,6 +63,76 @@ if (!fs.existsSync(overridesPath)) {
|
|
|
42
63
|
fs.writeFileSync(overridesPath, '/* draply */\n', 'utf8');
|
|
43
64
|
}
|
|
44
65
|
|
|
66
|
+
// Config — stored in ~/.draply/ to avoid accidental git commits (#19)
|
|
67
|
+
const configDir = path.join(require('os').homedir(), '.draply');
|
|
68
|
+
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
|
|
69
|
+
const configPath = path.join(configDir, 'config.json');
|
|
70
|
+
function loadConfig() { try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; } }
|
|
71
|
+
function saveConfig(cfg) { fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8'); }
|
|
72
|
+
|
|
73
|
+
// Multi-provider AI call (#10)
|
|
74
|
+
async function callAI(cfg, prompt) {
|
|
75
|
+
const provider = cfg.provider || 'groq';
|
|
76
|
+
const https = require('https');
|
|
77
|
+
|
|
78
|
+
// Anthropic — different API format
|
|
79
|
+
if (provider === 'anthropic') {
|
|
80
|
+
const body = JSON.stringify({
|
|
81
|
+
model: cfg.model || 'claude-sonnet-4-20250514', max_tokens: 8192,
|
|
82
|
+
messages: [{ role: 'user', content: prompt }]
|
|
83
|
+
});
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const req = https.request({
|
|
86
|
+
hostname: 'api.anthropic.com', path: '/v1/messages', method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': cfg.apiKey, 'anthropic-version': '2023-06-01', 'Content-Length': Buffer.byteLength(body) }
|
|
88
|
+
}, r => {
|
|
89
|
+
const ch = []; r.on('data', c => ch.push(c));
|
|
90
|
+
r.on('end', () => { try { const d = JSON.parse(Buffer.concat(ch).toString()); d.error ? reject(new Error(d.error.message)) : resolve(d.content?.[0]?.text || ''); } catch(e) { reject(new Error('Invalid Anthropic response')); } });
|
|
91
|
+
});
|
|
92
|
+
req.on('error', reject); req.write(body); req.end();
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Ollama — local HTTP
|
|
97
|
+
if (provider === 'ollama') {
|
|
98
|
+
const http2 = require('http');
|
|
99
|
+
const body = JSON.stringify({ model: cfg.model || 'llama3', messages: [{ role: 'user', content: prompt }], stream: false });
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const req = http2.request({
|
|
102
|
+
hostname: cfg.ollamaHost || 'localhost', port: cfg.ollamaPort || 11434, path: '/api/chat', method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
|
|
104
|
+
}, r => {
|
|
105
|
+
const ch = []; r.on('data', c => ch.push(c));
|
|
106
|
+
r.on('end', () => { try { const d = JSON.parse(Buffer.concat(ch).toString()); resolve(d.message?.content || ''); } catch(e) { reject(new Error('Invalid Ollama response')); } });
|
|
107
|
+
});
|
|
108
|
+
req.on('error', reject); req.write(body); req.end();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// OpenAI / Groq — compatible API
|
|
113
|
+
const hosts = {
|
|
114
|
+
openai: ['api.openai.com', '/v1/chat/completions', 'gpt-4o-mini'],
|
|
115
|
+
groq: ['api.groq.com', '/openai/v1/chat/completions', 'llama-3.3-70b-versatile']
|
|
116
|
+
};
|
|
117
|
+
const [hostname, apiPath, defaultModel] = hosts[provider] || hosts.groq;
|
|
118
|
+
const body = JSON.stringify({ model: cfg.model || defaultModel, messages: [{ role: 'user', content: prompt }], temperature: 0.1, max_tokens: 8192 });
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const req = https.request({
|
|
121
|
+
hostname, path: apiPath, method: 'POST',
|
|
122
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Length': Buffer.byteLength(body) }
|
|
123
|
+
}, r => {
|
|
124
|
+
const ch = []; r.on('data', c => ch.push(c));
|
|
125
|
+
r.on('end', () => { try { const d = JSON.parse(Buffer.concat(ch).toString()); d.error ? reject(new Error(d.error?.message || JSON.stringify(d.error))) : resolve(d.choices?.[0]?.message?.content || ''); } catch(e) { reject(new Error('Invalid API response')); } });
|
|
126
|
+
});
|
|
127
|
+
req.on('error', reject); req.write(body); req.end();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// API key validation (#8)
|
|
132
|
+
async function validateApiKey(cfg) {
|
|
133
|
+
try { await callAI(cfg, 'Say "ok"'); return true; } catch { return false; }
|
|
134
|
+
}
|
|
135
|
+
|
|
45
136
|
|
|
46
137
|
const server = http.createServer((req, res) => {
|
|
47
138
|
|
|
@@ -94,10 +185,7 @@ const server = http.createServer((req, res) => {
|
|
|
94
185
|
return;
|
|
95
186
|
}
|
|
96
187
|
|
|
97
|
-
// ── Config endpoint (get/set API key)
|
|
98
|
-
const configPath = path.join(process.cwd(), 'draply.config.json');
|
|
99
|
-
function loadConfig() { try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; } }
|
|
100
|
-
function saveConfig(cfg) { fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8'); }
|
|
188
|
+
// ── Config endpoint (get/set API key) — config stored in ~/.draply/ (#19) ──
|
|
101
189
|
|
|
102
190
|
if (req.url === '/draply-config') {
|
|
103
191
|
if (req.method === 'GET') {
|
|
@@ -123,6 +211,24 @@ const server = http.createServer((req, res) => {
|
|
|
123
211
|
}
|
|
124
212
|
}
|
|
125
213
|
|
|
214
|
+
// ── Validate API key endpoint (#8) ─────────────────────────────────────────
|
|
215
|
+
if (req.url === '/draply-validate-key' && req.method === 'POST') {
|
|
216
|
+
let body = '';
|
|
217
|
+
req.on('data', c => body += c);
|
|
218
|
+
req.on('end', async () => {
|
|
219
|
+
try {
|
|
220
|
+
const { apiKey, provider } = JSON.parse(body);
|
|
221
|
+
const valid = await validateApiKey({ apiKey, provider: provider || 'groq' });
|
|
222
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
223
|
+
res.end(JSON.stringify({ ok: true, valid }));
|
|
224
|
+
} catch (e) {
|
|
225
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
226
|
+
res.end(JSON.stringify({ ok: true, valid: false, error: e.message }));
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
126
232
|
// ── AI Apply endpoint ───────────────────────────────────────────────────────
|
|
127
233
|
if ((req.url === '/draply-ai-apply' || req.url === '/draply-save') && req.method === 'POST') {
|
|
128
234
|
let body = '';
|
|
@@ -133,16 +239,22 @@ const server = http.createServer((req, res) => {
|
|
|
133
239
|
const cfg = loadConfig();
|
|
134
240
|
|
|
135
241
|
// If no config, fallback to saving standard CSS
|
|
242
|
+
// Always write CSS backup (#13)
|
|
243
|
+
const cssLines = [];
|
|
244
|
+
for (const ch of (changes || [])) {
|
|
245
|
+
if (!ch.selector || ch.type === 'create') continue;
|
|
246
|
+
const cProps = Object.entries(ch.props).filter(([k]) => k !== 'googleFont' && k !== 'innerHTML' && k !== 'innerText' && k !== 'src').map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
247
|
+
if (cProps) {
|
|
248
|
+
const label = ch.selector.split('>').pop().trim();
|
|
249
|
+
cssLines.push(`/* ${label} */\n${ch.selector} {\n${cProps}\n}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (cssLines.length) {
|
|
253
|
+
fs.writeFileSync(overridesPath, '/* draply — auto-generated backup */\n\n' + cssLines.join('\n\n') + '\n', 'utf8');
|
|
254
|
+
console.log(` \x1b[36m✓\x1b[0m CSS backup written to draply.css`);
|
|
255
|
+
}
|
|
256
|
+
|
|
136
257
|
if (!cfg.apiKey) {
|
|
137
|
-
const lines = [];
|
|
138
|
-
for (const ch of (changes || [])) {
|
|
139
|
-
if (!ch.selector) continue;
|
|
140
|
-
const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
141
|
-
const label = ch.selector.split('>').pop().trim();
|
|
142
|
-
lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
|
|
143
|
-
}
|
|
144
|
-
const css = '/* draply */\n\n' + lines.join('\n\n') + '\n';
|
|
145
|
-
fs.writeFileSync(overridesPath, css, 'utf8');
|
|
146
258
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
147
259
|
res.end(JSON.stringify({ ok: true, fallback: true }));
|
|
148
260
|
return;
|
|
@@ -201,12 +313,18 @@ const server = http.createServer((req, res) => {
|
|
|
201
313
|
const parts = c.selector.split('.');
|
|
202
314
|
return parts.length > 1 ? parts.pop() : '';
|
|
203
315
|
}).filter(Boolean);
|
|
316
|
+
let idsToFind = items.map(c => {
|
|
317
|
+
const parts = c.selector.split('#');
|
|
318
|
+
return parts.length > 1 ? parts.pop() : '';
|
|
319
|
+
}).filter(Boolean);
|
|
204
320
|
|
|
205
321
|
let ctxStart = -1, ctxEnd = -1;
|
|
206
322
|
for (let i = 0; i < lines.length; i++) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
323
|
+
const hasClass = classNamesToFind.some(cls => lines[i].includes(cls));
|
|
324
|
+
const hasId = idsToFind.some(id => lines[i].includes(id));
|
|
325
|
+
if (hasClass || hasId) {
|
|
326
|
+
const s = Math.max(0, i - 60);
|
|
327
|
+
const e = Math.min(lines.length - 1, i + 60);
|
|
210
328
|
if (ctxStart === -1 || s < ctxStart) ctxStart = s;
|
|
211
329
|
if (ctxEnd === -1 || e > ctxEnd) ctxEnd = e;
|
|
212
330
|
}
|
|
@@ -265,29 +383,8 @@ Return ONLY the patch blocks.`;
|
|
|
265
383
|
|
|
266
384
|
let apiResult = '';
|
|
267
385
|
try {
|
|
268
|
-
// Groq
|
|
269
|
-
|
|
270
|
-
model: 'llama-3.3-70b-versatile',
|
|
271
|
-
messages: [{ role: 'user', content: prompt }],
|
|
272
|
-
temperature: 0.1, max_tokens: 8192
|
|
273
|
-
});
|
|
274
|
-
apiResult = await new Promise((resolve, reject) => {
|
|
275
|
-
const https = require('https');
|
|
276
|
-
const req = https.request({
|
|
277
|
-
hostname: 'api.groq.com', path: '/openai/v1/chat/completions',
|
|
278
|
-
method: 'POST',
|
|
279
|
-
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Length': Buffer.byteLength(groqBody) }
|
|
280
|
-
}, res => {
|
|
281
|
-
const chunks = [];
|
|
282
|
-
res.on('data', c => chunks.push(c));
|
|
283
|
-
res.on('end', () => {
|
|
284
|
-
const data = JSON.parse(Buffer.concat(chunks).toString());
|
|
285
|
-
if (data.error) reject(new Error(data.error.message));
|
|
286
|
-
else resolve(data.choices?.[0]?.message?.content || '');
|
|
287
|
-
});
|
|
288
|
-
});
|
|
289
|
-
req.on('error', reject); req.write(groqBody); req.end();
|
|
290
|
-
});
|
|
386
|
+
// AI call — supports Groq/OpenAI/Anthropic/Ollama (#10)
|
|
387
|
+
apiResult = await callAI(cfg, prompt);
|
|
291
388
|
|
|
292
389
|
const patches = [];
|
|
293
390
|
const patchRegex = /<search>([\s\S]*?)<\/search>[\s\S]*?<replace>([\s\S]*?)<\/replace>/g;
|
|
@@ -329,15 +426,16 @@ Return ONLY the patch blocks.`;
|
|
|
329
426
|
console.log(apiResult);
|
|
330
427
|
|
|
331
428
|
// Forced fallback for creation if AI failed match but we have a creation request
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
console.log(
|
|
429
|
+
const creationItems = items.filter(it => it.type === 'create');
|
|
430
|
+
if (creationItems.length > 0) {
|
|
431
|
+
console.log(`Attempting forced creation fallback for ${creationItems.length} element(s)...`);
|
|
335
432
|
const index = content.toLowerCase().lastIndexOf('</body>');
|
|
336
433
|
if (index >= 0) {
|
|
337
|
-
const
|
|
434
|
+
const insertHTML = creationItems.map(ci => ci.outerHTML).join('\n');
|
|
435
|
+
const patched = content.slice(0, index) + insertHTML + '\n' + content.slice(index);
|
|
338
436
|
const final = rawContent.includes('\r\n') ? patched.replace(/\n/g, '\r\n') : patched;
|
|
339
437
|
fs.writeFileSync(filePath, final, 'utf8');
|
|
340
|
-
console.log(` \x1b[32m✓\x1b[0m Forced creation applied to ${path.basename(filePath)}`);
|
|
438
|
+
console.log(` \x1b[32m✓\x1b[0m Forced creation applied (${creationItems.length} elements) to ${path.basename(filePath)}`);
|
|
341
439
|
items.forEach(item => results.push({ selector: item.selector, ok: true }));
|
|
342
440
|
continue;
|
|
343
441
|
}
|
package/package.json
CHANGED
package/src/overlay.js
CHANGED
|
@@ -478,15 +478,10 @@
|
|
|
478
478
|
<div id="__ps_sidebar__">
|
|
479
479
|
<div class="ps-hdr">
|
|
480
480
|
<div class="ps-brand">
|
|
481
|
-
<svg width="
|
|
482
|
-
<rect x="2" y="2" width="7" height="7" rx="1.5" fill="#7fff6e"/>
|
|
483
|
-
<rect x="11" y="2" width="7" height="7" rx="1.5" fill="#7fff6e" opacity=".55"/>
|
|
484
|
-
<rect x="2" y="11" width="7" height="7" rx="1.5" fill="#7fff6e" opacity=".55"/>
|
|
485
|
-
<rect x="11" y="11" width="7" height="7" rx="1.5" fill="#7fff6e" opacity=".25"/>
|
|
486
|
-
</svg>
|
|
481
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7fff6e" stroke-width="2.5"><rect width="18" height="18" x="3" y="3" rx="4"/></svg>
|
|
487
482
|
<div>
|
|
488
483
|
<div class="ps-bname">Draply</div>
|
|
489
|
-
<div class="ps-bver">
|
|
484
|
+
<div class="ps-bver">v1.5.0</div>
|
|
490
485
|
</div>
|
|
491
486
|
</div>
|
|
492
487
|
<button class="ps-xbtn" id="__ps_x__">✕</button>
|
|
@@ -499,7 +494,9 @@
|
|
|
499
494
|
<div class="ps-it" id="__t_ins__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 11 2 2 4-4"/><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></span> Inspect <span class="ps-pro-badge">PRO</span></div>
|
|
500
495
|
<div class="ps-it" id="__t_rsz__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="M17 21h2a2 2 0 0 0 2-2"/><path d="M21 12v3"/><path d="m21 3-5 5"/><path d="M3 7V5a2 2 0 0 1 2-2"/><path d="m5 21 4.144-4.144a1.21 1.21 0 0 1 1.712 0L13 19"/><path d="M9 3h3"/><rect x="3" y="11" width="10" height="10" rx="1"/></svg></span> Resize <span class="ps-pro-badge">PRO</span></div>
|
|
501
496
|
<div class="ps-it" id="__t_clr__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg></span> Colors <span class="ps-pro-badge">PRO</span></div>
|
|
502
|
-
<div class="ps-it" id="__t_typ__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22h6a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v6"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M3 16v-1.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5V16"/><path d="M6 22h2"/><path d="M7 14v8"/></svg></span> Typography
|
|
497
|
+
<div class="ps-it" id="__t_typ__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22h6a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v6"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M3 16v-1.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5V16"/><path d="M6 22h2"/><path d="M7 14v8"/></svg></span> Typography</div>
|
|
498
|
+
<div class="ps-it" id="__t_lay__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h10"/><path d="M7 16h10"/></svg></span> Layers</div>
|
|
499
|
+
|
|
503
500
|
|
|
504
501
|
<div class="ps-it" id="__t_ast__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/><circle cx="9" cy="9" r="2"/></svg></span> Assets <span class="ps-pro-badge">PRO</span></div>
|
|
505
502
|
|
|
@@ -546,9 +543,41 @@
|
|
|
546
543
|
<input type="color" class="ps-color-native" id="__cp_bd__" value="#cccccc">
|
|
547
544
|
</div>
|
|
548
545
|
</div>
|
|
549
|
-
|
|
546
|
+
|
|
547
|
+
<!-- #11: Advanced styles -->
|
|
548
|
+
<div class="ps-sec">Advanced</div>
|
|
549
|
+
<div class="ps-row">
|
|
550
|
+
<span class="ps-lbl">Opacity</span>
|
|
551
|
+
<input type="range" id="__st_opacity__" min="0" max="1" step="0.05" value="1" style="flex:1;accent-color:#7fff6e">
|
|
552
|
+
<span class="ps-lbl" id="__st_opacity_val__">1.0</span>
|
|
553
|
+
</div>
|
|
554
|
+
<div class="ps-row">
|
|
555
|
+
<span class="ps-lbl">Radius</span>
|
|
556
|
+
<input type="number" class="ps-inp" id="__st_radius__" min="0" max="100" value="0" style="width:50px;flex:none">
|
|
557
|
+
<span class="ps-lbl">px</span>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="ps-row">
|
|
560
|
+
<span class="ps-lbl">Shadow</span>
|
|
561
|
+
<select class="ps-select" id="__st_shadow__">
|
|
562
|
+
<option value="none">None</option>
|
|
563
|
+
<option value="0 2px 4px rgba(0,0,0,0.1)">Soft</option>
|
|
564
|
+
<option value="0 4px 12px rgba(0,0,0,0.15)">Medium</option>
|
|
565
|
+
<option value="0 8px 24px rgba(0,0,0,0.2)">Deep</option>
|
|
566
|
+
</select>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<button class="ps-apply" id="__clr_apply__">APPLY STYLES</button>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
<!-- #12: LAYERS PANEL -->
|
|
573
|
+
<div class="ps-sub" id="__sub_lay__">
|
|
574
|
+
<div class="ps-sec" style="padding-top:0">Elements on page</div>
|
|
575
|
+
<div id="__lay_list__" style="max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:4px">
|
|
576
|
+
<div style="color:#444466;font-size:10px;text-align:center;padding:10px">Select an element to view tree</div>
|
|
577
|
+
</div>
|
|
550
578
|
</div>
|
|
551
579
|
|
|
580
|
+
|
|
552
581
|
<!-- TYPOGRAPHY SUB-PANEL -->
|
|
553
582
|
<div class="ps-sub" id="__sub_typ__">
|
|
554
583
|
<div class="ps-sec" style="padding-top:0">Selected: <span id="__typ_el_name__" style="color:#7fff6e">—</span></div>
|
|
@@ -771,6 +800,10 @@
|
|
|
771
800
|
subClr.classList.toggle('v', t === 'clr');
|
|
772
801
|
subTyp.classList.toggle('v', t === 'typ');
|
|
773
802
|
subAst.classList.toggle('v', t === 'ast');
|
|
803
|
+
// #12: Layers sub-panel toggle
|
|
804
|
+
const subLay = document.getElementById('__sub_lay__');
|
|
805
|
+
if (subLay) subLay.classList.toggle('v', t === 'lay');
|
|
806
|
+
if (t === 'lay') updateLayers();
|
|
774
807
|
toast(toolHints[t]);
|
|
775
808
|
}
|
|
776
809
|
|
|
@@ -788,6 +821,9 @@
|
|
|
788
821
|
subTyp.classList.remove('v');
|
|
789
822
|
subAst.classList.remove('v');
|
|
790
823
|
subUns.classList.remove('v');
|
|
824
|
+
const subLay = document.getElementById('__sub_lay__');
|
|
825
|
+
if (subLay) subLay.classList.remove('v');
|
|
826
|
+
}
|
|
791
827
|
}
|
|
792
828
|
|
|
793
829
|
// ── HOVER ────────────────────────────────────────────────────────────────
|
|
@@ -1064,6 +1100,13 @@
|
|
|
1064
1100
|
const bd = cs.borderColor || '#cccccc';
|
|
1065
1101
|
const bw = cs.borderWidth;
|
|
1066
1102
|
|
|
1103
|
+
// #14: Support SVG fill/stroke (#14)
|
|
1104
|
+
const isSvg = el.namespaceURI === 'http://www.w3.org/2000/svg';
|
|
1105
|
+
if (isSvg) {
|
|
1106
|
+
fg = cs.fill;
|
|
1107
|
+
bd = cs.stroke;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1067
1110
|
// Background — detect transparent
|
|
1068
1111
|
if (isTransparent(bg)) {
|
|
1069
1112
|
cpBgTrans.checked = true;
|
|
@@ -1087,6 +1130,27 @@
|
|
|
1087
1130
|
swBd.style.background = cpBd.value;
|
|
1088
1131
|
swBd.style.opacity = '1';
|
|
1089
1132
|
}
|
|
1133
|
+
|
|
1134
|
+
// #11: Advanced styles detection
|
|
1135
|
+
const opVal = parseFloat(cs.opacity) || 1;
|
|
1136
|
+
document.getElementById('__st_opacity__').value = opVal;
|
|
1137
|
+
document.getElementById('__st_opacity_val__').textContent = opVal.toFixed(2);
|
|
1138
|
+
document.getElementById('__st_radius__').value = parseInt(cs.borderRadius) || 0;
|
|
1139
|
+
document.getElementById('__st_shadow__').value = cs.boxShadow === 'none' ? 'none' : '0 4px 12px rgba(0,0,0,0.15)'; // simple match
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// #11: Live preview for advanced styles
|
|
1143
|
+
document.getElementById('__st_opacity__').oninput = e => {
|
|
1144
|
+
const v = e.target.value;
|
|
1145
|
+
document.getElementById('__st_opacity_val__').textContent = parseFloat(v).toFixed(2);
|
|
1146
|
+
if (state.selectedEl) state.selectedEl.style.opacity = v;
|
|
1147
|
+
};
|
|
1148
|
+
document.getElementById('__st_radius__').oninput = e => {
|
|
1149
|
+
if (state.selectedEl) state.selectedEl.style.borderRadius = e.target.value + 'px';
|
|
1150
|
+
};
|
|
1151
|
+
document.getElementById('__st_shadow__').onchange = e => {
|
|
1152
|
+
if (state.selectedEl) state.selectedEl.style.boxShadow = e.target.value === 'none' ? '' : e.target.value;
|
|
1153
|
+
};
|
|
1090
1154
|
}
|
|
1091
1155
|
|
|
1092
1156
|
document.getElementById('__clr_apply__').onclick = () => {
|
|
@@ -1094,14 +1158,30 @@
|
|
|
1094
1158
|
const el = state.selectedEl;
|
|
1095
1159
|
const bgVal = cpBgTrans.checked ? 'transparent' : cpBg.value;
|
|
1096
1160
|
const bdVal = cpBdTrans.checked ? 'none' : cpBd.value;
|
|
1097
|
-
|
|
1161
|
+
const opVal = document.getElementById('__st_opacity__').value;
|
|
1162
|
+
const radVal = document.getElementById('__st_radius__').value + 'px';
|
|
1163
|
+
const shVal = document.getElementById('__st_shadow__').value;
|
|
1164
|
+
|
|
1098
1165
|
const prevProps = {
|
|
1099
1166
|
'background-color': el.style.backgroundColor || '',
|
|
1100
1167
|
'color': el.style.color || '',
|
|
1101
|
-
'border': el.style.border || ''
|
|
1168
|
+
'border': el.style.border || '',
|
|
1169
|
+
'opacity': el.style.opacity || '',
|
|
1170
|
+
'border-radius': el.style.borderRadius || '',
|
|
1171
|
+
'box-shadow': el.style.boxShadow || ''
|
|
1102
1172
|
};
|
|
1173
|
+
|
|
1174
|
+
if (el.namespaceURI === 'http://www.w3.org/2000/svg') {
|
|
1175
|
+
el.style.fill = cpFg.value;
|
|
1176
|
+
el.style.stroke = cpBdTrans.checked ? 'none' : bdVal;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1103
1179
|
el.style.backgroundColor = bgVal;
|
|
1104
1180
|
el.style.color = cpFg.value;
|
|
1181
|
+
el.style.opacity = opVal;
|
|
1182
|
+
el.style.borderRadius = radVal;
|
|
1183
|
+
el.style.boxShadow = shVal === 'none' ? '' : shVal;
|
|
1184
|
+
|
|
1105
1185
|
if (cpBdTrans.checked) {
|
|
1106
1186
|
el.style.border = 'none';
|
|
1107
1187
|
} else {
|
|
@@ -1109,8 +1189,12 @@
|
|
|
1109
1189
|
if (getComputedStyle(el).borderWidth === '0px') el.style.borderWidth = '1px';
|
|
1110
1190
|
if (getComputedStyle(el).borderStyle === 'none') el.style.borderStyle = 'solid';
|
|
1111
1191
|
}
|
|
1112
|
-
rec(el, {
|
|
1113
|
-
|
|
1192
|
+
rec(el, {
|
|
1193
|
+
'background-color': bgVal, color: cpFg.value,
|
|
1194
|
+
'border': cpBdTrans.checked ? 'none' : `1px solid ${bdVal}`,
|
|
1195
|
+
'opacity': opVal, 'border-radius': radVal, 'box-shadow': shVal === 'none' ? '' : shVal
|
|
1196
|
+
}, prevProps);
|
|
1197
|
+
toast('🎨 Styles applied!');
|
|
1114
1198
|
};
|
|
1115
1199
|
|
|
1116
1200
|
function rgb2hex(rgb) {
|
|
@@ -1341,30 +1425,37 @@
|
|
|
1341
1425
|
e.preventDefault(); e.stopPropagation();
|
|
1342
1426
|
if (!pendingAsset || !placingEl) return;
|
|
1343
1427
|
|
|
1344
|
-
if (e.shiftKey) {
|
|
1345
|
-
//
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1428
|
+
if (e.shiftKey || e.altKey) {
|
|
1429
|
+
// #6: Better insertion targeting
|
|
1430
|
+
const target = e.target;
|
|
1431
|
+
if (target.tagName.toLowerCase() === 'img') {
|
|
1432
|
+
const prevSrc = target.getAttribute('src');
|
|
1433
|
+
target.src = pendingAsset.src;
|
|
1434
|
+
rec(target, { src: pendingAsset.src }, { src: prevSrc || '' });
|
|
1435
|
+
toast('🖼️ Image replaced!');
|
|
1351
1436
|
} else {
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1437
|
+
// Option to insert INSIDE clicked container if Alt is held
|
|
1438
|
+
if (e.altKey) {
|
|
1439
|
+
placeAsset(pendingAsset, 0, 0, 120, 120, target);
|
|
1440
|
+
toast('✦ Placed inside ' + target.tagName.toLowerCase());
|
|
1441
|
+
} else {
|
|
1442
|
+
const cs = getComputedStyle(target);
|
|
1443
|
+
const prevBg = cs.backgroundImage;
|
|
1444
|
+
target.style.backgroundImage = `url('${pendingAsset.src}')`;
|
|
1445
|
+
target.style.backgroundSize = 'cover';
|
|
1446
|
+
rec(target, { backgroundImage: `url('${pendingAsset.src}')`, backgroundSize: 'cover' }, { backgroundImage: prevBg });
|
|
1447
|
+
toast('🖼️ Background set!');
|
|
1448
|
+
}
|
|
1359
1449
|
}
|
|
1360
1450
|
} else {
|
|
1361
|
-
// PLACE mode
|
|
1451
|
+
// PLACE mode (standard)
|
|
1362
1452
|
const x = e.clientX - 60;
|
|
1363
1453
|
const y = e.clientY - 60;
|
|
1364
1454
|
placeAsset(pendingAsset, x, y, 120, 120);
|
|
1365
1455
|
}
|
|
1366
1456
|
cancelPlacing();
|
|
1367
1457
|
}
|
|
1458
|
+
}
|
|
1368
1459
|
|
|
1369
1460
|
function onPlacingCancel(e) {
|
|
1370
1461
|
if (e.key === 'Escape') cancelPlacing();
|
|
@@ -1379,15 +1470,19 @@
|
|
|
1379
1470
|
document.removeEventListener('keydown', onPlacingCancel);
|
|
1380
1471
|
}
|
|
1381
1472
|
|
|
1382
|
-
// Place asset permanently on page (
|
|
1383
|
-
function placeAsset(asset, x, y, w, h) {
|
|
1473
|
+
// #6: Place asset permanently on page (optionally inside a parent)
|
|
1474
|
+
function placeAsset(asset, x, y, w, h, parent = document.body) {
|
|
1384
1475
|
const wrap = document.createElement('div');
|
|
1385
1476
|
wrap.className = 'ps-asset-placed';
|
|
1386
1477
|
const uid = 'ps-asset-' + Date.now();
|
|
1387
1478
|
wrap.id = uid;
|
|
1388
|
-
|
|
1479
|
+
|
|
1480
|
+
// If we have a specific parent, use relative positioning if needed
|
|
1481
|
+
const posType = parent === document.body ? 'fixed' : 'absolute';
|
|
1482
|
+
wrap.style.cssText = `position:${posType};left:${Math.round(x)}px;top:${Math.round(y)}px;width:${w}px;height:${h}px;z-index:1;`;
|
|
1389
1483
|
wrap.innerHTML = `<img src="${asset.src}" draggable="false" alt="${asset.name}">`;
|
|
1390
|
-
|
|
1484
|
+
parent.appendChild(wrap);
|
|
1485
|
+
|
|
1391
1486
|
|
|
1392
1487
|
// Click to select this placed asset (for z-index control)
|
|
1393
1488
|
wrap.addEventListener('click', e => {
|
|
@@ -1460,6 +1555,44 @@
|
|
|
1460
1555
|
toast('▼ Moved to back (z:' + nz + ')');
|
|
1461
1556
|
};
|
|
1462
1557
|
|
|
1558
|
+
// #12: LAYERS PANEL LOGIC
|
|
1559
|
+
function updateLayers() {
|
|
1560
|
+
const list = document.getElementById('__lay_list__');
|
|
1561
|
+
list.innerHTML = '';
|
|
1562
|
+
|
|
1563
|
+
// Find interesting elements (headers, sections, divs with classes, images)
|
|
1564
|
+
const items = document.querySelectorAll('body > *:not(#__ps__), section *, header *, .container *');
|
|
1565
|
+
const filtered = [...items].filter(el => {
|
|
1566
|
+
if (ps(el)) return false;
|
|
1567
|
+
return el.tagName !== 'SCRIPT' && el.tagName !== 'STYLE' && (el.children.length === 0 || el.id || el.className);
|
|
1568
|
+
}).slice(0, 50); // limit for performance
|
|
1569
|
+
|
|
1570
|
+
if (!filtered.length) {
|
|
1571
|
+
list.innerHTML = '<div style="color:#444;font-size:10px;text-align:center;padding:10px">No elements found</div>';
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
filtered.forEach(el => {
|
|
1576
|
+
const row = document.createElement('div');
|
|
1577
|
+
row.style.cssText = 'padding:6px 8px;border-radius:4px;background:#1a1a2a;border:1px solid #2a2a3a;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:10px;color:#8080a8';
|
|
1578
|
+
if (state.selectedEl === el) {
|
|
1579
|
+
row.style.borderColor = '#7fff6e';
|
|
1580
|
+
row.style.background = 'rgba(127,255,110,0.1)';
|
|
1581
|
+
row.style.color = '#7fff6e';
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const icon = el.tagName === 'IMG' ? '🖼️' : el.tagName.match(/H[1-6]/) ? 'Tt' : '📦';
|
|
1585
|
+
const name = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + el.className.split(' ')[0] : '');
|
|
1586
|
+
|
|
1587
|
+
row.innerHTML = `<span>${icon}</span> <span style="flex:1;overflow:hidden;text-overflow:ellipsis">${name}</span>`;
|
|
1588
|
+
row.onclick = () => {
|
|
1589
|
+
select(el);
|
|
1590
|
+
updateLayers();
|
|
1591
|
+
};
|
|
1592
|
+
list.appendChild(row);
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1463
1596
|
zSet.onclick = () => {
|
|
1464
1597
|
if (!selectedPlaced) return;
|
|
1465
1598
|
const nz = parseInt(zVal.value) || 0;
|
|
@@ -1534,7 +1667,7 @@
|
|
|
1534
1667
|
isCreate,
|
|
1535
1668
|
pixelshiftId: el.dataset.pixelshiftId || null,
|
|
1536
1669
|
selector,
|
|
1537
|
-
exactFile: getReactSource(el),
|
|
1670
|
+
exactFile: getReactSource(el) || window.location.pathname.replace(/^\//, '') || null, // fallback for vanilla HTML (#7)
|
|
1538
1671
|
file: el.dataset.pixelshiftFile || null,
|
|
1539
1672
|
props,
|
|
1540
1673
|
tagName: el.tagName.toLowerCase(),
|
|
@@ -1598,12 +1731,23 @@
|
|
|
1598
1731
|
// Remove from history
|
|
1599
1732
|
const idx = history.findIndex(x => x.hid === h.hid);
|
|
1600
1733
|
if (idx >= 0) history.splice(idx, 1);
|
|
1601
|
-
// Rebuild state.changes
|
|
1734
|
+
// Rebuild state.changes — preserve all original fields (#1, #2)
|
|
1602
1735
|
state.changes = [];
|
|
1603
1736
|
history.forEach(x => {
|
|
1604
1737
|
const key = x.selector;
|
|
1605
1738
|
const i = state.changes.findIndex(c => (c.pixelshiftId || c.selector) === key);
|
|
1606
|
-
if (i >= 0)
|
|
1739
|
+
if (i >= 0) {
|
|
1740
|
+
Object.assign(state.changes[i].props, x.props);
|
|
1741
|
+
} else {
|
|
1742
|
+
state.changes.push({
|
|
1743
|
+
type: x.isCreate ? 'create' : 'css',
|
|
1744
|
+
isCreate: x.isCreate || false,
|
|
1745
|
+
selector: key,
|
|
1746
|
+
props: { ...x.props },
|
|
1747
|
+
outerHTML: x.isCreate && x.el ? x.el.outerHTML : undefined,
|
|
1748
|
+
tagName: x.el ? x.el.tagName?.toLowerCase() : undefined
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1607
1751
|
});
|
|
1608
1752
|
updateUnsUI();
|
|
1609
1753
|
toast('↩ Reverted');
|
|
@@ -1612,38 +1756,63 @@
|
|
|
1612
1756
|
sv.addEventListener('click', async () => {
|
|
1613
1757
|
// Check key config status
|
|
1614
1758
|
let hasKey = false;
|
|
1759
|
+
let cfgProvider = 'groq';
|
|
1615
1760
|
try {
|
|
1616
1761
|
const cfgRes = await fetch('/draply-config');
|
|
1617
1762
|
const cfg = await cfgRes.json();
|
|
1618
1763
|
hasKey = cfg.hasKey;
|
|
1764
|
+
cfgProvider = cfg.provider || 'groq';
|
|
1619
1765
|
} catch (e) {}
|
|
1620
1766
|
|
|
1621
1767
|
if (!hasKey) {
|
|
1622
|
-
|
|
1768
|
+
// Ask for provider first (#8)
|
|
1769
|
+
const provider = prompt('Draply AI Save: Choose provider (groq / openai / anthropic / ollama):', 'groq');
|
|
1770
|
+
if (!provider) { toast('Save aborted'); return; }
|
|
1771
|
+
const key = provider === 'ollama' ? 'local' : prompt(`Enter your ${provider.toUpperCase()} API key:`);
|
|
1623
1772
|
if (key) {
|
|
1624
|
-
|
|
1773
|
+
sv.disabled = true; sv.textContent = 'Validating...';
|
|
1774
|
+
try {
|
|
1775
|
+
const vRes = await fetch('/draply-validate-key', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ apiKey: key.trim(), provider: provider.trim() }) });
|
|
1776
|
+
const vData = await vRes.json();
|
|
1777
|
+
if (!vData.valid && provider !== 'ollama') {
|
|
1778
|
+
toast('⚠ Invalid API key'); sv.disabled = false; sv.textContent = 'Save'; return;
|
|
1779
|
+
}
|
|
1780
|
+
} catch { /* allow through if validation endpoint unavailable */ }
|
|
1781
|
+
await fetch('/draply-config', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ apiKey: key.trim(), provider: provider.trim() }) });
|
|
1782
|
+
sv.disabled = false; sv.textContent = 'Save';
|
|
1625
1783
|
} else {
|
|
1626
|
-
toast('Save aborted:
|
|
1784
|
+
toast('Save aborted: API Key required');
|
|
1627
1785
|
return;
|
|
1628
1786
|
}
|
|
1629
1787
|
}
|
|
1630
1788
|
|
|
1789
|
+
// Progress indicator with animation (#9)
|
|
1631
1790
|
sv.disabled = true;
|
|
1632
|
-
sv.textContent = '
|
|
1791
|
+
sv.textContent = '';
|
|
1792
|
+
sv.innerHTML = '<span style="display:inline-flex;align-items:center;gap:4px"><span style="animation:spin 1s linear infinite;display:inline-block">@</span> Applying...</span>';
|
|
1793
|
+
// Add spin keyframe if not exists
|
|
1794
|
+
if (!document.getElementById('__ps_spin_style__')) {
|
|
1795
|
+
const spinStyle = document.createElement('style');
|
|
1796
|
+
spinStyle.id = '__ps_spin_style__';
|
|
1797
|
+
spinStyle.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
|
|
1798
|
+
document.head.appendChild(spinStyle);
|
|
1799
|
+
}
|
|
1633
1800
|
try {
|
|
1634
1801
|
const r = await fetch('/draply-ai-apply', {
|
|
1635
1802
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1636
1803
|
body: JSON.stringify({ changes: state.changes })
|
|
1637
1804
|
});
|
|
1638
1805
|
const d = await r.json();
|
|
1639
|
-
if (d.ok)
|
|
1640
|
-
|
|
1806
|
+
if (d.ok) {
|
|
1807
|
+
const msg = d.fallback ? 'Saved to draply.css (No AI Key)' : `✅ AI Applied ${d.applied || ''}/${d.total || ''} to Source!`;
|
|
1808
|
+
toast(msg);
|
|
1809
|
+
} else toast('⚠ Error: ' + (d.error || 'unknown'));
|
|
1641
1810
|
} catch {
|
|
1642
1811
|
toast('⚠ Server unreachable');
|
|
1643
1812
|
}
|
|
1644
1813
|
|
|
1645
1814
|
state.changes = []; history.length = 0;
|
|
1646
|
-
sv.textContent = 'Save';
|
|
1815
|
+
sv.innerHTML = 'Save'; sv.textContent = 'Save';
|
|
1647
1816
|
updateUnsUI();
|
|
1648
1817
|
});
|
|
1649
1818
|
|
|
@@ -1654,6 +1823,82 @@
|
|
|
1654
1823
|
|
|
1655
1824
|
document.getElementById('__uns_save__').onclick = () => sv.click();
|
|
1656
1825
|
|
|
1826
|
+
// ══════════════════════════════════════════
|
|
1827
|
+
// KEYBOARD SHORTCUTS (#4, #5, #15)
|
|
1828
|
+
// ══════════════════════════════════════════
|
|
1829
|
+
document.addEventListener('keydown', e => {
|
|
1830
|
+
// Ignore if typing in an input/textarea/contenteditable
|
|
1831
|
+
const tag = e.target.tagName.toLowerCase();
|
|
1832
|
+
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
|
|
1833
|
+
|
|
1834
|
+
// Ctrl+Z — Undo last change (#4)
|
|
1835
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
|
1836
|
+
e.preventDefault();
|
|
1837
|
+
if (history.length > 0) {
|
|
1838
|
+
revertChange(history[history.length - 1]);
|
|
1839
|
+
} else {
|
|
1840
|
+
toast('Nothing to undo');
|
|
1841
|
+
}
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// Ctrl+Shift+Z — Redo (not implemented yet, just prevent default)
|
|
1846
|
+
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') {
|
|
1847
|
+
e.preventDefault();
|
|
1848
|
+
toast('Redo not available yet');
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// Delete / Backspace — remove selected element (#5)
|
|
1853
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
1854
|
+
if (state.selectedEl && !ps(state.selectedEl)) {
|
|
1855
|
+
e.preventDefault();
|
|
1856
|
+
const el = state.selectedEl;
|
|
1857
|
+
// Record removal in history
|
|
1858
|
+
rec(el, { display: 'none' }, { display: el.style.display || '' });
|
|
1859
|
+
el.style.display = 'none';
|
|
1860
|
+
// Deselect
|
|
1861
|
+
el.classList.remove('__ps__', '__ps_multi__');
|
|
1862
|
+
state.selectedEl = null;
|
|
1863
|
+
state.selectedEls = [];
|
|
1864
|
+
hdl.classList.remove('v');
|
|
1865
|
+
toast('🗑 Element hidden (Delete)');
|
|
1866
|
+
}
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// Preview mode toggle: P key (#15)
|
|
1871
|
+
if (e.key === 'p' || e.key === 'P') {
|
|
1872
|
+
if (!e.ctrlKey && !e.metaKey) {
|
|
1873
|
+
e.preventDefault();
|
|
1874
|
+
togglePreview();
|
|
1875
|
+
}
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
// ══════════════════════════════════════════
|
|
1881
|
+
// PREVIEW MODE (#15)
|
|
1882
|
+
// ══════════════════════════════════════════
|
|
1883
|
+
let previewMode = false;
|
|
1884
|
+
function togglePreview() {
|
|
1885
|
+
previewMode = !previewMode;
|
|
1886
|
+
const psRoot = document.getElementById('__ps__');
|
|
1887
|
+
if (previewMode) {
|
|
1888
|
+
psRoot.style.display = 'none';
|
|
1889
|
+
// Remove all selection highlights
|
|
1890
|
+
document.querySelectorAll('.__ps__, .__ps_multi__, .__ph__').forEach(el => {
|
|
1891
|
+
el.classList.remove('__ps__', '__ps_multi__', '__ph__');
|
|
1892
|
+
});
|
|
1893
|
+
hdl.classList.remove('v');
|
|
1894
|
+
Object.values(rhs).forEach(h => h.classList.remove('v'));
|
|
1895
|
+
toast('👁 Preview mode — press P to exit');
|
|
1896
|
+
} else {
|
|
1897
|
+
psRoot.style.display = '';
|
|
1898
|
+
toast('Editor restored');
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1657
1902
|
// ══════════════════════════════════════════
|
|
1658
1903
|
// UTILS
|
|
1659
1904
|
// ══════════════════════════════════════════
|