@ashraf_mizo/htmlcanvas 1.0.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 +28 -0
- package/editor/alignment.js +211 -0
- package/editor/assets.js +724 -0
- package/editor/clipboard.js +177 -0
- package/editor/coords.js +121 -0
- package/editor/crop.js +325 -0
- package/editor/cssVars.js +134 -0
- package/editor/domModel.js +161 -0
- package/editor/editor.css +1996 -0
- package/editor/editor.js +833 -0
- package/editor/guides.js +513 -0
- package/editor/history.js +135 -0
- package/editor/index.html +540 -0
- package/editor/layers.js +389 -0
- package/editor/logo-final.svg +21 -0
- package/editor/logo-toolbar.svg +21 -0
- package/editor/manipulation.js +864 -0
- package/editor/multiSelect.js +436 -0
- package/editor/properties.js +1583 -0
- package/editor/selection.js +432 -0
- package/editor/serializer.js +160 -0
- package/editor/shortcuts.js +143 -0
- package/editor/slidePanel.js +361 -0
- package/editor/slides.js +101 -0
- package/editor/snap.js +98 -0
- package/editor/textEdit.js +538 -0
- package/editor/zoom.js +96 -0
- package/package.json +28 -0
- package/server.js +588 -0
package/server.js
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { WebSocketServer } from 'ws';
|
|
4
|
+
import { createServer as netCreate } from 'net';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import { exec } from 'child_process';
|
|
10
|
+
import { promisify } from 'util';
|
|
11
|
+
import chokidar from 'chokidar';
|
|
12
|
+
import multer from 'multer';
|
|
13
|
+
import { chromium } from 'playwright';
|
|
14
|
+
import { hasVarReference, parseInlineStyle } from './editor/cssVars.js';
|
|
15
|
+
|
|
16
|
+
const execAsync = promisify(exec);
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = path.dirname(__filename);
|
|
19
|
+
|
|
20
|
+
// ── Config ──────────────────────────────────────────────────────────────────
|
|
21
|
+
const CONFIG_DIR = path.join(os.homedir(), '.htmlcanvas');
|
|
22
|
+
const RECENTS_FILE = path.join(CONFIG_DIR, 'recents.json');
|
|
23
|
+
const COMPONENTS_DIR = path.join(CONFIG_DIR, 'components');
|
|
24
|
+
const UPLOADS_DIR = path.join(CONFIG_DIR, 'uploads');
|
|
25
|
+
const MAX_RECENTS = 10;
|
|
26
|
+
|
|
27
|
+
// ── Multer (image upload) ─────────────────────────────────────────────────────
|
|
28
|
+
const storage = multer.diskStorage({
|
|
29
|
+
destination: UPLOADS_DIR,
|
|
30
|
+
filename: (req, file, cb) => {
|
|
31
|
+
const ext = path.extname(file.originalname) || '';
|
|
32
|
+
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const upload = multer({ storage });
|
|
36
|
+
|
|
37
|
+
// ── Module-level state ───────────────────────────────────────────────────────
|
|
38
|
+
let currentFile = null; // { path, content, filename }
|
|
39
|
+
let annotatedContent = null; // Annotated HTML with data-hc-id attributes (from editor)
|
|
40
|
+
let previewMiddleware = null;
|
|
41
|
+
let fileWatcher = null;
|
|
42
|
+
let wsClients = new Set();
|
|
43
|
+
|
|
44
|
+
// ── Helpers: port selection ──────────────────────────────────────────────────
|
|
45
|
+
function probePort(port) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const server = netCreate();
|
|
48
|
+
server.once('error', () => resolve(false));
|
|
49
|
+
server.once('listening', () => { server.close(); resolve(true); });
|
|
50
|
+
server.listen(port, '127.0.0.1');
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function findAvailablePort(start = 3000, max = 3010) {
|
|
55
|
+
for (let port = start; port <= max; port++) {
|
|
56
|
+
if (await probePort(port)) return port;
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`No available port in range ${start}–${max}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Helpers: recents ────────────────────────────────────────────────────────
|
|
62
|
+
async function getRecents() {
|
|
63
|
+
try {
|
|
64
|
+
const raw = await fs.readFile(RECENTS_FILE, 'utf-8');
|
|
65
|
+
const paths = JSON.parse(raw);
|
|
66
|
+
if (!Array.isArray(paths)) return [];
|
|
67
|
+
const checks = await Promise.all(
|
|
68
|
+
paths.map(p =>
|
|
69
|
+
fs.access(p).then(() => p).catch(() => null)
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
return checks.filter(Boolean);
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function addRecent(filePath) {
|
|
79
|
+
const recents = await getRecents();
|
|
80
|
+
const deduped = [filePath, ...recents.filter(p => p !== filePath)].slice(0, MAX_RECENTS);
|
|
81
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
82
|
+
await fs.writeFile(RECENTS_FILE, JSON.stringify(deduped, null, 2), 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Helpers: preview directory ───────────────────────────────────────────────
|
|
86
|
+
function setPreviewDirectory(dirPath) {
|
|
87
|
+
previewMiddleware = express.static(dirPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Helpers: CSS variable audit (Plan 02) ────────────────────────────────────
|
|
91
|
+
/**
|
|
92
|
+
* Scans HTML content for inline style attributes and counts how many CSS
|
|
93
|
+
* property values contain var() references. Diagnostic only — never blocks.
|
|
94
|
+
* @param {string} content - Raw HTML string
|
|
95
|
+
* @returns {number} Count of var() references found across all inline styles
|
|
96
|
+
*/
|
|
97
|
+
function auditCSSVariables(content) {
|
|
98
|
+
const styleRegex = /style="([^"]*)"/g;
|
|
99
|
+
let varCount = 0;
|
|
100
|
+
let match;
|
|
101
|
+
while ((match = styleRegex.exec(content)) !== null) {
|
|
102
|
+
const props = parseInlineStyle(match[1]);
|
|
103
|
+
for (const value of Object.values(props)) {
|
|
104
|
+
if (hasVarReference(value)) varCount++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return varCount;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Helpers: file watcher ────────────────────────────────────────────────────
|
|
111
|
+
function watchFile(filePath) {
|
|
112
|
+
if (fileWatcher) {
|
|
113
|
+
fileWatcher.close();
|
|
114
|
+
fileWatcher = null;
|
|
115
|
+
}
|
|
116
|
+
fileWatcher = chokidar.watch(filePath, { ignoreInitial: true });
|
|
117
|
+
fileWatcher.on('change', () => {
|
|
118
|
+
broadcastToClients({ type: 'file-changed', path: filePath });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Helpers: WebSocket broadcast ────────────────────────────────────────────
|
|
123
|
+
function broadcastToClients(message) {
|
|
124
|
+
const data = JSON.stringify(message);
|
|
125
|
+
for (const client of wsClients) {
|
|
126
|
+
if (client.readyState === 1) {
|
|
127
|
+
client.send(data);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Express app ─────────────────────────────────────────────────────────────
|
|
133
|
+
const app = express();
|
|
134
|
+
app.use(express.json({ limit: '10mb' }));
|
|
135
|
+
|
|
136
|
+
// Serve editor shell
|
|
137
|
+
app.use('/', express.static(path.join(__dirname, 'editor')));
|
|
138
|
+
|
|
139
|
+
// Preview route — serves the opened file's parent directory
|
|
140
|
+
app.use('/preview', (req, res, next) => {
|
|
141
|
+
if (previewMiddleware) {
|
|
142
|
+
previewMiddleware(req, res, next);
|
|
143
|
+
} else {
|
|
144
|
+
res.status(404).send('No file open');
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Uploads route — serves uploaded images from ~/.htmlcanvas/uploads/
|
|
149
|
+
app.use('/uploads', express.static(UPLOADS_DIR));
|
|
150
|
+
|
|
151
|
+
// ── API: set annotated HTML (DOM model with data-hc-id) ──────────────────────
|
|
152
|
+
app.post('/api/set-annotated', (req, res) => {
|
|
153
|
+
const { content } = req.body;
|
|
154
|
+
if (typeof content !== 'string') {
|
|
155
|
+
return res.status(400).json({ error: 'content must be a string' });
|
|
156
|
+
}
|
|
157
|
+
annotatedContent = content;
|
|
158
|
+
res.json({ ok: true });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── Route: render opened file with asset resolution ─────────────────────────
|
|
162
|
+
app.get('/render', (req, res) => {
|
|
163
|
+
const rawContent = annotatedContent || (currentFile && currentFile.content);
|
|
164
|
+
if (!rawContent) {
|
|
165
|
+
return res.status(404).send('No file open');
|
|
166
|
+
}
|
|
167
|
+
let html = rawContent;
|
|
168
|
+
|
|
169
|
+
if (currentFile.path) {
|
|
170
|
+
// Inject <base> tag for relative asset resolution via /preview/
|
|
171
|
+
const baseHref = '/preview/';
|
|
172
|
+
if (/<head([^>]*)>/i.test(html)) {
|
|
173
|
+
html = html.replace(/<head([^>]*)>/i, '<head$1><base href="' + baseHref + '">');
|
|
174
|
+
} else {
|
|
175
|
+
html = '<base href="' + baseHref + '">' + html;
|
|
176
|
+
}
|
|
177
|
+
// Rewrite root-relative src/href to go through /preview
|
|
178
|
+
html = html.replace(/(src|href)=["']\/(?!\/)/gi, '$1="/preview/');
|
|
179
|
+
// Rewrite root-relative url() in inline/embedded CSS
|
|
180
|
+
html = html.replace(/url\(\s*['"]?\/(?!\/)/gi, "url('/preview/");
|
|
181
|
+
} else {
|
|
182
|
+
console.log('Rendering drag-dropped file — local assets may not resolve');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Inject editor-only styles: spacing between .page slides for clear separation
|
|
186
|
+
const editorStyles = `<style data-hc-editor>
|
|
187
|
+
.page { margin-bottom: 40px !important; box-shadow: 0 2px 16px rgba(0,0,0,0.12) !important; }
|
|
188
|
+
.page:last-child { margin-bottom: 0 !important; }
|
|
189
|
+
body { background: transparent !important; }
|
|
190
|
+
</style>`;
|
|
191
|
+
if (/<head([^>]*)>/i.test(html)) {
|
|
192
|
+
html = html.replace(/<\/head>/i, editorStyles + '</head>');
|
|
193
|
+
} else {
|
|
194
|
+
html = editorStyles + html;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
res.set('Content-Type', 'text/html; charset=utf-8');
|
|
198
|
+
res.send(html);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ── API: open dialog ─────────────────────────────────────────────────────────
|
|
202
|
+
app.get('/api/open-dialog', async (req, res) => {
|
|
203
|
+
try {
|
|
204
|
+
const tmpScript = path.join(os.tmpdir(), 'hc-open.ps1');
|
|
205
|
+
const ps1 = `
|
|
206
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
207
|
+
Add-Type @"
|
|
208
|
+
using System;
|
|
209
|
+
using System.Runtime.InteropServices;
|
|
210
|
+
public class Win32 {
|
|
211
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
212
|
+
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
|
|
213
|
+
}
|
|
214
|
+
"@
|
|
215
|
+
|
|
216
|
+
$dialog = New-Object System.Windows.Forms.OpenFileDialog
|
|
217
|
+
$dialog.Filter = 'HTML Files (*.html)|*.html|All Files (*.*)|*.*'
|
|
218
|
+
$dialog.Title = 'Open HTML File - HTMLCanvas'
|
|
219
|
+
|
|
220
|
+
# Create a hidden topmost owner window to force dialog to front
|
|
221
|
+
$owner = New-Object System.Windows.Forms.Form
|
|
222
|
+
$owner.TopMost = $true
|
|
223
|
+
$owner.ShowInTaskbar = $false
|
|
224
|
+
$owner.WindowState = 'Minimized'
|
|
225
|
+
$owner.Show()
|
|
226
|
+
$owner.Hide()
|
|
227
|
+
[Win32]::SetForegroundWindow($owner.Handle) | Out-Null
|
|
228
|
+
$result = $dialog.ShowDialog($owner)
|
|
229
|
+
$owner.Dispose()
|
|
230
|
+
|
|
231
|
+
if ($result -eq 'OK') { Write-Output $dialog.FileName }
|
|
232
|
+
`;
|
|
233
|
+
|
|
234
|
+
await fs.writeFile(tmpScript, ps1, 'utf-8');
|
|
235
|
+
console.log('Opening file dialog...');
|
|
236
|
+
const { stdout, stderr } = await execAsync(
|
|
237
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -STA -File "${tmpScript}"`,
|
|
238
|
+
{ windowsHide: false }
|
|
239
|
+
);
|
|
240
|
+
if (stderr) console.warn('PowerShell stderr:', stderr);
|
|
241
|
+
|
|
242
|
+
const trimmed = stdout.trim();
|
|
243
|
+
if (!trimmed) return res.json({ cancelled: true });
|
|
244
|
+
|
|
245
|
+
const resolved = path.resolve(trimmed);
|
|
246
|
+
const content = await fs.readFile(resolved, 'utf-8');
|
|
247
|
+
await addRecent(resolved);
|
|
248
|
+
|
|
249
|
+
currentFile = { path: resolved, content, filename: path.basename(resolved) };
|
|
250
|
+
annotatedContent = null; // Clear stale annotated content
|
|
251
|
+
setPreviewDirectory(path.dirname(resolved));
|
|
252
|
+
watchFile(resolved);
|
|
253
|
+
|
|
254
|
+
res.json({ path: resolved, content, filename: path.basename(resolved) });
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error('Open dialog failed:', err);
|
|
257
|
+
res.status(500).json({ error: `File dialog failed: ${err.message}` });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── API: get file by path (for recent files) ─────────────────────────────────
|
|
262
|
+
app.get('/api/file', async (req, res) => {
|
|
263
|
+
const raw = req.query.path;
|
|
264
|
+
if (!raw) return res.status(400).json({ error: 'path query param required' });
|
|
265
|
+
|
|
266
|
+
const resolved = path.resolve(decodeURIComponent(raw));
|
|
267
|
+
const content = await fs.readFile(resolved, 'utf-8');
|
|
268
|
+
await addRecent(resolved);
|
|
269
|
+
|
|
270
|
+
currentFile = { path: resolved, content, filename: path.basename(resolved) };
|
|
271
|
+
annotatedContent = null; // Clear stale annotated content
|
|
272
|
+
setPreviewDirectory(path.dirname(resolved));
|
|
273
|
+
watchFile(resolved);
|
|
274
|
+
|
|
275
|
+
res.json({ path: resolved, content, filename: path.basename(resolved) });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ── API: save ────────────────────────────────────────────────────────────────
|
|
279
|
+
app.post('/api/save', async (req, res) => {
|
|
280
|
+
if (!currentFile || !currentFile.path) {
|
|
281
|
+
return res.status(400).json({ error: 'No file with known path is open. Use Export instead.' });
|
|
282
|
+
}
|
|
283
|
+
const { content } = req.body;
|
|
284
|
+
if (typeof content !== 'string') {
|
|
285
|
+
return res.status(400).json({ error: 'content must be a string' });
|
|
286
|
+
}
|
|
287
|
+
await fs.writeFile(currentFile.path, content, 'utf-8');
|
|
288
|
+
currentFile.content = content;
|
|
289
|
+
|
|
290
|
+
// CSS variable audit — informational only (Plan 02)
|
|
291
|
+
const varCount = auditCSSVariables(content);
|
|
292
|
+
console.log(`Saved ${path.basename(currentFile.path)} — ${varCount} CSS variable reference(s) preserved.`);
|
|
293
|
+
|
|
294
|
+
res.json({ ok: true, path: currentFile.path, varReferencesPreserved: varCount });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ── API: export dialog ───────────────────────────────────────────────────────
|
|
298
|
+
app.get('/api/export-dialog', async (req, res) => {
|
|
299
|
+
try {
|
|
300
|
+
const tmpScript = path.join(os.tmpdir(), 'hc-save.ps1');
|
|
301
|
+
const ps1 = `
|
|
302
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
303
|
+
Add-Type @"
|
|
304
|
+
using System;
|
|
305
|
+
using System.Runtime.InteropServices;
|
|
306
|
+
public class Win32Export {
|
|
307
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
308
|
+
}
|
|
309
|
+
"@
|
|
310
|
+
|
|
311
|
+
$dialog = New-Object System.Windows.Forms.SaveFileDialog
|
|
312
|
+
$dialog.Filter = 'HTML Files (*.html)|*.html'
|
|
313
|
+
$dialog.Title = 'Export As - HTMLCanvas'
|
|
314
|
+
$dialog.FileName = 'export.html'
|
|
315
|
+
|
|
316
|
+
$owner = New-Object System.Windows.Forms.Form
|
|
317
|
+
$owner.TopMost = $true
|
|
318
|
+
$owner.ShowInTaskbar = $false
|
|
319
|
+
$owner.WindowState = 'Minimized'
|
|
320
|
+
$owner.Show()
|
|
321
|
+
$owner.Hide()
|
|
322
|
+
[Win32Export]::SetForegroundWindow($owner.Handle) | Out-Null
|
|
323
|
+
$result = $dialog.ShowDialog($owner)
|
|
324
|
+
$owner.Dispose()
|
|
325
|
+
|
|
326
|
+
if ($result -eq 'OK') { Write-Output $dialog.FileName }
|
|
327
|
+
`;
|
|
328
|
+
|
|
329
|
+
await fs.writeFile(tmpScript, ps1, 'utf-8');
|
|
330
|
+
const { stdout, stderr } = await execAsync(
|
|
331
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -STA -File "${tmpScript}"`,
|
|
332
|
+
{ windowsHide: false }
|
|
333
|
+
);
|
|
334
|
+
if (stderr) console.warn('PowerShell stderr:', stderr);
|
|
335
|
+
|
|
336
|
+
const trimmed = stdout.trim();
|
|
337
|
+
if (!trimmed) return res.json({ cancelled: true });
|
|
338
|
+
|
|
339
|
+
const resolved = path.resolve(trimmed);
|
|
340
|
+
res.json({ exportPath: resolved });
|
|
341
|
+
} catch (err) {
|
|
342
|
+
console.error('Export dialog failed:', err);
|
|
343
|
+
res.status(500).json({ error: `File dialog failed: ${err.message}` });
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ── API: export ──────────────────────────────────────────────────────────────
|
|
348
|
+
app.post('/api/export', async (req, res) => {
|
|
349
|
+
const { content, exportPath } = req.body;
|
|
350
|
+
if (typeof content !== 'string' || !exportPath) {
|
|
351
|
+
return res.status(400).json({ error: 'content and exportPath required' });
|
|
352
|
+
}
|
|
353
|
+
const resolved = path.resolve(exportPath);
|
|
354
|
+
await fs.writeFile(resolved, content, 'utf-8');
|
|
355
|
+
|
|
356
|
+
// CSS variable audit — informational only (Plan 02)
|
|
357
|
+
const varCount = auditCSSVariables(content);
|
|
358
|
+
console.log(`Exported ${path.basename(resolved)} — ${varCount} CSS variable reference(s) preserved.`);
|
|
359
|
+
|
|
360
|
+
res.json({ ok: true, path: resolved, varReferencesPreserved: varCount });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ── API: export PDF ──────────────────────────────────────────────────────────
|
|
364
|
+
app.post('/api/export-pdf', async (req, res) => {
|
|
365
|
+
const { content } = req.body;
|
|
366
|
+
if (typeof content !== 'string') {
|
|
367
|
+
return res.status(400).json({ error: 'content must be a string' });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Show save dialog for PDF
|
|
371
|
+
let pdfPath;
|
|
372
|
+
try {
|
|
373
|
+
const tmpScript = path.join(os.tmpdir(), 'hc-pdf.ps1');
|
|
374
|
+
const defaultName = currentFile?.filename
|
|
375
|
+
? currentFile.filename.replace(/\.html?$/i, '.pdf')
|
|
376
|
+
: 'export.pdf';
|
|
377
|
+
const ps1 = `
|
|
378
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
379
|
+
Add-Type @"
|
|
380
|
+
using System;
|
|
381
|
+
using System.Runtime.InteropServices;
|
|
382
|
+
public class Win32Pdf {
|
|
383
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
384
|
+
}
|
|
385
|
+
"@
|
|
386
|
+
|
|
387
|
+
$dialog = New-Object System.Windows.Forms.SaveFileDialog
|
|
388
|
+
$dialog.Filter = 'PDF Files (*.pdf)|*.pdf'
|
|
389
|
+
$dialog.Title = 'Export as PDF - HTMLCanvas'
|
|
390
|
+
$dialog.FileName = '${defaultName}'
|
|
391
|
+
|
|
392
|
+
$owner = New-Object System.Windows.Forms.Form
|
|
393
|
+
$owner.TopMost = $true
|
|
394
|
+
$owner.ShowInTaskbar = $false
|
|
395
|
+
$owner.WindowState = 'Minimized'
|
|
396
|
+
$owner.Show()
|
|
397
|
+
$owner.Hide()
|
|
398
|
+
[Win32Pdf]::SetForegroundWindow($owner.Handle) | Out-Null
|
|
399
|
+
$result = $dialog.ShowDialog($owner)
|
|
400
|
+
$owner.Dispose()
|
|
401
|
+
|
|
402
|
+
if ($result -eq 'OK') { Write-Output $dialog.FileName }
|
|
403
|
+
`;
|
|
404
|
+
await fs.writeFile(tmpScript, ps1, 'utf-8');
|
|
405
|
+
const { stdout } = await execAsync(
|
|
406
|
+
`powershell -NoProfile -ExecutionPolicy Bypass -STA -File "${tmpScript}"`,
|
|
407
|
+
{ windowsHide: false }
|
|
408
|
+
);
|
|
409
|
+
const trimmed = stdout.trim();
|
|
410
|
+
if (!trimmed) return res.json({ cancelled: true });
|
|
411
|
+
pdfPath = path.resolve(trimmed);
|
|
412
|
+
} catch (err) {
|
|
413
|
+
console.error('PDF save dialog failed:', err);
|
|
414
|
+
return res.status(500).json({ error: `File dialog failed: ${err.message}` });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Write HTML to a temp file so Playwright can load it with assets
|
|
418
|
+
let tempHtmlPath = null;
|
|
419
|
+
let browser = null;
|
|
420
|
+
try {
|
|
421
|
+
// If we have a current file, write the HTML next to it so relative assets resolve.
|
|
422
|
+
// Otherwise use a temp directory.
|
|
423
|
+
const baseDir = currentFile?.path ? path.dirname(currentFile.path) : os.tmpdir();
|
|
424
|
+
tempHtmlPath = path.join(baseDir, `_hc_pdf_export_${Date.now()}.html`);
|
|
425
|
+
await fs.writeFile(tempHtmlPath, content, 'utf-8');
|
|
426
|
+
|
|
427
|
+
console.log('Launching Playwright for PDF export...');
|
|
428
|
+
browser = await chromium.launch({ headless: true });
|
|
429
|
+
const page = await browser.newPage();
|
|
430
|
+
|
|
431
|
+
// Load the HTML file with proper base URL for asset resolution
|
|
432
|
+
await page.goto(`file:///${tempHtmlPath.replace(/\\/g, '/')}`, {
|
|
433
|
+
waitUntil: 'networkidle',
|
|
434
|
+
timeout: 30000,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Detect if this is a slide deck (.page elements)
|
|
438
|
+
const pageCount = await page.evaluate(() => document.querySelectorAll('.page').length);
|
|
439
|
+
|
|
440
|
+
let pdfOptions;
|
|
441
|
+
if (pageCount > 0) {
|
|
442
|
+
// Slide deck: get the .page dimensions and use them as the PDF page size
|
|
443
|
+
const pageDims = await page.evaluate(() => {
|
|
444
|
+
const firstPage = document.querySelector('.page');
|
|
445
|
+
if (!firstPage) return null;
|
|
446
|
+
const style = getComputedStyle(firstPage);
|
|
447
|
+
return {
|
|
448
|
+
width: parseFloat(style.width),
|
|
449
|
+
height: parseFloat(style.height),
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const pdfW = pageDims ? pageDims.width : 794;
|
|
454
|
+
const pdfH = pageDims ? pageDims.height : 1123;
|
|
455
|
+
|
|
456
|
+
pdfOptions = {
|
|
457
|
+
path: pdfPath,
|
|
458
|
+
width: `${pdfW}px`,
|
|
459
|
+
height: `${pdfH}px`,
|
|
460
|
+
printBackground: true,
|
|
461
|
+
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
462
|
+
};
|
|
463
|
+
} else {
|
|
464
|
+
// Regular HTML: use A4
|
|
465
|
+
pdfOptions = {
|
|
466
|
+
path: pdfPath,
|
|
467
|
+
format: 'A4',
|
|
468
|
+
printBackground: true,
|
|
469
|
+
margin: { top: '20px', right: '20px', bottom: '20px', left: '20px' },
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
await page.pdf(pdfOptions);
|
|
474
|
+
console.log(`PDF exported: ${path.basename(pdfPath)} (${pageCount} pages detected)`);
|
|
475
|
+
|
|
476
|
+
res.json({ ok: true, path: pdfPath, pages: pageCount });
|
|
477
|
+
} catch (err) {
|
|
478
|
+
console.error('PDF export failed:', err);
|
|
479
|
+
res.status(500).json({ error: `PDF export failed: ${err.message}` });
|
|
480
|
+
} finally {
|
|
481
|
+
if (browser) await browser.close().catch(() => {});
|
|
482
|
+
// Clean up temp HTML file
|
|
483
|
+
if (tempHtmlPath) {
|
|
484
|
+
await fs.unlink(tempHtmlPath).catch(() => {});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ── API: register drag-and-drop ──────────────────────────────────────────────
|
|
490
|
+
app.post('/api/register-drag', (req, res) => {
|
|
491
|
+
const { content, filename } = req.body;
|
|
492
|
+
if (typeof content !== 'string' || !filename) {
|
|
493
|
+
return res.status(400).json({ error: 'content and filename required' });
|
|
494
|
+
}
|
|
495
|
+
currentFile = { path: null, content, filename };
|
|
496
|
+
res.json({ ok: true, pathKnown: false });
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// ── API: recents ─────────────────────────────────────────────────────────────
|
|
500
|
+
app.get('/api/recents', async (req, res) => {
|
|
501
|
+
const recents = await getRecents();
|
|
502
|
+
res.json({ recents });
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
// ── API: assets (list sibling asset folder) ──────────────────────────────────
|
|
506
|
+
app.get('/api/assets', async (req, res) => {
|
|
507
|
+
if (!currentFile?.path) return res.json({ assets: [] });
|
|
508
|
+
const fileDir = path.dirname(currentFile.path);
|
|
509
|
+
const candidates = ['assets', 'images', 'img', 'media', 'Photos Assets'];
|
|
510
|
+
let assetDir = null;
|
|
511
|
+
for (const name of candidates) {
|
|
512
|
+
const candidate = path.join(fileDir, name);
|
|
513
|
+
try { await fs.access(candidate); assetDir = candidate; break; } catch {}
|
|
514
|
+
}
|
|
515
|
+
if (!assetDir) return res.json({ assets: [], dir: null });
|
|
516
|
+
const files = await fs.readdir(assetDir);
|
|
517
|
+
const assets = files
|
|
518
|
+
.filter(f => /\.(png|jpg|jpeg|gif|svg|webp|avif)$/i.test(f))
|
|
519
|
+
.map(f => ({
|
|
520
|
+
name: f,
|
|
521
|
+
url: `/preview/${path.basename(assetDir)}/${f}`,
|
|
522
|
+
type: f.toLowerCase().endsWith('.svg') ? 'svg' : 'image'
|
|
523
|
+
}));
|
|
524
|
+
res.json({ assets, dir: path.basename(assetDir) });
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ── API: components (GET — list saved components) ─────────────────────────────
|
|
528
|
+
app.get('/api/components', async (req, res) => {
|
|
529
|
+
await fs.mkdir(COMPONENTS_DIR, { recursive: true });
|
|
530
|
+
const files = await fs.readdir(COMPONENTS_DIR);
|
|
531
|
+
const components = await Promise.all(
|
|
532
|
+
files.filter(f => f.endsWith('.html')).map(async f => ({
|
|
533
|
+
name: f.replace('.html', ''),
|
|
534
|
+
html: await fs.readFile(path.join(COMPONENTS_DIR, f), 'utf-8'),
|
|
535
|
+
}))
|
|
536
|
+
);
|
|
537
|
+
res.json({ components });
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// ── API: components (POST — save component) ───────────────────────────────────
|
|
541
|
+
app.post('/api/components', async (req, res) => {
|
|
542
|
+
const { name, html } = req.body;
|
|
543
|
+
if (!name || !html) return res.status(400).json({ error: 'name and html required' });
|
|
544
|
+
await fs.mkdir(COMPONENTS_DIR, { recursive: true });
|
|
545
|
+
const safeName = name.replace(/[^a-z0-9-_]/gi, '-');
|
|
546
|
+
await fs.writeFile(path.join(COMPONENTS_DIR, `${safeName}.html`), html, 'utf-8');
|
|
547
|
+
res.json({ ok: true, name: safeName });
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// ── API: upload (POST — image upload via multer) ──────────────────────────────
|
|
551
|
+
app.post('/api/upload', (req, res) => {
|
|
552
|
+
upload.single('file')(req, res, (err) => {
|
|
553
|
+
if (err) {
|
|
554
|
+
console.error('Upload error:', err);
|
|
555
|
+
return res.status(500).json({ error: `Upload failed: ${err.message}` });
|
|
556
|
+
}
|
|
557
|
+
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
|
558
|
+
const servePath = `/uploads/${req.file.filename}`;
|
|
559
|
+
res.json({ url: servePath, originalName: req.file.originalname });
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// ── Global error handler ─────────────────────────────────────────────────────
|
|
564
|
+
app.use((err, req, res, _next) => {
|
|
565
|
+
console.error(err);
|
|
566
|
+
res.status(500).json({ error: err.message });
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// ── Startup ──────────────────────────────────────────────────────────────────
|
|
570
|
+
await fs.mkdir(UPLOADS_DIR, { recursive: true });
|
|
571
|
+
const port = await findAvailablePort();
|
|
572
|
+
const httpServer = createServer(app);
|
|
573
|
+
|
|
574
|
+
// WebSocket server
|
|
575
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
576
|
+
wss.on('connection', (ws) => {
|
|
577
|
+
wsClients.add(ws);
|
|
578
|
+
ws.on('close', () => wsClients.delete(ws));
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
httpServer.listen(port, () => {
|
|
582
|
+
console.log(`HTMLCanvas running at http://localhost:${port}`);
|
|
583
|
+
try {
|
|
584
|
+
exec(`start http://localhost:${port}`);
|
|
585
|
+
} catch (e) {
|
|
586
|
+
console.warn('Could not auto-open browser:', e.message);
|
|
587
|
+
}
|
|
588
|
+
});
|