Zum Inhalt springen

MediaWiki:Organigramm.js

Aus wikimitlinks069

Hinweis: Leere nach dem Veröffentlichen den Browser-Cache, um die Änderungen sehen zu können.

  • Firefox/Safari: Umschalttaste drücken und gleichzeitig Aktualisieren anklicken oder entweder Strg+F5 oder Strg+R (⌘+R auf dem Mac) drücken
  • Google Chrome: Umschalttaste+Strg+R (⌘+Umschalttaste+R auf dem Mac) drücken
  • Edge: Strg+F5 drücken oder Strg drücken und gleichzeitig Aktualisieren anklicken
/**
 * Organigramm-Werkzeuge für wikimitlinks069
 *
 * Macht die Seite „Organigramm Die Linke Frankfurt“ (und jede andere Seite
 * mit <div id="lo-werkzeuge"></div>) interaktiver:
 *  - Suchfeld: zeigt nur passende Punkte, klappt sie auf und markiert die Treffer;
 *    Enter oder „Zum ersten Treffer“ springt zum ersten passenden Punkt
 *  - „Alle aufklappen“ / „Alle zuklappen“
 *  - Klick auf einen Link zu einem Punkt (#lo-…): klappt ihn auf, springt hin, hebt ihn kurz hervor
 *  - Klick auf die Titelzeile eines Punkts klappt ihn auf oder zu
 *  - „#“ hinter dem Titel kopiert einen Link direkt zu diesem Punkt
 *
 * Die Inhalte bleiben normaler Wikitext. Ein Punkt sieht so aus:
 *   <div class="mw-collapsible mw-collapsed" style="…">
 *   <span id="lo-eindeutige-id"></span>'''Titel''' – <span style="color:#6B5560">kurze Beschreibung</span>
 *   <div class="mw-collapsible-content">
 *   Text …
 *   </div>
 *   </div>
 *
 * Geladen über MediaWiki:Common.js, Gestaltung in MediaWiki:Organigramm.css.
 * Stand: 20.10.2026
 */
( function () {
	'use strict';

	var MIN_ZEICHEN = 2;

	function norm( text ) {
		return ( text || '' )
			.replace( /­/g, '' )
			.replace( /\s+/g, ' ' )
			.toLowerCase();
	}

	/* Punkte auf der Seite finden und ihren eigenen Text sammeln */
	function sammlePunkte( root ) {
		var punkte = [];
		var nachElement = new Map();

		root.querySelectorAll( 'span[id^="lo-"]' ).forEach( function ( anker ) {
			var el = anker.closest( '.mw-collapsible' );
			if ( !el || nachElement.has( el ) ) {
				return;
			}
			// Der Anker muss in der Titelzeile stehen, nicht im aufklappbaren Inhalt
			var inhalt = anker.closest( '.mw-collapsible-content' );
			if ( inhalt && el.contains( inhalt ) ) {
				return;
			}
			var kopf = anker.parentElement !== el ? anker.parentElement : null;
			var punkt = {
				el: el,
				id: anker.id,
				kopf: kopf,
				titel: ( kopf ? ( kopf.querySelector( 'b' ) || kopf ) : anker ).textContent.trim(),
				eltern: null,
				textKnoten: [],
				text: ''
			};
			el.classList.add( 'lo-knoten' );
			if ( kopf ) {
				kopf.classList.add( 'lo-kopf' );
			}
			punkte.push( punkt );
			nachElement.set( el, punkt );
		} );

		punkte.forEach( function ( p ) {
			var hoeher = p.el.parentElement && p.el.parentElement.closest( '.mw-collapsible' );
			while ( hoeher && !nachElement.has( hoeher ) ) {
				hoeher = hoeher.parentElement && hoeher.parentElement.closest( '.mw-collapsible' );
			}
			p.eltern = hoeher ? nachElement.get( hoeher ) : null;

			// Eigener Text: alles im Punkt, aber ohne verschachtelte Punkte und ohne Bedienknöpfe
			var walker = document.createTreeWalker( p.el, NodeFilter.SHOW_TEXT, {
				acceptNode: function ( knoten ) {
					var e = knoten.parentElement;
					if ( !e ) {
						return NodeFilter.FILTER_REJECT;
					}
					var naechster = e.closest( '.mw-collapsible' );
					if ( naechster !== p.el ) {
						return NodeFilter.FILTER_REJECT;
					}
					if ( e.closest( '.mw-collapsible-toggle, .lo-link' ) ) {
						return NodeFilter.FILTER_REJECT;
					}
					return NodeFilter.FILTER_ACCEPT;
				}
			} );
			var teile = [];
			while ( walker.nextNode() ) {
				p.textKnoten.push( walker.currentNode );
				teile.push( walker.currentNode.data );
			}
			p.text = norm( teile.join( ' ' ) );
		} );

		return { liste: punkte, nachElement: nachElement };
	}

	function istZu( p ) {
		return p.el.classList.contains( 'mw-collapsed' );
	}

	function aufklappen( p ) {
		var api = $( p.el ).data( 'mw-collapsible' );
		if ( api && istZu( p ) ) {
			api.expand();
		}
	}

	function zuklappen( p ) {
		var api = $( p.el ).data( 'mw-collapsible' );
		if ( api && !istZu( p ) ) {
			api.collapse();
		}
	}

	function aufblitzen( el ) {
		el.classList.remove( 'lo-flash' );
		// Neustart der Animation erzwingen
		void el.offsetWidth;
		el.classList.add( 'lo-flash' );
		setTimeout( function () {
			el.classList.remove( 'lo-flash' );
		}, 2200 );
	}

	function linkKopieren( p ) {
		var url = location.origin + mw.util.getUrl( mw.config.get( 'wgPageName' ) ) + '#' + p.id;
		var fertig = function () {
			mw.notify( 'Link zu „' + p.titel + '“ kopiert.', { tag: 'lo-link' } );
		};
		if ( navigator.clipboard && navigator.clipboard.writeText ) {
			navigator.clipboard.writeText( url ).then( fertig, function () {
				window.prompt( 'Link zum Kopieren:', url );
			} );
		} else {
			window.prompt( 'Link zum Kopieren:', url );
		}
	}

	function init( $content ) {
		var root = $content[ 0 ];
		var host = root && root.querySelector( '#lo-werkzeuge' );
		if ( !host || host.getAttribute( 'data-lo-bereit' ) ) {
			return;
		}
		host.setAttribute( 'data-lo-bereit', '1' );

		var daten = sammlePunkte( root );
		var punkte = daten.liste;
		if ( !punkte.length ) {
			return;
		}
		var nachId = new Map();
		punkte.forEach( function ( p ) {
			nachId.set( p.id, p );
		} );

		/* Bedienleiste */
		host.innerHTML =
			'<div class="lo-leiste" role="search">' +
				'<label class="lo-suche">' +
					'<span class="lo-sr">Im Organigramm suchen</span>' +
					'<input type="search" autocomplete="off" spellcheck="false" ' +
						'placeholder="Im Organigramm suchen, z. B. Ortsbeirat">' +
				'</label>' +
				'<button type="button" data-lo-aktion="auf">Alle aufklappen</button>' +
				'<button type="button" data-lo-aktion="zu">Alle zuklappen</button>' +
				'<div class="lo-status" hidden>' +
					'<span class="lo-status-text" role="status" aria-live="polite"></span>' +
					'<button type="button" class="lo-sprung" hidden>Zum ersten Treffer ↓</button>' +
				'</div>' +
			'</div>';
		var eingabe = host.querySelector( 'input' );
		var statusBox = host.querySelector( '.lo-status' );
		var status = host.querySelector( '.lo-status-text' );
		var sprungKnopf = host.querySelector( '.lo-sprung' );
		var ersterTreffer = null;

		function zeigeStatus( text, mitSprung ) {
			status.textContent = text;
			sprungKnopf.hidden = !mitSprung;
			statusBox.hidden = !text && !mitSprung;
		}

		/* „#“-Knopf und klickbare Titelzeile */
		punkte.forEach( function ( p ) {
			if ( !p.kopf ) {
				return;
			}
			var knopf = document.createElement( 'button' );
			knopf.type = 'button';
			knopf.className = 'lo-link';
			knopf.textContent = '#';
			knopf.title = 'Link zu diesem Punkt kopieren';
			knopf.setAttribute( 'aria-label', 'Link zu „' + p.titel + '“ kopieren' );
			knopf.addEventListener( 'click', function ( e ) {
				e.stopPropagation();
				linkKopieren( p );
			} );
			var titel = p.kopf.querySelector( 'b' );
			if ( titel && titel.parentNode === p.kopf ) {
				titel.after( knopf );
			} else {
				p.kopf.appendChild( knopf );
			}

			p.kopf.addEventListener( 'click', function ( e ) {
				if ( e.target.closest( 'a, button, input, label' ) ) {
					return;
				}
				var auswahl = window.getSelection && window.getSelection().toString();
				if ( auswahl ) {
					return;
				}
				var api = $( p.el ).data( 'mw-collapsible' );
				if ( api ) {
					api.toggle();
				}
			} );
		} );

		/* Suche */
		var vonSucheGeoeffnet = new Set();
		var kannMarkieren = !!( window.CSS && CSS.highlights && window.Highlight );
		if ( kannMarkieren ) {
			// Steht hier statt in der CSS-Seite, weil der Editor ::highlight noch nicht kennt
			mw.util.addCSS( '::highlight(lo-treffer){background-color:#ffe066;color:#202122}' );
		}

		function markierungenLoeschen() {
			if ( kannMarkieren ) {
				CSS.highlights.delete( 'lo-treffer' );
			}
		}

		function markieren( begriffe, treffer ) {
			if ( !kannMarkieren ) {
				return;
			}
			var bereiche = [];
			treffer.forEach( function ( p ) {
				p.textKnoten.forEach( function ( tk ) {
					var klein = tk.data.toLowerCase();
					begriffe.forEach( function ( b ) {
						var i = klein.indexOf( b );
						while ( i !== -1 && bereiche.length < 600 ) {
							var r = new Range();
							r.setStart( tk, i );
							r.setEnd( tk, i + b.length );
							bereiche.push( r );
							i = klein.indexOf( b, i + b.length );
						}
					} );
				} );
			} );
			CSS.highlights.set( 'lo-treffer', new Highlight( ...bereiche ) );
		}

		function sucheZuruecksetzen() {
			punkte.forEach( function ( p ) {
				p.el.classList.remove( 'lo-hidden', 'lo-hit' );
			} );
			vonSucheGeoeffnet.forEach( zuklappen );
			vonSucheGeoeffnet.clear();
			markierungenLoeschen();
			host.classList.remove( 'lo-sucht' );
			ersterTreffer = null;
			zeigeStatus( '', false );
		}

		function suchen( roh ) {
			sucheZuruecksetzen();
			var q = norm( roh ).trim();
			if ( q.length < MIN_ZEICHEN ) {
				return [];
			}
			// Erst nach der genauen Wortfolge suchen, sonst nach allen Wörtern einzeln
			var begriffe = [ q ];
			var alsPhrase = punkte.some( function ( p ) {
				return p.text.indexOf( q ) !== -1;
			} );
			if ( !alsPhrase ) {
				begriffe = q.split( ' ' ).filter( Boolean );
			}
			punkte.forEach( function ( p ) {
				p.selbst = begriffe.every( function ( b ) {
					return p.text.indexOf( b ) !== -1;
				} );
				p.darunter = false;
				p.darueber = false;
			} );
			punkte.forEach( function ( p ) {
				if ( !p.selbst ) {
					return;
				}
				for ( var e = p.eltern; e; e = e.eltern ) {
					e.darunter = true;
				}
			} );
			punkte.forEach( function ( p ) {
				for ( var e = p.eltern; e; e = e.eltern ) {
					if ( e.selbst ) {
						p.darueber = true;
						break;
					}
				}
			} );
			var treffer = [];
			punkte.forEach( function ( p ) {
				var sichtbar = p.selbst || p.darunter || p.darueber;
				p.el.classList.toggle( 'lo-hidden', !sichtbar );
				p.el.classList.toggle( 'lo-hit', !!p.selbst );
				if ( p.selbst ) {
					treffer.push( p );
				}
				if ( ( p.selbst || p.darunter ) && istZu( p ) ) {
					aufklappen( p );
					vonSucheGeoeffnet.add( p );
				}
			} );
			host.classList.add( 'lo-sucht' );
			markieren( begriffe, treffer );
			ersterTreffer = treffer[ 0 ] || null;
			zeigeStatus(
				treffer.length === 0 ? 'Kein Punkt gefunden.' :
					( treffer.length === 1 ? '1 Punkt gefunden.' : treffer.length + ' Punkte gefunden.' ),
				!!ersterTreffer
			);
			return treffer;
		}

		var verzoegerung;
		eingabe.addEventListener( 'input', function () {
			clearTimeout( verzoegerung );
			verzoegerung = setTimeout( function () {
				suchen( eingabe.value );
			}, 180 );
		} );
		function zumErstenTreffer() {
			if ( ersterTreffer ) {
				ersterTreffer.el.scrollIntoView( { behavior: 'smooth', block: 'start' } );
				aufblitzen( ersterTreffer.el );
			}
		}
		sprungKnopf.addEventListener( 'click', zumErstenTreffer );

		eingabe.addEventListener( 'keydown', function ( e ) {
			if ( e.key === 'Escape' ) {
				eingabe.value = '';
				suchen( '' );
			} else if ( e.key === 'Enter' ) {
				e.preventDefault();
				clearTimeout( verzoegerung );
				suchen( eingabe.value );
				zumErstenTreffer();
			}
		} );

		/* Alle auf- und zuklappen */
		host.addEventListener( 'click', function ( e ) {
			var knopf = e.target.closest( '[data-lo-aktion]' );
			if ( !knopf ) {
				return;
			}
			var auf = knopf.getAttribute( 'data-lo-aktion' ) === 'auf';
			punkte.forEach( function ( p ) {
				if ( p.el.classList.contains( 'lo-hidden' ) ) {
					return;
				}
				if ( auf ) {
					aufklappen( p );
				} else {
					zuklappen( p );
				}
			} );
			zeigeStatus( auf ? 'Alle Punkte aufgeklappt.' : 'Alle Punkte zugeklappt.', !!ersterTreffer );
		} );

		/* Sprünge zu Punkten */
		function springeZu( id, sanft ) {
			var ziel = document.getElementById( id );
			if ( !ziel || !root.contains( ziel ) ) {
				return;
			}
			if ( ziel.closest( '.lo-hidden' ) ) {
				eingabe.value = '';
				suchen( '' );
			}
			$( ziel ).parents( '.mw-collapsible' ).each( function () {
				var p = daten.nachElement.get( this );
				if ( p ) {
					aufklappen( p );
				} else if ( this.classList.contains( 'mw-collapsed' ) && $( this ).data( 'mw-collapsible' ) ) {
					$( this ).data( 'mw-collapsible' ).expand();
				}
			} );
			ziel.scrollIntoView( { behavior: sanft ? 'smooth' : 'auto', block: 'start' } );
			var punkt = nachId.get( id );
			if ( punkt ) {
				aufblitzen( punkt.el );
			}
		}

		root.addEventListener( 'click', function ( e ) {
			var a = e.target.closest( 'a[href^="#lo-"]' );
			if ( !a || e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0 ) {
				return;
			}
			var id = decodeURIComponent( a.getAttribute( 'href' ).slice( 1 ) );
			if ( !document.getElementById( id ) ) {
				return;
			}
			e.preventDefault();
			if ( location.hash !== '#' + id ) {
				history.pushState( null, '', '#' + id );
			}
			springeZu( id, true );
		} );

		window.addEventListener( 'popstate', function () {
			var h = location.hash.slice( 1 );
			if ( h.indexOf( 'lo-' ) === 0 ) {
				springeZu( decodeURIComponent( h ), false );
			}
		} );

		if ( location.hash.indexOf( '#lo-' ) === 0 ) {
			springeZu( decodeURIComponent( location.hash.slice( 1 ) ), false );
		}
	}

	mw.hook( 'wikipage.content' ).add( function ( $content ) {
		if ( !$content.find( '#lo-werkzeuge' ).length ) {
			return;
		}
		mw.loader.using( [ 'jquery.makeCollapsible', 'mediawiki.util' ] ).then( function () {
			// Sicherstellen, dass die Punkte schon aufklappbar sind (macht nichts doppelt)
			$content.find( '.mw-collapsible' ).makeCollapsible();
			init( $content );
		} );
	} );
}() );