MediaWiki:Common.js: Difference between revisions

From No Way Out Wiki
No edit summary
No edit summary
Line 1: Line 1:
/* ImageMap Highlighter — table legend edition (JS only; no CSS here)
/* ImageMap Highlighter
   - Renders a numbered table (No., Label) under each imagemap
   - Highlights mapped regions on hover/focus and syncs with legend items
  - Hovering a table row highlights the mapped region (and vice versa)
   - Builds a compact multi-column legend (via CSS grid)
   - Robust map lookup via <img usemap> / siblings
   Public domain; based on user:קיפודנחש (hewiki)
  - Safe to run multiple times (per-image guard)
   Public domain; based on the original by user:קיפודנחש (hewiki)
*/
*/
(function ($, mw) {
(function ($, mw) {
   'use strict';
   'use strict';


   // ---------- Config ----------
   // ---- Global guard (don’t register twice) ----
   var myClassName = 'imageMapHighlighterArtefacts',
   if (window.IMH_GRID_LOADED) return;
      liHighlightClass = 'liHighlighting',
  window.IMH_GRID_LOADED = true;
      specialAreaMark = 'area_mark',
      specialLiClassesMark = 'list_classes',
      areaHighLighting = { fillStyle: 'rgba(0,0,0,0.35)', strokeStyle: 'yellow', lineJoin: 'round', lineWidth: 2 },
      hilightDivMarker = '.imageMapHighlighter',
      he = mw && mw.config && mw.config.get('wgUserLanguage') === 'he',
      expandLegend = he ? 'הצגת מקרא' : 'Show legend',
      collapseLegend = he ? 'הסתרת המקרא' : 'Hide legend',
      liEvents = 'mouseover mouseout focus blur',
      mouseEvents = 'mouseover mouseout',
      highlightEvents = ['mouseover', 'focus'];


   // ---------- Drawing ----------
   // ---- Config ----
  var ART = 'imageMapHighlighterArtefacts'; // artifact class prefix
  var WRAP_CLASS = 'im-wrap';
  var liHighlightClass = 'liHighlighting';
  var specialAreaMark = 'area_mark';
  var specialLiClassesMark = 'list_classes';
  var hilightDivMarker = '.imageMapHighlighter';
  var areaHighLighting = { fillStyle: 'rgba(0,0,0,0.35)', strokeStyle: 'yellow', lineJoin: 'round', lineWidth: 2 };
  var he = mw && mw.config && mw.config.get('wgUserLanguage') === 'he';
  var expandLegend = he ? 'הצגת מקרא' : 'Show legend';
  var collapseLegend = he ? 'הסתרת המקרא' : 'Hide legend';
  var liEvents = 'mouseover mouseout focus blur';
  var mouseEvents = 'mouseover mouseout';
  var highlightEvents = ['mouseover', 'focus'];
 
  // ---- Drawing ----
   function drawMarker(context, areas) {
   function drawMarker(context, areas) {
     function drawPoly(coords) {
     function drawPoly(coords) {
       context.moveTo(coords.shift(), coords.shift());
       context.moveTo(coords[0], coords[1]);
       while (coords.length) context.lineTo(coords.shift(), coords.shift());
       for (var i = 2; i < coords.length; i += 2) context.lineTo(coords[i], coords[i+1]);
     }
     }
     for (var i = 0; i < areas.length; i++) {
     for (var i = 0; i < areas.length; i++) {
       var a = areas[i], coords = a.coords.split(',').map(Number);
       var a = areas[i], coords = (a.coords || '').split(',').map(Number);
      if (!coords.length) continue;
       context.beginPath();
       context.beginPath();
       switch (a.shape) {
       switch (a.shape) {
        case 'rect':
        case null:
        case '':
          // MW 1.45+ sometimes omits shape="rect"
          drawPoly([coords[0], coords[1], coords[0], coords[3], coords[2], coords[3], coords[2], coords[1]]);
          break;
         case 'circle':
         case 'circle':
           context.arc(coords[0], coords[1], coords[2], 0, Math.PI * 2);
           context.arc(coords[0], coords[1], coords[2], 0, Math.PI * 2);
Line 45: Line 43:
           drawPoly(coords);
           drawPoly(coords);
           break;
           break;
        case 'rect':
        case null:
        case '':
        default:
          // Some MW versions omit shape="rect"
          drawPoly([coords[0], coords[1], coords[0], coords[3], coords[2], coords[3], coords[2], coords[1]]);
       }
       }
       context.closePath();
       context.closePath();
Line 52: Line 56:
   }
   }


   // ---------- Legend hover handler (rows or legacy <li>) ----------
   // ---- Legend hover handler (grid items) ----
   function mouseAction(e) {
   function mouseAction(e) {
     var $this = $(this),
     var $this = $(this),
         activate = highlightEvents.indexOf(e.type) >= 0,
         activate = highlightEvents.indexOf(e.type) >= 0,
         caption = ($this.find('.im-label a').text() || $this.find('.im-label').text() || $this.text()).trim(),
         cap = ($this.find('.im-label a').text() || $this.find('.im-label').text() || $this.text()).trim(),
         $legend = $this.closest('.im-legend, ol'),
         $legend = $this.closest('.im-legend'),
         context = $legend.data('context'),
         context = $legend.data('context'),
         special = $legend.data(specialAreaMark);
         special = $legend.data(specialAreaMark);


     $this.toggleClass(liHighlightClass, activate);
     $this.toggleClass(liHighlightClass, activate);
    if (!context) return;
     context.clearRect(0, 0, context.canvas.width, context.canvas.height);
     context.clearRect(0, 0, context.canvas.width, context.canvas.height);


     var $items = $legend.is('ol') ? $legend.children('li') : $legend.find('tbody tr.im-row');
     $legend.find('.im-row').each(function () {
    $items.each(function () {
       var $row = $(this);
       var $item = $(this);
       var rowCap = ($row.find('.im-label a').text() || $row.find('.im-label').text() || $row.text()).trim();
       var licap = ($item.find('.im-label a').text() || $item.find('.im-label').text() || $item.text()).trim();
       var param = (activate && rowCap === cap)
       var param;
         ? (special && special.hover) || areaHighLighting
      if (activate && licap === caption) {
         : (special && special.nover && (special.nover[rowCap] || special.nover.default));
         param = (special && special.hover) || areaHighLighting;
 
      } else {
         param = special && special.nover && (special.nover[licap] || special.nover.default);
      }
       if (param) {
       if (param) {
         $.extend(context, param);
         $.extend(context, param);
         drawMarker(context, $item.data('areas') || []);
         drawMarker(context, $row.data('areas') || []);
       }
       }
     });
     });
   }
   }


   // ---------- Build one imagemap ----------
   // ---- Build one imagemap ----
   function handleOneMap() {
   function handleOneMap() {
     var img = $(this);
     var $img = $(this);
    if ($img.data('imhProcessed')) return; // once per image


    // guard: process each image once
     // wait for real layout size
    if (img.data('imhProcessed')) return;
 
     // wait until the image has real layout size
     if (!this.complete || !this.naturalWidth) {
     if (!this.complete || !this.naturalWidth) {
       img.one('load', handleOneMap);
       $img.one('load', handleOneMap);
       return;
       return;
     }
     }
Line 96: Line 96:
     var w = this.width, h = this.height;
     var w = this.width, h = this.height;


     // Find the <map> reliably
     // Find the <map>
     var map = (function () {
     var $map = (function () {
       var m = img.parent().siblings('map:first');
       var m = $img.parent().siblings('map:first');
       if (m.length) return m;
       if (m.length) return m;
       var usemap = img.attr('usemap'); // e.g. "#imagemap-123"
       var usemap = $img.attr('usemap'); // "#name"
       if (usemap) {
       if (usemap) {
         var name = usemap.replace(/^#/, '');
         var name = usemap.replace(/^#/, '');
Line 106: Line 106:
         if (m.length) return m.first();
         if (m.length) return m.first();
       }
       }
       return img.closest(hilightDivMarker).find('map:first');
       return $img.closest(hilightDivMarker).find('map:first');
     })();
     })();
    if (!$map.length || !$map.find('area').length) return;
    // Reuse/create wrapper
    var $wrap = $img.closest('.' + WRAP_CLASS);
    if (!$wrap.length) {
      $wrap = $('<div>', { 'class': WRAP_CLASS })
        .css({ position: 'relative', width: w + 'px', height: h + 'px' });
      $img.before($wrap);
      $wrap.append($img);
    } else {
      // Clear any prior artifacts inside wrapper
      $wrap.find('canvas.' + ART + ', img.' + ART).remove();
    }


     if (!map.length || !$('area', map).length) return; // nothing to do
     // Remove any old legends we (or other scripts) made after the wrapper
    $wrap.nextAll('div.im-legend, ol.' + ART + ', hr.' + ART).remove();


     // Layer sandwich: bg image (absolute), canvas (absolute), original img (absolute, transparent)
     // Layer sandwich: bg image (z0), canvas (z1), original (z2, transparent)
     var dims = { position: 'absolute', width: w + 'px', height: h + 'px', border: 0, top: 0, left: 0 };
     var dims = { position: 'absolute', width: w + 'px', height: h + 'px', border: 0, top: 0, left: 0 };
     var jcanvas = $('<canvas>', { 'class': myClassName }).css(dims).attr({ width: w, height: h });
     var $bg = $('<img>', { 'class': ART, src: $img.attr('src') }).css($.extend({ zIndex: 0 }, dims));
     var bgimg = $('<img>', { 'class': myClassName, src: img.attr('src') }).css(dims);
     var $cv = $('<canvas>', { 'class': ART }).css($.extend({ zIndex: 1 }, dims)).attr({ width: w, height: h });
     var context = $.extend(jcanvas[0].getContext('2d'), areaHighLighting);
     var ctx = $.extend($cv[0].getContext('2d'), areaHighLighting);


     var wrapper = $('<div>').css({ position: 'relative', width: w + 'px', height: h + 'px' });
     $img.css({ position: 'absolute', zIndex: 2 }).fadeTo(1, 0);
    img.before(wrapper);
     $wrap.append($bg).append($cv);
     wrapper.append(bgimg).append(jcanvas).append(img);
     $img.data('imhProcessed', true);
     img.fadeTo(1, 0); // mouse events still come from the original image
    img.data('imhProcessed', true);


     // Legend wrapper + table
     // Legend wrapper + grid
     var $legendWrap = $('<div>', {
     var $legend = $('<div>', {
       'class': myClassName + ' im-legend mw-collapsible mw-collapsed'
       'class': ART + ' im-legend mw-collapsible mw-collapsed'
     }).attr({
     }).attr({ 'data-expandtext': expandLegend, 'data-collapsetext': collapseLegend })
      'data-expandtext': expandLegend,
      .data(specialAreaMark, $img.closest(hilightDivMarker).data(specialAreaMark))
      'data-collapsetext': collapseLegend
      .data('context', ctx);
    });


     var $table = $(
     var $grid = $('<div class="im-grid" role="list"></div>');
      '<table class="' + myClassName + ' im-table" aria-label="Image map legend">' +
    $legend.append($grid);
        '<thead><tr>' +
    $wrap.after($('<hr>', { 'class': ART }).css('clear', 'both')).after($legend);
          '<th class="im-num">#</th>' +
          '<th class="im-label">Location</th>' +
        '</tr></thead>' +
        '<tbody></tbody>' +
      '</table>'
    );


    $legendWrap.append($table);
     // Build rows (collapse duplicate titles)
    wrapper.after($('<hr>', { 'class': myClassName }).css('clear', 'both')).after($legendWrap);
 
     // Collapse duplicate titles into one row
     var rowsByTitle = Object.create(null);
     var rowsByTitle = Object.create(null);
     var idx = 0, $someRow;
     var idx = 0, $someRow;
    var specialHighlight = img.closest(hilightDivMarker).data(specialAreaMark);
     var specialLiClasses = $img.closest(hilightDivMarker).data(specialLiClassesMark);
     var specialLiClasses = img.closest(hilightDivMarker).data(specialLiClassesMark);


     $('area', map).each(function () {
     $map.find('area').each(function () {
       var title = this.title || '';
       var title = this.title || ('Area ' + (idx + 1));
       var href = this.href || '#';
       var href = this.href || '#';
       var key = title; // use plain text for matching
       var key = title;
      var $row = rowsByTitle[key];


      var $row = rowsByTitle[key];
       if (!$row) {
       if (!$row) {
         idx += 1;
         idx += 1;
         $row = $('<tr>', { 'class': myClassName + ' im-row' })
         $row = $('<div>', { 'class': ART + ' im-row', 'role': 'listitem', 'tabindex': '0' })
           .append($('<td class="im-num">').text(idx))
           .append($('<span class="im-num">').text(idx))
           .append($('<td class="im-label">').append($('<a>', { href: href }).text(title)))
           .append($('<span class="im-label">').append($('<a>', { href: href }).text(title)))
           .on(liEvents, mouseAction)
           .on(liEvents, mouseAction)
           .data('areas', [])
           .data('areas', []);
          .appendTo($table.find('tbody'));
        if (specialLiClasses) {
 
          var extra = specialLiClasses[title] || specialLiClasses['default'];
        var addClass = specialLiClasses && (specialLiClasses[title] || specialLiClasses['default']);
          if (extra) $row.addClass(extra);
        if (addClass) $row.addClass(addClass);
        }
 
         rowsByTitle[key] = $row;
         rowsByTitle[key] = $row;
        $grid.append($row);
       }
       }


      // bind area ↔ row hover
       $row.data('areas').push(this);
       $row.data('areas').push(this);
       $someRow = $row;
       $someRow = $row;
Line 178: Line 178:


     if ($someRow) $someRow.trigger('mouseout');
     if ($someRow) $someRow.trigger('mouseout');
    // store drawing context on the wrapper for mouseAction
    $legendWrap.data(specialAreaMark, specialHighlight).data('context', context);


     mw.loader.using('jquery.makeCollapsible').then(function () {
     mw.loader.using('jquery.makeCollapsible').then(function () {
       $legendWrap.makeCollapsible();
       $legend.makeCollapsible();
     });
     });
   }
   }


   // ---------- Init ----------
   // ---- Init (only run where needed) ----
   function init($scope) {
   function init($scope) {
     var $roots = ($scope && $scope.length) ? $scope : $(document);
     var $root = ($scope && $scope.length) ? $scope : $(document);
     $roots.find(hilightDivMarker + ' img').each(handleOneMap);
     var $targets = $root.find(hilightDivMarker + ' img');
    if (!$targets.length) return;
    $targets.each(handleOneMap);
   }
   }


   // Run on ready and when content changes (VE previews, Ajax loads, etc.)
   // Run on ready and on content updates (VE/Ajax)
   $(function () { init(); });
   $(function () { init(); });
   mw.hook('wikipage.content').add(function ($c) { init($c); });
   mw.hook('wikipage.content').add(function ($c) { init($c); });


})(jQuery, mw);
})(jQuery, mw);

Revision as of 04:50, 25 September 2025

/* ImageMap Highlighter
   - Highlights mapped regions on hover/focus and syncs with legend items
   - Builds a compact multi-column legend (via CSS grid)
   Public domain; based on user:קיפודנחש (hewiki)
*/
(function ($, mw) {
  'use strict';

  // ---- Global guard (don’t register twice) ----
  if (window.IMH_GRID_LOADED) return;
  window.IMH_GRID_LOADED = true;

  // ---- Config ----
  var ART = 'imageMapHighlighterArtefacts'; // artifact class prefix
  var WRAP_CLASS = 'im-wrap';
  var liHighlightClass = 'liHighlighting';
  var specialAreaMark = 'area_mark';
  var specialLiClassesMark = 'list_classes';
  var hilightDivMarker = '.imageMapHighlighter';
  var areaHighLighting = { fillStyle: 'rgba(0,0,0,0.35)', strokeStyle: 'yellow', lineJoin: 'round', lineWidth: 2 };
  var he = mw && mw.config && mw.config.get('wgUserLanguage') === 'he';
  var expandLegend = he ? 'הצגת מקרא' : 'Show legend';
  var collapseLegend = he ? 'הסתרת המקרא' : 'Hide legend';
  var liEvents = 'mouseover mouseout focus blur';
  var mouseEvents = 'mouseover mouseout';
  var highlightEvents = ['mouseover', 'focus'];

  // ---- Drawing ----
  function drawMarker(context, areas) {
    function drawPoly(coords) {
      context.moveTo(coords[0], coords[1]);
      for (var i = 2; i < coords.length; i += 2) context.lineTo(coords[i], coords[i+1]);
    }
    for (var i = 0; i < areas.length; i++) {
      var a = areas[i], coords = (a.coords || '').split(',').map(Number);
      if (!coords.length) continue;
      context.beginPath();
      switch (a.shape) {
        case 'circle':
          context.arc(coords[0], coords[1], coords[2], 0, Math.PI * 2);
          break;
        case 'poly':
          drawPoly(coords);
          break;
        case 'rect':
        case null:
        case '':
        default:
          // Some MW versions omit shape="rect"
          drawPoly([coords[0], coords[1], coords[0], coords[3], coords[2], coords[3], coords[2], coords[1]]);
      }
      context.closePath();
      context.stroke();
      context.fill();
    }
  }

  // ---- Legend hover handler (grid items) ----
  function mouseAction(e) {
    var $this = $(this),
        activate = highlightEvents.indexOf(e.type) >= 0,
        cap = ($this.find('.im-label a').text() || $this.find('.im-label').text() || $this.text()).trim(),
        $legend = $this.closest('.im-legend'),
        context = $legend.data('context'),
        special = $legend.data(specialAreaMark);

    $this.toggleClass(liHighlightClass, activate);
    if (!context) return;
    context.clearRect(0, 0, context.canvas.width, context.canvas.height);

    $legend.find('.im-row').each(function () {
      var $row = $(this);
      var rowCap = ($row.find('.im-label a').text() || $row.find('.im-label').text() || $row.text()).trim();
      var param = (activate && rowCap === cap)
        ? (special && special.hover) || areaHighLighting
        : (special && special.nover && (special.nover[rowCap] || special.nover.default));

      if (param) {
        $.extend(context, param);
        drawMarker(context, $row.data('areas') || []);
      }
    });
  }

  // ---- Build one imagemap ----
  function handleOneMap() {
    var $img = $(this);
    if ($img.data('imhProcessed')) return; // once per image

    // wait for real layout size
    if (!this.complete || !this.naturalWidth) {
      $img.one('load', handleOneMap);
      return;
    }

    var w = this.width, h = this.height;

    // Find the <map>
    var $map = (function () {
      var m = $img.parent().siblings('map:first');
      if (m.length) return m;
      var usemap = $img.attr('usemap'); // "#name"
      if (usemap) {
        var name = usemap.replace(/^#/, '');
        m = $('map[name="' + name + '"]');
        if (m.length) return m.first();
      }
      return $img.closest(hilightDivMarker).find('map:first');
    })();
    if (!$map.length || !$map.find('area').length) return;

    // Reuse/create wrapper
    var $wrap = $img.closest('.' + WRAP_CLASS);
    if (!$wrap.length) {
      $wrap = $('<div>', { 'class': WRAP_CLASS })
        .css({ position: 'relative', width: w + 'px', height: h + 'px' });
      $img.before($wrap);
      $wrap.append($img);
    } else {
      // Clear any prior artifacts inside wrapper
      $wrap.find('canvas.' + ART + ', img.' + ART).remove();
    }

    // Remove any old legends we (or other scripts) made after the wrapper
    $wrap.nextAll('div.im-legend, ol.' + ART + ', hr.' + ART).remove();

    // Layer sandwich: bg image (z0), canvas (z1), original (z2, transparent)
    var dims = { position: 'absolute', width: w + 'px', height: h + 'px', border: 0, top: 0, left: 0 };
    var $bg = $('<img>', { 'class': ART, src: $img.attr('src') }).css($.extend({ zIndex: 0 }, dims));
    var $cv = $('<canvas>', { 'class': ART }).css($.extend({ zIndex: 1 }, dims)).attr({ width: w, height: h });
    var ctx = $.extend($cv[0].getContext('2d'), areaHighLighting);

    $img.css({ position: 'absolute', zIndex: 2 }).fadeTo(1, 0);
    $wrap.append($bg).append($cv);
    $img.data('imhProcessed', true);

    // Legend wrapper + grid
    var $legend = $('<div>', {
      'class': ART + ' im-legend mw-collapsible mw-collapsed'
    }).attr({ 'data-expandtext': expandLegend, 'data-collapsetext': collapseLegend })
      .data(specialAreaMark, $img.closest(hilightDivMarker).data(specialAreaMark))
      .data('context', ctx);

    var $grid = $('<div class="im-grid" role="list"></div>');
    $legend.append($grid);
    $wrap.after($('<hr>', { 'class': ART }).css('clear', 'both')).after($legend);

    // Build rows (collapse duplicate titles)
    var rowsByTitle = Object.create(null);
    var idx = 0, $someRow;
    var specialLiClasses = $img.closest(hilightDivMarker).data(specialLiClassesMark);

    $map.find('area').each(function () {
      var title = this.title || ('Area ' + (idx + 1));
      var href = this.href || '#';
      var key = title;
      var $row = rowsByTitle[key];

      if (!$row) {
        idx += 1;
        $row = $('<div>', { 'class': ART + ' im-row', 'role': 'listitem', 'tabindex': '0' })
          .append($('<span class="im-num">').text(idx))
          .append($('<span class="im-label">').append($('<a>', { href: href }).text(title)))
          .on(liEvents, mouseAction)
          .data('areas', []);
        if (specialLiClasses) {
          var extra = specialLiClasses[title] || specialLiClasses['default'];
          if (extra) $row.addClass(extra);
        }
        rowsByTitle[key] = $row;
        $grid.append($row);
      }

      $row.data('areas').push(this);
      $someRow = $row;
      $(this).on(mouseEvents, function (e) { $row.trigger(e.type); });
    });

    if ($someRow) $someRow.trigger('mouseout');

    mw.loader.using('jquery.makeCollapsible').then(function () {
      $legend.makeCollapsible();
    });
  }

  // ---- Init (only run where needed) ----
  function init($scope) {
    var $root = ($scope && $scope.length) ? $scope : $(document);
    var $targets = $root.find(hilightDivMarker + ' img');
    if (!$targets.length) return;
    $targets.each(handleOneMap);
  }

  // Run on ready and on content updates (VE/Ajax)
  $(function () { init(); });
  mw.hook('wikipage.content').add(function ($c) { init($c); });

})(jQuery, mw);