heroshot 0.14.2 → 0.16.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/README.md CHANGED
@@ -30,7 +30,9 @@ npx heroshot
30
30
 
31
31
  First run opens a browser with a visual editor. Pick elements, adjust padding, style borders, edit text, and add annotations (arrows, rectangles, callouts). Screenshots land in `heroshots/`, config saves to `.heroshot/config.json`. Next run regenerates everything headlessly.
32
32
 
33
- https://github.com/user-attachments/assets/1636d404-1e5f-4151-9aba-d5676ed3ff2a
33
+ <p align="center">
34
+ <img src="https://github.com/omachala/heroshot/blob/main/assets/video/demo-v0.14.gif?raw=true" alt="Heroshot demo" width="720">
35
+ </p>
34
36
 
35
37
  ## Use in Your Docs
36
38
 
package/dist/cli/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { C as intro, D as setVerbose, E as outro, O as spinner, S as error, T as note, _ as loadConfig, a as launchBrowser, c as generateSessionKey, d as loadSession, f as saveLocalKey, g as getConfigPath, h as ensureHeroshotDirectory, k as verbose, l as getSessionPath, m as sessionExists, n as snippetAction, o as DEFAULT_VIEWPORT, p as saveSession, r as sync, s as EDITOR_DIR, u as loadLocalKey, v as saveConfig, w as log, x as generateUid, y as VIEWPORT_PRESETS } from "../snippet-B6Lg_Ant.js";
2
+ import { C as intro, D as setVerbose, E as outro, O as spinner, S as error, T as note, _ as loadConfig, a as launchBrowser, c as generateSessionKey, d as loadSession, f as saveLocalKey, g as getConfigPath, h as ensureHeroshotDirectory, k as verbose, l as getSessionPath, m as sessionExists, n as snippetAction, o as DEFAULT_VIEWPORT, p as saveSession, r as sync, s as EDITOR_DIR, u as loadLocalKey, v as saveConfig, w as log, x as generateUid, y as VIEWPORT_PRESETS } from "../snippet-Fc-PkcTD.js";
3
3
  import { existsSync, readFileSync, rmSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -65,6 +65,151 @@ function dispatchHighlightJob(options) {
65
65
  } }));
66
66
  }
67
67
 
68
+ //#endregion
69
+ //#region src/browser/toConfigScreenshot.ts
70
+ /**
71
+ * Convert ScreenshotData to Screenshot for config.
72
+ * Filename is derived from name at sync time - not stored in config.
73
+ */
74
+ function toConfigScreenshot(data) {
75
+ return {
76
+ id: data.id,
77
+ name: data.name,
78
+ url: data.url,
79
+ selector: data.selector,
80
+ ...data.padding && { padding: {
81
+ top: Math.round(data.padding.top),
82
+ right: Math.round(data.padding.right),
83
+ bottom: Math.round(data.padding.bottom),
84
+ left: Math.round(data.padding.left)
85
+ } },
86
+ ...data.scroll && { scroll: data.scroll },
87
+ ...data.paddingFill && { paddingFill: data.paddingFill },
88
+ ...data.paddingColor && { paddingColor: data.paddingColor },
89
+ ...data.elementFill && { elementFill: data.elementFill },
90
+ ...data.elementColor && { elementColor: data.elementColor },
91
+ ...data.textOverrides && Object.keys(data.textOverrides).length > 0 && { textOverrides: data.textOverrides },
92
+ ...data.annotations && data.annotations.length > 0 && { annotations: data.annotations },
93
+ ...data.borderWidth && { borderWidth: data.borderWidth },
94
+ ...data.borderColor && { borderColor: data.borderColor },
95
+ ...data.borderRadius && { borderRadius: data.borderRadius }
96
+ };
97
+ }
98
+
99
+ //#endregion
100
+ //#region src/browser/saveCurrentConfig.ts
101
+ /**
102
+ * Save current screenshot and browser settings state to config file.
103
+ */
104
+ function saveCurrentConfig(configPath, allScreenshots, browserSettings, hiddenElements) {
105
+ const config = loadConfig(configPath);
106
+ config.screenshots = allScreenshots.map(toConfigScreenshot);
107
+ if (browserSettings) {
108
+ config.browser = {
109
+ ...config.browser,
110
+ viewport: browserSettings.viewport,
111
+ ...browserSettings.colorScheme && { colorScheme: browserSettings.colorScheme },
112
+ ...browserSettings.deviceScaleFactor && { deviceScaleFactor: browserSettings.deviceScaleFactor }
113
+ };
114
+ if (browserSettings.outputDirectory) config.outputDirectory = browserSettings.outputDirectory;
115
+ if (browserSettings.outputFormat) config.outputFormat = browserSettings.outputFormat;
116
+ else delete config.outputFormat;
117
+ if (browserSettings.jpegQuality && browserSettings.outputFormat === "jpeg") config.jpegQuality = browserSettings.jpegQuality;
118
+ if (browserSettings.workers) config.workers = browserSettings.workers;
119
+ else delete config.workers;
120
+ }
121
+ if (hiddenElements && Object.keys(hiddenElements).length > 0) config.hiddenElements = hiddenElements;
122
+ else delete config.hiddenElements;
123
+ saveConfig(configPath, config);
124
+ verbose("Config saved");
125
+ }
126
+
127
+ //#endregion
128
+ //#region src/browser/handleEvent.ts
129
+ function createEventHandler(state, browser, context) {
130
+ const save = () => saveCurrentConfig(state.configPath, state.allScreenshots, state.updatedBrowserSettings, state.hiddenElements);
131
+ return (event) => {
132
+ switch (event.type) {
133
+ case "screenshot-added":
134
+ state.allScreenshots.push(event.data);
135
+ verbose(`Added: ${event.data.name}`);
136
+ save();
137
+ break;
138
+ case "screenshot-updated": {
139
+ const index = state.allScreenshots.findIndex(({ id }) => id === event.data.id);
140
+ if (index !== -1) {
141
+ state.allScreenshots[index] = event.data;
142
+ verbose(`Updated: ${event.data.name}`);
143
+ save();
144
+ }
145
+ break;
146
+ }
147
+ case "screenshot-removed": {
148
+ const index = state.allScreenshots.findIndex(({ id }) => id === event.id);
149
+ if (index !== -1) {
150
+ const [removed] = state.allScreenshots.splice(index, 1);
151
+ verbose(`Removed: ${removed?.name ?? event.id}`);
152
+ save();
153
+ }
154
+ break;
155
+ }
156
+ case "screenshot-selected": {
157
+ const [currentPage] = context.pages();
158
+ if (!currentPage) break;
159
+ state.selectedId = event.id;
160
+ state.sidebarExpanded = true;
161
+ if (currentPage.url() === event.url) {
162
+ state.pendingJob = {
163
+ type: "highlight",
164
+ selector: event.selector,
165
+ screenshotId: event.id
166
+ };
167
+ currentPage.evaluate(dispatchHighlightJob, {
168
+ selector: event.selector,
169
+ screenshotId: event.id
170
+ }).catch(() => {});
171
+ } else {
172
+ state.pendingJob = {
173
+ type: "navigate-and-highlight",
174
+ url: event.url,
175
+ selector: event.selector,
176
+ screenshotId: event.id
177
+ };
178
+ currentPage.goto(event.url, { waitUntil: "domcontentloaded" }).catch(() => {});
179
+ }
180
+ break;
181
+ }
182
+ case "settings-updated":
183
+ state.updatedBrowserSettings = event.data;
184
+ verbose(`Settings updated: ${JSON.stringify(event.data)}`);
185
+ save();
186
+ break;
187
+ case "hidden-elements-updated": {
188
+ const { domain, selectors } = event;
189
+ state.hiddenElements = selectors.length === 0 ? Object.fromEntries(Object.entries(state.hiddenElements).filter(([k]) => k !== domain)) : {
190
+ ...state.hiddenElements,
191
+ [domain]: selectors
192
+ };
193
+ verbose(`Hidden elements updated for ${domain}: ${selectors.length} selectors`);
194
+ save();
195
+ break;
196
+ }
197
+ case "job-complete":
198
+ state.pendingJob = null;
199
+ break;
200
+ case "done":
201
+ (async () => {
202
+ try {
203
+ saveSession(await context.storageState(), state.sessionKey);
204
+ verbose("Session saved");
205
+ } catch {}
206
+ await browser.close();
207
+ })();
208
+ break;
209
+ }
210
+ };
211
+ }
212
+
68
213
  //#endregion
69
214
  //#region src/browser/schema.ts
70
215
  /**
@@ -118,7 +263,11 @@ const browserSettingsSchema = z.object({
118
263
  height: z.number()
119
264
  }),
120
265
  colorScheme: z.enum(["light", "dark"]).optional(),
121
- deviceScaleFactor: z.number().optional()
266
+ deviceScaleFactor: z.number().optional(),
267
+ outputDirectory: z.string().optional(),
268
+ outputFormat: z.enum(["png", "jpeg"]).optional(),
269
+ jpegQuality: z.number().int().min(1).max(100).optional(),
270
+ workers: z.number().int().min(1).optional()
122
271
  });
123
272
  /** Schema for toolbar events (discriminated union) */
124
273
  const toolbarEventSchema = z.discriminatedUnion("type", [
@@ -144,6 +293,11 @@ const toolbarEventSchema = z.discriminatedUnion("type", [
144
293
  type: z.literal("settings-updated"),
145
294
  data: browserSettingsSchema
146
295
  }),
296
+ z.object({
297
+ type: z.literal("hidden-elements-updated"),
298
+ domain: z.string(),
299
+ selectors: z.array(z.string())
300
+ }),
147
301
  z.object({ type: z.literal("job-complete") }),
148
302
  z.object({ type: z.literal("done") })
149
303
  ]);
@@ -202,7 +356,7 @@ async function injectToolbar(page, options) {
202
356
  }
203
357
  }
204
358
  async function doInjectToolbar(page, options) {
205
- const { screenshots, pendingJob, selectedId, sidebarExpanded, onEvent } = options;
359
+ const { screenshots, settings, pendingJob, selectedId, sidebarExpanded, hiddenElements, onEvent } = options;
206
360
  if (!exposedPages.has(page)) {
207
361
  await page.exposeFunction("__heroshotEmit", (eventJson) => {
208
362
  onEvent(toolbarEventSchema.parse(JSON.parse(eventJson)));
@@ -214,14 +368,16 @@ async function doInjectToolbar(page, options) {
214
368
  return;
215
369
  }
216
370
  const scriptContent = readFileSync(`${EDITOR_DIR}/dist/editor.js`, "utf8");
217
- await page.evaluate(`(function(screenshots, pendingJob, selectedId, sidebarExpanded, scriptContent) {
371
+ await page.evaluate(`(function(screenshots, settings, pendingJob, selectedId, sidebarExpanded, hiddenElements, scriptContent) {
218
372
  // Initialize __heroshot global namespace
219
373
  globalThis.__heroshot = {
220
374
  initialized: false,
221
375
  screenshots: screenshots,
376
+ settings: settings,
222
377
  pendingJob: pendingJob,
223
378
  selectedId: selectedId,
224
379
  sidebarExpanded: sidebarExpanded,
380
+ hiddenElements: hiddenElements,
225
381
  // This emit function is why we use string evaluation.
226
382
  // As a nested function property, it would get __name() wrapped
227
383
  // if we used a typed function approach.
@@ -235,56 +391,7 @@ async function doInjectToolbar(page, options) {
235
391
  var script = document.createElement('script');
236
392
  script.textContent = scriptContent;
237
393
  document.body.appendChild(script);
238
- })(${JSON.stringify(screenshots)}, ${JSON.stringify(pendingJob)}, ${JSON.stringify(selectedId)}, ${JSON.stringify(sidebarExpanded)}, ${JSON.stringify(scriptContent)})`);
239
- }
240
-
241
- //#endregion
242
- //#region src/browser/toConfigScreenshot.ts
243
- /**
244
- * Convert ScreenshotData to Screenshot for config.
245
- * Filename is derived from name at sync time - not stored in config.
246
- */
247
- function toConfigScreenshot(data) {
248
- return {
249
- id: data.id,
250
- name: data.name,
251
- url: data.url,
252
- selector: data.selector,
253
- ...data.padding && { padding: {
254
- top: Math.round(data.padding.top),
255
- right: Math.round(data.padding.right),
256
- bottom: Math.round(data.padding.bottom),
257
- left: Math.round(data.padding.left)
258
- } },
259
- ...data.scroll && { scroll: data.scroll },
260
- ...data.paddingFill && { paddingFill: data.paddingFill },
261
- ...data.paddingColor && { paddingColor: data.paddingColor },
262
- ...data.elementFill && { elementFill: data.elementFill },
263
- ...data.elementColor && { elementColor: data.elementColor },
264
- ...data.textOverrides && Object.keys(data.textOverrides).length > 0 && { textOverrides: data.textOverrides },
265
- ...data.annotations && data.annotations.length > 0 && { annotations: data.annotations },
266
- ...data.borderWidth && { borderWidth: data.borderWidth },
267
- ...data.borderColor && { borderColor: data.borderColor },
268
- ...data.borderRadius && { borderRadius: data.borderRadius }
269
- };
270
- }
271
-
272
- //#endregion
273
- //#region src/browser/saveCurrentConfig.ts
274
- /**
275
- * Save current screenshot and browser settings state to config file.
276
- */
277
- function saveCurrentConfig(configPath, allScreenshots, browserSettings) {
278
- const config = loadConfig(configPath);
279
- config.screenshots = allScreenshots.map(toConfigScreenshot);
280
- if (browserSettings) config.browser = {
281
- ...config.browser,
282
- viewport: browserSettings.viewport,
283
- ...browserSettings.colorScheme && { colorScheme: browserSettings.colorScheme },
284
- ...browserSettings.deviceScaleFactor && { deviceScaleFactor: browserSettings.deviceScaleFactor }
285
- };
286
- saveConfig(configPath, config);
287
- verbose("Config saved");
394
+ })(${JSON.stringify(screenshots)}, ${JSON.stringify(settings)}, ${JSON.stringify(pendingJob)}, ${JSON.stringify(selectedId)}, ${JSON.stringify(sidebarExpanded)}, ${JSON.stringify(hiddenElements)}, ${JSON.stringify(scriptContent)})`);
288
395
  }
289
396
 
290
397
  //#endregion
@@ -311,12 +418,24 @@ async function setup(options = {}) {
311
418
  }
312
419
  }
313
420
  const allScreenshots = configToScreenshotData(config.screenshots);
314
- let pendingJob = null;
315
- let selectedId = null;
316
- let sidebarExpanded = false;
317
- let updatedBrowserSettings = null;
318
- const save = () => {
319
- saveCurrentConfig(configPath, allScreenshots, updatedBrowserSettings);
421
+ const initialSettings = {
422
+ viewport,
423
+ ...options.colorScheme && { colorScheme: options.colorScheme },
424
+ ...config.browser?.deviceScaleFactor && { deviceScaleFactor: config.browser.deviceScaleFactor },
425
+ outputDirectory: config.outputDirectory,
426
+ outputFormat: config.outputFormat,
427
+ jpegQuality: config.jpegQuality,
428
+ workers: config.workers
429
+ };
430
+ const browserState = {
431
+ allScreenshots,
432
+ pendingJob: null,
433
+ selectedId: null,
434
+ sidebarExpanded: false,
435
+ updatedBrowserSettings: null,
436
+ hiddenElements: config.hiddenElements ?? {},
437
+ configPath,
438
+ sessionKey
320
439
  };
321
440
  const { browser, context } = await launchBrowser({
322
441
  headless: false,
@@ -325,88 +444,22 @@ async function setup(options = {}) {
325
444
  colorScheme: options.colorScheme
326
445
  });
327
446
  setupSpinner.stop("Browser ready");
328
- const handleEvent = (event) => {
329
- switch (event.type) {
330
- case "screenshot-added":
331
- allScreenshots.push(event.data);
332
- verbose(`Added: ${event.data.name}`);
333
- save();
334
- break;
335
- case "screenshot-updated": {
336
- const index = allScreenshots.findIndex(({ id }) => id === event.data.id);
337
- if (index !== -1) {
338
- allScreenshots[index] = event.data;
339
- verbose(`Updated: ${event.data.name}`);
340
- save();
341
- }
342
- break;
343
- }
344
- case "screenshot-removed": {
345
- const index = allScreenshots.findIndex(({ id }) => id === event.id);
346
- if (index !== -1) {
347
- const [removed] = allScreenshots.splice(index, 1);
348
- verbose(`Removed: ${removed?.name ?? event.id}`);
349
- save();
350
- }
351
- break;
352
- }
353
- case "screenshot-selected": {
354
- const [currentPage] = context.pages();
355
- if (!currentPage) break;
356
- selectedId = event.id;
357
- sidebarExpanded = true;
358
- if (currentPage.url() === event.url) {
359
- pendingJob = {
360
- type: "highlight",
361
- selector: event.selector,
362
- screenshotId: event.id
363
- };
364
- currentPage.evaluate(dispatchHighlightJob, {
365
- selector: event.selector,
366
- screenshotId: event.id
367
- }).catch(() => {});
368
- } else {
369
- pendingJob = {
370
- type: "navigate-and-highlight",
371
- url: event.url,
372
- selector: event.selector,
373
- screenshotId: event.id
374
- };
375
- currentPage.goto(event.url, { waitUntil: "domcontentloaded" }).catch(() => {});
376
- }
377
- break;
378
- }
379
- case "settings-updated":
380
- updatedBrowserSettings = event.data;
381
- verbose(`Settings updated: ${JSON.stringify(event.data)}`);
382
- save();
383
- break;
384
- case "job-complete":
385
- pendingJob = null;
386
- break;
387
- case "done":
388
- (async () => {
389
- try {
390
- saveSession(await context.storageState(), sessionKey);
391
- verbose("Session saved");
392
- } catch {}
393
- await browser.close();
394
- })();
395
- break;
396
- }
397
- };
447
+ const handleEvent = createEventHandler(browserState, browser, context);
448
+ const injectOptions = () => ({
449
+ screenshots: browserState.allScreenshots,
450
+ settings: initialSettings,
451
+ pendingJob: browserState.pendingJob,
452
+ selectedId: browserState.selectedId,
453
+ sidebarExpanded: browserState.sidebarExpanded,
454
+ hiddenElements: browserState.hiddenElements,
455
+ onEvent: handleEvent
456
+ });
398
457
  const setupPage = (page) => {
399
458
  page.on("domcontentloaded", async () => {
400
459
  const url = page.url();
401
460
  if (!url.startsWith("http")) return;
402
461
  try {
403
- await injectToolbar(page, {
404
- screenshots: allScreenshots,
405
- pendingJob,
406
- selectedId,
407
- sidebarExpanded,
408
- onEvent: handleEvent
409
- });
462
+ await injectToolbar(page, injectOptions());
410
463
  verbose(`Toolbar injected on ${url}`);
411
464
  } catch (error) {
412
465
  verbose(`Toolbar injection failed on ${url}: ${error instanceof Error ? error.message : String(error)}`);
@@ -418,22 +471,16 @@ async function setup(options = {}) {
418
471
  if (context.pages().length === 0) browser.close().catch(() => {});
419
472
  });
420
473
  };
421
- context.on("page", (page) => {
474
+ const initPage = (page) => {
422
475
  setupPage(page);
423
476
  handlePageClose(page);
424
- });
477
+ };
478
+ context.on("page", initPage);
425
479
  const page = context.pages()[0] ?? await context.newPage();
426
- setupPage(page);
427
- handlePageClose(page);
480
+ initPage(page);
428
481
  await page.goto("https://heroshot.sh/welcome", { waitUntil: "domcontentloaded" });
429
482
  try {
430
- await injectToolbar(page, {
431
- screenshots: allScreenshots,
432
- pendingJob,
433
- selectedId,
434
- sidebarExpanded,
435
- onEvent: handleEvent
436
- });
483
+ await injectToolbar(page, injectOptions());
437
484
  verbose("Toolbar injected on welcome page");
438
485
  } catch (error) {
439
486
  verbose(`Initial toolbar injection failed: ${error instanceof Error ? error.message : String(error)}`);