Jump to content

MediaWiki:BlueprintLoader.js: Difference between revisions

From EverQuest Legends Wiki
Created page with "/** * EQL Wiki - Dynamic Page Blueprint Loader * * On the source editor for a page that does not yet exist: * * 1. Reads blueprint headings dynamically from Help:Contents. * 2. Displays a blueprint selector above the normal source editor. * 3. Fetches the selected Help:Contents section on demand. * 4. Extracts the first <pre>...</pre> block from that section. * 5. Loads that exact wikitext into #wpTextbox1. * * Help:Contents remains the single source of truth...."
 
Update dynamic Blueprint loader and editor tools
Line 1: Line 1:
/**
/**
  * EQL Wiki - Dynamic Page Blueprint Loader
  * EQL Wiki - Editor Tools / Dynamic Blueprint Loader
  *
  *
  * On the source editor for a page that does not yet exist:
  * Source-editor behavior:
  *
  * - On a new, truly empty page: show the Help:Contents Blueprint selector.
* 1. Reads blueprint headings dynamically from Help:Contents.
  * - On every source edit page: show the Icon Finder button.
  * 2. Displays a blueprint selector above the normal source editor.
  * - Heavy Icon Finder logic is lazy-loaded only when opened.
* 3. Fetches the selected Help:Contents section on demand.
* 4. Extracts the first <pre>...</pre> block from that section.
  * 5. Loads that exact wikitext into #wpTextbox1.
*
* Help:Contents remains the single source of truth.
  */
  */
( function ( mw, $ ) {
( function ( mw, $ ) {
Line 16: Line 11:


var BLUEPRINT_PAGE = 'Help:Contents';
var BLUEPRINT_PAGE = 'Help:Contents';
var PANEL_ID = 'eql-blueprint-loader';
var TOOLS_ID = 'eql-editor-tools';
var ICON_FINDER_PAGE = 'MediaWiki:IconFinder.js';


/**
* Convert a small HTML heading string returned by tocdata
* into plain text.
*/
function htmlToText( html ) {
function htmlToText( html ) {
var div = document.createElement( 'div' );
var div = document.createElement( 'div' );
div.innerHTML = String( html || '' );
div.innerHTML = String( html || '' );
return ( div.textContent || div.innerText || '' ).trim();
return ( div.textContent || div.innerText || '' ).trim();
}
}


/**
* Turn headings such as:
*
*  NPC Page Blueprints
*  Merchant Page Blueprints
*
* into friendly dropdown labels.
*
* We deliberately retain the heading wording rather than
* maintaining a second hard-coded list.
*/
function makeLabel( heading ) {
function makeLabel( heading ) {
return heading
return heading
Line 47: Line 26:
}
}


/**
* Get editor contents through textSelection.
*
* This works with the normal textarea, WikiEditor,
* and CodeMirror.
*/
function getEditorContents( $textarea ) {
function getEditorContents( $textarea ) {
try {
try {
return String(
return String( $textarea.textSelection( 'getContents' ) || '' );
$textarea.textSelection( 'getContents' ) || ''
);
} catch ( e ) {
} catch ( e ) {
return String( $textarea.val() || '' );
return String( $textarea.val() || '' );
Line 63: Line 34:
}
}


/**
* Replace the complete editor contents.
*
* textSelection is intentionally used instead of .val()
* because it also works when CodeMirror is active.
*/
function setEditorContents( $textarea, text ) {
function setEditorContents( $textarea, text ) {
try {
try {
$textarea.textSelection(
$textarea.textSelection( 'setContents', text );
'setContents',
text
);
} catch ( e ) {
} catch ( e ) {
$textarea.val( text );
$textarea.val( text );
Line 84: Line 46:
}
}


/**
* Extract the first <pre>...</pre> block from the raw
* section wikitext.
*
* This allows Help:Contents to contain explanatory prose
* around the blueprint without copying that prose into
* the new page.
*
* Also supports:
*
*  <pre><nowiki>...</nowiki></pre>
*/
function extractBlueprint( sectionText ) {
function extractBlueprint( sectionText ) {
var match;
var match;
Line 112: Line 62:
blueprint = match[ 1 ];
blueprint = match[ 1 ];


/*
* If somebody later wraps a blueprint in <nowiki>,
* remove only the outer wrapper.
*/
blueprint = blueprint.replace(
blueprint = blueprint.replace(
/^\s*<nowiki>([\s\S]*?)<\/nowiki>\s*$/i,
/^\s*<nowiki>([\s\S]*?)<\/nowiki>\s*$/i,
Line 121: Line 67:
);
);


/*
* Avoid leading blank lines while preserving the
* blueprint's internal formatting.
*/
blueprint = blueprint
blueprint = blueprint
.replace( /^\s*\n/, '' )
.replace( /^\s*\n/, '' )
Line 132: Line 74:
}
}


/**
* Read the Help:Contents table of contents.
*
* tocdata is preferred. A fallback to the older "sections"
* property is included so this remains tolerant of an
* older parser/API configuration.
*/
function fetchBlueprintSections( api ) {
function fetchBlueprintSections( api ) {
return api.get( {
return api.get( {
Line 170: Line 105:
fallbackData &&
fallbackData &&
fallbackData.parse &&
fallbackData.parse &&
Array.isArray(
Array.isArray( fallbackData.parse.sections )
fallbackData.parse.sections
)
)
)
? fallbackData.parse.sections
? fallbackData.parse.sections
Line 180: Line 113:
}
}


/**
* Return only real Blueprint headings.
*
* Any heading whose visible text ends in:
*
*  Blueprint
*  Blueprints
*
* is automatically eligible.
*/
function normalizeBlueprintSections( sections ) {
function normalizeBlueprintSections( sections ) {
return sections
return sections
.map( function ( section ) {
.map( function ( section ) {
var heading = htmlToText(
var heading = htmlToText( section.line || '' );
section.line || ''
);


return {
return {
heading: heading,
heading: heading,
label: makeLabel( heading ),
label: makeLabel( heading ),
index: String(
index: String( section.index || '' ),
section.index || ''
),
anchor:
anchor:
section.linkAnchor ||
section.linkAnchor ||
Line 212: Line 131:
return (
return (
section.index &&
section.index &&
/\bBlueprints?\s*$/i.test(
/\bBlueprints?\s*$/i.test( section.heading )
section.heading
)
);
);
} );
} );
}
}


/**
function fetchBlueprintWikitext( api, sectionIndex ) {
* Retrieve the raw wikitext of one specific section.
*/
function fetchBlueprintWikitext(
api,
sectionIndex
) {
return api.get( {
return api.get( {
action: 'parse',
action: 'parse',
Line 239: Line 150:
: '';
: '';


/*
* formatversion=2 normally returns a string.
* Keep compatibility with the older "*" wrapper.
*/
if (
if (
wikitext &&
wikitext &&
typeof wikitext === 'object' &&
typeof wikitext === 'object' &&
Object.prototype.hasOwnProperty.call(
Object.prototype.hasOwnProperty.call( wikitext, '*' )
wikitext,
'*'
)
) {
) {
wikitext = wikitext[ '*' ];
wikitext = wikitext[ '*' ];
Line 259: Line 163:


function addStyles() {
function addStyles() {
if (
if ( document.getElementById( 'eql-editor-tools-styles' ) ) {
document.getElementById(
'eql-blueprint-loader-styles'
)
) {
return;
return;
}
}


$( '<style>', {
$( '<style>', {
id: 'eql-blueprint-loader-styles',
id: 'eql-editor-tools-styles',
text:
text:
'#' + PANEL_ID + ' {' +
'#' + TOOLS_ID + '{' +
'box-sizing:border-box;' +
'box-sizing:border-box;' +
'margin:0 0 1rem 0;' +
'margin:0 0 1rem 0;' +
'padding:1rem 1.1rem;' +
'padding:1rem 1.1rem;' +
'background:' +
'background:linear-gradient(180deg,rgba(16,21,29,.97),rgba(9,13,19,.98));' +
'linear-gradient(' +
'180deg,' +
'rgba(16,21,29,.97),' +
'rgba(9,13,19,.98)' +
');' +
'border:1px solid rgba(216,183,92,.38);' +
'border:1px solid rgba(216,183,92,.38);' +
'border-left:5px solid rgba(216,183,92,.82);' +
'border-left:5px solid rgba(216,183,92,.82);' +
'border-radius:8px;' +
'border-radius:8px;' +
'box-shadow:' +
'box-shadow:0 9px 24px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.035);' +
'0 9px 24px rgba(0,0,0,.3),' +
'}' +
'inset 0 1px 0 rgba(255,255,255,.035);' +
 
'#' + TOOLS_ID + ' .eql-editor-tools-row{' +
'display:grid;' +
'grid-template-columns:minmax(0,1fr) auto;' +
'gap:1rem;' +
'align-items:end;' +
'}' +
'}' +


'#' + PANEL_ID + ' .eql-blueprint-title {' +
'#' + TOOLS_ID + ' .eql-blueprint-title,' +
'#' + TOOLS_ID + ' .eql-iconfinder-title{' +
'margin:0 0 .35rem 0;' +
'margin:0 0 .35rem 0;' +
'color:#d8b75c;' +
'color:#d8b75c;' +
'font-family:Georgia,' +
'font-family:Georgia,Times New Roman,serif;' +
'Times New Roman,serif;' +
'font-size:1.05rem;' +
'font-size:1.2rem;' +
'font-weight:700;' +
'font-weight:700;' +
'letter-spacing:.035em;' +
'letter-spacing:.035em;' +
'}' +
'}' +


'#' + PANEL_ID + ' .eql-blueprint-description {' +
'#' + TOOLS_ID + ' .eql-blueprint-description{' +
'margin:0 0 .8rem 0;' +
'margin:0 0 .7rem 0;' +
'color:#aeb8c7;' +
'color:#aeb8c7;' +
'font-size:.93rem;' +
'font-size:.9rem;' +
'line-height:1.4;' +
'line-height:1.35;' +
'}' +
'}' +


'#' + PANEL_ID + ' .eql-blueprint-controls {' +
'#' + TOOLS_ID + ' .eql-blueprint-controls{' +
'display:flex;' +
'display:flex;' +
'flex-wrap:wrap;' +
'flex-wrap:wrap;' +
Line 312: Line 212:
'}' +
'}' +


'#' + PANEL_ID + ' select {' +
'#' + TOOLS_ID + ' select{' +
'box-sizing:border-box;' +
'box-sizing:border-box;' +
'min-width:260px;' +
'min-width:260px;' +
Line 324: Line 224:
'}' +
'}' +


'#' + PANEL_ID + ' button {' +
'#' + TOOLS_ID + ' button{' +
'min-height:2.35rem;' +
'min-height:2.35rem;' +
'padding:.35rem .85rem;' +
'padding:.35rem .85rem;' +
'background:' +
'background:linear-gradient(180deg,#806d32,#594817);' +
'linear-gradient(' +
'180deg,' +
'#806d32,' +
'#594817' +
');' +
'border:1px solid #b69c4b;' +
'border:1px solid #b69c4b;' +
'border-radius:5px;' +
'border-radius:5px;' +
Line 340: Line 235:
'}' +
'}' +


'#' + PANEL_ID + ' button:disabled {' +
'#' + TOOLS_ID + ' button:hover:not(:disabled){' +
'filter:brightness(1.08);' +
'}' +
 
'#' + TOOLS_ID + ' button:disabled{' +
'cursor:default;' +
'cursor:default;' +
'opacity:.55;' +
'opacity:.55;' +
'}' +
'}' +


'#' + PANEL_ID + ' .eql-blueprint-source {' +
'#' + TOOLS_ID + ' .eql-blueprint-source{' +
'color:#b9c8ff;' +
'color:#b9c8ff;' +
'font-size:.9rem;' +
'font-size:.9rem;' +
'}' +
'}' +


'#' + PANEL_ID + ' .eql-blueprint-status {' +
'#' + TOOLS_ID + ' .eql-blueprint-status{' +
'display:block;' +
'display:block;' +
'margin-top:.65rem;' +
'margin-top:.55rem;' +
'min-height:1.2em;' +
'min-height:1.2em;' +
'color:#aeb8c7;' +
'color:#aeb8c7;' +
'font-size:.88rem;' +
'font-size:.86rem;' +
'}' +
 
'#' + TOOLS_ID + ' .eql-iconfinder-launch{' +
'display:flex;' +
'flex-direction:column;' +
'align-items:stretch;' +
'justify-content:flex-end;' +
'min-width:150px;' +
'}' +
 
'#eql-icon-finder-toggle{' +
'white-space:nowrap;' +
'}' +
 
'#eql-icon-finder-host{' +
'margin-top:1rem;' +
'padding-top:1rem;' +
'border-top:1px solid rgba(216,183,92,.22);' +
'}' +
 
'#eql-icon-finder-host[hidden]{display:none!important;}' +
 
'@media(max-width:760px){' +
'#' + TOOLS_ID + ' .eql-editor-tools-row{' +
'grid-template-columns:1fr;' +
'}' +
'#' + TOOLS_ID + ' .eql-iconfinder-launch{' +
'min-width:0;' +
'}' +
'#' + TOOLS_ID + ' select{' +
'width:100%;' +
'min-width:0;' +
'}' +
'}'
'}'
} ).appendTo( document.head );
} ).appendTo( document.head );
}
}


function buildPanel(
function loadIconFinderScript() {
$textarea,
if ( window.EQLIconFinder ) {
api,
return Promise.resolve( window.EQLIconFinder );
blueprints
}
) {
var $panel;
var $select;
var $button;
var $status;
var $sourceLink;
var $editorHost;


if (
if ( window.eqlIconFinderLoadingPromise ) {
document.getElementById(
return window.eqlIconFinderLoadingPromise;
PANEL_ID
)
) {
return;
}
}


addStyles();
window.eqlIconFinderLoadingPromise = new Promise( function ( resolve, reject ) {
var script = document.createElement( 'script' );
 
script.src = mw.util.getUrl( ICON_FINDER_PAGE, {
action: 'raw',
ctype: 'text/javascript'
} );
 
script.async = true;
 
script.onload = function () {
if ( window.EQLIconFinder ) {
resolve( window.EQLIconFinder );
} else {
reject( new Error( 'IconFinder.js loaded without exposing EQLIconFinder.' ) );
}
};
 
script.onerror = function () {
reject( new Error( 'Could not load IconFinder.js.' ) );
};
 
document.head.appendChild( script );
} );
 
return window.eqlIconFinderLoadingPromise;
}
 
function buildIconFinderLaunch( $row, $tools ) {
var $launch = $( '<div>', {
class: 'eql-iconfinder-launch'
} ).appendTo( $row );
 
$( '<div>', {
class: 'eql-iconfinder-title',
text: 'ICON FINDER'
} ).appendTo( $launch );
 
var $button = $( '<button>', {
id: 'eql-icon-finder-toggle',
type: 'button',
text: 'Icon Finder',
'aria-expanded': 'false'
} ).appendTo( $launch );
 
var $host = $( '<div>', {
id: 'eql-icon-finder-host',
hidden: true
} ).appendTo( $tools );
 
$button.on( 'click', function () {
var opening = $host.prop( 'hidden' );
 
if ( !opening ) {
$host.prop( 'hidden', true );
$button.attr( 'aria-expanded', 'false' );
return;
}
 
$button.prop( 'disabled', true );
$button.text( 'Loading…' );
 
loadIconFinderScript()
.then( function ( finder ) {
return finder.init( $host[ 0 ] );
} )
.then( function () {
$host.prop( 'hidden', false );
$button.attr( 'aria-expanded', 'true' );
} )
.catch( function ( error ) {
if ( window.console && console.error ) {
console.error( 'EQL Icon Finder:', error );
}
 
$host
.prop( 'hidden', false )
.text( 'Icon Finder could not be loaded.' );


$panel = $( '<div>', {
$button.attr( 'aria-expanded', 'true' );
id: PANEL_ID
} )
.then( function () {
$button.prop( 'disabled', false );
$button.text( 'Icon Finder' );
} );
} );
} );
}
function buildBlueprintArea( $row, $textarea, api ) {
var $area = $( '<div>', {
class: 'eql-blueprint-area'
} ).appendTo( $row );


$( '<div>', {
$( '<div>', {
class: 'eql-blueprint-title',
class: 'eql-blueprint-title',
text: 'CREATE FROM A BLUEPRINT'
text: 'CREATE FROM A BLUEPRINT'
} ).appendTo( $panel );
} ).appendTo( $area );


$( '<div>', {
$( '<div>', {
class: 'eql-blueprint-description',
class: 'eql-blueprint-description',
text:
text:
'Load a standard page skeleton from ' +
'Load a standard page skeleton from Help:Contents, ' +
'Help:Contents, or continue with the ' +
'or continue with the empty editor below.'
'empty editor below.'
} ).appendTo( $area );
} ).appendTo( $panel );


var $controls = $( '<div>', {
var $controls = $( '<div>', {
class: 'eql-blueprint-controls'
class: 'eql-blueprint-controls'
} ).appendTo( $panel );
} ).appendTo( $area );


$select = $( '<select>', {
var $select = $( '<select>', {
'aria-label': 'Select page blueprint'
'aria-label': 'Select page blueprint',
disabled: true
} ).appendTo( $controls );
} ).appendTo( $controls );


$( '<option>', {
$( '<option>', {
value: '',
value: '',
text: 'Select a blueprint...'
text: 'Loading blueprints...'
} ).appendTo( $select );
} ).appendTo( $select );


blueprints.forEach( function ( blueprint ) {
var $button = $( '<button>', {
$( '<option>', {
value: blueprint.index,
text: blueprint.label
} )
.attr(
'data-anchor',
blueprint.anchor
)
.appendTo( $select );
} );
 
$button = $( '<button>', {
type: 'button',
type: 'button',
text: 'Load Blueprint',
text: 'Load Blueprint',
Line 430: Line 426:
} ).appendTo( $controls );
} ).appendTo( $controls );


$sourceLink = $( '<a>', {
var $sourceLink = $( '<a>', {
class: 'eql-blueprint-source',
class: 'eql-blueprint-source',
href: mw.util.getUrl(
href: mw.util.getUrl( BLUEPRINT_PAGE ),
BLUEPRINT_PAGE
),
text: 'View Blueprints',
text: 'View Blueprints',
target: '_blank'
target: '_blank',
rel: 'noopener'
} ).appendTo( $controls );
} ).appendTo( $controls );


$status = $( '<span>', {
var $status = $( '<span>', {
class: 'eql-blueprint-status',
class: 'eql-blueprint-status',
'aria-live': 'polite'
'aria-live': 'polite'
} ).appendTo( $panel );
} ).appendTo( $area );


/*
fetchBlueprintSections( api )
* Put the selector ABOVE the actual editor UI,
.then( function ( sections ) {
* not inside or instead of the editor.
var blueprints = normalizeBlueprintSections( sections );
*/
$editorHost =
$textarea.closest( '.wikiEditor-ui' );


if ( !$editorHost.length ) {
$select.empty();
$editorHost = $textarea;
 
}
$( '<option>', {
value: '',
text: blueprints.length ?
'Select a blueprint...' :
'No blueprints found'
} ).appendTo( $select );
 
blueprints.forEach( function ( blueprint ) {
$( '<option>', {
value: blueprint.index,
text: blueprint.label
} )
.attr( 'data-anchor', blueprint.anchor )
.appendTo( $select );
} );
 
$select.prop( 'disabled', !blueprints.length );
} )
.catch( function ( error ) {
if ( window.console && console.error ) {
console.error( 'EQL Blueprint Loader:', error );
}


$panel.insertBefore( $editorHost );
$select.empty().append(
$( '<option>', {
value: '',
text: 'Could not load blueprints'
} )
);


$select.on( 'change', function () {
$status.text(
var $option =
'Could not read Blueprint headings from Help:Contents.'
$select.find(
'option:selected'
);
);
} );


var anchor =
$select.on( 'change', function () {
$option.attr(
var $option = $select.find( 'option:selected' );
'data-anchor'
var anchor = $option.attr( 'data-anchor' ) || '';
) || '';


$button.prop(
$button.prop( 'disabled', !$select.val() );
'disabled',
!$select.val()
);


if ( anchor ) {
if ( anchor ) {
$sourceLink.attr(
$sourceLink.attr(
'href',
'href',
mw.util.getUrl(
mw.util.getUrl( BLUEPRINT_PAGE ) + '#' + anchor
BLUEPRINT_PAGE
) +
'#' +
anchor
);
 
$sourceLink.text(
'View This Blueprint'
);
);
$sourceLink.text( 'View This Blueprint' );
} else {
} else {
$sourceLink.attr(
$sourceLink.attr( 'href', mw.util.getUrl( BLUEPRINT_PAGE ) );
'href',
$sourceLink.text( 'View Blueprints' );
mw.util.getUrl(
BLUEPRINT_PAGE
)
);
 
$sourceLink.text(
'View Blueprints'
);
}
}


Line 503: Line 501:


$button.on( 'click', function () {
$button.on( 'click', function () {
var sectionIndex =
var sectionIndex = $select.val();
$select.val();
 
var existing;
var existing;


Line 512: Line 508:
}
}


existing =
existing = getEditorContents( $textarea );
getEditorContents(
$textarea
);


/*
* Usually the panel only appears on an empty
* creation page. This protects work if the
* contributor typed something before selecting
* a blueprint.
*/
if (
if (
existing.trim() &&
existing.trim() &&
!window.confirm(
!window.confirm(
'The editor already contains text. ' +
'The editor already contains text. Replace it with the selected blueprint?'
'Replace it with the selected blueprint?'
)
)
) {
) {
Line 533: Line 519:
}
}


$button.prop(
$button.prop( 'disabled', true );
'disabled',
$select.prop( 'disabled', true );
true
$status.text( 'Loading current blueprint from Help:Contents...' );
);
 
$select.prop(
'disabled',
true
);
 
$status.text(
'Loading current blueprint from Help:Contents...'
);


fetchBlueprintWikitext(
fetchBlueprintWikitext( api, sectionIndex )
api,
.then( function ( sectionText ) {
sectionIndex
var blueprint = extractBlueprint( sectionText );
).then(
function ( sectionText ) {
var blueprint =
extractBlueprint(
sectionText
);


if ( blueprint === null ) {
if ( blueprint === null ) {
throw new Error(
throw new Error( 'No <pre> block found.' );
'No <pre> block found.'
);
}
}


setEditorContents(
setEditorContents( $textarea, blueprint );
$textarea,
 
blueprint
$status.text(
'Blueprint loaded. Edit the fields below, then publish normally.'
);
);
} )
.catch( function ( error ) {
if ( window.console && console.error ) {
console.error( 'EQL Blueprint Loader:', error );
}


$status.text(
$status.text(
'Blueprint loaded. ' +
'Could not load this blueprint. Check Help:Contents and make sure the section contains a <pre> block.'
'Edit the fields below, then publish normally.'
);
}
).catch( function ( error ) {
if (
window.console &&
console.error
) {
console.error(
'EQL Blueprint Loader:',
error
);
);
}
} )
 
.then( function () {
$status.text(
$select.prop( 'disabled', false );
'Could not load this blueprint. ' +
$button.prop( 'disabled', !$select.val() );
'Check Help:Contents and make ' +
} );
'sure the section contains a <pre> block.'
);
} ).then( function () {
$select.prop(
'disabled',
false
);
 
$button.prop(
'disabled',
!$select.val()
);
} );
} );
} );
}
}


function init() {
function init() {
var action = mw.config.get( 'wgAction' );
var $textarea;
var $textarea;
var $tools;
var $row;
var $editorHost;
var api;
var api;
var existing;
var isNewPage;
var editorIsEmpty;


/*
if ( [ 'edit', 'submit' ].indexOf( action ) === -1 ) {
* Only source editing / creation.
*/
if (
[
'edit',
'submit'
].indexOf(
mw.config.get( 'wgAction' )
) === -1
) {
return;
}
 
/*
* Existing articles should not get the creation panel.
*/
if (
Number(
mw.config.get(
'wgArticleId'
)
) !== 0
) {
return;
return;
}
}
Line 641: Line 573:
}
}


existing =
if ( document.getElementById( TOOLS_ID ) ) {
getEditorContents(
$textarea
);
 
/*
* Do not interfere with other preloaded creation
* workflows, such as the three-class Build Guide
* generator.
*
* The blueprint chooser is specifically for a truly
* empty creation editor.
*/
if ( existing.trim() ) {
return;
return;
}
}


api = new mw.Api();
api = new mw.Api();
isNewPage = Number( mw.config.get( 'wgArticleId' ) ) === 0;
editorIsEmpty = !getEditorContents( $textarea ).trim();
addStyles();


fetchBlueprintSections(
$tools = $( '<div>', {
api
id: TOOLS_ID
).then( function ( sections ) {
} );
var blueprints =
 
normalizeBlueprintSections(
$row = $( '<div>', {
sections
class: 'eql-editor-tools-row'
);
} ).appendTo( $tools );
 
if ( isNewPage && editorIsEmpty ) {
buildBlueprintArea( $row, $textarea, api );
} else {
$( '<div>', {
class: 'eql-editor-tools-spacer'
} ).appendTo( $row );
}
 
buildIconFinderLaunch( $row, $tools );
 
$editorHost = $textarea.closest( '.wikiEditor-ui' );


if ( !blueprints.length ) {
if ( !$editorHost.length ) {
return;
$editorHost = $textarea;
}
}


buildPanel(
$tools.insertBefore( $editorHost );
$textarea,
api,
blueprints
);
} ).catch( function ( error ) {
if (
window.console &&
console.error
) {
console.error(
'EQL Blueprint Loader:',
error
);
}
} );
}
}



Revision as of 01:38, 10 August 2026

/**
 * EQL Wiki - Editor Tools / Dynamic Blueprint Loader
 *
 * Source-editor behavior:
 * - On a new, truly empty page: show the Help:Contents Blueprint selector.
 * - On every source edit page: show the Icon Finder button.
 * - Heavy Icon Finder logic is lazy-loaded only when opened.
 */
( function ( mw, $ ) {
	'use strict';

	var BLUEPRINT_PAGE = 'Help:Contents';
	var TOOLS_ID = 'eql-editor-tools';
	var ICON_FINDER_PAGE = 'MediaWiki:IconFinder.js';

	function htmlToText( html ) {
		var div = document.createElement( 'div' );
		div.innerHTML = String( html || '' );
		return ( div.textContent || div.innerText || '' ).trim();
	}

	function makeLabel( heading ) {
		return heading
			.replace( /\s+Blueprints?\s*$/i, '' )
			.trim() + ' Blueprint';
	}

	function getEditorContents( $textarea ) {
		try {
			return String( $textarea.textSelection( 'getContents' ) || '' );
		} catch ( e ) {
			return String( $textarea.val() || '' );
		}
	}

	function setEditorContents( $textarea, text ) {
		try {
			$textarea.textSelection( 'setContents', text );
		} catch ( e ) {
			$textarea.val( text );
		}

		$textarea.trigger( 'input' );
		$textarea.trigger( 'change' );
		$textarea.trigger( 'focus' );
	}

	function extractBlueprint( sectionText ) {
		var match;
		var blueprint;

		sectionText = String( sectionText || '' );

		match = sectionText.match(
			/<pre(?:\s[^>]*)?>([\s\S]*?)<\/pre>/i
		);

		if ( !match ) {
			return null;
		}

		blueprint = match[ 1 ];

		blueprint = blueprint.replace(
			/^\s*<nowiki>([\s\S]*?)<\/nowiki>\s*$/i,
			'$1'
		);

		blueprint = blueprint
			.replace( /^\s*\n/, '' )
			.replace( /\s+$/, '' );

		return blueprint + '\n';
	}

	function fetchBlueprintSections( api ) {
		return api.get( {
			action: 'parse',
			page: BLUEPRINT_PAGE,
			prop: 'tocdata',
			formatversion: 2
		} ).then( function ( data ) {
			var tocdata =
				data &&
				data.parse &&
				data.parse.tocdata;

			var sections =
				tocdata &&
				Array.isArray( tocdata.sections )
					? tocdata.sections
					: [];

			if ( sections.length ) {
				return sections;
			}

			return api.get( {
				action: 'parse',
				page: BLUEPRINT_PAGE,
				prop: 'sections',
				formatversion: 2
			} ).then( function ( fallbackData ) {
				return (
					fallbackData &&
					fallbackData.parse &&
					Array.isArray( fallbackData.parse.sections )
				)
					? fallbackData.parse.sections
					: [];
			} );
		} );
	}

	function normalizeBlueprintSections( sections ) {
		return sections
			.map( function ( section ) {
				var heading = htmlToText( section.line || '' );

				return {
					heading: heading,
					label: makeLabel( heading ),
					index: String( section.index || '' ),
					anchor:
						section.linkAnchor ||
						section.anchor ||
						''
				};
			} )
			.filter( function ( section ) {
				return (
					section.index &&
					/\bBlueprints?\s*$/i.test( section.heading )
				);
			} );
	}

	function fetchBlueprintWikitext( api, sectionIndex ) {
		return api.get( {
			action: 'parse',
			page: BLUEPRINT_PAGE,
			prop: 'wikitext',
			section: sectionIndex,
			formatversion: 2
		} ).then( function ( data ) {
			var wikitext =
				data &&
				data.parse
					? data.parse.wikitext
					: '';

			if (
				wikitext &&
				typeof wikitext === 'object' &&
				Object.prototype.hasOwnProperty.call( wikitext, '*' )
			) {
				wikitext = wikitext[ '*' ];
			}

			return String( wikitext || '' );
		} );
	}

	function addStyles() {
		if ( document.getElementById( 'eql-editor-tools-styles' ) ) {
			return;
		}

		$( '<style>', {
			id: 'eql-editor-tools-styles',
			text:
				'#' + TOOLS_ID + '{' +
					'box-sizing:border-box;' +
					'margin:0 0 1rem 0;' +
					'padding:1rem 1.1rem;' +
					'background:linear-gradient(180deg,rgba(16,21,29,.97),rgba(9,13,19,.98));' +
					'border:1px solid rgba(216,183,92,.38);' +
					'border-left:5px solid rgba(216,183,92,.82);' +
					'border-radius:8px;' +
					'box-shadow:0 9px 24px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.035);' +
				'}' +

				'#' + TOOLS_ID + ' .eql-editor-tools-row{' +
					'display:grid;' +
					'grid-template-columns:minmax(0,1fr) auto;' +
					'gap:1rem;' +
					'align-items:end;' +
				'}' +

				'#' + TOOLS_ID + ' .eql-blueprint-title,' +
				'#' + TOOLS_ID + ' .eql-iconfinder-title{' +
					'margin:0 0 .35rem 0;' +
					'color:#d8b75c;' +
					'font-family:Georgia,Times New Roman,serif;' +
					'font-size:1.05rem;' +
					'font-weight:700;' +
					'letter-spacing:.035em;' +
				'}' +

				'#' + TOOLS_ID + ' .eql-blueprint-description{' +
					'margin:0 0 .7rem 0;' +
					'color:#aeb8c7;' +
					'font-size:.9rem;' +
					'line-height:1.35;' +
				'}' +

				'#' + TOOLS_ID + ' .eql-blueprint-controls{' +
					'display:flex;' +
					'flex-wrap:wrap;' +
					'align-items:center;' +
					'gap:.65rem;' +
				'}' +

				'#' + TOOLS_ID + ' select{' +
					'box-sizing:border-box;' +
					'min-width:260px;' +
					'max-width:100%;' +
					'min-height:2.35rem;' +
					'padding:.35rem .55rem;' +
					'background:#0b1017;' +
					'border:1px solid rgba(216,183,92,.36);' +
					'border-radius:5px;' +
					'color:#e2e2cf;' +
				'}' +

				'#' + TOOLS_ID + ' button{' +
					'min-height:2.35rem;' +
					'padding:.35rem .85rem;' +
					'background:linear-gradient(180deg,#806d32,#594817);' +
					'border:1px solid #b69c4b;' +
					'border-radius:5px;' +
					'color:#fff5cc;' +
					'font-weight:700;' +
					'cursor:pointer;' +
				'}' +

				'#' + TOOLS_ID + ' button:hover:not(:disabled){' +
					'filter:brightness(1.08);' +
				'}' +

				'#' + TOOLS_ID + ' button:disabled{' +
					'cursor:default;' +
					'opacity:.55;' +
				'}' +

				'#' + TOOLS_ID + ' .eql-blueprint-source{' +
					'color:#b9c8ff;' +
					'font-size:.9rem;' +
				'}' +

				'#' + TOOLS_ID + ' .eql-blueprint-status{' +
					'display:block;' +
					'margin-top:.55rem;' +
					'min-height:1.2em;' +
					'color:#aeb8c7;' +
					'font-size:.86rem;' +
				'}' +

				'#' + TOOLS_ID + ' .eql-iconfinder-launch{' +
					'display:flex;' +
					'flex-direction:column;' +
					'align-items:stretch;' +
					'justify-content:flex-end;' +
					'min-width:150px;' +
				'}' +

				'#eql-icon-finder-toggle{' +
					'white-space:nowrap;' +
				'}' +

				'#eql-icon-finder-host{' +
					'margin-top:1rem;' +
					'padding-top:1rem;' +
					'border-top:1px solid rgba(216,183,92,.22);' +
				'}' +

				'#eql-icon-finder-host[hidden]{display:none!important;}' +

				'@media(max-width:760px){' +
					'#' + TOOLS_ID + ' .eql-editor-tools-row{' +
						'grid-template-columns:1fr;' +
					'}' +
					'#' + TOOLS_ID + ' .eql-iconfinder-launch{' +
						'min-width:0;' +
					'}' +
					'#' + TOOLS_ID + ' select{' +
						'width:100%;' +
						'min-width:0;' +
					'}' +
				'}'
		} ).appendTo( document.head );
	}

	function loadIconFinderScript() {
		if ( window.EQLIconFinder ) {
			return Promise.resolve( window.EQLIconFinder );
		}

		if ( window.eqlIconFinderLoadingPromise ) {
			return window.eqlIconFinderLoadingPromise;
		}

		window.eqlIconFinderLoadingPromise = new Promise( function ( resolve, reject ) {
			var script = document.createElement( 'script' );

			script.src = mw.util.getUrl( ICON_FINDER_PAGE, {
				action: 'raw',
				ctype: 'text/javascript'
			} );

			script.async = true;

			script.onload = function () {
				if ( window.EQLIconFinder ) {
					resolve( window.EQLIconFinder );
				} else {
					reject( new Error( 'IconFinder.js loaded without exposing EQLIconFinder.' ) );
				}
			};

			script.onerror = function () {
				reject( new Error( 'Could not load IconFinder.js.' ) );
			};

			document.head.appendChild( script );
		} );

		return window.eqlIconFinderLoadingPromise;
	}

	function buildIconFinderLaunch( $row, $tools ) {
		var $launch = $( '<div>', {
			class: 'eql-iconfinder-launch'
		} ).appendTo( $row );

		$( '<div>', {
			class: 'eql-iconfinder-title',
			text: 'ICON FINDER'
		} ).appendTo( $launch );

		var $button = $( '<button>', {
			id: 'eql-icon-finder-toggle',
			type: 'button',
			text: 'Icon Finder',
			'aria-expanded': 'false'
		} ).appendTo( $launch );

		var $host = $( '<div>', {
			id: 'eql-icon-finder-host',
			hidden: true
		} ).appendTo( $tools );

		$button.on( 'click', function () {
			var opening = $host.prop( 'hidden' );

			if ( !opening ) {
				$host.prop( 'hidden', true );
				$button.attr( 'aria-expanded', 'false' );
				return;
			}

			$button.prop( 'disabled', true );
			$button.text( 'Loading…' );

			loadIconFinderScript()
				.then( function ( finder ) {
					return finder.init( $host[ 0 ] );
				} )
				.then( function () {
					$host.prop( 'hidden', false );
					$button.attr( 'aria-expanded', 'true' );
				} )
				.catch( function ( error ) {
					if ( window.console && console.error ) {
						console.error( 'EQL Icon Finder:', error );
					}

					$host
						.prop( 'hidden', false )
						.text( 'Icon Finder could not be loaded.' );

					$button.attr( 'aria-expanded', 'true' );
				} )
				.then( function () {
					$button.prop( 'disabled', false );
					$button.text( 'Icon Finder' );
				} );
		} );
	}

	function buildBlueprintArea( $row, $textarea, api ) {
		var $area = $( '<div>', {
			class: 'eql-blueprint-area'
		} ).appendTo( $row );

		$( '<div>', {
			class: 'eql-blueprint-title',
			text: 'CREATE FROM A BLUEPRINT'
		} ).appendTo( $area );

		$( '<div>', {
			class: 'eql-blueprint-description',
			text:
				'Load a standard page skeleton from Help:Contents, ' +
				'or continue with the empty editor below.'
		} ).appendTo( $area );

		var $controls = $( '<div>', {
			class: 'eql-blueprint-controls'
		} ).appendTo( $area );

		var $select = $( '<select>', {
			'aria-label': 'Select page blueprint',
			disabled: true
		} ).appendTo( $controls );

		$( '<option>', {
			value: '',
			text: 'Loading blueprints...'
		} ).appendTo( $select );

		var $button = $( '<button>', {
			type: 'button',
			text: 'Load Blueprint',
			disabled: true
		} ).appendTo( $controls );

		var $sourceLink = $( '<a>', {
			class: 'eql-blueprint-source',
			href: mw.util.getUrl( BLUEPRINT_PAGE ),
			text: 'View Blueprints',
			target: '_blank',
			rel: 'noopener'
		} ).appendTo( $controls );

		var $status = $( '<span>', {
			class: 'eql-blueprint-status',
			'aria-live': 'polite'
		} ).appendTo( $area );

		fetchBlueprintSections( api )
			.then( function ( sections ) {
				var blueprints = normalizeBlueprintSections( sections );

				$select.empty();

				$( '<option>', {
					value: '',
					text: blueprints.length ?
						'Select a blueprint...' :
						'No blueprints found'
				} ).appendTo( $select );

				blueprints.forEach( function ( blueprint ) {
					$( '<option>', {
						value: blueprint.index,
						text: blueprint.label
					} )
						.attr( 'data-anchor', blueprint.anchor )
						.appendTo( $select );
				} );

				$select.prop( 'disabled', !blueprints.length );
			} )
			.catch( function ( error ) {
				if ( window.console && console.error ) {
					console.error( 'EQL Blueprint Loader:', error );
				}

				$select.empty().append(
					$( '<option>', {
						value: '',
						text: 'Could not load blueprints'
					} )
				);

				$status.text(
					'Could not read Blueprint headings from Help:Contents.'
				);
			} );

		$select.on( 'change', function () {
			var $option = $select.find( 'option:selected' );
			var anchor = $option.attr( 'data-anchor' ) || '';

			$button.prop( 'disabled', !$select.val() );

			if ( anchor ) {
				$sourceLink.attr(
					'href',
					mw.util.getUrl( BLUEPRINT_PAGE ) + '#' + anchor
				);
				$sourceLink.text( 'View This Blueprint' );
			} else {
				$sourceLink.attr( 'href', mw.util.getUrl( BLUEPRINT_PAGE ) );
				$sourceLink.text( 'View Blueprints' );
			}

			$status.text( '' );
		} );

		$button.on( 'click', function () {
			var sectionIndex = $select.val();
			var existing;

			if ( !sectionIndex ) {
				return;
			}

			existing = getEditorContents( $textarea );

			if (
				existing.trim() &&
				!window.confirm(
					'The editor already contains text. Replace it with the selected blueprint?'
				)
			) {
				return;
			}

			$button.prop( 'disabled', true );
			$select.prop( 'disabled', true );
			$status.text( 'Loading current blueprint from Help:Contents...' );

			fetchBlueprintWikitext( api, sectionIndex )
				.then( function ( sectionText ) {
					var blueprint = extractBlueprint( sectionText );

					if ( blueprint === null ) {
						throw new Error( 'No <pre> block found.' );
					}

					setEditorContents( $textarea, blueprint );

					$status.text(
						'Blueprint loaded. Edit the fields below, then publish normally.'
					);
				} )
				.catch( function ( error ) {
					if ( window.console && console.error ) {
						console.error( 'EQL Blueprint Loader:', error );
					}

					$status.text(
						'Could not load this blueprint. Check Help:Contents and make sure the section contains a <pre> block.'
					);
				} )
				.then( function () {
					$select.prop( 'disabled', false );
					$button.prop( 'disabled', !$select.val() );
				} );
		} );
	}

	function init() {
		var action = mw.config.get( 'wgAction' );
		var $textarea;
		var $tools;
		var $row;
		var $editorHost;
		var api;
		var isNewPage;
		var editorIsEmpty;

		if ( [ 'edit', 'submit' ].indexOf( action ) === -1 ) {
			return;
		}

		$textarea = $( '#wpTextbox1' );

		if ( !$textarea.length ) {
			return;
		}

		if ( document.getElementById( TOOLS_ID ) ) {
			return;
		}

		api = new mw.Api();
		isNewPage = Number( mw.config.get( 'wgArticleId' ) ) === 0;
		editorIsEmpty = !getEditorContents( $textarea ).trim();

		addStyles();

		$tools = $( '<div>', {
			id: TOOLS_ID
		} );

		$row = $( '<div>', {
			class: 'eql-editor-tools-row'
		} ).appendTo( $tools );

		if ( isNewPage && editorIsEmpty ) {
			buildBlueprintArea( $row, $textarea, api );
		} else {
			$( '<div>', {
				class: 'eql-editor-tools-spacer'
			} ).appendTo( $row );
		}

		buildIconFinderLaunch( $row, $tools );

		$editorHost = $textarea.closest( '.wikiEditor-ui' );

		if ( !$editorHost.length ) {
			$editorHost = $textarea;
		}

		$tools.insertBefore( $editorHost );
	}

	mw.loader.using( [
		'mediawiki.api',
		'mediawiki.util',
		'jquery.textSelection'
	] ).then( function () {
		$( init );
	} );

}( mediaWiki, jQuery ) );