jekyll-image-links 0.1.2 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19b54b99566ef68cb4781a9ac97bbf0244aecdc8df97de25842b379916d5ffb6
4
- data.tar.gz: f8398c0bbfabd847e30639200b95b11bccee7e205938f440eb0d96a1b6da83ab
3
+ metadata.gz: a9d85919e65b8d1b2cf32c8d1ffccc9b149f8d84e7950020f5d291e0262dee60
4
+ data.tar.gz: f933adffe5a1970b78669ae40edd0f5595342c01e43e7f1f53c50072a1f5236b
5
5
  SHA512:
6
- metadata.gz: 67dc7db67ce397dd3c9df03c8c84681a7fb60731a717520e5d30d6b5c57ab3a8f3be1fbdddcd564ccecad632941982d4d7311e79a67487b4f72020b9fa7025d2
7
- data.tar.gz: 84f6ddb951f954fcf32d8af81fcef097104c16d987824abc95766654e64d9895f4728669c321cfb2f9de250d601b3ff86d1e1e9f8c87a18dc5753fc9b23d6559
6
+ metadata.gz: 4454cc915358b308486232099c78a6db594d6c86318e4608cf490ba9a2b6de954a76f276c0e2b91a61127115535ca1eb550ab901a2218cd42be86acd5b7ac83d
7
+ data.tar.gz: 59a5bbac2e96705896e0ddbcedefe36b215f3fc6fbe541c562b4e3a3b91f8f604df03fd0570daf86bcc0898059715eae29a22fb95c7c489457c27a603ff6e76f
@@ -17,42 +17,140 @@
17
17
  return !!getHoverPopup();
18
18
  }
19
19
 
20
+ function logError(message, error) {
21
+ if (error) console.error(`[jil] ${message}`, error);
22
+ else console.error(`[jil] ${message}`);
23
+ }
24
+
25
+ function reportMapProblems(img, mapData) {
26
+ const src = mapData.src || img.getAttribute("src") || "image map";
27
+ const width = Number(mapData.width);
28
+ const height = Number(mapData.height);
29
+
30
+ if (!width || !height) {
31
+ logError(`${src}: map data is missing width or height`);
32
+ }
33
+
34
+ const regions = mapData.regions;
35
+ if (!Array.isArray(regions) || regions.length === 0) {
36
+ logError(`${src}: map data has no regions`);
37
+ }
38
+
39
+ const checkSize = () => {
40
+ const naturalWidth = img.naturalWidth;
41
+ const naturalHeight = img.naturalHeight;
42
+ if (!naturalWidth || !naturalHeight) {
43
+ logError(`${src}: image failed to load`);
44
+ return;
45
+ }
46
+ if (!width || !height) return;
47
+ if (naturalWidth === width && naturalHeight === height) return;
48
+ logError(`${src}: image is ${naturalWidth}×${naturalHeight} but map data is ${width}×${height}`);
49
+ };
50
+
51
+ img.addEventListener("error", () => {
52
+ logError(`${src}: image failed to load`);
53
+ });
54
+
55
+ if (img.complete) checkSize();
56
+ else img.addEventListener("load", checkSize);
57
+
58
+ (regions || []).forEach((region, index) => {
59
+ const name = region.title || region.href || `region ${index + 1}`;
60
+ const points = region.points;
61
+ if (!region.href) logError(`${src}: "${name}" is missing href`);
62
+ if (!Array.isArray(points) || points.length < 3) {
63
+ logError(`${src}: "${name}" needs at least 3 points`);
64
+ return;
65
+ }
66
+ if (!width || !height) return;
67
+
68
+ const xs = points.map((point) => point[0]);
69
+ const ys = points.map((point) => point[1]);
70
+ const outside = xs.some((x) => x < 0 || x > width) || ys.some((y) => y < 0 || y > height);
71
+ if (!outside) return;
72
+ logError(
73
+ `${src}: "${name}" is outside ${width}×${height} (x ${Math.min(...xs)}–${Math.max(...xs)}, y ${Math.min(...ys)}–${Math.max(...ys)})`
74
+ );
75
+ });
76
+ }
77
+
20
78
  function init() {
21
- document.querySelectorAll("[data-jil-map]").forEach(initMapHost);
79
+ document.querySelectorAll("img.jil-map-image[data-jil-regions]").forEach((img) => {
80
+ if (img.closest("[data-jil-map]")) return;
81
+ logError(
82
+ `${img.getAttribute("src") || "image"} was not enhanced (${img.getAttribute("data-jil-regions")})`
83
+ );
84
+ });
85
+
86
+ document.querySelectorAll("[data-jil-map]").forEach((host) => {
87
+ try {
88
+ initMapHost(host);
89
+ } catch (error) {
90
+ logError(`Failed to init ${host.id || "image map"}`, error);
91
+ }
92
+ });
22
93
  }
23
94
 
24
95
  function initMapHost(host) {
25
96
  if (host.dataset.jilReady === "true") return;
26
97
  host.dataset.jilReady = "true";
27
98
 
28
- const mapData = JSON.parse(host.dataset.jilMap);
99
+ let mapData;
100
+ try {
101
+ mapData = JSON.parse(host.dataset.jilMap);
102
+ } catch (error) {
103
+ logError(`Invalid map JSON on ${host.id || "image map"}`, error);
104
+ return;
105
+ }
106
+
29
107
  const inline = host.dataset.jilInline !== "false";
30
108
  const viewer = host.dataset.jilViewer !== "false";
31
109
  const labels = host.dataset.jilLabels === "true";
32
110
  const img = host.querySelector(".jil-map-image");
33
111
 
34
- if (!img) return;
112
+ if (!img) {
113
+ logError(`${mapData.src || host.id || "image map"}: missing .jil-map-image`);
114
+ return;
115
+ }
35
116
 
117
+ reportMapProblems(img, mapData);
36
118
  if (labels) renderLabels(host, mapData, img);
37
119
  if (inline) bindInlineClicks(host, mapData, img);
38
120
  if (viewer) addViewerButton(host, mapData);
39
121
  }
40
122
 
123
+ function bindOverlayGeometry(overlay, host, mapData, img) {
124
+ const update = () => {
125
+ const geom = getMapGeometry(img, mapData);
126
+ if (!geom || !geom.width || !geom.height) return;
127
+ overlay.style.left = `${geom.offsetX}px`;
128
+ overlay.style.top = `${geom.offsetY}px`;
129
+ overlay.style.width = `${geom.width}px`;
130
+ overlay.style.height = `${geom.height}px`;
131
+ };
132
+
133
+ if (img.complete) update();
134
+ img.addEventListener("load", update);
135
+ window.addEventListener("resize", update);
136
+ if (typeof ResizeObserver !== "undefined") {
137
+ const observer = new ResizeObserver(update);
138
+ observer.observe(host);
139
+ observer.observe(img);
140
+ }
141
+ }
142
+
41
143
  function renderLabels(host, mapData, img) {
42
144
  const overlay = document.createElement("div");
43
145
  overlay.className = "jil-label-layer";
44
146
  host.appendChild(overlay);
147
+ bindOverlayGeometry(overlay, host, mapData, img);
45
148
 
46
149
  const update = () => {
47
150
  overlay.innerHTML = "";
48
151
  const geom = getMapGeometry(img, mapData);
49
152
  if (!geom || !geom.width || !geom.height) return;
50
153
 
51
- overlay.style.left = `${geom.offsetX}px`;
52
- overlay.style.top = `${geom.offsetY}px`;
53
- overlay.style.width = `${geom.width}px`;
54
- overlay.style.height = `${geom.height}px`;
55
-
56
154
  mapData.regions.forEach((region) => {
57
155
  const center = polygonCenter(region.points);
58
156
  if (!center) return;
@@ -248,7 +346,21 @@
248
346
  });
249
347
  zoomFit.addEventListener("click", fitZoom);
250
348
 
349
+ let dragStart = null;
350
+ let didPan = false;
351
+
352
+ const cursorForPoint = (event) => {
353
+ const point = canvasPointFromEvent(canvas, state.zoom, event);
354
+ const region = firstIntersectedRegion(mapData.regions, point);
355
+ return region ? "pointer" : "grab";
356
+ };
357
+
251
358
  canvas.addEventListener("click", (event) => {
359
+ if (didPan) {
360
+ didPan = false;
361
+ event.preventDefault();
362
+ return;
363
+ }
252
364
  const point = canvasPointFromEvent(canvas, state.zoom, event);
253
365
  const region = firstIntersectedRegion(mapData.regions, point);
254
366
  if (!region) return;
@@ -257,15 +369,21 @@
257
369
  });
258
370
 
259
371
  canvas.addEventListener("mousemove", (event) => {
260
- const point = canvasPointFromEvent(canvas, state.zoom, event);
261
- const region = firstIntersectedRegion(mapData.regions, point);
262
- canvas.style.cursor = region ? "pointer" : "grab";
372
+ if (dragStart) return;
373
+ canvas.style.cursor = cursorForPoint(event);
263
374
  });
264
375
 
265
- let dragStart = null;
266
376
  canvas.addEventListener("mousedown", (event) => {
267
- if (event.button !== 2) return;
377
+ // Left-drag empty areas to pan; right-drag pans from anywhere.
378
+ if (event.button === 0) {
379
+ const point = canvasPointFromEvent(canvas, state.zoom, event);
380
+ if (firstIntersectedRegion(mapData.regions, point)) return;
381
+ } else if (event.button !== 2) {
382
+ return;
383
+ }
384
+
268
385
  event.preventDefault();
386
+ didPan = false;
269
387
  dragStart = {
270
388
  x: event.clientX,
271
389
  y: event.clientY,
@@ -275,15 +393,25 @@
275
393
  canvas.style.cursor = "grabbing";
276
394
  });
277
395
 
278
- window.addEventListener("mouseup", () => {
396
+ window.addEventListener("mouseup", (event) => {
397
+ if (!dragStart) return;
279
398
  dragStart = null;
280
- canvas.style.cursor = "grab";
399
+ canvas.style.cursor = cursorForPoint(event);
400
+ // Clear after the trailing click so a pan doesn't open a region.
401
+ if (didPan) {
402
+ setTimeout(() => {
403
+ didPan = false;
404
+ }, 0);
405
+ }
281
406
  });
282
407
 
283
408
  window.addEventListener("mousemove", (event) => {
284
409
  if (!dragStart) return;
285
- scroll.scrollLeft = dragStart.scrollLeft + (dragStart.x - event.clientX);
286
- scroll.scrollTop = dragStart.scrollTop + (dragStart.y - event.clientY);
410
+ const dx = dragStart.x - event.clientX;
411
+ const dy = dragStart.y - event.clientY;
412
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) didPan = true;
413
+ scroll.scrollLeft = dragStart.scrollLeft + dx;
414
+ scroll.scrollTop = dragStart.scrollTop + dy;
287
415
  });
288
416
 
289
417
  canvas.addEventListener("contextmenu", (event) => event.preventDefault());
@@ -299,6 +427,9 @@
299
427
  state.image = image;
300
428
  fitZoom();
301
429
  };
430
+ image.onerror = () => {
431
+ logError(`${mapData.src || "viewer image"}: viewer image failed to load`);
432
+ };
302
433
  image.src = mapData.src;
303
434
 
304
435
  return {
@@ -324,13 +455,22 @@
324
455
  const jhp = useHoverPopup() ? getHoverPopup() : null;
325
456
  if (jhp) {
326
457
  event.preventDefault();
327
- await jhp.openLink({
328
- href: region.href,
329
- title: region.title || region.label || region.href,
330
- clientX: event.clientX,
331
- clientY: event.clientY,
332
- isPermanent: true,
333
- });
458
+ try {
459
+ await jhp.openLink({
460
+ href: region.href,
461
+ title: region.title || region.label || region.href,
462
+ clientX: event.clientX,
463
+ clientY: event.clientY,
464
+ isPermanent: true,
465
+ });
466
+ } catch (error) {
467
+ logError(`Failed to open ${region.href}`, error);
468
+ }
469
+ return;
470
+ }
471
+
472
+ if (!region.href) {
473
+ logError("Region is missing href");
334
474
  return;
335
475
  }
336
476
 
@@ -448,7 +588,10 @@
448
588
  }
449
589
 
450
590
  function start() {
451
- if (!document.querySelector(ROOT_SELECTOR)) return;
591
+ if (!document.querySelector(ROOT_SELECTOR)) {
592
+ logError("image_links.js loaded without data-jil-root");
593
+ return;
594
+ }
452
595
 
453
596
  const run = () => init();
454
597
 
@@ -17,7 +17,10 @@ module Jekyll
17
17
  html = MapRenderer.enhance_html(html, site: site, page: doc, cfg: cfg)
18
18
  doc.output = inject_assets(html, assets_path: assets_path, site: site, cfg: cfg)
19
19
  rescue StandardError => e
20
- Jekyll.logger.warn("jekyll-image-links:", "Failed to process #{doc.relative_path}: #{e.class}: #{e.message}")
20
+ Jekyll.logger.error(
21
+ "jekyll-image-links:",
22
+ "Failed to process #{doc.relative_path}: #{e.class}: #{e.message}"
23
+ )
21
24
  end
22
25
  end
23
26
  end
@@ -23,7 +23,7 @@ module Jekyll
23
23
  html = html.gsub(PORTABLE_FIGURE) do |figure_html|
24
24
  enhance_portable_markup(figure_html, site: site, page: page, cfg: cfg)
25
25
  rescue StandardError => e
26
- Jekyll.logger.warn("jekyll-image-links:", "Failed to enhance portable figure: #{e.message}")
26
+ log_error("#{page_label(page)} #{markup_context(figure_html)}: #{e.class}: #{e.message}")
27
27
  figure_html
28
28
  end
29
29
 
@@ -32,7 +32,7 @@ module Jekyll
32
32
 
33
33
  enhance_portable_markup(image_html, site: site, page: page, cfg: cfg)
34
34
  rescue StandardError => e
35
- Jekyll.logger.warn("jekyll-image-links:", "Failed to enhance portable image map: #{e.message}")
35
+ log_error("#{page_label(page)} #{markup_context(image_html)}: #{e.class}: #{e.message}")
36
36
  image_html
37
37
  end
38
38
  end
@@ -170,6 +170,8 @@ module Jekyll
170
170
  "title" => title,
171
171
  "regions" => normalize_regions(regions, site: site, page: page, liquid_context: liquid_context),
172
172
  }
173
+ warn_if_regions_out_of_bounds!(map_data["regions"], width, height, src)
174
+ warn_if_image_dimensions_differ!(site, page, attrs["src"], width, height)
173
175
 
174
176
  render_interactive(
175
177
  map_data,
@@ -300,6 +302,170 @@ module Jekyll
300
302
  end
301
303
  end
302
304
 
305
+ def warn_if_regions_out_of_bounds!(regions, width, height, src)
306
+ regions.each do |region|
307
+ points = region["points"] || []
308
+ next if points.empty?
309
+
310
+ xs = points.map { |point| point[0] }
311
+ ys = points.map { |point| point[1] }
312
+ outside = xs.any? { |x| x.negative? || x > width } || ys.any? { |y| y.negative? || y > height }
313
+ next unless outside
314
+
315
+ name = region["title"] || region["href"]
316
+ log_error(
317
+ "#{src}: region \"#{name}\" is outside #{width}×#{height} (x #{xs.min}–#{xs.max}, y #{ys.min}–#{ys.max})"
318
+ )
319
+ end
320
+ end
321
+
322
+ def warn_if_image_dimensions_differ!(site, page, src, width, height)
323
+ path = local_image_path(site, page, src)
324
+ if path.nil?
325
+ log_error("#{src}: image file not found") unless remote_url?(src)
326
+ return
327
+ end
328
+
329
+ actual = read_image_size(path)
330
+ return unless actual
331
+
332
+ actual_width, actual_height = actual
333
+ return if actual_width == width && actual_height == height
334
+
335
+ log_error("#{src}: image file is #{actual_width}×#{actual_height} but map data is #{width}×#{height}")
336
+ end
337
+
338
+ def local_image_path(site, page, src)
339
+ return nil if src.to_s.strip.empty? || remote_url?(src)
340
+
341
+ path = src.to_s
342
+ baseurl = site.baseurl.to_s
343
+ baseurl = "" if baseurl == "/"
344
+ path = path.delete_prefix(baseurl) if !baseurl.empty? && (path == baseurl || path.start_with?("#{baseurl}/"))
345
+
346
+ candidates = [path]
347
+ if !path.start_with?("/") && page&.respond_to?(:relative_path) && page.relative_path
348
+ candidates << File.join(File.dirname(page.relative_path), path)
349
+ end
350
+
351
+ candidates.each do |candidate|
352
+ full_path = Jekyll.sanitized_path(site.source, candidate)
353
+ return full_path if File.file?(full_path)
354
+ end
355
+ nil
356
+ end
357
+
358
+ def remote_url?(src)
359
+ src.to_s.start_with?("http://", "https://", "data:")
360
+ end
361
+
362
+ def read_image_size(path)
363
+ File.open(path, "rb") do |file|
364
+ header = file.read(32)
365
+ return nil unless header && header.bytesize >= 24
366
+
367
+ header = header.b
368
+ if header.start_with?("\x89PNG\r\n\x1A\n".b)
369
+ return [header[16, 4].unpack1("N"), header[20, 4].unpack1("N")]
370
+ end
371
+ if header.start_with?("GIF87a".b, "GIF89a".b)
372
+ return header[6, 4].unpack("v2")
373
+ end
374
+ if header.start_with?("RIFF".b) && header[8, 4] == "WEBP".b
375
+ return read_webp_size(file, header)
376
+ end
377
+ return read_jpeg_size(file) if header.start_with?("\xFF\xD8".b)
378
+ end
379
+ nil
380
+ rescue StandardError => e
381
+ log_error("#{path}: could not read image dimensions: #{e.class}: #{e.message}")
382
+ nil
383
+ end
384
+
385
+ def read_webp_size(file, header)
386
+ format = header[12, 4]
387
+ if format == "VP8X".b
388
+ file.seek(24)
389
+ data = file.read(6)
390
+ return nil unless data&.bytesize == 6
391
+
392
+ bytes = data.bytes
393
+ width = 1 + (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16))
394
+ height = 1 + (bytes[3] | (bytes[4] << 8) | (bytes[5] << 16))
395
+ [width, height]
396
+ elsif format == "VP8 ".b
397
+ file.seek(26)
398
+ data = file.read(4)
399
+ return nil unless data&.bytesize == 4
400
+
401
+ width = data[0, 2].unpack1("v") & 0x3fff
402
+ height = data[2, 2].unpack1("v") & 0x3fff
403
+ [width, height]
404
+ elsif format == "VP8L".b
405
+ file.seek(21)
406
+ data = file.read(4)
407
+ return nil unless data&.bytesize == 4
408
+
409
+ b0, b1, b2, b3 = data.bytes
410
+ width = 1 + (((b1 & 0x3F) << 8) | b0)
411
+ height = 1 + (((b3 & 0x0F) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6))
412
+ [width, height]
413
+ end
414
+ end
415
+
416
+ def read_jpeg_size(file)
417
+ file.seek(2)
418
+ loop do
419
+ byte = file.read(1)
420
+ return nil unless byte == "\xFF".b
421
+
422
+ marker = nil
423
+ loop do
424
+ byte = file.read(1)
425
+ return nil unless byte
426
+ next if byte == "\xFF".b
427
+
428
+ marker = byte.ord
429
+ break
430
+ end
431
+ next if marker == 0xD8 || marker == 0xD9
432
+ return nil if marker == 0xDA
433
+
434
+ length_bytes = file.read(2)
435
+ return nil unless length_bytes&.bytesize == 2
436
+
437
+ length = length_bytes.unpack1("n")
438
+ return nil if length < 2
439
+
440
+ if [0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF].include?(marker)
441
+ data = file.read(5)
442
+ return nil unless data&.bytesize == 5
443
+
444
+ _precision, height, width = data.unpack("Cnn")
445
+ return [width, height]
446
+ end
447
+
448
+ file.seek(length - 2, IO::SEEK_CUR)
449
+ end
450
+ end
451
+
452
+ def page_label(page)
453
+ return page.relative_path if page.respond_to?(:relative_path) && page.relative_path
454
+ return page.path if page.respond_to?(:path) && page.path
455
+
456
+ "page"
457
+ end
458
+
459
+ def markup_context(markup)
460
+ src = markup[/src="([^"]*)"/, 1]
461
+ regions = markup[/data-jil-regions="([^"]*)"/, 1]
462
+ [regions, src].compact.reject(&:empty?).join(" ")
463
+ end
464
+
465
+ def log_error(message)
466
+ Jekyll.logger.error("jekyll-image-links:", message)
467
+ end
468
+
303
469
  def validate_dimensions!(width, height, src)
304
470
  unless width.positive? && height.positive?
305
471
  raise ArgumentError, "image_map requires native width and height in the YAML file or numeric width/height attributes"
@@ -15,14 +15,22 @@ module Jekyll
15
15
  cfg = (site.config["image_links"] || {})
16
16
  regions_body = @attrs["file"] ? nil : super
17
17
 
18
- figure = MapRenderer.render_portable_figure(
19
- @attrs,
20
- site: site,
21
- page: context.registers[:page],
22
- liquid_context: context,
23
- cfg: cfg,
24
- regions_body: regions_body
25
- )
18
+ begin
19
+ figure = MapRenderer.render_portable_figure(
20
+ @attrs,
21
+ site: site,
22
+ page: context.registers[:page],
23
+ liquid_context: context,
24
+ cfg: cfg,
25
+ regions_body: regions_body
26
+ )
27
+ rescue StandardError => e
28
+ page = context.registers[:page]
29
+ page_label = page.respond_to?(:relative_path) ? page.relative_path : "page"
30
+ src = @attrs["src"] || @attrs["file"]
31
+ Jekyll.logger.error("jekyll-image-links:", "#{page_label} #{src}: #{e.class}: #{e.message}")
32
+ raise
33
+ end
26
34
 
27
35
  <<~HTML
28
36
  {::nomarkdown}
@@ -1,5 +1,5 @@
1
1
  module Jekyll
2
2
  module ImageLinks
3
- VERSION = "0.1.2"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-image-links
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - directsun