Jump to content

MediaWiki:Gadget-PageBuilder.js: Difference between revisions

From Ketchuki
No edit summary
No edit summary
 
(One intermediate revision by the same user not shown)
Line 9: Line 9:
}
}


const MARKUP = "<div class=\"app\">\n  <header class=\"top\">\n    <h1 class=\"brand\">Page builder</h1>\n    <div class=\"title-field\">\n      <label for=\"pb-pageTitle\">Page</label>\n      <input type=\"text\" id=\"pb-pageTitle\" readonly>\n    </div>\n    <div class=\"top-actions\">\n      <span class=\"hist\"><button class=\"btn\" id=\"pb-undoBtn\" type=\"button\" title=\"Undo (Ctrl+Z)\" disabled>Undo</button><button class=\"btn\" id=\"pb-redoBtn\" type=\"button\" title=\"Redo (Ctrl+Y)\" disabled>Redo</button></span>\n      <button class=\"btn\" id=\"pb-importBtn\" type=\"button\">Import</button>\n      <button class=\"btn\" id=\"pb-presetsBtn\" type=\"button\">Presets</button>\n      <button class=\"btn danger\" id=\"pb-clearPage\" type=\"button\">Clear page</button>\n      <button class=\"btn\" id=\"pb-cancelBuilder\" type=\"button\">Cancel</button>\n      <button class=\"btn\" id=\"pb-doneBuilder\" type=\"button\" title=\"Put your changes in the edit box and go back to the editor\">Done</button>\n      <button class=\"btn primary\" id=\"pb-saveBuilder\" type=\"button\">Save page…</button>\n    </div>\n  </header>\n\n  <div class=\"main\">\n    <aside class=\"pane palette\" aria-label=\"Blocks\">\n      <div class=\"pal-groups\" id=\"pb-palette\"></div>\n      <p class=\"pal-hint\">Drag a block onto the page, or click it to add it at the end.</p>\n      <nav class=\"outline\" aria-labelledby=\"pb-outlineHeading\">\n        <div class=\"outline-head\"><h2 id=\"pb-outlineHeading\">Outline</h2><span class=\"outline-tools\"><button type=\"button\" class=\"linkbtn\" id=\"pb-collapseAll\">Collapse all</button><button type=\"button\" class=\"linkbtn\" id=\"pb-expandAll\">Expand all</button></span></div>\n        <ul class=\"outline-list\" id=\"pb-outline\"></ul>\n      </nav>\n    </aside>\n\n    <main class=\"pane canvas\" id=\"pb-canvas\" aria-label=\"Page layout\">\n      <div class=\"canvas-inner\">\n        <div class=\"blocks\" id=\"pb-blocks\"></div>\n      </div>\n    </main>\n\n    <section class=\"pane output\" aria-label=\"Output\">\n      <div class=\"out-head\">\n        <div class=\"tabs\" role=\"tablist\">\n          <button class=\"tab\" role=\"tab\" id=\"pb-tab-code\" aria-controls=\"pb-panel-code\" aria-selected=\"true\" type=\"button\">Wikitext</button>\n         <button class=\"tab\" role=\"tab\" id=\"pb-tab-prev\" aria-controls=\"pb-panel-prev\" aria-selected=\"false\" type=\"button\">Preview</button>\n          <button class=\"tab\" role=\"tab\" id=\"pb-tab-chk\" aria-controls=\"pb-panel-chk\" aria-selected=\"false\" type=\"button\">Checks</button>\n       </div>\n        <div class=\"out-tools\">\n          <span class=\"meta\" id=\"pb-meta\"></span>\n          <button class=\"btn\" id=\"pb-copyBtn\" type=\"button\">Copy wikitext</button>\n        </div>\n     </div>\n     <div class=\"panel\" id=\"pb-panel-code\" role=\"tabpanel\" aria-labelledby=\"pb-tab-code\"><pre class=\"wikitext\" id=\"pb-code\"></pre></div>\n      <div class=\"panel\" id=\"pb-panel-prev\" role=\"tabpanel\" aria-labelledby=\"pb-tab-prev\" hidden><p class=\"pb-status\" id=\"pb-liveStatus\" role=\"status\"></p><div class=\"pb-live mw-body-content\" id=\"pb-live\"></div><div class=\"wiki\" id=\"pb-preview\" hidden></div></div>\n      <div class=\"panel\" id=\"pb-panel-chk\" role=\"tabpanel\" aria-labelledby=\"pb-tab-chk\" hidden><div class=\"checks-panel\" id=\"pb-checks\"></div></div>\n    </section>\n  </div>\n</div>\n<dialog class=\"dlg\" id=\"pb-importDlg\" aria-labelledby=\"pb-importHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-importHeading\">Import wikitext</h2>\n    <p class=\"note\">Paste wikitext from another page or open a file. Anything the editor doesn’t have a block for is kept as raw wikitext, so nothing is lost.</p>\n    <textarea id=\"pb-importText\" class=\"mono\" spellcheck=\"false\" aria-label=\"Wikitext to import\" placeholder=\"{{Infobox&#10;| title = …&#10;}}&#10;&#10;'''Page''' is …&#10;&#10;== Section ==\"></textarea>\n    <div class=\"dlg-row\"><label class=\"btn filebtn\">Open a file<input type=\"file\" id=\"pb-importFile\" class=\"sr-only\" accept=\".txt,.wiki,.wikitext,.mediawiki,text/plain\"></label><span class=\"meta\" id=\"pb-importFileName\"></span></div>\n    <input type=\"hidden\" id=\"pb-importTitleInput\">\n    <fieldset class=\"dlg-mode\"><legend>Where should it go?</legend><label class=\"check\"><input type=\"radio\" name=\"importMode\" value=\"replace\" checked><span>Replace the current page</span></label><label class=\"check\"><input type=\"radio\" name=\"importMode\" value=\"append\"><span>Add to the end</span></label></fieldset>\n    <p class=\"dlg-err\" id=\"pb-importErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-importCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-importGo\" type=\"button\">Import</button></div>\n  </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-presetDlg\" aria-labelledby=\"pb-presetHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-presetHeading\">Start from a preset</h2>\n   <p class=\"note\" id=\"pb-presetNote\"></p>\n    <div class=\"presets\" id=\"pb-presetList\"></div>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-presetCancel\" type=\"button\">Cancel</button></div>\n  </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-saveDlg\" aria-labelledby=\"pb-saveHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-saveHeading\">Save page</h2>\n    <label class=\"field\"><span>Summary</span><input type=\"text\" id=\"pb-saveSummary\" maxlength=\"500\" placeholder=\"Briefly describe your changes\"></label>\n    <div class=\"checks\"><label class=\"check\" id=\"pb-saveMinorWrap\"><input type=\"checkbox\" id=\"pb-saveMinor\"><span>This is a minor edit</span></label><label class=\"check\"><input type=\"checkbox\" id=\"pb-saveWatch\"><span>Watch this page</span></label></div>\n    <details class=\"tplcode\" id=\"pb-saveReview\"><summary>Review your changes</summary><div class=\"pb-live pb-diff\" id=\"pb-saveDiff\"></div></details>\n    <p class=\"dlg-err\" id=\"pb-saveErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-saveCancel\" type=\"button\">Keep editing</button><button class=\"btn primary\" id=\"pb-saveGo\" type=\"button\">Save page</button></div>\n  </div>\n</dialog>\n<div class=\"ac\" id=\"pb-acList\" role=\"listbox\" aria-label=\"Suggestions\" hidden></div>\n<div class=\"toast\" id=\"pb-toast\" role=\"status\" hidden><span id=\"pb-toastMsg\"></span><button type=\"button\" id=\"pb-toastUndo\">Undo</button></div>";
const MARKUP = "<div class=\"app\">\n  <header class=\"top\">\n    <h1 class=\"brand\">Page builder</h1>\n    <div class=\"title-field\">\n      <label for=\"pb-pageTitle\">Page</label>\n      <input type=\"text\" id=\"pb-pageTitle\" readonly>\n    </div>\n    <div class=\"top-actions\">\n      <span class=\"hist\"><button class=\"btn\" id=\"pb-undoBtn\" type=\"button\" title=\"Undo (Ctrl+Z)\" disabled>Undo</button><button class=\"btn\" id=\"pb-redoBtn\" type=\"button\" title=\"Redo (Ctrl+Y)\" disabled>Redo</button></span>\n      <button class=\"btn\" id=\"pb-importBtn\" type=\"button\">Import</button>\n      <button class=\"btn\" id=\"pb-presetsBtn\" type=\"button\">Presets</button>\n      <button class=\"btn danger\" id=\"pb-clearPage\" type=\"button\">Clear page</button>\n      <button class=\"btn\" id=\"pb-cancelBuilder\" type=\"button\">Cancel</button>\n      <button class=\"btn\" id=\"pb-doneBuilder\" type=\"button\" title=\"Put your changes in the edit box and go back to the editor\">Done</button>\n      <button class=\"btn primary\" id=\"pb-saveBuilder\" type=\"button\">Save page…</button>\n    </div>\n  </header>\n\n  <div class=\"main\">\n    <aside class=\"pane palette\" aria-label=\"Blocks\">\n      <div class=\"pal-groups\" id=\"pb-palette\"></div>\n      <p class=\"pal-hint\">Drag a block onto the page, or click it to add it at the end. Press / to find one by name.</p>\n      <nav class=\"outline\" aria-labelledby=\"pb-outlineHeading\">\n        <div class=\"outline-head\"><h2 id=\"pb-outlineHeading\">Outline</h2><span class=\"outline-tools\"><button type=\"button\" class=\"linkbtn\" id=\"pb-collapseAll\">Collapse all</button><button type=\"button\" class=\"linkbtn\" id=\"pb-expandAll\">Expand all</button></span></div>\n        <ul class=\"outline-list\" id=\"pb-outline\"></ul>\n      </nav>\n    </aside>\n\n    <main class=\"pane canvas\" id=\"pb-canvas\" aria-label=\"Page layout\">\n      <div class=\"canvas-inner\">\n        <div class=\"banner\" id=\"pb-draftBanner\" role=\"status\" hidden><p id=\"pb-draftMsg\"></p><span class=\"banner-actions\"><button class=\"btn primary\" id=\"pb-draftRestore\" type=\"button\">Restore it</button><button class=\"btn\" id=\"pb-draftDiscard\" type=\"button\">Discard it</button></span></div>\n        <div class=\"blocks\" id=\"pb-blocks\"></div>\n      </div>\n    </main>\n\n    <section class=\"pane output\" aria-label=\"Output\">\n      <div class=\"out-head\">\n       <div class=\"tabs\" role=\"tablist\">\n          <button class=\"tab\" role=\"tab\" id=\"pb-tab-code\" aria-controls=\"pb-panel-code\" aria-selected=\"true\" type=\"button\">Wikitext</button>\n          <button class=\"tab\" role=\"tab\" id=\"pb-tab-prev\" aria-controls=\"pb-panel-prev\" aria-selected=\"false\" type=\"button\">Preview</button>\n         <button class=\"tab\" role=\"tab\" id=\"pb-tab-chk\" aria-controls=\"pb-panel-chk\" aria-selected=\"false\" type=\"button\">Checks</button>\n        </div>\n       <div class=\"out-tools\">\n         <span class=\"meta\" id=\"pb-meta\"></span>\n          <button class=\"btn\" id=\"pb-copyBtn\" type=\"button\">Copy wikitext</button>\n        </div>\n      </div>\n      <div class=\"panel\" id=\"pb-panel-code\" role=\"tabpanel\" aria-labelledby=\"pb-tab-code\"><pre class=\"wikitext\" id=\"pb-code\"></pre></div>\n      <div class=\"panel\" id=\"pb-panel-prev\" role=\"tabpanel\" aria-labelledby=\"pb-tab-prev\" hidden><p class=\"pb-status\" id=\"pb-liveStatus\" role=\"status\"></p><div class=\"pb-live mw-body-content\" id=\"pb-live\"></div><div class=\"wiki\" id=\"pb-preview\" hidden></div></div>\n     <div class=\"panel\" id=\"pb-panel-chk\" role=\"tabpanel\" aria-labelledby=\"pb-tab-chk\" hidden><div class=\"checks-panel\" id=\"pb-checks\"></div></div>\n    </section>\n  </div>\n</div>\n<dialog class=\"dlg\" id=\"pb-importDlg\" aria-labelledby=\"pb-importHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-importHeading\">Import wikitext</h2>\n    <p class=\"note\">Paste wikitext from another page or open a file. Anything the editor doesn’t have a block for is kept as raw wikitext, so nothing is lost.</p>\n    <textarea id=\"pb-importText\" class=\"mono\" spellcheck=\"false\" aria-label=\"Wikitext to import\" placeholder=\"{\u007BInfobox&#10;| title = …&#10;}}&#10;&#10;'''Page''' is …&#10;&#10;== Section ==\"></textarea>\n    <div class=\"dlg-row\"><label class=\"btn filebtn\">Open a file<input type=\"file\" id=\"pb-importFile\" class=\"sr-only\" accept=\".txt,.wiki,.wikitext,.mediawiki,text/plain\"></label><span class=\"meta\" id=\"pb-importFileName\"></span></div>\n    <input type=\"hidden\" id=\"pb-importTitleInput\">\n    <fieldset class=\"dlg-mode\"><legend>Where should it go?</legend><label class=\"check\"><input type=\"radio\" name=\"importMode\" value=\"replace\" checked><span>Replace the current page</span></label><label class=\"check\"><input type=\"radio\" name=\"importMode\" value=\"append\"><span>Add to the end</span></label></fieldset>\n    <p class=\"dlg-err\" id=\"pb-importErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-importCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-importGo\" type=\"button\">Import</button></div>\n  </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-presetDlg\" aria-labelledby=\"pb-presetHeading\">\n <div class=\"dlg-body\">\n    <h2 id=\"pb-presetHeading\">Start from a preset</h2>\n    <p class=\"note\" id=\"pb-presetNote\"></p>\n    <div class=\"presets\" id=\"pb-presetList\"></div>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-presetCancel\" type=\"button\">Cancel</button></div>\n  </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-citeDlg\" aria-labelledby=\"pb-citeHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-citeHeading\">Add a reference</h2>\n    <div id=\"pb-citeReuse\"></div>\n    <div class=\"row\"><label class=\"field\"><span>Kind of source</span><select id=\"pb-citeKind\"><option value=\"web\">Web page</option><option value=\"book\">Book</option><option value=\"other\">Something else</option></select></label><label class=\"field\" id=\"pb-citeFmtWrap\"><span>Format</span><select id=\"pb-citeFmt\"><option value=\"plain\">Plain text (works on any wiki)</option><option value=\"tpl\">Cite templates (cite web, cite book)</option></select></label></div>\n    <label class=\"field\" data-for=\"web book\"><span>Title</span><input type=\"text\" id=\"pb-citeTitle\"></label>\n    <label class=\"field\" data-for=\"web\"><span>Link</span><input type=\"text\" id=\"pb-citeUrl\" placeholder=\"https://\"></label>\n    <div class=\"row\" data-for=\"web book\"><label class=\"field\"><span>Author</span><input type=\"text\" id=\"pb-citeAuthor\"></label><label class=\"field\"><span id=\"pb-citeWorkLabel\">Website</span><input type=\"text\" id=\"pb-citeWork\"></label></div>\n    <div class=\"row\" data-for=\"web book\"><label class=\"field\"><span>Published</span><input type=\"text\" id=\"pb-citeDate\" placeholder=\"2026-10-24\"></label><label class=\"field\" data-for=\"web\"><span>Accessed</span><input type=\"text\" id=\"pb-citeAccess\"></label></div>\n    <label class=\"field\" data-for=\"other\"><span>Reference text</span><textarea id=\"pb-citeText\" rows=\"3\"></textarea></label>\n    <label class=\"field\"><span>Name (optional)</span><input type=\"text\" id=\"pb-citeName\" placeholder=\"Lets you use this reference again elsewhere on the page\"></label>\n    <p class=\"dlg-err\" id=\"pb-citeErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-citeCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-citeGo\" type=\"button\">Add reference</button></div>\n  </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-upDlg\" aria-labelledby=\"pb-upHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-upHeading\">Upload to your wiki</h2>\n    <div id=\"pb-upList\"></div>\n    <label class=\"field\"><span>Description</span><input type=\"text\" id=\"pb-upDesc\" placeholder=\"What the picture shows, and where it came from\"></label>\n    <p class=\"dlg-err\" id=\"pb-upErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-upCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-upGo\" type=\"button\">Upload</button></div>\n  </div>\n</dialog>\n<div class=\"qi\" id=\"pb-qiBox\" role=\"dialog\" aria-label=\"Add a block\" hidden><input type=\"text\" id=\"pb-qiInput\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"pb-qiList\" placeholder=\"Type to find a block\" autocomplete=\"off\"><div class=\"qi-list\" id=\"pb-qiList\" role=\"listbox\" aria-label=\"Blocks\"></div></div>\n<dialog class=\"dlg\" id=\"pb-saveDlg\" aria-labelledby=\"pb-saveHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-saveHeading\">Save page</h2>\n    <label class=\"field\"><span>Summary</span><input type=\"text\" id=\"pb-saveSummary\" maxlength=\"500\" placeholder=\"Briefly describe your changes\"></label>\n    <div class=\"checks\"><label class=\"check\" id=\"pb-saveMinorWrap\"><input type=\"checkbox\" id=\"pb-saveMinor\"><span>This is a minor edit</span></label><label class=\"check\"><input type=\"checkbox\" id=\"pb-saveWatch\"><span>Watch this page</span></label></div>\n    <details class=\"tplcode\" id=\"pb-saveReview\"><summary>Review your changes</summary><div class=\"pb-live pb-diff\" id=\"pb-saveDiff\"></div></details>\n    <p class=\"dlg-err\" id=\"pb-saveErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-saveCancel\" type=\"button\">Keep editing</button><button class=\"btn primary\" id=\"pb-saveGo\" type=\"button\">Save page</button></div>\n  </div>\n</dialog>\n<div class=\"ac\" id=\"pb-acList\" role=\"listbox\" aria-label=\"Suggestions\" hidden></div>\n<div class=\"toast\" id=\"pb-toast\" role=\"status\" hidden><span id=\"pb-toastMsg\"></span><button type=\"button\" id=\"pb-toastUndo\">Undo</button></div>";


function createApp( root, hooks ) {
function createApp( root, hooks ) {
Line 35: Line 35:
  dup:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5" y="5" width="8.5" height="8.5" rx="1"/><path d="M3 10.5V3.5a1 1 0 0 1 1-1h6.5"/></svg>',
  dup:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5" y="5" width="8.5" height="8.5" rx="1"/><path d="M3 10.5V3.5a1 1 0 0 1 1-1h6.5"/></svg>',
  x:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
  x:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
  up2:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 8.5 8 4l4.5 4.5M3.5 12.5 8 8l4.5 4.5"/></svg>',
  down2:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 3.5 8 8l4.5-4.5M3.5 7.5 8 12l4.5-4.5"/></svg>',
  fold:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1"/><path d="M5 8h6"/></svg>',
  unfold:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1"/><path d="M5 8h6M8 5v6"/></svg>',
  pic:'<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3" y="6" width="26" height="20" rx="1.5"/><circle cx="11" cy="13" r="2.5"/><path d="M3 23l8-7 6 5 4-3 8 6"/></svg>'
  pic:'<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3" y="6" width="26" height="20" rx="1.5"/><circle cx="11" cy="13" r="2.5"/><path d="M3 23l8-7 6 5 4-3 8 6"/></svg>'
};
};
const ph=f=>`<div class="ph">${ICON.pic}<span>${esc(f||'Example.jpg')}</span></div>`;
const ph=f=>`<div class="ph">${ICON.pic}<span>${esc(f||'Example.jpg')}</span></div>`;
function infoboxAttrs(b){let a='';if(b.layout==='stacked')a+=' layout="stacked"';if(b.accentOn&&b.accent)a+=` accent-color-default="${esc(b.accent)}"`;return a}
function infoboxTemplate(b){
function infoboxTemplate(b){
  const head=[`<infobox${infoboxAttrs(b)}>`,'  <title source="title"><default>{\u007BPAGENAME}}</default></title>','  <image source="image">','    <caption source="caption"/>','  </image>'];
  if(b.mode!=='dedicated'){
  if(b.mode!=='dedicated'){
    const n=Math.max(20,b.rows.length);
    const n=Math.max(20,b.rows.length);
    const out=['<infobox>','  <title source="title"><default>{{PAGENAME}}</default></title>','  <image source="image">','    <caption source="caption"/>','  </image>'];
    for(let i=1;i<=n;i++)head.push(`  <data source="data${i}"><label>{\u007B{label${i}|}}}</label></data>`);
    for(let i=1;i<=n;i++)out.push(`  <data source="data${i}"><label>{{{label${i}|}}}</label></data>`);
    head.push('</infobox>');
    out.push('</infobox>');
    return head.join('\n');
    return out.join('\n');
  }
  }
  const rows=b.rows.filter(r=>r.k.trim());
  const params={title:{label:'Title',description:'Box title. Leave blank to use the page name.',type:'string'},image:{label:'Image',description:'File name, without File:',type:'wiki-file-name'},caption:{label:'Caption',description:'Text under the image',type:'string'}};
  const params={title:{label:'Title',description:'Box title. Leave blank to use the page name.',type:'string'},image:{label:'Image',description:'File name, without File:',type:'wiki-file-name'},caption:{label:'Caption',description:'Text under the image',type:'string'}};
  rows.forEach(r=>{params[r.k.trim()]={label:niceKey(r.k),type:'string'}});
  const body=[];let open=false;
  return ['<infobox>','  <title source="title"><default>{{PAGENAME}}</default></title>','  <image source="image">','   <caption source="caption"/>','  </image>',
  b.rows.filter(r=>r.k.trim()).forEach(r=>{const lab=String(r.label||'').trim()||niceKey(r.k);params[r.k.trim()]={label:lab,type:'string'};
    ...rows.map(r=>`  <data source="${esc(r.k.trim())}"><label>${esc(niceKey(r.k))}</label></data>`),
    const h=String(r.header||'').trim();
    '</infobox><noinclude>','<templatedata>',JSON.stringify({params,format:'block'},null,2),'</templatedata>','</noinclude>'].join('\n');
    if(h){if(open)body.push('  </group>');body.push('  <group>',`   <header>${esc(h)}</header>`);open=true}
    body.push(`${open?'    ':' '}<data source="${esc(r.k.trim())}"><label>${esc(lab)}</label></data>`)});
  if(open)body.push('  </group>');
  return head.concat(body,['</infobox><noinclude>','<templatedata>',JSON.stringify({params,format:'block'},null,2),'</templatedata>','</noinclude>']).join('\n');
}
function infoboxDesigner(b){
  const g=b.mode!=='dedicated';
  let h=`<div class="row">${fSelect('Layout','layout',b.layout||'',[['','Labels beside values'],['stacked','Labels above values']])}<div class="field"><span>Accent colour</span><span class="colorrow">${fCheck('Use one','accentOn',!!b.accentOn,true)}${b.accentOn?`<input type="color" data-path="accent" value="${esc(b.accent||'#3366cc')}" aria-label="Accent colour">`:''}</span></div></div>`;
  if(!g){const rows=b.rows.map((r,i)=>r.k.trim()?`<tr><td><code>${esc(r.k.trim())}</code></td><td><input type="text" data-path="rows.${i}.label" value="${esc(r.label||'')}" placeholder="${esc(niceKey(r.k))}" aria-label="Label for ${esc(r.k.trim())}"></td><td><input type="text" data-path="rows.${i}.header" value="${esc(r.header||'')}" placeholder="No new section" aria-label="Section that starts at ${esc(r.k.trim())}"></td></tr>`:'').join('');
    if(rows)h+=`<div class="grid-wrap"><table class="design"><thead><tr><th scope="col">Field</th><th scope="col">Label shown</th><th scope="col">Starts a section called</th></tr></thead><tbody>${rows}</tbody></table></div>`}
  return h;
}
}


Line 73: Line 87:
  +`<span class="fgrp">${FB('bold','Bold','<b>B</b>','Bold (Ctrl+B)')}${FB('italic','Italic','<i>I</i>','Italic (Ctrl+I)')}${FB('under','Underline','<u>U</u>','Underline (Ctrl+U)')}${FB('strike','Strikethrough','<s>S</s>')}${FB('sup','Superscript','x<sup>2</sup>')}${FB('sub','Subscript','x<sub>2</sub>')}${FB('small','Small text','<small>Small</small>')}${FB('big','Big text','<span style="font-size:1.15em">Big</span>')}${FB('code','Inline code','<code>code</code>')}</span>`
  +`<span class="fgrp">${FB('bold','Bold','<b>B</b>','Bold (Ctrl+B)')}${FB('italic','Italic','<i>I</i>','Italic (Ctrl+I)')}${FB('under','Underline','<u>U</u>','Underline (Ctrl+U)')}${FB('strike','Strikethrough','<s>S</s>')}${FB('sup','Superscript','x<sup>2</sup>')}${FB('sub','Subscript','x<sub>2</sub>')}${FB('small','Small text','<small>Small</small>')}${FB('big','Big text','<span style="font-size:1.15em">Big</span>')}${FB('code','Inline code','<code>code</code>')}</span>`
  +`<span class="fgrp"><label class="fcolor" title="Text colour"><span>Colour</span><input type="color" data-fmtcolor="color" value="#d73333" aria-label="Text colour"></label><label class="fcolor" title="Highlight"><span>Highlight</span><input type="color" data-fmtcolor="bg" value="#fef6c8" aria-label="Highlight colour"></label></span>`
  +`<span class="fgrp"><label class="fcolor" title="Text colour"><span>Colour</span><input type="color" data-fmtcolor="color" value="#d73333" aria-label="Text colour"></label><label class="fcolor" title="Highlight"><span>Highlight</span><input type="color" data-fmtcolor="bg" value="#fef6c8" aria-label="Highlight colour"></label></span>`
  +`<span class="fgrp">${FB('link','Wiki link','Wiki link','Wiki link (Ctrl+K)')}${FB('ext','Web link','Web link')}${FB('ref','Reference','Reference')}${FB('tpl','Template','{{ }}','Template')}</span>`
  +`<span class="fgrp">${FB('link','Wiki link','Wiki link','Wiki link (Ctrl+K)')}${FB('ext','Web link','Web link')}${FB('ref','Reference','Reference')}${FB('tpl','Template','{\u007B }}','Template')}</span>`
  +`<span class="fgrp">${FB('br','Line break','Line break')}${FB('nowiki','No wiki','No wiki','Show markup as plain text')}${FB('clear','Clear formatting','Clear','Remove formatting from the selection')}<select data-fmtsym aria-label="Insert a symbol"><option value="">Symbol</option>${SYMS.map(([v,l])=>`<option value="${esc(v)}">${v==='&nbsp;'?'␣':esc(v)}  ${esc(l)}</option>`).join('')}</select></span></div>`}
  +`<span class="fgrp">${FB('br','Line break','Line break')}${FB('nowiki','No wiki','No wiki','Show markup as plain text')}${FB('clear','Clear formatting','Clear','Remove formatting from the selection')}<select data-fmtsym aria-label="Insert a symbol"><option value="">Symbol</option>${SYMS.map(([v,l])=>`<option value="${esc(v)}">${v==='&nbsp;'?'␣':esc(v)}  ${esc(l)}</option>`).join('')}</select></span></div>`}


Line 105: Line 119:
}
}
const THUMBS='<div class="thumbs" data-thumbs></div>';
const THUMBS='<div class="thumbs" data-thumbs></div>';
const UPBTN=multi=>`<div class="uprow"><label class="btn filebtn">${multi?'Upload pictures':'Upload a picture'}<input type="file" accept="image/*"${multi?' multiple':''} class="sr-only" data-upload></label><span class="hint">or drop ${multi?'them':'one'} onto this block</span></div>`;
const GALLERY_MODES=[['','Grid'],['packed','Packed rows'],['packed-hover','Packed, captions on hover'],['packed-overlay','Packed, captions over pictures'],['nolines','Grid without borders'],['slideshow','Slideshow']];
const NOTE_STYLES={info:['#3366cc','#eaf3ff'],success:['#14866d','#d5fdf4'],warning:['#edab00','#fef6e7'],danger:['#d73333','#fee7e6'],plain:['#a2a9b1','#f8f9fa']};
const noteStyle=k=>{const [bd,bg]=NOTE_STYLES[k]||NOTE_STYLES.info;return `border:1px solid ${bd};border-left:4px solid ${bd};background:${bg};color:#202122;padding:8px 12px;margin:1em 0`};
const COLL_STYLE='border:1px solid #a2a9b1;padding:6px 10px;margin:1em 0';
const reEsc=x=>x.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
const COLL_RE=new RegExp('^<div class="mw-collapsible( mw-collapsed)?" style="'+reEsc(COLL_STYLE)+'">\\n<div style="font-weight:bold">(.*)</div>\\n<div class="mw-collapsible-content">\\n([\\s\\S]*)\\n</div>\\n</div>$');
function divBlock(w){
  let m=w.match(/^<div class="pb-note pb-note-(\w+)" style="([^"]*)">([\s\S]*)<\/div>$/);
  if(m){if(!NOTE_STYLES[m[1]]||m[2]!==noteStyle(m[1]))return null;const t=m[3].match(/^'''([^\n]+?)'''\n\n([\s\S]*)$/);return ['note',{kind:m[1],title:t?t[1]:'',text:t?t[2]:m[3]}]}
  m=w.match(COLL_RE);if(m)return ['collapsible',{collapsed:!!m[1],title:m[2],text:m[3]}];
  return null;
}


const GROUPS=[['text','Text',['heading','paragraph','bullets','numbers','quote','code']],
const GROUPS=[['text','Text',['heading','paragraph','bullets','numbers','quote','note','collapsible','code']],
              ['media','Media and data',['infobox','image','gallery','table']],
              ['media','Media and data',['infobox','image','gallery','table']],
              ['wiki','Wiki features',['template','hatnote','toc','refs','categories','cattree','rule','raw']]];
              ['wiki','Wiki features',['template','hatnote','toc','refs','categories','cattree','rule','raw']]];
Line 142: Line 169:
  init:()=>({text:'',source:'',fmt:'blockquote'}),
  init:()=>({text:'',source:'',fmt:'blockquote'}),
  sum:b=>b.text,
  sum:b=>b.text,
  form:b=>`${fmtBar()}${fArea('Quotation','text',b.text,'',3)}<div class="row">${fText('Source (optional)','source',b.source,'Who said it')}${fSelect('Markup','fmt',b.fmt,[['blockquote','<blockquote> (any wiki)'],['template','{{Quote}} (needs that template)']])}</div>`,
  form:b=>`${fmtBar()}${fArea('Quotation','text',b.text,'',3)}<div class="row">${fText('Source (optional)','source',b.source,'Who said it')}${fSelect('Markup','fmt',b.fmt,[['blockquote','<blockquote> (any wiki)'],['template','{\u007BQuote}} (needs that template)']])}</div>`,
  wiki:b=>{const t=b.text.trim(),s=b.source.trim();return b.fmt==='template'?`{{Quote|text=${t}${s?`|author=${s}`:''}}}`:`<blockquote>${t}${s?`<br />— ${s}`:''}</blockquote>`},
  wiki:b=>{const t=b.text.trim(),s=b.source.trim();return b.fmt==='template'?`{\u007BQuote|text=${t}${s?`|author=${s}`:''}}}`:`<blockquote>${t}${s?`<br />— ${s}`:''}</blockquote>`},
  html:(b,c)=>`<blockquote><p>${c.inl(b.text)}</p>${b.source.trim()?`<p class="qsrc">— ${c.inl(b.source)}</p>`:''}</blockquote>`},
  html:(b,c)=>`<blockquote><p>${c.inl(b.text)}</p>${b.source.trim()?`<p class="qsrc">— ${c.inl(b.source)}</p>`:''}</blockquote>`},


Line 154: Line 181:


infobox:{label:'Infobox',tok:'<infobox>',desc:'A Portable Infobox with any labels you like',
infobox:{label:'Infobox',tok:'<infobox>',desc:'A Portable Infobox with any labels you like',
  init:()=>({mode:'generic',template:'Infobox',name:'',image:'',caption:'',rows:[{k:'',v:''}]}),
  init:()=>({mode:'generic',template:'Infobox',name:'',image:'',caption:'',rows:[{k:'',v:''}],layout:'',accentOn:false,accent:'#3366cc'}),
  sum:b=>b.name||b.template,
  sum:b=>b.name||b.template,
  form:b=>{const g=b.mode!=='dedicated';const tn=`<b data-tplname>Template:${esc(b.template.trim()||'Infobox')}</b>`;
  form:b=>{const g=b.mode!=='dedicated';const nm=tdKey(b.template)||'Infobox';const tn=`<b data-tplname>Template:${esc(nm)}</b>`;
    return `<div class="row">${fSelect('Kind','mode',b.mode,[['generic','Generic: any labels'],['dedicated','Dedicated: fixed field names']],true)}${fText('Template name','template',b.template,g?'Infobox':'Infobox character')}</div><div class="row">${fText('Title','name',b.name,'Leave blank to use the page name')}${fText('Image file','image',b.image,'Example.jpg')}</div>${fText('Image caption','caption',b.caption)}${fmtBar()}<p class="sub">${g?'Rows: type any label you like':'Fields'}</p>${kvRows(b.rows,'rows','delrow',g?'Label':'field_name',g?'Value':'value')}<div><button type="button" class="add" data-act="addrow">${g?'Add row':'Add field'}</button></div>${g?'':tdFillBtn(b)}<details class="tplcode"${openTpl.has(b.id)?' open':''}><summary>Code for the template page</summary><p class="note">${g?`Create ${tn} once with this code, and any page can use it with its own labels. It holds ${Math.max(20,b.rows.length)} rows. If a page ever needs more, this code grows to fit, so paste the new version over the old one.`:`Portable Infobox draws the box from a template page. Create ${tn} once with this code, and every article can use it. The TemplateData part gives VisualEditor a form for these fields.`}</p><pre class="mini" data-tplcode>${esc(infoboxTemplate(b))}</pre><div><button type="button" class="btn" data-act="copytpl">Copy template code</button></div></details>`},
    return `<div class="row">${fSelect('Kind','mode',b.mode,[['generic','Generic: any labels'],['dedicated','Dedicated: fixed field names']],true)}${fText('Template name','template',b.template,g?'Infobox':'Infobox character')}</div><div class="row">${fText('Title','name',b.name,'Leave blank to use the page name')}${fText('Image file','image',b.image,'Example.jpg')}</div>${fText('Image caption','caption',b.caption)}${fmtBar()}<p class="sub">${g?'Rows: type any label you like':'Fields'}</p>${kvRows(b.rows,'rows','delrow',g?'Label':'field_name',g?'Value':'value')}<div><button type="button" class="add" data-act="addrow">${g?'Add row':'Add field'}</button></div>${g?'':tdFillBtn(b)}<details class="tplcode"${openTpl.has(b.id)?' open':''}><summary>Design the template</summary><p class="note">${g?`This is the code for ${tn}. Save it to the wiki once, and any page can use the box with its own labels. It holds ${Math.max(20,b.rows.length)} rows, and grows if a page ever needs more.`:`This is the code for ${tn}. Save it to the wiki once and every page of this kind can use it. Its TemplateData gives VisualEditor and this builder a form for the fields.`}</p>${infoboxDesigner(b)}<pre class="mini" data-tplcode>${esc(infoboxTemplate(b))}</pre><div class="btnrow"><button type="button" class="btn" data-act="copytpl">Copy template code</button>${hooks.saveTemplate?`<button type="button" class="btn primary" data-act="savetpl">Save to Template:${esc(nm)}</button>`:''}</div></details>`},
  wiki:b=>{
  wiki:b=>{
    const base=[['title',b.name],['image',stripFile(b.image)],['caption',b.caption]].filter(p=>String(p[1]).trim());
    const base=[['title',b.name],['image',stripFile(b.image)],['caption',b.caption]].filter(p=>String(p[1]).trim());
Line 165: Line 192:
    const pairs=base.concat(rest);
    const pairs=base.concat(rest);
    const w=pairs.reduce((m,p)=>Math.max(m,p[0].length),0);
    const w=pairs.reduce((m,p)=>Math.max(m,p[0].length),0);
    return [`{{${b.template.trim()||'Infobox'}`,...pairs.map(([k,v])=>`| ${k.padEnd(w)} = ${String(v).trim()}`),'}}'].join('\n')},
    return [`{\u007B${b.template.trim()||'Infobox'}`,...pairs.map(([k,v])=>`| ${k.padEnd(w)} = ${String(v).trim()}`),'}}'].join('\n')},
  html:(b,c)=>`<table class="infobox"><caption>${b.name.trim()?c.inl(b.name):esc(state.title.trim()||'Page name')}</caption>${b.image.trim()?`<tr><td colspan="2" class="ib-img">${ph(stripFile(b.image))}${b.caption.trim()?`<div class="ib-cap">${c.inl(b.caption)}</div>`:''}</td></tr>`:''}${(b.mode!=='dedicated'?b.rows.filter(r=>r.v.trim()).map(r=>[c.inl(r.k),r.v]):b.rows.filter(r=>r.k.trim()).map(r=>[esc(niceKey(r.k)),r.v])).map(([l,v])=>`<tr><th scope="row">${l}</th><td>${c.inl(v)}</td></tr>`).join('')}</table>`},
  html:(b,c)=>{const g=b.mode!=='dedicated';
    const rows=g?b.rows.filter(r=>r.v.trim()).map(r=>`<tr><th scope="row">${c.inl(r.k)}</th><td>${c.inl(r.v)}</td></tr>`).join('')
      :b.rows.filter(r=>r.k.trim()).map(r=>`${String(r.header||'').trim()?`<tr><th colspan="2" class="ib-head">${esc(r.header.trim())}</th></tr>`:''}<tr><th scope="row">${esc(String(r.label||'').trim()||niceKey(r.k))}</th><td>${c.inl(r.v)}</td></tr>`).join('');
    return `<table class="infobox"${b.accentOn&&b.accent?` style="border-top:4px solid ${esc(b.accent)}"`:''}><caption>${b.name.trim()?c.inl(b.name):esc(state.title.trim()||'Page name')}</caption>${b.image.trim()?`<tr><td colspan="2" class="ib-img">${ph(stripFile(b.image))}${b.caption.trim()?`<div class="ib-cap">${c.inl(b.caption)}</div>`:''}</td></tr>`:''}${rows}</table>`}},


image:{label:'Image',tok:'[[File:]]',desc:'A picture with an optional caption',
image:{label:'Image',tok:'[\u005BFile:]]',desc:'A picture with an optional caption',
  init:()=>({file:'',caption:'',frame:'thumb',align:'',size:''}),
  init:()=>({file:'',caption:'',frame:'thumb',align:'',size:''}),
  sum:b=>stripFile(b.file),
  sum:b=>stripFile(b.file),
  form:b=>`<div class="row">${fText('File name','file',b.file,'Example.jpg')}${fText('Caption','caption',b.caption)}</div><div class="row r-3">${fSelect('Style','frame',b.frame,[['thumb','Thumbnail'],['frame','Framed'],['frameless','Frameless'],['','Plain']])}${fSelect('Position','align',b.align,[['','Default'],['right','Right'],['left','Left'],['center','Centre'],['none','None']])}${fText('Width (px)','size',b.size,'250')}</div>${THUMBS}`,
  form:b=>`<div class="row">${fText('File name','file',b.file,'Example.jpg')}${fText('Caption','caption',b.caption)}</div><div class="row r-3">${fSelect('Style','frame',b.frame,[['thumb','Thumbnail'],['frame','Framed'],['frameless','Frameless'],['','Plain']])}${fSelect('Position','align',b.align,[['','Default'],['right','Right'],['left','Left'],['center','Centre'],['none','None']])}${fText('Width (px)','size',b.size,'250')}</div>${hooks.upload?UPBTN(false):''}${THUMBS}`,
  wiki:b=>{const p=['File:'+(stripFile(b.file)||'Example.jpg')];if(b.frame)p.push(b.frame);if(b.align)p.push(b.align);
  wiki:b=>{const p=['File:'+(stripFile(b.file)||'Example.jpg')];if(b.frame)p.push(b.frame);if(b.align)p.push(b.align);
    const s=b.size.trim().replace(/px$/i,'');if(s)p.push(/^\d+$/.test(s)?s+'px':b.size.trim());if(b.caption.trim())p.push(b.caption.trim());return `[[${p.join('|')}]]`},
    const s=b.size.trim().replace(/px$/i,'');if(s)p.push(/^\d+$/.test(s)?s+'px':b.size.trim());if(b.caption.trim())p.push(b.caption.trim());return `[\u005B${p.join('|')}]]`},
  html:(b,c)=>{const framed=b.frame==='thumb'||b.frame==='frame';const al=b.align||(framed?'right':'');const w=(parseInt(b.size,10)||(b.frame==='frame'?300:220))+(framed?8:0);
  html:(b,c)=>{const framed=b.frame==='thumb'||b.frame==='frame';const al=b.align||(framed?'right':'');const w=(parseInt(b.size,10)||(b.frame==='frame'?300:220))+(framed?8:0);
    return `<figure class="thumb${framed?' framed':''} al-${al||'inline'}" style="width:${w}px">${ph(stripFile(b.file))}${framed&&b.caption.trim()?`<figcaption>${c.inl(b.caption)}</figcaption>`:''}</figure>`}},
    return `<figure class="thumb${framed?' framed':''} al-${al||'inline'}" style="width:${w}px">${ph(stripFile(b.file))}${framed&&b.caption.trim()?`<figcaption>${c.inl(b.caption)}</figcaption>`:''}</figure>`}},


gallery:{label:'Gallery',tok:'<gallery>',desc:'A grid of images',
gallery:{label:'Gallery',tok:'<gallery>',desc:'A grid or slideshow of images',
  init:()=>({items:'Example.jpg|First caption\nExample2.jpg|Second caption'}),
  init:()=>({items:'Example.jpg|First caption\nExample2.jpg|Second caption',mode:'',heights:'',caption:''}),
  sum:b=>lines(b.items).length+' images',
  sum:b=>lines(b.items).length+' images',
  form:b=>fArea('Images','items',b.items,'One image per line: File name.jpg|Caption',4,true)+THUMBS,
  form:b=>`<div class="row r-3">${fSelect('Style','mode',b.mode||'',GALLERY_MODES)}${fText('Caption (optional)','caption',b.caption||'')}${fText('Row height (px)','heights',b.heights||'','150')}</div>`+fArea('Images','items',b.items,'One image per line: File name.jpg|Caption',4,true)+(hooks.upload?UPBTN(true):'')+THUMBS,
  wiki:b=>`<gallery>\n${lines(b.items).map(l=>l.trim()).join('\n')}\n</gallery>`,
  wiki:b=>{let a='';if(b.mode)a+=` mode="${b.mode}"`;const h=String(b.heights||'').trim().replace(/px$/i,'');if(h)a+=` heights="${/^\d+$/.test(h)?h+'px':h}"`;
  html:(b,c)=>`<div class="gallery">${lines(b.items).map(l=>{const [f,...cap]=l.split('|');return `<figure>${ph(stripFile(f))}<figcaption>${c.inl(cap.join('|'))}</figcaption></figure>`}).join('')}</div>`},
    const cap=String(b.caption||'').trim();if(cap)a+=` caption="${cap.replace(/"/g,'&quot;')}"`;
    return `<gallery${a}>\n${lines(b.items).map(l=>l.trim()).join('\n')}\n</gallery>`},
  html:(b,c)=>`${String(b.caption||'').trim()?`<p class="gallery-cap">${c.inl(b.caption)}</p>`:''}<div class="gallery">${lines(b.items).map(l=>{const [f,...cap]=l.split('|');return `<figure>${ph(stripFile(f))}<figcaption>${c.inl(cap.join('|'))}</figcaption></figure>`}).join('')}</div>`},


table:{label:'Table',tok:'{| |}',desc:'A wikitable with rows and columns',
table:{label:'Table',tok:'{| |}',desc:'A wikitable with rows and columns',
  init:()=>({caption:'',header:true,sortable:false,cells:[['Header 1','Header 2'],['','']]}),
  init:()=>({caption:'',header:true,sortable:false,cells:[['Header 1','Header 2'],['','']],align:[]}),
  sum:b=>b.caption||`${b.cells.length} rows, ${(b.cells[0]||[]).length} columns`,
  sum:b=>b.caption||`${b.cells.length} rows, ${(b.cells[0]||[]).length} columns`,
  form:b=>`${fText('Caption (optional)','caption',b.caption)}<div class="checks">${fCheck('First row is a header','header',b.header,true)}${fCheck('Sortable columns','sortable',b.sortable)}</div>${fmtBar()}<div class="grid-wrap"><table class="cells">${b.cells.map((r,i)=>`<tr>${r.map((v,j)=>`<td><input type="text" data-path="cells.${i}.${j}" value="${esc(v)}" aria-label="Row ${i+1}, column ${j+1}"${i===0&&b.header?' class="hcell"':''}></td>`).join('')}</tr>`).join('')}</table></div><div class="btnrow"><button type="button" class="add" data-act="addtrow">Add row</button><button type="button" class="add" data-act="addcol">Add column</button><button type="button" class="add" data-act="deltrow">Remove last row</button><button type="button" class="add" data-act="delcol">Remove last column</button></div>`,
  form:b=>{const w=(b.cells[0]||[]).length,al=b.align||[];
  wiki:b=>{const out=[`{| class="wikitable${b.sortable?' sortable':''}"`];if(b.caption.trim())out.push('|+ '+b.caption.trim());
    return `${fText('Caption (optional)','caption',b.caption)}<div class="checks">${fCheck('First row is a header','header',b.header,true)}${fCheck('Sortable columns','sortable',b.sortable)}</div>${fmtBar()}<div class="grid-wrap"><table class="cells"><tr class="alignrow">${Array.from({length:w},(x,j)=>`<td><select data-path="align.${j}" aria-label="Alignment of column ${j+1}">${[['','Align left'],['center','Centre'],['right','Align right']].map(([v,t])=>`<option value="${v}"${(al[j]||'')===v?' selected':''}>${t}</option>`).join('')}</select></td>`).join('')}<td></td></tr>${b.cells.map((r,i)=>`<tr>${r.map((v,j)=>`<td><input type="text" data-path="cells.${i}.${j}" value="${esc(v)}" aria-label="Row ${i+1}, column ${j+1}"${i===0&&b.header?' class="hcell"':''}></td>`).join('')}<td class="trow-tools"><button type="button" class="icon" data-act="trowup" data-i="${i}" aria-label="Move row ${i+1} up" title="Move row up">${ICON.up}</button><button type="button" class="icon" data-act="trowdown" data-i="${i}" aria-label="Move row ${i+1} down" title="Move row down">${ICON.down}</button><button type="button" class="icon del" data-act="trowdel" data-i="${i}" aria-label="Remove row ${i+1}" title="Remove row">${ICON.x}</button></td></tr>`).join('')}</table></div><p class="hint">Tip: copy cells from a spreadsheet and paste them into any cell here.</p><div class="btnrow"><button type="button" class="add" data-act="addtrow">Add row</button><button type="button" class="add" data-act="addcol">Add column</button><button type="button" class="add" data-act="delcol">Remove last column</button></div>`},
    b.cells.forEach((r,i)=>{if(i>0)out.push('|-');out.push(i===0&&b.header?'! '+r.map(x=>x.trim()).join(' !! '):'| '+r.map(x=>x.trim()).join(' || '))});
  wiki:b=>{const out=[`{| class="wikitable${b.sortable?' sortable':''}"`];if(b.caption.trim())out.push('|+ '+b.caption.trim());const al=b.align||[];
    b.cells.forEach((r,i)=>{if(i>0)out.push('|-');out.push(i===0&&b.header?'! '+r.map(x=>x.trim()).join(' !! '):'| '+r.map((x,j)=>al[j]?`style="text-align:${al[j]}" | ${x.trim()}`:x.trim()).join(' || '))});
    out.push('|}');return out.join('\n')},
    out.push('|}');return out.join('\n')},
  html:(b,c)=>`<div class="tablewrap"><table class="wikitable">${b.caption.trim()?`<caption>${c.inl(b.caption)}</caption>`:''}${b.cells.map((r,i)=>`<tr>${r.map(x=>i===0&&b.header?`<th>${c.inl(x)}</th>`:`<td>${c.inl(x)}</td>`).join('')}</tr>`).join('')}</table></div>`},
  html:(b,c)=>{const al=b.align||[];return `<div class="tablewrap"><table class="wikitable">${b.caption.trim()?`<caption>${c.inl(b.caption)}</caption>`:''}${b.cells.map((r,i)=>`<tr>${r.map((x,j)=>i===0&&b.header?`<th>${c.inl(x)}</th>`:`<td${al[j]?` style="text-align:${al[j]}"`:''}>${c.inl(x)}</td>`).join('')}</tr>`).join('')}</table></div>`}},


template:{label:'Template',tok:'{{ }}',desc:'Any template from your wiki',
template:{label:'Template',tok:'{\u007B }}',desc:'Any template from your wiki',
  init:()=>({name:'',params:[{k:'',v:''}]}),
  init:()=>({name:'',params:[{k:'',v:''}]}),
  sum:b=>b.name,
  sum:b=>b.name,
  form:templateForm,
  form:templateForm,
  wiki:b=>{const ps=b.params.filter(p=>(p.k.trim()||p.v.trim())&&!(p.td&&!p.v.trim()));const n=b.name.trim()||'Template name';
  wiki:b=>{const ps=b.params.filter(p=>(p.k.trim()||p.v.trim())&&!(p.td&&!p.v.trim()));const n=b.name.trim()||'Template name';
    if(ps.filter(p=>p.k.trim()).length>=3)return `{{${n}\n${ps.map(p=>`| ${p.k.trim()?p.k.trim()+' = ':''}${p.v.trim()}`).join('\n')}\n}}`;
    if(ps.filter(p=>p.k.trim()).length>=3)return `{\u007B${n}\n${ps.map(p=>`| ${p.k.trim()?p.k.trim()+' = ':''}${p.v.trim()}`).join('\n')}\n}}`;
    return `{{${n}${ps.map(p=>'|'+(p.k.trim()?p.k.trim()+'=':'')+p.v.trim()).join('')}}}`},
    return `{\u007B${n}${ps.map(p=>'|'+(p.k.trim()?p.k.trim()+'=':'')+p.v.trim()).join('')}}}`},
  html:b=>`<div class="tpl"><code>{{${esc(b.name.trim()||'Template name')}}}</code> renders here once the page is saved on your wiki.</div>`},
  html:b=>`<div class="tpl"><code>{\u007B${esc(b.name.trim()||'Template name')}}}</code> renders here once the page is saved on your wiki.</div>`},
 
note:{label:'Note box',tok:'<div>',desc:'A coloured box for tips, warnings or notices',
  init:()=>({kind:'info',title:'',text:''}),
  sum:b=>b.title||b.text,
  form:b=>`<div class="row r-wide">${fText('Title (optional)','title',b.title,'Heads up')}${fSelect('Colour','kind',b.kind,[['info','Blue'],['success','Green'],['warning','Yellow'],['danger','Red'],['plain','Grey']])}</div>${fmtBar()}${fArea('Text','text',b.text,'What the box says',3)}`,
  wiki:b=>{const k=NOTE_STYLES[b.kind]?b.kind:'info',t=b.title.trim(),x=b.text.trim();return `<div class="pb-note pb-note-${k}" style="${noteStyle(k)}">${t?`'''${t}'''\n\n`:''}${x}</div>`},
  html:(b,c)=>`<div style="${noteStyle(b.kind)}">${b.title.trim()?`<p><b>${c.inl(b.title)}</b></p>`:''}${b.text.trim().split(/\n\s*\n/).map(x=>`<p>${c.inl(x)}</p>`).join('')}</div>`},
 
collapsible:{label:'Collapsible section',tok:'mw-collapsible',desc:'A box readers can open and close',
  init:()=>({title:'More details',text:'',collapsed:true}),
  sum:b=>b.title,
  form:b=>`${fText('Title','title',b.title,'Shown when the box is closed')}${fCheck('Start closed','collapsed',b.collapsed)}${fmtBar()}${fArea('Content','text',b.text,'Anything: text, lists, images, tables…',4)}`,
  wiki:b=>`<div class="mw-collapsible${b.collapsed?' mw-collapsed':''}" style="${COLL_STYLE}">\n<div style="font-weight:bold">${b.title.trim()||'Details'}</div>\n<div class="mw-collapsible-content">\n${b.text.trim()}\n</div>\n</div>`,
  html:(b,c)=>`<div class="coll"><div class="coll-title">${c.inl(b.title.trim()||'Details')} <span class="muted">[${b.collapsed?'show':'hide'}]</span></div>${b.collapsed?'':b.text.trim().split(/\n\s*\n/).map(x=>`<p>${c.inl(x)}</p>`).join('')}</div>`},


hatnote:{label:'Main article link',tok:":''Main''",desc:'Points readers to a more detailed page',
hatnote:{label:'Main article link',tok:":''Main''",desc:'Points readers to a more detailed page',
  init:()=>({kind:'Main',target:'',markup:'plain'}),
  init:()=>({kind:'Main',target:'',markup:'plain'}),
  sum:b=>b.target,
  sum:b=>b.target,
  form:b=>`<div class="row">${fSelect('Type','kind',b.kind,[['Main','Main article'],['See also','See also'],['Further','Further information']])}${fText('Page name','target',b.target,'Detailed page')}</div>${fSelect('Markup','markup',b.markup,[['plain','Plain wikitext (works now)'],['template','{{Main}} / {{See also}} / {{Further}} (needs those templates)']])}`,
  form:b=>`<div class="row">${fSelect('Type','kind',b.kind,[['Main','Main article'],['See also','See also'],['Further','Further information']])}${fText('Page name','target',b.target,'Detailed page')}</div>${fSelect('Markup','markup',b.markup,[['plain','Plain wikitext (works now)'],['template','{\u007BMain}} / {\u007BSee also}} / {\u007BFurther}} (needs those templates)']])}`,
  wiki:b=>{const t=b.target.trim()||'Page name';return b.markup==='template'?`{{${b.kind}|${t}}}`:`:''${({Main:'Main article','See also':'See also',Further:'Further information'})[b.kind]}: [[${t}]]''`},
  wiki:b=>{const t=b.target.trim()||'Page name';return b.markup==='template'?`{\u007B${b.kind}|${t}}}`:`:''${({Main:'Main article','See also':'See also',Further:'Further information'})[b.kind]}: [\u005B${t}]]''`},
  html:b=>`<div class="hatnote">${{Main:'Main article','See also':'See also',Further:'Further information'}[b.kind]}: <a class="wl">${esc(b.target.trim()||'Page name')}</a></div>`},
  html:b=>`<div class="hatnote">${{Main:'Main article','See also':'See also',Further:'Further information'}[b.kind]}: <a class="wl">${esc(b.target.trim()||'Page name')}</a></div>`},


Line 221: Line 268:
refs:{label:'References',tok:'<references/>',desc:'Lists every <ref> on the page',
refs:{label:'References',tok:'<references/>',desc:'Lists every <ref> on the page',
  init:()=>({heading:true,style:'references'}),
  init:()=>({heading:true,style:'references'}),
  sum:b=>b.style==='reflist'?'{{Reflist}}':'<references />',
  sum:b=>b.style==='reflist'?'{\u007BReflist}}':'<references />',
  form:b=>`<div class="row">${fSelect('Markup','style',b.style,[['references','<references /> (any wiki)'],['reflist','{{Reflist}} (needs that template)']])}<div class="field"><span>&nbsp;</span>${fCheck('Add a “References” heading','heading',b.heading)}</div></div>`,
  form:b=>`<div class="row">${fSelect('Markup','style',b.style,[['references','<references /> (any wiki)'],['reflist','{\u007BReflist}} (needs that template)']])}<div class="field"><span>&nbsp;</span>${fCheck('Add a “References” heading','heading',b.heading)}</div></div>`,
  wiki:b=>`${b.heading?'== References ==\n':''}${b.style==='reflist'?'{{Reflist}}':'<references />'}`,
  wiki:b=>`${b.heading?'== References ==\n':''}${b.style==='reflist'?'{\u007BReflist}}':'<references />'}`,
  html:(b,c)=>`${b.heading?'<h2>References</h2>':''}${c.refs.length?`<ol class="refs">${c.refs.map(r=>`<li>${fmt(r,c)}</li>`).join('')}</ol>`:'<p class="muted">Add references to paragraphs with the Reference button.</p>'}`},
  html:(b,c)=>`${b.heading?'<h2>References</h2>':''}${c.refs.length?`<ol class="refs">${c.refs.map(r=>`<li>${fmt(r,c)}</li>`).join('')}</ol>`:'<p class="muted">Add references to paragraphs with the Reference button.</p>'}`},


categories:{label:'Categories',tok:'[[Category:]]',desc:'File the page under categories',
categories:{label:'Categories',tok:'[\u005BCategory:]]',desc:'File the page under categories',
  init:()=>({items:''}),
  init:()=>({items:''}),
  sum:b=>lines(b.items).join(', '),
  sum:b=>lines(b.items).join(', '),
  form:b=>fArea('Categories','items',b.items,'One category per line',3),
  form:b=>fArea('Categories','items',b.items,'One category per line',3),
  wiki:b=>lines(b.items).map(l=>`[[Category:${l.trim().replace(/^Category:/i,'')}]]`).join('\n'),
  wiki:b=>lines(b.items).map(l=>`[\u005BCategory:${l.trim().replace(/^Category:/i,'')}]]`).join('\n'),
  html:(b,c)=>{lines(b.items).forEach(l=>c.cats.push(l.trim().replace(/^Category:/i,'')));return ''}},
  html:(b,c)=>{lines(b.items).forEach(l=>c.cats.push(l.trim().replace(/^Category:/i,'')));return ''}},


Line 262: Line 309:
  return {title:'Sourdough bread',blocks:[
  return {title:'Sourdough bread',blocks:[
    mk('infobox',{template:'Infobox',name:'Sourdough bread',image:'Sourdough loaf.jpg',caption:'A round country loaf',
    mk('infobox',{template:'Infobox',name:'Sourdough bread',image:'Sourdough loaf.jpg',caption:'A round country loaf',
      rows:[{k:'Type',v:'Bread'},{k:'Main ingredient',v:'Flour, water, salt'},{k:'Leavening',v:'Wild yeast and [[lactic acid bacteria]]'}]}),
      rows:[{k:'Type',v:'Bread'},{k:'Main ingredient',v:'Flour, water, salt'},{k:'Leavening',v:'Wild yeast and [\u005Blactic acid bacteria]]'}]}),
    mk('paragraph',{text:"'''Sourdough''' is a [[bread]] leavened by a naturally fermented starter rather than commercial [[yeast]].<ref>Replace this with your source.</ref> The bacteria in the starter give the loaf its mildly sour flavour and help it keep longer."}),
    mk('paragraph',{text:"'''Sourdough''' is a [\u005Bbread]] leavened by a naturally fermented starter rather than commercial [\u005Byeast]].<ref>Replace this with your source.</ref> The bacteria in the starter give the loaf its mildly sour flavour and help it keep longer."}),
    mk('toc'),
    mk('toc'),
    mk('heading',{text:'History'}),
    mk('heading',{text:'History'}),
Line 273: Line 320:
    mk('table',{caption:'Typical hydration levels',sortable:true,cells:[['Style','Hydration','Crumb'],['Sandwich loaf','65%','Tight'],['Country loaf','75%','Open'],['Ciabatta','80%+','Very open']]}),
    mk('table',{caption:'Typical hydration levels',sortable:true,cells:[['Style','Hydration','Crumb'],['Sandwich loaf','65%','Tight'],['Country loaf','75%','Open'],['Ciabatta','80%+','Very open']]}),
    mk('heading',{text:'See also'}),
    mk('heading',{text:'See also'}),
    mk('bullets',{items:'[[Rye bread]]\n[[Soda bread]]'}),
    mk('bullets',{items:'[\u005BRye bread]]\n[\u005BSoda bread]]'}),
    mk('refs'),
    mk('refs'),
    mk('categories',{items:'Breads\nFermented foods'})
    mk('categories',{items:'Breads\nFermented foods'})
Line 281: Line 328:
const BUILTIN_PRESETS=[
const BUILTIN_PRESETS=[
  {name:'Person',description:'An infobox with a few details, About and Friends sections, and the People category.',
  {name:'Person',description:'An infobox with a few details, About and Friends sections, and the People category.',
  wikitext:"{{Infobox\n| title  = $TITLE\n| image  = \n| caption = \n| label1  = Pronouns\n| data1  = \n| label2  = Location\n| data2  = \n| label3  = Interests\n| data3  = \n}}\n'''$TITLE''' is …\n\n== About ==\nWrite a few lines about them here.\n\n== Friends ==\n* [[Name of a friend]]\n\n[[Category:People]]"},
  wikitext:"{\u007BInfobox\n| title  = $TITLE\n| image  = \n| caption = \n| label1  = Pronouns\n| data1  = \n| label2  = Location\n| data2  = \n| label3  = Interests\n| data3  = \n}}\n'''$TITLE''' is …\n\n== About ==\nWrite a few lines about them here.\n\n== Friends ==\n* [\u005BName of a friend]]\n\n[\u005BCategory:People]]"},
  {name:'Article',description:'An introduction, Overview and History sections, See also, References and a category.',
  {name:'Article',description:'An introduction, Overview and History sections, See also, References and a category.',
  wikitext:"'''$TITLE''' is …\n\n== Overview ==\nWhat it is and why it matters.\n\n== History ==\nHow it came about.\n\n== See also ==\n* [[Related page]]\n\n== References ==\n<references />\n\n[[Category:Category name]]"}
  wikitext:"'''$TITLE''' is …\n\n== Overview ==\nWhat it is and why it matters.\n\n== History ==\nHow it came about.\n\n== See also ==\n* [\u005BRelated page]]\n\n== References ==\n<references />\n\n[\u005BCategory:Category name]]"}
];
];
const EXAMPLE_PRESET={name:'Example: Sourdough bread',description:'A finished page that uses most kinds of blocks.',build:example};
const EXAMPLE_PRESET={name:'Example: Sourdough bread',description:'A finished page that uses most kinds of blocks.',build:example};
Line 308: Line 355:
    .replace(/\[(https?:\/\/[^\s\]]+) ([^\]]+)\]/g,'<a class="ext">$2</a>')
    .replace(/\[(https?:\/\/[^\s\]]+) ([^\]]+)\]/g,'<a class="ext">$2</a>')
    .replace(/\[(https?:\/\/[^\s\]]+)\]/g,()=>`<a class="ext">[${++c.ext}]</a>`)
    .replace(/\[(https?:\/\/[^\s\]]+)\]/g,()=>`<a class="ext">[${++c.ext}]</a>`)
    .replace(/\{\{([^{}]+)\}\}/g,'<span class="tpl-inline">{{$1}}</span>')
    .replace(/\{\{([^{}]+)\}\}/g,'<span class="tpl-inline">{\u007B$1}}</span>')
    .replace(/&lt;(\/?)(u|s|del|ins|sup|sub|small|big|code|mark|b|i)&gt;/gi,'<$1$2>')
    .replace(/&lt;(\/?)(u|s|del|ins|sup|sub|small|big|code|mark|b|i)&gt;/gi,'<$1$2>')
    .replace(/&lt;br\s*\/?&gt;/gi,'<br>')
    .replace(/&lt;br\s*\/?&gt;/gi,'<br>')
Line 349: Line 396:
  <div class="block-head">
  <div class="block-head">
    <span class="grip" title="Drag to reorder">${ICON.grip}</span>
    <span class="grip" title="Drag to reorder">${ICON.grip}</span>
    <button type="button" class="b-title" data-act="toggle" aria-expanded="${!isC}"><span class="name">${d.label}</span><span class="summary">${esc(String(d.sum(b)||'').slice(0,80))}</span></button>
    <button type="button" class="b-title" data-act="toggle" aria-expanded="${!isC}"><span class="name">${d.label}</span><span class="summary">${esc(String(d.sum(b)||'').slice(0,80))}</span><span class="foldnote"></span></button>
    <span class="tools">
    <span class="tools">${b.type==='heading'?`
      <button type="button" class="icon" data-act="secup" aria-label="Move this section up" title="Move this section up">${ICON.up2}</button>
      <button type="button" class="icon" data-act="secdown" aria-label="Move this section down" title="Move this section down">${ICON.down2}</button>
      <button type="button" class="icon" data-act="fold" aria-pressed="${folded.has(b.id)}" aria-label="${folded.has(b.id)?'Show':'Hide'} the blocks in this section" title="${folded.has(b.id)?'Show':'Hide'} the blocks in this section">${folded.has(b.id)?ICON.unfold:ICON.fold}</button>`:''}
      <button type="button" class="icon" data-act="up" aria-label="Move up" title="Move up">${ICON.up}</button>
      <button type="button" class="icon" data-act="up" aria-label="Move up" title="Move up">${ICON.up}</button>
      <button type="button" class="icon" data-act="down" aria-label="Move down" title="Move down">${ICON.down}</button>
      <button type="button" class="icon" data-act="down" aria-label="Move down" title="Move down">${ICON.down}</button>
Line 364: Line 414:
    `<div class="empty"><strong>This page is empty</strong><p>Drag a block from the list into this space, or click one to add it at the end.</p></div>`;
    `<div class="empty"><strong>This page is empty</strong><p>Drag a block from the list into this space, or click one to add it at the end.</p></div>`;
  blocksEl.querySelectorAll('[data-thumbs]').forEach(t=>fillThumbs(t.closest('.block')));
  blocksEl.querySelectorAll('[data-thumbs]').forEach(t=>fillThumbs(t.closest('.block')));
  applyFold();
}
}
const folded=new Set();
const lvlOf=b=>b.type==='heading'?(+b.level||2):(b.type==='refs'&&b.heading?2:null);
function sectionEnd(i){const L=lvlOf(state.blocks[i])||2;for(let j=i+1;j<state.blocks.length;j++){const l=lvlOf(state.blocks[j]);if(l!=null&&l<=L)return j}return state.blocks.length}
function applyFold(){
  let cur=null;const counts={};
  state.blocks.forEach(b=>{const l=lvlOf(b);if(cur&&l!=null&&l<=cur.l)cur=null;
    const card=cardOf(b.id);
    if(cur){counts[cur.id]++;if(card)card.classList.add('fold-hid')}else if(card)card.classList.remove('fold-hid');
    if(!cur&&b.type==='heading'&&folded.has(b.id)){cur={id:b.id,l};counts[b.id]=0}});
  [...folded].forEach(id=>{if(!state.blocks.some(b=>b.id===id))folded.delete(id)});
  blocksEl.querySelectorAll('.block').forEach(c=>{const s=c.querySelector('.foldnote');if(!s)return;const n=folded.has(c.dataset.id)?counts[c.dataset.id]||0:0;s.textContent=n?`${n} block${n===1?'':'s'} hidden`:''});
}
function revealBlock(id){const idx=state.blocks.findIndex(b=>b.id===id);let changed=false;
  state.blocks.forEach((b,i)=>{if(b.type==='heading'&&folded.has(b.id)&&i<idx&&sectionEnd(i)>idx){folded.delete(b.id);changed=true;const c=cardOf(b.id);if(c){const t=document.createElement('div');t.innerHTML=cardHtml(b);c.replaceWith(t.firstElementChild)}}});
  if(changed)applyFold()}
function moveSection(i,dir){const L=lvlOf(state.blocks[i])||2,end=sectionEnd(i);
  if(dir<0){let p=-1;for(let j=i-1;j>=0;j--){const l=lvlOf(state.blocks[j]);if(l!=null&&l<=L){if(l===L&&state.blocks[j].type==='heading')p=j;break}}
    if(p<0){showToast('This is already the first section at its level.');return false}
    const mine=state.blocks.splice(i,end-i);state.blocks.splice(p,0,...mine)}
  else{const nb=state.blocks[end];if(!nb||nb.type!=='heading'||lvlOf(nb)!==L){showToast('This is already the last section at its level.');return false}
    const theirs=state.blocks.splice(end,sectionEnd(end)-end);state.blocks.splice(i,0,...theirs)}
  return true}
function commit(){renderCanvas();updateOutput();save();record()}
function commit(){renderCanvas();updateOutput();save();record()}


Line 384: Line 458:
function redo(){acHide();if(hist.timer)record();if(hist.i<hist.stack.length-1){hist.i++;restoreFrom(hist.stack[hist.i])}}
function redo(){acHide();if(hist.timer)record();if(hist.i<hist.stack.length-1){hist.i++;restoreFrom(hist.stack[hist.i])}}
function rerenderCard(id){const c=cardOf(id),b=state.blocks.find(x=>x.id===id);if(!c||!b)return;const k=focusKey();
function rerenderCard(id){const c=cardOf(id),b=state.blocks.find(x=>x.id===id);if(!c||!b)return;const k=focusKey();
  const t=document.createElement('div');t.innerHTML=cardHtml(b);const n=t.firstElementChild;c.replaceWith(n);if(k&&k.id===id)refocus(k);fillThumbs(n)}
  const t=document.createElement('div');t.innerHTML=cardHtml(b);const n=t.firstElementChild;c.replaceWith(n);if(k&&k.id===id)refocus(k);fillThumbs(n);applyFold()}


function fillThumbs(card){if(!card||!hooks.thumb)return;const box=card.querySelector('[data-thumbs]');if(!box)return;
function fillThumbs(card){if(!card||!hooks.thumb)return;const box=card.querySelector('[data-thumbs]');if(!box)return;
Line 400: Line 474:
  el.innerHTML=hs.length?hs.map(b=>{const t=b.type==='refs'?'References':(b.text.trim()||'Untitled section');const l=b.type==='refs'?2:Math.min(5,Math.max(2,+b.level||2));
  el.innerHTML=hs.length?hs.map(b=>{const t=b.type==='refs'?'References':(b.text.trim()||'Untitled section');const l=b.type==='refs'?2:Math.min(5,Math.max(2,+b.level||2));
    return `<li class="l${l}"><button type="button" data-goto="${b.id}" title="${esc(t)}">${esc(t)}</button></li>`}).join(''):'<li><p class="outline-empty">Headings you add show up here.</p></li>'}
    return `<li class="l${l}"><button type="button" data-goto="${b.id}" title="${esc(t)}">${esc(t)}</button></li>`}).join(''):'<li><p class="outline-empty">Headings you add show up here.</p></li>'}
function gotoBlock(id){const c=cardOf(id);if(!c)return;
function gotoBlock(id){revealBlock(id);const c=cardOf(id);if(!c)return;
  if(collapsed.has(id)){collapsed.delete(id);c.classList.remove('collapsed');const t=c.querySelector('[data-act="toggle"]');if(t)t.setAttribute('aria-expanded','true')}
  if(collapsed.has(id)){collapsed.delete(id);c.classList.remove('collapsed');const t=c.querySelector('[data-act="toggle"]');if(t)t.setAttribute('aria-expanded','true')}
  if(c.scrollIntoView)c.scrollIntoView({block:'start',behavior:smooth()});flash(id);
  if(c.scrollIntoView)c.scrollIntoView({block:'start',behavior:smooth()});flash(id);
Line 408: Line 482:


function addBlock(type,idx){
function addBlock(type,idx){
  const b=mk(type);state.blocks.splice(idx,0,b);commit();
  const b=mk(type);state.blocks.splice(idx,0,b);commit();revealBlock(b.id);
  const c=cardOf(b.id);
  const c=cardOf(b.id);
  if(c){c.scrollIntoView({block:'nearest',behavior:'smooth'});flash(b.id);
  if(c){c.scrollIntoView({block:'nearest',behavior:'smooth'});flash(b.id);
Line 414: Line 488:
}
}


function setPath(o,path,v){const p=path.split('.');let t=o;for(let i=0;i<p.length-1;i++)t=t[p[i]];t[p[p.length-1]]=v}
function setPath(o,path,v){const p=path.split('.');let t=o;for(let i=0;i<p.length-1;i++){if(t[p[i]]==null)t[p[i]]=/^\d+$/.test(p[i+1])?[]:{};t=t[p[i]]}t[p[p.length-1]]=v}


function onField(e){
function onField(e){
Line 423: Line 497:
  else{const s=card.querySelector('.summary');if(s)s.textContent=String(DEFS[b.type].sum(b)||'').slice(0,80);
  else{const s=card.querySelector('.summary');if(s)s.textContent=String(DEFS[b.type].sum(b)||'').slice(0,80);
    const tc=card.querySelector('[data-tplcode]');if(tc)tc.textContent=infoboxTemplate(b);
    const tc=card.querySelector('[data-tplcode]');if(tc)tc.textContent=infoboxTemplate(b);
    const tn=card.querySelector('[data-tplname]');if(tn)tn.textContent='Template:'+(b.template.trim()||'Infobox')}
    const tn=card.querySelector('[data-tplname]');if(tn)tn.textContent='Template:'+(tdKey(b.template)||'Infobox');
    const sb=card.querySelector('[data-act="savetpl"]');if(sb&&!sb.classList.contains('armed'))sb.textContent='Save to Template:'+(tdKey(b.template)||'Infobox')}
  updateOutput();save();recordSoon();
  updateOutput();save();recordSoon();
}
}
Line 439: Line 514:
  const card=el.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
  const card=el.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
  if(hooks.templateData&&((b.type==='template'&&el.dataset.path==='name')||(b.type==='infobox'&&el.dataset.path==='template')))setTimeout(()=>rerenderCard(b.id),0);
  if(hooks.templateData&&((b.type==='template'&&el.dataset.path==='name')||(b.type==='infobox'&&el.dataset.path==='template')))setTimeout(()=>rerenderCard(b.id),0);
  else if((b.type==='image'&&el.dataset.path==='file')||(b.type==='gallery'&&el.dataset.path==='items'))fillThumbs(card)});
  else if((b.type==='image'&&el.dataset.path==='file')||(b.type==='gallery'&&el.dataset.path==='items'))fillThumbs(card);
  else if(b.type==='infobox'&&b.mode==='dedicated'&&/^rows\.\d+\.k$/.test(el.dataset.path))setTimeout(()=>rerenderCard(b.id),0)});
blocksEl.addEventListener('change',e=>{if(e.target.matches('select,input[type=checkbox]'))onField(e)});
blocksEl.addEventListener('change',e=>{if(e.target.matches('select,input[type=checkbox]'))onField(e)});


Line 451: Line 527:
const WRAP={bold:["'''","'''",'bold text'],italic:["''","''",'italic text'],under:['<u>','</u>','underlined text'],strike:['<s>','</s>','struck-out text'],
const WRAP={bold:["'''","'''",'bold text'],italic:["''","''",'italic text'],under:['<u>','</u>','underlined text'],strike:['<s>','</s>','struck-out text'],
  sup:['<sup>','</sup>','2'],sub:['<sub>','</sub>','2'],small:['<small>','</small>','small text'],big:['<big>','</big>','big text'],code:['<code>','</code>','code'],
  sup:['<sup>','</sup>','2'],sub:['<sub>','</sub>','2'],small:['<small>','</small>','small text'],big:['<big>','</big>','big text'],code:['<code>','</code>','code'],
  link:['[[',']]','Page name'],ext:['[https://example.com ',']','link text'],ref:['<ref>','</ref>','Source details'],tpl:['{{','}}','Template name'],nowiki:['<nowiki>','</nowiki>','text']};
  link:['[\u005B',']]','Page name'],ext:['[https://example.com ',']','link text'],ref:['<ref>','</ref>','Source details'],tpl:['{\u007B','}}','Template name'],nowiki:['<nowiki>','</nowiki>','text']};
function applyFmt(card,kind,arg){
function applyFmt(card,kind,arg){
  if(kind==='ref'){openCite(card);return}
  const f=fieldFor(card);if(!f)return;
  const f=fieldFor(card);if(!f)return;
  const v=f.value,s=f.selectionStart==null?v.length:f.selectionStart,e=f.selectionEnd==null?v.length:f.selectionEnd,sel=v.slice(s,e);
  const v=f.value,s=f.selectionStart==null?v.length:f.selectionStart,e=f.selectionEnd==null?v.length:f.selectionEnd,sel=v.slice(s,e);
Line 627: Line 704:
    case 'copytpl':copyText(infoboxTemplate(b)).then(ok=>{btn.textContent=ok?'Copied':'Select the code above to copy it';setTimeout(()=>btn.textContent='Copy template code',1800)});return;
    case 'copytpl':copyText(infoboxTemplate(b)).then(ok=>{btn.textContent=ok?'Copied':'Select the code above to copy it';setTimeout(()=>btn.textContent='Copy template code',1800)});return;
    case 'addrow':b.rows.push({k:'',v:''});commit();refocus(`[data-path="rows.${b.rows.length-1}.k"]`);return;
    case 'addrow':b.rows.push({k:'',v:''});commit();refocus(`[data-path="rows.${b.rows.length-1}.k"]`);return;
    case 'secup':case 'secdown':if(moveSection(i,act==='secup'?-1:1)){commit();flash(id);refocus(`[data-act="${act}"]`)}return;
    case 'fold':folded.has(id)?folded.delete(id):folded.add(id);rerenderCard(id);refocus('[data-act="fold"]');return;
    case 'savetpl':saveTemplatePage(b,btn);return;
    case 'trowup':{const r=+btn.dataset.i;if(r>0){[b.cells[r-1],b.cells[r]]=[b.cells[r],b.cells[r-1]];commit();refocus(`[data-act="trowup"][data-i="${r-1}"]`)}return}
    case 'trowdown':{const r=+btn.dataset.i;if(r<b.cells.length-1){[b.cells[r+1],b.cells[r]]=[b.cells[r],b.cells[r+1]];commit();refocus(`[data-act="trowdown"][data-i="${r+1}"]`)}return}
    case 'trowdel':if(b.cells.length>1){b.cells.splice(+btn.dataset.i,1);commit()}return;
    case 'tdfill':{const m=tdMissing(b);if(m.length){b.rows=b.rows.filter(r=>r.k.trim()||r.v.trim());m.forEach(k=>b.rows.push({k,v:''}));commit()}return}
    case 'tdfill':{const m=tdMissing(b);if(m.length){b.rows=b.rows.filter(r=>r.k.trim()||r.v.trim());m.forEach(k=>b.rows.push({k,v:''}));commit()}return}
    case 'delrow':b.rows.splice(+btn.dataset.i,1);if(!b.rows.length)b.rows.push({k:'',v:''});commit();return;
    case 'delrow':b.rows.splice(+btn.dataset.i,1);if(!b.rows.length)b.rows.push({k:'',v:''});commit();return;
Line 634: Line 717:
    case 'addcol':b.cells.forEach(r=>r.push(''));commit();refocus(`[data-path="cells.0.${b.cells[0].length-1}"]`);return;
    case 'addcol':b.cells.forEach(r=>r.push(''));commit();refocus(`[data-path="cells.0.${b.cells[0].length-1}"]`);return;
    case 'deltrow':if(b.cells.length>1){b.cells.pop();commit()}return;
    case 'deltrow':if(b.cells.length>1){b.cells.pop();commit()}return;
    case 'delcol':if(b.cells[0].length>1){b.cells.forEach(r=>r.pop());commit()}return;
    case 'delcol':if(b.cells[0].length>1){b.cells.forEach(r=>r.pop());if(b.align&&b.align.length>=b.cells[0].length+1)b.align.length=b.cells[0].length;commit()}return;
  }
  }
});
});
Line 683: Line 766:
function splitTop(s){const out=[];let cur='',d=0;
function splitTop(s){const out=[];let cur='',d=0;
  for(let i=0;i<s.length;){const two=s.substr(i,2);
  for(let i=0;i<s.length;){const two=s.substr(i,2);
    if(two==='{{'||two==='[['){d++;cur+=two;i+=2}
    if(two==='{\u007B'||two==='[\u005B'){d++;cur+=two;i+=2}
    else if((two==='}}'||two===']]')&&d>0){d--;cur+=two;i+=2}
    else if((two==='}}'||two===']]')&&d>0){d--;cur+=two;i+=2}
    else if(s[i]==='|'&&d===0){out.push(cur);cur='';i++}
    else if(s[i]==='|'&&d===0){out.push(cur);cur='';i++}
Line 689: Line 772:
  out.push(cur);return out}
  out.push(cur);return out}
function topEq(s){let d=0;for(let i=0;i<s.length;i++){const two=s.substr(i,2);
function topEq(s){let d=0;for(let i=0;i<s.length;i++){const two=s.substr(i,2);
  if(two==='{{'||two==='[['){d++;i++}else if((two==='}}'||two===']]')&&d>0){d--;i++}else if(s[i]==='='&&d===0)return i}return -1}
  if(two==='{\u007B'||two==='[\u005B'){d++;i++}else if((two==='}}'||two===']]')&&d>0){d--;i++}else if(s[i]==='='&&d===0)return i}return -1}
function matchPair(s,start,open,close){let d=0;for(let i=start;i<s.length;){
function matchPair(s,start,open,close){let d=0;for(let i=start;i<s.length;){
  if(s.startsWith(open,i)){d++;i+=2}else if(s.startsWith(close,i)){d--;i+=2;if(d===0)return i}else i++}return -1}
  if(s.startsWith(open,i)){d++;i+=2}else if(s.startsWith(close,i)){d--;i+=2;if(d===0)return i}else i++}return -1}
Line 745: Line 828:
    if(l.startsWith('|-')){if(l.slice(2).trim())return null;cur=null;continue}
    if(l.startsWith('|-')){if(l.slice(2).trim())return null;cur=null;continue}
    if(l[0]==='!'||l[0]==='|'){const h=l[0]==='!';const cells=l.slice(1).split(h?/!!|\|\|/:/\|\|/);
    if(l[0]==='!'||l[0]==='|'){const h=l[0]==='!';const cells=l.slice(1).split(h?/!!|\|\|/:/\|\|/);
      if(!cur){cur={c:[],h:[]};rows.push(cur)}
      if(!cur){cur={c:[],h:[],a:[]};rows.push(cur)}
      for(const c of cells){if(splitTop(c).length>1)return null;cur.c.push(c.trim());cur.h.push(h)}continue}
      for(const c of cells){const parts=splitTop(c);if(parts.length>2)return null;
        if(parts.length===2){const am=parts[0].trim().match(/^style\s*=\s*"\s*text-align\s*:\s*(left|center|right)\s*;?\s*"$/i);if(!am||h)return null;cur.c.push(parts[1].trim());cur.a.push(am[1].toLowerCase()==='left'?'':am[1].toLowerCase())}
        else{cur.c.push(c.trim());cur.a.push('')}
        cur.h.push(h)}continue}
    return null}
    return null}
  if(!rows.length)return null;
  if(!rows.length)return null;
Line 752: Line 838:
  if(!header&&rows[0].h.some(Boolean))return null;
  if(!header&&rows[0].h.some(Boolean))return null;
  if(rows.slice(1).some(r=>r.h.some(Boolean)))return null;
  if(rows.slice(1).some(r=>r.h.some(Boolean)))return null;
  const w=Math.max(...rows.map(r=>r.c.length));
  const w=Math.max(...rows.map(r=>r.c.length));const align=[];
  return {caption,header,sortable:cls.includes('sortable'),cells:rows.map(r=>r.c.concat(Array(w-r.c.length).fill('')))};
  for(let j=0;j<w;j++){const vals=new Set(rows.slice(header?1:0).map(r=>r.a[j]||''));if(vals.size>1)return null;align.push(vals.size?[...vals][0]:'')}
  return {caption,header,sortable:cls.includes('sortable'),cells:rows.map(r=>r.c.concat(Array(w-r.c.length).fill(''))),align};
}
}
function tagBlock(tag,a,inner,attrText){
function tagBlock(tag,a,inner,attrText){
  const keys=Object.keys(a);const trimNl=s=>s.replace(/^\n/,'').replace(/\n\s*$/,'');
  const keys=Object.keys(a);const trimNl=s=>s.replace(/^\n/,'').replace(/\n\s*$/,'');
  switch(tag){
  switch(tag){
    case 'gallery':return attrText?null:['gallery',{items:lines(inner).map(l=>l.trim()).join('\n')}];
    case 'gallery':{if(keys.some(k=>!['mode','heights','caption'].includes(k)))return null;const mode=a.mode&&a.mode!==true?String(a.mode).toLowerCase():'';
      if(mode&&!GALLERY_MODES.some(x=>x[0]===mode))return null;
      return ['gallery',{items:lines(inner).map(l=>l.trim()).join('\n'),mode,heights:a.heights&&a.heights!==true?String(a.heights):'',caption:a.caption&&a.caption!==true?String(a.caption).replace(/&quot;/g,'"'):''}]}
    case 'syntaxhighlight':case 'source':
    case 'syntaxhighlight':case 'source':
      if(!a.lang||a.lang===true||keys.some(k=>k!=='lang'&&k!=='line'))return null;
      if(!a.lang||a.lang===true||keys.some(k=>k!=='lang'&&k!=='line'))return null;
Line 786: Line 875:
    while((m=re.exec(r))){if(m[1])d--;else if(!m[3])d++;if(d<=0)return re.lastIndex}return -1};
    while((m=re.exec(r))){if(m[1])d--;else if(!m[3])d++;if(d<=0)return re.lastIndex}return -1};
  const standalone=k=>{const l=L[k];const r=L.slice(k).join('\n');
  const standalone=k=>{const l=L[k];const r=L.slice(k).join('\n');
    if(l.startsWith('{{'))return span(r,matchPair(r,0,'{{','}}'))>0;
    if(l.startsWith('{\u007B'))return span(r,matchPair(r,0,'{\u007B','}}'))>0;
    if(/^\[\[\s*(File|Image)\s*:/i.test(l))return span(r,matchPair(r,0,'[[',']]'))>0;
    if(/^\[\[\s*(File|Image)\s*:/i.test(l))return span(r,matchPair(r,0,'[\u005B',']]'))>0;
    return false};
    return false};
  const blockTagRe=new RegExp(`^<(${BLOCK_TAGS})\\b`,'i');
  const blockTagRe=new RegExp(`^<(${BLOCK_TAGS})\\b`,'i');
Line 820: Line 909:
      if(n){const inner=r.slice(m[0].length,innerEnd);const whole=take(n);const blk=tagBlock(tag,attrsOf(attr),inner,attr.trim());if(blk)add(blk[0],blk[1]);else raw(whole);return}
      if(n){const inner=r.slice(m[0].length,innerEnd);const whole=take(n);const blk=tagBlock(tag,attrsOf(attr),inner,attr.trim());if(blk)add(blk[0],blk[1]);else raw(whole);return}
    }
    }
    if(/^<div class="(pb-note |mw-collapsible)/.test(line)){r=rest();e=tagSpan(r,'div');n=span(r,e);
      if(n){const blk=divBlock(r.slice(0,e));if(blk){i+=n;add(blk[0],blk[1]);return}}}
    if((m=line.match(blockTagRe))){r=rest();e=tagSpan(r,m[1]);n=span(r,e);if(n){raw(take(n));return}if(e<0){raw(take(1));return}}
    if((m=line.match(blockTagRe))){r=rest();e=tagSpan(r,m[1]);n=span(r,e);if(n){raw(take(n));return}if(e<0){raw(take(1));return}}
    if(line.startsWith('{{')){r=rest();e=matchPair(r,0,'{{','}}');n=span(r,e);
    if(line.startsWith('{\u007B')){r=rest();e=matchPair(r,0,'{\u007B','}}');n=span(r,e);
      if(n){const whole=r.slice(0,e);i+=n;const blk=templateBlock(whole.slice(2,-2));if(blk)add(blk[0],blk[1]);else raw(whole);return}}
      if(n){const whole=r.slice(0,e);i+=n;const blk=templateBlock(whole.slice(2,-2));if(blk)add(blk[0],blk[1]);else raw(whole);return}}
    if(/^\[\[\s*(File|Image)\s*:/i.test(line)){r=rest();e=matchPair(r,0,'[[',']]');n=span(r,e);
    if(/^\[\[\s*(File|Image)\s*:/i.test(line)){r=rest();e=matchPair(r,0,'[\u005B',']]');n=span(r,e);
      if(n){const whole=r.slice(0,e);i+=n;const p=imageBlock(whole.slice(2,-2));if(p)add('image',p);else raw(whole);return}}
      if(n){const whole=r.slice(0,e);i+=n;const p=imageBlock(whole.slice(2,-2));if(p)add('image',p);else raw(whole);return}}
    const start=i;i++;
    const start=i;i++;
Line 926: Line 1,017:
  const first=cardOf(blocks[0].id);if(first&&first.scrollIntoView)first.scrollIntoView({block:'nearest'});
  const first=cardOf(blocks[0].id);if(first&&first.scrollIntoView)first.scrollIntoView({block:'nearest'});
});
});
const showDlg=d=>{try{d.showModal()}catch(e){d.setAttribute('open','')}};
const hideDlg=d=>{try{d.close()}catch(e){d.removeAttribute('open')}};
let citeTarget=null;
function citeKindChanged(){const k=$('#pb-citeKind').value;$('#pb-citeDlg').querySelectorAll('[data-for]').forEach(el=>{el.hidden=!el.dataset.for.split(' ').includes(k)});
  $('#pb-citeWorkLabel').textContent=k==='book'?'Publisher':'Website';$('#pb-citeFmtWrap').hidden=k==='other'}
function openCite(card){const f=fieldFor(card);if(!f)return;
  citeTarget={f,pos:f.selectionEnd==null?f.value.length:f.selectionEnd};
  [$('#pb-citeTitle'),$('#pb-citeUrl'),$('#pb-citeAuthor'),$('#pb-citeWork'),$('#pb-citeDate'),$('#pb-citeText'),$('#pb-citeName')].forEach(x=>{x.value=''});
  $('#pb-citeAccess').value=new Date().toISOString().slice(0,10);$('#pb-citeErr').textContent='';
  const names=[];gen().replace(/<ref\s+name\s*=\s*"?([^"\/>]+?)"?\s*>/gi,(m,n)=>{n=n.trim();if(n&&!names.includes(n))names.push(n);return m});
  $('#pb-citeReuse').innerHTML=names.length?`<p class="sub">Use a reference that’s already on the page</p><div class="cite-reuse">${names.map(n=>`<button type="button" class="btn" data-reuse="${esc(n)}">${esc(n)}</button>`).join('')}</div><p class="sub">Or add a new one</p>`:'';
  citeKindChanged();showDlg($('#pb-citeDlg'));$('#pb-citeTitle').focus()}
function citeInsert(t){if(!citeTarget)return;const {f,pos}=citeTarget;citeTarget=null;hideDlg($('#pb-citeDlg'));
  const v=f.value;f.value=v.slice(0,pos)+t+v.slice(pos);f.focus();try{f.setSelectionRange(pos+t.length,pos+t.length)}catch(e){}
  f.dispatchEvent(new Event('input',{bubbles:true}))}
function citeWikitext(){
  const k=$('#pb-citeKind').value,tpl=$('#pb-citeFmt').value==='tpl',g=x=>$(x).value.trim();
  const title=g('#pb-citeTitle'),url=g('#pb-citeUrl'),author=g('#pb-citeAuthor'),work=g('#pb-citeWork'),date=g('#pb-citeDate'),access=g('#pb-citeAccess'),text=g('#pb-citeText');
  let body;
  if(k==='other'){if(!text)return {err:'Write the reference text first.'};body=text}
  else{
    if(k==='web'&&!title&&!url)return {err:'Add a title or a link.'};
    if(k==='book'&&!title)return {err:'Add the book’s title.'};
    if(tpl){const ps=[['title',title],['url',k==='web'?url:''],['author',author],[k==='web'?'website':'publisher',work],['date',date],['access-date',k==='web'&&url?access:'']].filter(x=>x[1]);
      body=`{\u007B${k==='web'?'cite web':'cite book'}${ps.map(([a,v])=>`|${a}=${v}`).join('')}}}`}
    else{const parts=[];if(author)parts.push(author.replace(/\.$/,'')+'.');
      if(k==='web')parts.push(url?(title?`"[${url} ${title}]".`:`[${url}].`):`"${title}".`);else parts.push(`''${title}''.`);
      if(work)parts.push(k==='web'?`''${work}''.`:`${work}.`);if(date)parts.push(date+'.');if(k==='web'&&url&&access)parts.push(`Retrieved ${access}.`);
      body=parts.join(' ')}}
  const nm=g('#pb-citeName').replace(/["<>\/]/g,'');
  return {text:nm?`<ref name="${nm}">${body}</ref>`:`<ref>${body}</ref>`};
}
$('#pb-citeKind').addEventListener('change',citeKindChanged);
$('#pb-citeCancel').addEventListener('click',()=>{citeTarget=null;hideDlg($('#pb-citeDlg'))});
$('#pb-citeGo').addEventListener('click',()=>{const r=citeWikitext();if(r.err){$('#pb-citeErr').textContent=r.err;return}citeInsert(r.text)});
$('#pb-citeReuse').addEventListener('click',e=>{const b=e.target.closest('[data-reuse]');if(b)citeInsert(`<ref name="${b.dataset.reuse}" />`)});
let upTarget=null,upFiles=[];
const objUrl=f=>window.URL&&URL.createObjectURL?URL.createObjectURL(f):'';
const cleanName=n=>String(n).replace(/[#<>\[\]|{}:\/\\]+/g,'-').replace(/\s+/g,' ').trim();
function openUpload(id,files){if(!hooks.upload||!files.length)return;
  upTarget=id;upFiles=files.map(f=>({file:f,url:objUrl(f),name:cleanName(f.name),msg:'',bad:false}));
  $('#pb-upErr').textContent='';$('#pb-upGo').disabled=false;$('#pb-upGo').textContent=upFiles.length>1?`Upload ${upFiles.length} pictures`:'Upload';
  renderUpList();showDlg($('#pb-upDlg'));const i=$('#pb-upList input');if(i)i.focus()}
function renderUpList(){$('#pb-upList').innerHTML=upFiles.map((u,i)=>`<div class="upitem">${u.url?`<img src="${u.url}" alt="">`:''}<div class="upfields"><label class="field"><span>File name</span><input type="text" data-upi="${i}" value="${esc(u.name)}"${u.done?' disabled':''}></label><p class="upstate${u.bad?' bad':''}">${esc(u.msg)}</p>${u.dup&&!u.done?`<button type="button" class="linkbtn" data-usedup="${i}">Use File:${esc(u.dup)} instead</button>`:''}${u.canForce&&!u.done?`<button type="button" class="linkbtn" data-force="${i}">Upload anyway</button>`:''}</div></div>`).join('')}
function uploadProblem(u,err){const w=(err&&err.warnings)||{};u.bad=true;u.dup=null;u.canForce=false;
  if(w.exists||w['exists-normalized']||w['page-exists'])u.msg=`A file called ${u.name} is already on the wiki. Change the name to upload yours.`;
  else if(w.duplicate){u.dup=String([].concat(w.duplicate)[0]).replace(/_/g,' ');u.msg='This picture is already on the wiki.'}
  else if(w.badfilename){u.name=String(w.badfilename).replace(/_/g,' ');u.msg=`Your wiki needs a different name, so it suggests ${u.name}.`;u.canForce=true}
  else if(w['was-deleted']||w['duplicate-archive']){u.msg='A file with this name or content was deleted from the wiki before.';u.canForce=true}
  else if(Object.keys(w).length){u.msg='Your wiki warned about this file: '+Object.keys(w).join(', ')+'.';u.canForce=true}
  else u.msg=(err&&err.info)||'The upload didn’t work.'}
function runUploads(){const go=$('#pb-upGo');go.disabled=true;go.textContent='Uploading…';const desc=$('#pb-upDesc').value.trim();
  let chain=Promise.resolve();
  upFiles.filter(u=>!u.done).forEach(u=>{chain=chain.then(()=>{u.msg='Uploading…';u.bad=false;renderUpList();
    return Promise.resolve(hooks.upload(u.file,u.name,desc,!!u.force)).then(r=>{u.done=true;u.final=r.filename;u.msg='Uploaded'},err=>uploadProblem(u,err)).then(renderUpList)})});
  chain.then(()=>{go.disabled=false;go.textContent='Try again';if(upFiles.every(u=>u.done))finishUploads()})}
function finishUploads(){const b=state.blocks.find(x=>x.id===upTarget);const names=upFiles.filter(u=>u.done).map(u=>u.final);
  hideDlg($('#pb-upDlg'));upFiles.forEach(u=>{if(u.url&&window.URL&&URL.revokeObjectURL)URL.revokeObjectURL(u.url)});upFiles=[];
  if(!b||!names.length)return;
  if(b.type==='image')b.file=names[0];else b.items=lines(b.items).concat(names).join('\n');
  commit();revealBlock(b.id);flash(b.id);showToast(names.length===1?`Added ${names[0]}`:`Added ${names.length} pictures`)}
$('#pb-upGo').addEventListener('click',runUploads);
$('#pb-upCancel').addEventListener('click',()=>{if(upFiles.some(u=>u.done))finishUploads();else{hideDlg($('#pb-upDlg'));upFiles=[]}});
$('#pb-upList').addEventListener('input',e=>{const i=e.target.dataset.upi;if(i!=null&&upFiles[+i])upFiles[+i].name=e.target.value});
$('#pb-upList').addEventListener('click',e=>{const d=e.target.closest('[data-usedup]'),f=e.target.closest('[data-force]');
  if(d){const u=upFiles[+d.dataset.usedup];u.done=true;u.final=u.dup;u.bad=false;u.msg='Using the file that’s already on the wiki';renderUpList();if(upFiles.every(x=>x.done))finishUploads()}
  if(f){upFiles[+f.dataset.force].force=true;runUploads()}});
blocksEl.addEventListener('change',e=>{const inp=e.target;if(!inp.matches||!inp.matches('input[data-upload]'))return;const card=inp.closest('.block');
  openUpload(card.dataset.id,[...inp.files].filter(f=>/^image\//.test(f.type)).slice(0,20));inp.value=''});
const hasFiles=e=>!!(e.dataTransfer&&[...(e.dataTransfer.types||[])].includes('Files'));
const clearDropMarks=()=>blocksEl.querySelectorAll('.block.dropfile').forEach(c=>c.classList.remove('dropfile'));
canvasEl.addEventListener('dragover',e=>{if(drag||!hooks.upload||!hasFiles(e))return;e.preventDefault();e.dataTransfer.dropEffect='copy';
  const card=e.target.closest&&e.target.closest('.block');const t=card&&state.blocks.find(b=>b.id===card.dataset.id);clearDropMarks();
  if(t&&(t.type==='image'||t.type==='gallery')){card.classList.add('dropfile');hideLine()}else showLine(targetFor(e.clientY))});
canvasEl.addEventListener('dragleave',e=>{if(!canvasEl.contains(e.relatedTarget))clearDropMarks()});
canvasEl.addEventListener('drop',e=>{if(drag||!hooks.upload||!hasFiles(e))return;e.preventDefault();hideLine();clearDropMarks();
  const files=[...e.dataTransfer.files].filter(f=>/^image\//.test(f.type)).slice(0,20);if(!files.length){showToast('Only pictures can be uploaded here.');return}
  const card=e.target.closest&&e.target.closest('.block');const t=card&&state.blocks.find(b=>b.id===card.dataset.id);
  if(t&&(t.type==='image'||t.type==='gallery')){openUpload(t.id,t.type==='image'?files.slice(0,1):files);return}
  const before=targetFor(e.clientY);const idx=before?state.blocks.findIndex(b=>b.id===before):state.blocks.length;
  const nb=mk(files.length>1?'gallery':'image',files.length>1?{items:''}:{});state.blocks.splice(idx,0,nb);commit();openUpload(nb.id,files)});
function saveTemplatePage(b,btn){const name=tdKey(b.template)||'Infobox',label=`Save to Template:${name}`;
  const reset=()=>{btn.disabled=false;btn.classList.remove('armed');btn.textContent=label};
  const go=()=>{btn.disabled=true;btn.textContent='Saving…';
    Promise.resolve(hooks.saveTemplate(name,infoboxTemplate(b))).then(()=>{reset();tdCache.delete(name);existCache.set('Template:'+name,true);scheduleChecks();showToast(`Saved Template:${name}`)},
      err=>{reset();showToast(`Couldn’t save the template: ${(err&&err.info)||err||'unknown error'}`)})};
  if(btn.classList.contains('armed')){go();return}
  Promise.resolve(hooks.exists(['Template:'+name])).then(m=>{if(m&&m['Template:'+name]===false)go();
    else{btn.classList.add('armed');btn.textContent=`Replace the existing Template:${name}?`;setTimeout(()=>{if(btn.isConnected&&!btn.disabled&&btn.classList.contains('armed'))reset()},5000)}},go)}
blocksEl.addEventListener('paste',e=>{const f=e.target;const m=f.dataset&&f.dataset.path&&f.dataset.path.match(/^cells\.(\d+)\.(\d+)$/);if(!m)return;
  const txt=((e.clipboardData||window.clipboardData)||{getData:()=>''}).getData('text');if(!txt)return;
  const body=txt.replace(/\r/g,'').replace(/\n+$/,'');if(!/[\t\n]/.test(body))return;e.preventDefault();
  const card=f.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
  const rows=body.split('\n').map(l=>l.split('\t').map(c=>c.replace(/^"([\s\S]*)"$/,'$1').replace(/""/g,'"').replace(/\|/g,'&#124;').trim()));
  const r0=+m[1],c0=+m[2],width=Math.max(b.cells[0].length,c0+Math.max(...rows.map(r=>r.length)));
  b.cells.forEach(r=>{while(r.length<width)r.push('')});
  rows.forEach((cells,ri)=>{const R=r0+ri;while(b.cells.length<=R)b.cells.push(Array(width).fill(''));cells.forEach((v,ci)=>{b.cells[R][c0+ci]=v})});
  commit();showToast(`Pasted ${rows.length} row${rows.length===1?'':'s'}`)});
let qi=null;
const qiBox=$('#pb-qiBox'),qiInput=$('#pb-qiInput'),qiList=$('#pb-qiList');
function qiOpen(at,replace,anchor){qi={at,replace,anchor,i:0,items:[]};qiBox.hidden=false;qiInput.value='';qiFilter();
  const r=(anchor||canvasEl).getBoundingClientRect(),W=Math.min(320,window.innerWidth-16);
  qiBox.style.left=Math.max(8,Math.min(r.left+(anchor?0:24),window.innerWidth-W-8))+'px';
  qiBox.style.top=Math.max(8,Math.min(anchor?r.bottom+4:r.top+24,window.innerHeight-320))+'px';qiInput.focus()}
function qiFilter(){if(!qi)return;const q=qiInput.value.trim().toLowerCase();
  qi.items=GROUPS.reduce((a,g)=>a.concat(g[2]),[]).filter(t=>!q||(DEFS[t].label+' '+DEFS[t].desc+' '+t).toLowerCase().includes(q));qi.i=0;
  qiList.innerHTML=qi.items.length?qi.items.map((t,i)=>`<div class="ac-opt qi-opt${i?'':' on'}" role="option" id="${qiList.id}-o${i}" data-t="${t}" aria-selected="${i===0}"><span class="tok">${esc(DEFS[t].tok)}</span>${esc(DEFS[t].label)}</div>`).join(''):'<p class="note">No block matches that.</p>';
  if(qi.items.length)qiInput.setAttribute('aria-activedescendant',qiList.id+'-o0');else qiInput.removeAttribute('aria-activedescendant')}
function qiMove(d){if(!qi||!qi.items.length)return;qi.i=(qi.i+d+qi.items.length)%qi.items.length;
  qiList.querySelectorAll('.qi-opt').forEach((o,i)=>{o.classList.toggle('on',i===qi.i);o.setAttribute('aria-selected',String(i===qi.i))});
  const on=qiList.querySelector('.qi-opt.on');if(on){if(on.scrollIntoView)on.scrollIntoView({block:'nearest'});qiInput.setAttribute('aria-activedescendant',on.id)}}
function qiClose(back){if(!qi)return;const a=qi.anchor;qi=null;qiBox.hidden=true;if(back&&a&&a.isConnected)a.focus()}
function qiPick(t){if(!qi)return;const at=qi.at,rp=qi.replace;qiClose(false);
  if(rp){const i=state.blocks.findIndex(b=>b.id===rp);if(i>=0){state.blocks.splice(i,1);addBlock(t,i);return}}
  addBlock(t,Math.min(at,state.blocks.length))}
qiInput.addEventListener('input',qiFilter);
qiInput.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();qiMove(1)}else if(e.key==='ArrowUp'){e.preventDefault();qiMove(-1)}
  else if(e.key==='Enter'){e.preventDefault();if(qi&&qi.items[qi.i])qiPick(qi.items[qi.i])}else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();qiClose(true)}});
qiInput.addEventListener('blur',()=>setTimeout(()=>{if(qi&&document.activeElement!==qiInput)qiClose(false)},150));
qiList.addEventListener('mousedown',e=>e.preventDefault());
qiList.addEventListener('click',e=>{const o=e.target.closest('.qi-opt');if(o)qiPick(o.dataset.t)});
$('.app').addEventListener('keydown',e=>{if(e.key!=='/'||e.ctrlKey||e.metaKey||e.altKey||qi)return;
  const t=e.target;if(t.closest&&t.closest('input,textarea,select,[contenteditable="true"],dialog'))return;
  e.preventDefault();const card=document.activeElement&&document.activeElement.closest&&document.activeElement.closest('.block');
  const idx=card?state.blocks.findIndex(b=>b.id===card.dataset.id)+1:state.blocks.length;qiOpen(idx,null,null)});
blocksEl.addEventListener('input',e=>{const f=e.target;if(qi||!f.matches||!f.matches('textarea[data-path="text"]')||f.value!=='/')return;
  const card=f.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b||b.type!=='paragraph')return;
  qiOpen(state.blocks.indexOf(b),b.id,f)});


let presetCache=null;
let presetCache=null;
Line 964: Line 1,189:
  showToast,
  showToast,
  offerPresets(){openPresets(true)},
  offerPresets(){openPresets(true)},
  replace(text){let blocks;try{blocks=parseWikitext(text)}catch(e){blocks=[mk('raw',{text})]}state.blocks=blocks;collapsed.clear();commit()},
  hideMenus(){acHide()}
  hideMenus(){acHide()}
};
};
Line 1,006: Line 1,232:


const hooks = {
const hooks = {
onChange: () => scheduleLive(),
onChange: () => {
scheduleLive();
saveDraftSoon();
},
upload( file, name, desc, force ) {
return new Promise( ( resolve, reject ) => {
const params = { action: 'upload', filename: name, file: file, comment: desc || 'Uploaded with the page builder', formatversion: 2 };
if ( desc ) {
params.text = desc;
}
if ( force ) {
params.ignorewarnings = 1;
}
api.postWithEditToken( params, { contentType: 'multipart/form-data' } ).then( ( d ) => {
const u = d.upload || {};
if ( u.result === 'Success' ) {
resolve( { filename: String( u.filename || name ).replace( /_/g, ' ' ) } );
} else {
reject( { warnings: u.warnings || {}, info: u.result || 'The upload didn’t work.' } );
}
}, ( code, d ) => reject( { code: code, info: ( d && d.error && d.error.info ) || code } ) );
} );
},
saveTemplate( name, code ) {
return new Promise( ( resolve, reject ) => {
api.postWithEditToken( { action: 'edit', title: 'Template:' + name, text: code, summary: 'Infobox template made with the page builder', formatversion: 2 } ).then( ( d ) => {
if ( d.edit && d.edit.result === 'Success' ) {
resolve( true );
} else {
reject( { info: ( d.edit && d.edit.info ) || 'Your wiki didn’t accept the edit.' } );
}
}, ( code, d ) => reject( { info: ( d && d.error && d.error.info ) || code } ) );
} );
},
url: ( t ) => mw.util.getUrl( t ),
url: ( t ) => mw.util.getUrl( t ),
search( kind, query ) {
search( kind, query ) {
Line 1,146: Line 1,405:
cancelBtn.addEventListener( 'click', () => {
cancelBtn.addEventListener( 'click', () => {
if ( app.gen() === baseline || cancelArmed ) {
if ( app.gen() === baseline || cancelArmed ) {
clearTimeout( draftTimer );
draftTimer = null;
drafts.clear();
close();
close();
return;
return;
Line 1,303: Line 1,565:
return;
return;
}
}
drafts.clear();
box.value = box.defaultValue;
box.value = box.defaultValue;
$( window ).off( 'beforeunload' );
$( window ).off( 'beforeunload' );
Line 1,315: Line 1,578:
}
}
} );
} );
} );
const banner = q( '#pb-draftBanner' );
const draftMsg = q( '#pb-draftMsg' );
const draftKey = 'pb-draft:' + ( mw.config.get( 'wgWikiID' ) || '' ) + ':' + pageTitle + ':' + section;
const hashText = ( t ) => {
let h = 2166136261;
for ( let i = 0; i < t.length; i++ ) {
h ^= t.charCodeAt( i );
h = Math.imul( h, 16777619 );
}
return ( h >>> 0 ).toString( 16 ) + ':' + t.length;
};
const drafts = {
get() {
try {
return JSON.parse( localStorage.getItem( draftKey ) || 'null' );
} catch ( e ) {
return null;
}
},
set( v ) {
try {
localStorage.setItem( draftKey, JSON.stringify( v ) );
} catch ( e ) {}
},
clear() {
try {
localStorage.removeItem( draftKey );
} catch ( e ) {}
}
};
let draftTimer = null;
let pendingDraft = null;
function saveDraftNow() {
clearTimeout( draftTimer );
draftTimer = null;
if ( root.hidden ) {
return;
}
const text = app.gen();
if ( text === baseline ) {
drafts.clear();
return;
}
drafts.set( { text: text, base: hashText( box.defaultValue ), time: Date.now() } );
}
function saveDraftSoon() {
clearTimeout( draftTimer );
draftTimer = setTimeout( saveDraftNow, 1000 );
}
window.addEventListener( 'pagehide', () => {
if ( draftTimer ) {
saveDraftNow();
}
} );
const whenText = ( t ) => {
const m = Math.round( ( Date.now() - t ) / 60000 );
if ( m < 1 ) {
return 'a moment ago';
}
if ( m < 60 ) {
return m + ( m === 1 ? ' minute ago' : ' minutes ago' );
}
const hrs = Math.round( m / 60 );
if ( hrs < 24 ) {
return hrs + ( hrs === 1 ? ' hour ago' : ' hours ago' );
}
return 'on ' + new Date( t ).toLocaleString();
};
q( '#pb-draftRestore' ).addEventListener( 'click', () => {
if ( pendingDraft ) {
app.replace( pendingDraft.text );
app.showToast( 'Draft restored. Undo puts the page back the way it was.' );
}
pendingDraft = null;
banner.hidden = true;
} );
q( '#pb-draftDiscard' ).addEventListener( 'click', () => {
pendingDraft = null;
banner.hidden = true;
drafts.clear();
} );
} );


Line 1,332: Line 1,678:
}
}
scheduleLive();
scheduleLive();
if ( !text.trim() ) {
pendingDraft = null;
banner.hidden = true;
const draft = drafts.get();
if ( draft && typeof draft.text === 'string' ) {
if ( draft.base !== hashText( box.defaultValue ) ) {
drafts.clear();
} else if ( draft.text !== baseline ) {
pendingDraft = draft;
draftMsg.textContent = 'You have unsaved changes to this page from ' + whenText( draft.time ) + '. Do you want to carry on where you left off?';
banner.hidden = false;
}
}
if ( !text.trim() && !pendingDraft ) {
app.offerPresets();
app.offerPresets();
}
}

Latest revision as of 07:56, 26 October 2026

( function () {
	'use strict';

	if ( [ 'edit', 'submit' ].indexOf( mw.config.get( 'wgAction' ) ) === -1 ) {
		return;
	}
	if ( mw.config.get( 'wgPageContentModel' ) !== 'wikitext' ) {
		return;
	}

	const MARKUP = "<div class=\"app\">\n  <header class=\"top\">\n    <h1 class=\"brand\">Page builder</h1>\n    <div class=\"title-field\">\n      <label for=\"pb-pageTitle\">Page</label>\n      <input type=\"text\" id=\"pb-pageTitle\" readonly>\n    </div>\n    <div class=\"top-actions\">\n      <span class=\"hist\"><button class=\"btn\" id=\"pb-undoBtn\" type=\"button\" title=\"Undo (Ctrl+Z)\" disabled>Undo</button><button class=\"btn\" id=\"pb-redoBtn\" type=\"button\" title=\"Redo (Ctrl+Y)\" disabled>Redo</button></span>\n      <button class=\"btn\" id=\"pb-importBtn\" type=\"button\">Import</button>\n      <button class=\"btn\" id=\"pb-presetsBtn\" type=\"button\">Presets</button>\n      <button class=\"btn danger\" id=\"pb-clearPage\" type=\"button\">Clear page</button>\n      <button class=\"btn\" id=\"pb-cancelBuilder\" type=\"button\">Cancel</button>\n      <button class=\"btn\" id=\"pb-doneBuilder\" type=\"button\" title=\"Put your changes in the edit box and go back to the editor\">Done</button>\n      <button class=\"btn primary\" id=\"pb-saveBuilder\" type=\"button\">Save page…</button>\n    </div>\n  </header>\n\n  <div class=\"main\">\n    <aside class=\"pane palette\" aria-label=\"Blocks\">\n      <div class=\"pal-groups\" id=\"pb-palette\"></div>\n      <p class=\"pal-hint\">Drag a block onto the page, or click it to add it at the end. Press / to find one by name.</p>\n      <nav class=\"outline\" aria-labelledby=\"pb-outlineHeading\">\n        <div class=\"outline-head\"><h2 id=\"pb-outlineHeading\">Outline</h2><span class=\"outline-tools\"><button type=\"button\" class=\"linkbtn\" id=\"pb-collapseAll\">Collapse all</button><button type=\"button\" class=\"linkbtn\" id=\"pb-expandAll\">Expand all</button></span></div>\n        <ul class=\"outline-list\" id=\"pb-outline\"></ul>\n      </nav>\n    </aside>\n\n    <main class=\"pane canvas\" id=\"pb-canvas\" aria-label=\"Page layout\">\n      <div class=\"canvas-inner\">\n        <div class=\"banner\" id=\"pb-draftBanner\" role=\"status\" hidden><p id=\"pb-draftMsg\"></p><span class=\"banner-actions\"><button class=\"btn primary\" id=\"pb-draftRestore\" type=\"button\">Restore it</button><button class=\"btn\" id=\"pb-draftDiscard\" type=\"button\">Discard it</button></span></div>\n        <div class=\"blocks\" id=\"pb-blocks\"></div>\n      </div>\n    </main>\n\n    <section class=\"pane output\" aria-label=\"Output\">\n      <div class=\"out-head\">\n        <div class=\"tabs\" role=\"tablist\">\n          <button class=\"tab\" role=\"tab\" id=\"pb-tab-code\" aria-controls=\"pb-panel-code\" aria-selected=\"true\" type=\"button\">Wikitext</button>\n          <button class=\"tab\" role=\"tab\" id=\"pb-tab-prev\" aria-controls=\"pb-panel-prev\" aria-selected=\"false\" type=\"button\">Preview</button>\n          <button class=\"tab\" role=\"tab\" id=\"pb-tab-chk\" aria-controls=\"pb-panel-chk\" aria-selected=\"false\" type=\"button\">Checks</button>\n        </div>\n        <div class=\"out-tools\">\n          <span class=\"meta\" id=\"pb-meta\"></span>\n          <button class=\"btn\" id=\"pb-copyBtn\" type=\"button\">Copy wikitext</button>\n        </div>\n      </div>\n      <div class=\"panel\" id=\"pb-panel-code\" role=\"tabpanel\" aria-labelledby=\"pb-tab-code\"><pre class=\"wikitext\" id=\"pb-code\"></pre></div>\n      <div class=\"panel\" id=\"pb-panel-prev\" role=\"tabpanel\" aria-labelledby=\"pb-tab-prev\" hidden><p class=\"pb-status\" id=\"pb-liveStatus\" role=\"status\"></p><div class=\"pb-live mw-body-content\" id=\"pb-live\"></div><div class=\"wiki\" id=\"pb-preview\" hidden></div></div>\n      <div class=\"panel\" id=\"pb-panel-chk\" role=\"tabpanel\" aria-labelledby=\"pb-tab-chk\" hidden><div class=\"checks-panel\" id=\"pb-checks\"></div></div>\n    </section>\n  </div>\n</div>\n<dialog class=\"dlg\" id=\"pb-importDlg\" aria-labelledby=\"pb-importHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-importHeading\">Import wikitext</h2>\n    <p class=\"note\">Paste wikitext from another page or open a file. Anything the editor doesn’t have a block for is kept as raw wikitext, so nothing is lost.</p>\n    <textarea id=\"pb-importText\" class=\"mono\" spellcheck=\"false\" aria-label=\"Wikitext to import\" placeholder=\"{\u007BInfobox&#10;| title = …&#10;}}&#10;&#10;'''Page''' is …&#10;&#10;== Section ==\"></textarea>\n    <div class=\"dlg-row\"><label class=\"btn filebtn\">Open a file<input type=\"file\" id=\"pb-importFile\" class=\"sr-only\" accept=\".txt,.wiki,.wikitext,.mediawiki,text/plain\"></label><span class=\"meta\" id=\"pb-importFileName\"></span></div>\n    <input type=\"hidden\" id=\"pb-importTitleInput\">\n    <fieldset class=\"dlg-mode\"><legend>Where should it go?</legend><label class=\"check\"><input type=\"radio\" name=\"importMode\" value=\"replace\" checked><span>Replace the current page</span></label><label class=\"check\"><input type=\"radio\" name=\"importMode\" value=\"append\"><span>Add to the end</span></label></fieldset>\n    <p class=\"dlg-err\" id=\"pb-importErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-importCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-importGo\" type=\"button\">Import</button></div>\n  </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-presetDlg\" aria-labelledby=\"pb-presetHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-presetHeading\">Start from a preset</h2>\n    <p class=\"note\" id=\"pb-presetNote\"></p>\n    <div class=\"presets\" id=\"pb-presetList\"></div>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-presetCancel\" type=\"button\">Cancel</button></div>\n  </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-citeDlg\" aria-labelledby=\"pb-citeHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-citeHeading\">Add a reference</h2>\n    <div id=\"pb-citeReuse\"></div>\n    <div class=\"row\"><label class=\"field\"><span>Kind of source</span><select id=\"pb-citeKind\"><option value=\"web\">Web page</option><option value=\"book\">Book</option><option value=\"other\">Something else</option></select></label><label class=\"field\" id=\"pb-citeFmtWrap\"><span>Format</span><select id=\"pb-citeFmt\"><option value=\"plain\">Plain text (works on any wiki)</option><option value=\"tpl\">Cite templates (cite web, cite book)</option></select></label></div>\n    <label class=\"field\" data-for=\"web book\"><span>Title</span><input type=\"text\" id=\"pb-citeTitle\"></label>\n    <label class=\"field\" data-for=\"web\"><span>Link</span><input type=\"text\" id=\"pb-citeUrl\" placeholder=\"https://\"></label>\n    <div class=\"row\" data-for=\"web book\"><label class=\"field\"><span>Author</span><input type=\"text\" id=\"pb-citeAuthor\"></label><label class=\"field\"><span id=\"pb-citeWorkLabel\">Website</span><input type=\"text\" id=\"pb-citeWork\"></label></div>\n    <div class=\"row\" data-for=\"web book\"><label class=\"field\"><span>Published</span><input type=\"text\" id=\"pb-citeDate\" placeholder=\"2026-10-24\"></label><label class=\"field\" data-for=\"web\"><span>Accessed</span><input type=\"text\" id=\"pb-citeAccess\"></label></div>\n    <label class=\"field\" data-for=\"other\"><span>Reference text</span><textarea id=\"pb-citeText\" rows=\"3\"></textarea></label>\n    <label class=\"field\"><span>Name (optional)</span><input type=\"text\" id=\"pb-citeName\" placeholder=\"Lets you use this reference again elsewhere on the page\"></label>\n    <p class=\"dlg-err\" id=\"pb-citeErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-citeCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-citeGo\" type=\"button\">Add reference</button></div>\n  </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-upDlg\" aria-labelledby=\"pb-upHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-upHeading\">Upload to your wiki</h2>\n    <div id=\"pb-upList\"></div>\n    <label class=\"field\"><span>Description</span><input type=\"text\" id=\"pb-upDesc\" placeholder=\"What the picture shows, and where it came from\"></label>\n    <p class=\"dlg-err\" id=\"pb-upErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-upCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-upGo\" type=\"button\">Upload</button></div>\n  </div>\n</dialog>\n<div class=\"qi\" id=\"pb-qiBox\" role=\"dialog\" aria-label=\"Add a block\" hidden><input type=\"text\" id=\"pb-qiInput\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"pb-qiList\" placeholder=\"Type to find a block\" autocomplete=\"off\"><div class=\"qi-list\" id=\"pb-qiList\" role=\"listbox\" aria-label=\"Blocks\"></div></div>\n<dialog class=\"dlg\" id=\"pb-saveDlg\" aria-labelledby=\"pb-saveHeading\">\n  <div class=\"dlg-body\">\n    <h2 id=\"pb-saveHeading\">Save page</h2>\n    <label class=\"field\"><span>Summary</span><input type=\"text\" id=\"pb-saveSummary\" maxlength=\"500\" placeholder=\"Briefly describe your changes\"></label>\n    <div class=\"checks\"><label class=\"check\" id=\"pb-saveMinorWrap\"><input type=\"checkbox\" id=\"pb-saveMinor\"><span>This is a minor edit</span></label><label class=\"check\"><input type=\"checkbox\" id=\"pb-saveWatch\"><span>Watch this page</span></label></div>\n    <details class=\"tplcode\" id=\"pb-saveReview\"><summary>Review your changes</summary><div class=\"pb-live pb-diff\" id=\"pb-saveDiff\"></div></details>\n    <p class=\"dlg-err\" id=\"pb-saveErr\" role=\"alert\"></p>\n    <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-saveCancel\" type=\"button\">Keep editing</button><button class=\"btn primary\" id=\"pb-saveGo\" type=\"button\">Save page</button></div>\n  </div>\n</dialog>\n<div class=\"ac\" id=\"pb-acList\" role=\"listbox\" aria-label=\"Suggestions\" hidden></div>\n<div class=\"toast\" id=\"pb-toast\" role=\"status\" hidden><span id=\"pb-toastMsg\"></span><button type=\"button\" id=\"pb-toastUndo\">Undo</button></div>";

	function createApp( root, hooks ) {
		'use strict';

		const KEY='mw-page-builder-v1';
		const $=s=>root.querySelector(s);
		const own=(o,k)=>Object.prototype.hasOwnProperty.call(o,k);
		const esc=s=>String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
		const uid=()=>'b'+Date.now().toString(36)+Math.random().toString(36).slice(2,7);
		const stripFile=s=>String(s||'').trim().replace(/^(File|Image):/i,'');
		const lines=s=>String(s||'').split('\n').map(l=>l.replace(/\s+$/,'')).filter(l=>l.trim());
		const niceKey=k=>{const s=k.trim().replace(/_/g,' ');return s.charAt(0).toUpperCase()+s.slice(1)};

		const fText=(label,path,val,ph='')=>`<label class="field"><span>${label}</span><input type="text" data-path="${path}" value="${esc(val)}" placeholder="${esc(ph)}"></label>`;
		const fArea=(label,path,val,ph='',rows=4,mono=false)=>`<label class="field"><span>${label}</span><textarea data-path="${path}" rows="${rows}" placeholder="${esc(ph)}"${mono?' class="mono" spellcheck="false"':''}>${esc(val)}</textarea></label>`;
		const fSelect=(label,path,val,opts,rerender)=>`<label class="field"><span>${label}</span><select data-path="${path}"${rerender?' data-rerender="1"':''}>${opts.map(([v,t])=>`<option value="${esc(v)}"${String(v)===String(val)?' selected':''}>${esc(t)}</option>`).join('')}</select></label>`;
		const fCheck=(label,path,val,rerender)=>`<label class="check"><input type="checkbox" data-path="${path}"${val?' checked':''}${rerender?' data-rerender="1"':''}><span>${label}</span></label>`;
		const kvRows=(arr,listKey,delAct,kPh,vPh,keep)=>`<div class="kv-list">${arr.map((r,i)=>keep&&!keep(r)?'':`<div class="kv"><input type="text" data-path="${listKey}.${i}.k" value="${esc(r.k)}" placeholder="${esc(kPh)}" aria-label="Name ${i+1}"><input type="text" data-path="${listKey}.${i}.v" value="${esc(r.v)}" placeholder="${esc(vPh)}" aria-label="Value ${i+1}"><button type="button" class="icon del" data-act="${delAct}" data-i="${i}" aria-label="Remove row ${i+1}">${ICON.x}</button></div>`).join('')}</div>`;

		const ICON={
		  grip:'<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><circle cx="5.5" cy="3.5" r="1.4"/><circle cx="10.5" cy="3.5" r="1.4"/><circle cx="5.5" cy="8" r="1.4"/><circle cx="10.5" cy="8" r="1.4"/><circle cx="5.5" cy="12.5" r="1.4"/><circle cx="10.5" cy="12.5" r="1.4"/></svg>',
		  up:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M3.5 10 8 5.5 12.5 10"/></svg>',
		  down:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M3.5 6 8 10.5 12.5 6"/></svg>',
		  dup:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5" y="5" width="8.5" height="8.5" rx="1"/><path d="M3 10.5V3.5a1 1 0 0 1 1-1h6.5"/></svg>',
		  x:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
		  up2:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 8.5 8 4l4.5 4.5M3.5 12.5 8 8l4.5 4.5"/></svg>',
		  down2:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 3.5 8 8l4.5-4.5M3.5 7.5 8 12l4.5-4.5"/></svg>',
		  fold:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1"/><path d="M5 8h6"/></svg>',
		  unfold:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1"/><path d="M5 8h6M8 5v6"/></svg>',
		  pic:'<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3" y="6" width="26" height="20" rx="1.5"/><circle cx="11" cy="13" r="2.5"/><path d="M3 23l8-7 6 5 4-3 8 6"/></svg>'
		};
		const ph=f=>`<div class="ph">${ICON.pic}<span>${esc(f||'Example.jpg')}</span></div>`;
		function infoboxAttrs(b){let a='';if(b.layout==='stacked')a+=' layout="stacked"';if(b.accentOn&&b.accent)a+=` accent-color-default="${esc(b.accent)}"`;return a}
		function infoboxTemplate(b){
		  const head=[`<infobox${infoboxAttrs(b)}>`,'  <title source="title"><default>{\u007BPAGENAME}}</default></title>','  <image source="image">','    <caption source="caption"/>','  </image>'];
		  if(b.mode!=='dedicated'){
		    const n=Math.max(20,b.rows.length);
		    for(let i=1;i<=n;i++)head.push(`  <data source="data${i}"><label>{\u007B{label${i}|}}}</label></data>`);
		    head.push('</infobox>');
		    return head.join('\n');
		  }
		  const params={title:{label:'Title',description:'Box title. Leave blank to use the page name.',type:'string'},image:{label:'Image',description:'File name, without File:',type:'wiki-file-name'},caption:{label:'Caption',description:'Text under the image',type:'string'}};
		  const body=[];let open=false;
		  b.rows.filter(r=>r.k.trim()).forEach(r=>{const lab=String(r.label||'').trim()||niceKey(r.k);params[r.k.trim()]={label:lab,type:'string'};
		    const h=String(r.header||'').trim();
		    if(h){if(open)body.push('  </group>');body.push('  <group>',`    <header>${esc(h)}</header>`);open=true}
		    body.push(`${open?'    ':'  '}<data source="${esc(r.k.trim())}"><label>${esc(lab)}</label></data>`)});
		  if(open)body.push('  </group>');
		  return head.concat(body,['</infobox><noinclude>','<templatedata>',JSON.stringify({params,format:'block'},null,2),'</templatedata>','</noinclude>']).join('\n');
		}
		function infoboxDesigner(b){
		  const g=b.mode!=='dedicated';
		  let h=`<div class="row">${fSelect('Layout','layout',b.layout||'',[['','Labels beside values'],['stacked','Labels above values']])}<div class="field"><span>Accent colour</span><span class="colorrow">${fCheck('Use one','accentOn',!!b.accentOn,true)}${b.accentOn?`<input type="color" data-path="accent" value="${esc(b.accent||'#3366cc')}" aria-label="Accent colour">`:''}</span></div></div>`;
		  if(!g){const rows=b.rows.map((r,i)=>r.k.trim()?`<tr><td><code>${esc(r.k.trim())}</code></td><td><input type="text" data-path="rows.${i}.label" value="${esc(r.label||'')}" placeholder="${esc(niceKey(r.k))}" aria-label="Label for ${esc(r.k.trim())}"></td><td><input type="text" data-path="rows.${i}.header" value="${esc(r.header||'')}" placeholder="No new section" aria-label="Section that starts at ${esc(r.k.trim())}"></td></tr>`:'').join('');
		    if(rows)h+=`<div class="grid-wrap"><table class="design"><thead><tr><th scope="col">Field</th><th scope="col">Label shown</th><th scope="col">Starts a section called</th></tr></thead><tbody>${rows}</tbody></table></div>`}
		  return h;
		}

		function listWiki(items,ch){
		  return lines(items).map(l=>{const m=l.match(/^\s*([*#]*)\s*(.*)$/);return ch.repeat(m[1].length+1)+' '+m[2]}).join('\n');
		}
		function listHtml(items,tag,c){
		  let out='',depth=0;
		  lines(items).forEach(l=>{
		    const m=l.match(/^\s*([*#]*)\s*(.*)$/);const d=m[1].length+1;const t=c.inl(m[2]);
		    if(d>depth){while(depth<d){out+=`<${tag}>`;if(depth+1<d)out+='<li>';depth++}out+='<li>'+t}
		    else{out+='</li>';while(depth>d){out+=`</${tag}></li>`;depth--}out+='<li>'+t}
		  });
		  if(depth){out+='</li>';while(depth>0){out+=`</${tag}>`;depth--;if(depth>0)out+='</li>'}}
		  return out;
		}

		const SYMS=[['—','Em dash'],['–','En dash'],['…','Ellipsis'],['·','Middle dot'],['°','Degree'],['×','Multiply'],['±','Plus or minus'],['→','Right arrow'],['←','Left arrow'],['©','Copyright'],['®','Registered'],['™','Trademark'],['½','Half'],['&nbsp;','Non-breaking space']];
		const FB=(f,label,html,title)=>`<button type="button" data-act="fmt" data-fmt="${f}" aria-label="${label}" title="${title||label}">${html}</button>`;
		function fmtBar(){return `<div class="fmtbar" role="toolbar" aria-label="Formatting">`
		  +`<span class="fgrp">${FB('bold','Bold','<b>B</b>','Bold (Ctrl+B)')}${FB('italic','Italic','<i>I</i>','Italic (Ctrl+I)')}${FB('under','Underline','<u>U</u>','Underline (Ctrl+U)')}${FB('strike','Strikethrough','<s>S</s>')}${FB('sup','Superscript','x<sup>2</sup>')}${FB('sub','Subscript','x<sub>2</sub>')}${FB('small','Small text','<small>Small</small>')}${FB('big','Big text','<span style="font-size:1.15em">Big</span>')}${FB('code','Inline code','<code>code</code>')}</span>`
		  +`<span class="fgrp"><label class="fcolor" title="Text colour"><span>Colour</span><input type="color" data-fmtcolor="color" value="#d73333" aria-label="Text colour"></label><label class="fcolor" title="Highlight"><span>Highlight</span><input type="color" data-fmtcolor="bg" value="#fef6c8" aria-label="Highlight colour"></label></span>`
		  +`<span class="fgrp">${FB('link','Wiki link','Wiki link','Wiki link (Ctrl+K)')}${FB('ext','Web link','Web link')}${FB('ref','Reference','Reference')}${FB('tpl','Template','{\u007B }}','Template')}</span>`
		  +`<span class="fgrp">${FB('br','Line break','Line break')}${FB('nowiki','No wiki','No wiki','Show markup as plain text')}${FB('clear','Clear formatting','Clear','Remove formatting from the selection')}<select data-fmtsym aria-label="Insert a symbol"><option value="">Symbol</option>${SYMS.map(([v,l])=>`<option value="${esc(v)}">${v==='&nbsp;'?'␣':esc(v)}  ${esc(l)}</option>`).join('')}</select></span></div>`}

		const TD_AC={'wiki-file-name':'file','wiki-page-name':'page','wiki-template-name':'template','wiki-user-name':'page'};
		const tdCache=new Map();
		const tdKey=n=>{const s=String(n||'').trim().replace(/_/g,' ').replace(/^Template\s*:/i,'').trim();return s?s.charAt(0).toUpperCase()+s.slice(1):''};
		function tdFor(name){
		  if(!hooks.templateData)return null;
		  const k=tdKey(name);if(!k)return null;
		  let e=tdCache.get(k);
		  if(!e){e={state:'loading',data:null};tdCache.set(k,e);
		    hooks.templateData(k).then(d=>{e.state=d&&d.order&&d.order.length?'ok':'none';e.data=d;tdRefresh(k)},()=>{e.state='none';tdRefresh(k)})}
		  return e;
		}
		function tdRefresh(k){state.blocks.forEach(b=>{if((b.type==='template'&&tdKey(b.name)===k)||(b.type==='infobox'&&b.mode==='dedicated'&&tdKey(b.template)===k))rerenderCard(b.id)})}
		function tdMissing(b){const e=tdFor(b.template);if(!e||e.state!=='ok')return [];const have=new Set(b.rows.map(r=>r.k.trim()).concat(['title','image','caption']));return e.data.order.filter(k=>!have.has(k)&&e.data.params[k]&&!e.data.params[k].deprecated)}
		function tdFillBtn(b){const m=tdMissing(b);return m.length?`<div><button type="button" class="add" data-act="tdfill">Add ${m.length} documented field${m.length===1?'':'s'} from the template</button></div>`:''}
		function templateForm(b){
		  const e=tdFor(b.name),d=e&&e.state==='ok'?e.data:null;
		  let h=fText('Template name','name',b.name,'Citation needed');
		  if(e&&e.state==='loading')h+='<p class="note">Looking up this template’s parameters…</p>';
		  if(!d)return h+`<p class="sub">Parameters (leave the name blank for a numbered parameter)</p>${kvRows(b.params,'params','delparam','name','value')}<div><button type="button" class="add" data-act="addparam">Add parameter</button></div>`;
		  const known=new Set(d.order);
		  const keys=d.order.filter(k=>d.params[k]&&!(d.params[k].deprecated&&!b.params.some(p=>p.k.trim()===k&&p.v.trim())));
		  if(d.description)h+=`<p class="note">${esc(d.description)}</p>`;
		  h+=`<div class="tdfields">${keys.map(k=>{const p=d.params[k],cur=b.params.find(x=>x.k.trim()===k),v=cur?cur.v:'';
		    const ac=TD_AC[p.type]?` data-ac="${TD_AC[p.type]}"`:'';
		    const input=p.type==='content'||p.type==='unbalanced-wikitext'?`<textarea data-tdkey="${esc(k)}" rows="3"${ac}>${esc(v)}</textarea>`:`<input type="text" data-tdkey="${esc(k)}" value="${esc(v)}"${ac}>`;
		    return `<label class="field"><span>${esc(p.label||k)}${p.required?'<em class="req">required</em>':''}</span>${input}${p.description?`<small class="hint">${esc(p.description)}</small>`:''}</label>`}).join('')}</div>`;
		  return h+`<p class="sub">Other parameters</p>${kvRows(b.params,'params','delparam','name','value',r=>!known.has(r.k.trim()))}<div><button type="button" class="add" data-act="addparam">Add parameter</button></div>`;
		}
		const THUMBS='<div class="thumbs" data-thumbs></div>';
		const UPBTN=multi=>`<div class="uprow"><label class="btn filebtn">${multi?'Upload pictures':'Upload a picture'}<input type="file" accept="image/*"${multi?' multiple':''} class="sr-only" data-upload></label><span class="hint">or drop ${multi?'them':'one'} onto this block</span></div>`;
		const GALLERY_MODES=[['','Grid'],['packed','Packed rows'],['packed-hover','Packed, captions on hover'],['packed-overlay','Packed, captions over pictures'],['nolines','Grid without borders'],['slideshow','Slideshow']];
		const NOTE_STYLES={info:['#3366cc','#eaf3ff'],success:['#14866d','#d5fdf4'],warning:['#edab00','#fef6e7'],danger:['#d73333','#fee7e6'],plain:['#a2a9b1','#f8f9fa']};
		const noteStyle=k=>{const [bd,bg]=NOTE_STYLES[k]||NOTE_STYLES.info;return `border:1px solid ${bd};border-left:4px solid ${bd};background:${bg};color:#202122;padding:8px 12px;margin:1em 0`};
		const COLL_STYLE='border:1px solid #a2a9b1;padding:6px 10px;margin:1em 0';
		const reEsc=x=>x.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
		const COLL_RE=new RegExp('^<div class="mw-collapsible( mw-collapsed)?" style="'+reEsc(COLL_STYLE)+'">\\n<div style="font-weight:bold">(.*)</div>\\n<div class="mw-collapsible-content">\\n([\\s\\S]*)\\n</div>\\n</div>$');
		function divBlock(w){
		  let m=w.match(/^<div class="pb-note pb-note-(\w+)" style="([^"]*)">([\s\S]*)<\/div>$/);
		  if(m){if(!NOTE_STYLES[m[1]]||m[2]!==noteStyle(m[1]))return null;const t=m[3].match(/^'''([^\n]+?)'''\n\n([\s\S]*)$/);return ['note',{kind:m[1],title:t?t[1]:'',text:t?t[2]:m[3]}]}
		  m=w.match(COLL_RE);if(m)return ['collapsible',{collapsed:!!m[1],title:m[2],text:m[3]}];
		  return null;
		}

		const GROUPS=[['text','Text',['heading','paragraph','bullets','numbers','quote','note','collapsible','code']],
		              ['media','Media and data',['infobox','image','gallery','table']],
		              ['wiki','Wiki features',['template','hatnote','toc','refs','categories','cattree','rule','raw']]];

		const DEFS={
		heading:{label:'Heading',tok:'== ==',desc:'A section heading',
		  init:()=>({text:'New section',level:'2'}),
		  sum:b=>b.text,
		  form:b=>`<div class="row r-wide">${fText('Heading text','text',b.text,'Section title')}${fSelect('Level','level',b.level,[['2','Section'],['3','Subsection'],['4','Level 4'],['5','Level 5']])}</div>`,
		  wiki:b=>{const e='='.repeat(+b.level||2);return `${e} ${b.text.trim()||'Section'} ${e}`},
		  html:(b,c)=>{const l=Math.min(6,+b.level||2);return `<h${l}>${c.inl(b.text.trim()||'Section')}</h${l}>`}},

		paragraph:{label:'Paragraph',tok:'Abc',desc:'Body text with bold, links and references',
		  init:()=>({text:''}),
		  sum:b=>b.text,
		  form:b=>`${fmtBar()}${fArea('Text','text',b.text,'Write your text. Select words and use the buttons above to format them. A blank line starts a new paragraph.',5)}`,
		  wiki:b=>b.text.trim(),
		  html:(b,c)=>b.text.trim()?b.text.trim().split(/\n\s*\n/).map(p=>`<p>${c.inl(p)}</p>`).join(''):''},

		bullets:{label:'Bulleted list',tok:'* ',desc:'One item per line',
		  init:()=>({items:'First item\nSecond item'}),
		  sum:b=>lines(b.items).length+' items',
		  form:b=>fmtBar()+fArea('Items','items',b.items,'One item per line. Start a line with * to indent it a level.',4),
		  wiki:b=>listWiki(b.items,'*'),
		  html:(b,c)=>listHtml(b.items,'ul',c)},

		numbers:{label:'Numbered list',tok:'# ',desc:'One step per line',
		  init:()=>({items:'First step\nSecond step'}),
		  sum:b=>lines(b.items).length+' items',
		  form:b=>fmtBar()+fArea('Items','items',b.items,'One item per line. Start a line with * to indent it a level.',4),
		  wiki:b=>listWiki(b.items,'#'),
		  html:(b,c)=>listHtml(b.items,'ol',c)},

		quote:{label:'Quote',tok:'<blockquote>',desc:'An indented quotation',
		  init:()=>({text:'',source:'',fmt:'blockquote'}),
		  sum:b=>b.text,
		  form:b=>`${fmtBar()}${fArea('Quotation','text',b.text,'',3)}<div class="row">${fText('Source (optional)','source',b.source,'Who said it')}${fSelect('Markup','fmt',b.fmt,[['blockquote','<blockquote> (any wiki)'],['template','{\u007BQuote}} (needs that template)']])}</div>`,
		  wiki:b=>{const t=b.text.trim(),s=b.source.trim();return b.fmt==='template'?`{\u007BQuote|text=${t}${s?`|author=${s}`:''}}}`:`<blockquote>${t}${s?`<br />— ${s}`:''}</blockquote>`},
		  html:(b,c)=>`<blockquote><p>${c.inl(b.text)}</p>${b.source.trim()?`<p class="qsrc">— ${c.inl(b.source)}</p>`:''}</blockquote>`},

		code:{label:'Code block',tok:'<syntaxhighlight>',desc:'Syntax-highlighted code',
		  init:()=>({lang:'python',fmt:'syntaxhighlight',code:'',line:false}),
		  sum:b=>b.fmt==='pre'?'pre':b.lang,
		  form:b=>`<div class="row">${fSelect('Markup','fmt',b.fmt,[['syntaxhighlight','<syntaxhighlight>'],['pre','<pre> (any wiki)']])}${fSelect('Language','lang',b.lang,LANGS.concat(LANGS.includes(b.lang)?[]:[b.lang]).map(x=>[x,x]))}</div>${fCheck('Show line numbers','line',b.line)}${fArea('Code','code',b.code,'',6,true)}`,
		  wiki:b=>b.fmt==='pre'?`<pre>\n${b.code}\n</pre>`:`<syntaxhighlight lang="${b.lang}"${b.line?' line':''}>\n${b.code}\n</syntaxhighlight>`,
		  html:b=>`<pre class="code">${esc(b.code)}</pre>`},

		infobox:{label:'Infobox',tok:'<infobox>',desc:'A Portable Infobox with any labels you like',
		  init:()=>({mode:'generic',template:'Infobox',name:'',image:'',caption:'',rows:[{k:'',v:''}],layout:'',accentOn:false,accent:'#3366cc'}),
		  sum:b=>b.name||b.template,
		  form:b=>{const g=b.mode!=='dedicated';const nm=tdKey(b.template)||'Infobox';const tn=`<b data-tplname>Template:${esc(nm)}</b>`;
		    return `<div class="row">${fSelect('Kind','mode',b.mode,[['generic','Generic: any labels'],['dedicated','Dedicated: fixed field names']],true)}${fText('Template name','template',b.template,g?'Infobox':'Infobox character')}</div><div class="row">${fText('Title','name',b.name,'Leave blank to use the page name')}${fText('Image file','image',b.image,'Example.jpg')}</div>${fText('Image caption','caption',b.caption)}${fmtBar()}<p class="sub">${g?'Rows: type any label you like':'Fields'}</p>${kvRows(b.rows,'rows','delrow',g?'Label':'field_name',g?'Value':'value')}<div><button type="button" class="add" data-act="addrow">${g?'Add row':'Add field'}</button></div>${g?'':tdFillBtn(b)}<details class="tplcode"${openTpl.has(b.id)?' open':''}><summary>Design the template</summary><p class="note">${g?`This is the code for ${tn}. Save it to the wiki once, and any page can use the box with its own labels. It holds ${Math.max(20,b.rows.length)} rows, and grows if a page ever needs more.`:`This is the code for ${tn}. Save it to the wiki once and every page of this kind can use it. Its TemplateData gives VisualEditor and this builder a form for the fields.`}</p>${infoboxDesigner(b)}<pre class="mini" data-tplcode>${esc(infoboxTemplate(b))}</pre><div class="btnrow"><button type="button" class="btn" data-act="copytpl">Copy template code</button>${hooks.saveTemplate?`<button type="button" class="btn primary" data-act="savetpl">Save to Template:${esc(nm)}</button>`:''}</div></details>`},
		  wiki:b=>{
		    const base=[['title',b.name],['image',stripFile(b.image)],['caption',b.caption]].filter(p=>String(p[1]).trim());
		    const rest=b.mode!=='dedicated'
		      ?b.rows.filter(r=>r.k.trim()||r.v.trim()).flatMap((r,i)=>[[`label${i+1}`,r.k],[`data${i+1}`,r.v]])
		      :b.rows.filter(r=>r.k.trim()).map(r=>[r.k.trim(),r.v]);
		    const pairs=base.concat(rest);
		    const w=pairs.reduce((m,p)=>Math.max(m,p[0].length),0);
		    return [`{\u007B${b.template.trim()||'Infobox'}`,...pairs.map(([k,v])=>`| ${k.padEnd(w)} = ${String(v).trim()}`),'}}'].join('\n')},
		  html:(b,c)=>{const g=b.mode!=='dedicated';
		    const rows=g?b.rows.filter(r=>r.v.trim()).map(r=>`<tr><th scope="row">${c.inl(r.k)}</th><td>${c.inl(r.v)}</td></tr>`).join('')
		      :b.rows.filter(r=>r.k.trim()).map(r=>`${String(r.header||'').trim()?`<tr><th colspan="2" class="ib-head">${esc(r.header.trim())}</th></tr>`:''}<tr><th scope="row">${esc(String(r.label||'').trim()||niceKey(r.k))}</th><td>${c.inl(r.v)}</td></tr>`).join('');
		    return `<table class="infobox"${b.accentOn&&b.accent?` style="border-top:4px solid ${esc(b.accent)}"`:''}><caption>${b.name.trim()?c.inl(b.name):esc(state.title.trim()||'Page name')}</caption>${b.image.trim()?`<tr><td colspan="2" class="ib-img">${ph(stripFile(b.image))}${b.caption.trim()?`<div class="ib-cap">${c.inl(b.caption)}</div>`:''}</td></tr>`:''}${rows}</table>`}},

		image:{label:'Image',tok:'[\u005BFile:]]',desc:'A picture with an optional caption',
		  init:()=>({file:'',caption:'',frame:'thumb',align:'',size:''}),
		  sum:b=>stripFile(b.file),
		  form:b=>`<div class="row">${fText('File name','file',b.file,'Example.jpg')}${fText('Caption','caption',b.caption)}</div><div class="row r-3">${fSelect('Style','frame',b.frame,[['thumb','Thumbnail'],['frame','Framed'],['frameless','Frameless'],['','Plain']])}${fSelect('Position','align',b.align,[['','Default'],['right','Right'],['left','Left'],['center','Centre'],['none','None']])}${fText('Width (px)','size',b.size,'250')}</div>${hooks.upload?UPBTN(false):''}${THUMBS}`,
		  wiki:b=>{const p=['File:'+(stripFile(b.file)||'Example.jpg')];if(b.frame)p.push(b.frame);if(b.align)p.push(b.align);
		    const s=b.size.trim().replace(/px$/i,'');if(s)p.push(/^\d+$/.test(s)?s+'px':b.size.trim());if(b.caption.trim())p.push(b.caption.trim());return `[\u005B${p.join('|')}]]`},
		  html:(b,c)=>{const framed=b.frame==='thumb'||b.frame==='frame';const al=b.align||(framed?'right':'');const w=(parseInt(b.size,10)||(b.frame==='frame'?300:220))+(framed?8:0);
		    return `<figure class="thumb${framed?' framed':''} al-${al||'inline'}" style="width:${w}px">${ph(stripFile(b.file))}${framed&&b.caption.trim()?`<figcaption>${c.inl(b.caption)}</figcaption>`:''}</figure>`}},

		gallery:{label:'Gallery',tok:'<gallery>',desc:'A grid or slideshow of images',
		  init:()=>({items:'Example.jpg|First caption\nExample2.jpg|Second caption',mode:'',heights:'',caption:''}),
		  sum:b=>lines(b.items).length+' images',
		  form:b=>`<div class="row r-3">${fSelect('Style','mode',b.mode||'',GALLERY_MODES)}${fText('Caption (optional)','caption',b.caption||'')}${fText('Row height (px)','heights',b.heights||'','150')}</div>`+fArea('Images','items',b.items,'One image per line: File name.jpg|Caption',4,true)+(hooks.upload?UPBTN(true):'')+THUMBS,
		  wiki:b=>{let a='';if(b.mode)a+=` mode="${b.mode}"`;const h=String(b.heights||'').trim().replace(/px$/i,'');if(h)a+=` heights="${/^\d+$/.test(h)?h+'px':h}"`;
		    const cap=String(b.caption||'').trim();if(cap)a+=` caption="${cap.replace(/"/g,'&quot;')}"`;
		    return `<gallery${a}>\n${lines(b.items).map(l=>l.trim()).join('\n')}\n</gallery>`},
		  html:(b,c)=>`${String(b.caption||'').trim()?`<p class="gallery-cap">${c.inl(b.caption)}</p>`:''}<div class="gallery">${lines(b.items).map(l=>{const [f,...cap]=l.split('|');return `<figure>${ph(stripFile(f))}<figcaption>${c.inl(cap.join('|'))}</figcaption></figure>`}).join('')}</div>`},

		table:{label:'Table',tok:'{| |}',desc:'A wikitable with rows and columns',
		  init:()=>({caption:'',header:true,sortable:false,cells:[['Header 1','Header 2'],['','']],align:[]}),
		  sum:b=>b.caption||`${b.cells.length} rows, ${(b.cells[0]||[]).length} columns`,
		  form:b=>{const w=(b.cells[0]||[]).length,al=b.align||[];
		    return `${fText('Caption (optional)','caption',b.caption)}<div class="checks">${fCheck('First row is a header','header',b.header,true)}${fCheck('Sortable columns','sortable',b.sortable)}</div>${fmtBar()}<div class="grid-wrap"><table class="cells"><tr class="alignrow">${Array.from({length:w},(x,j)=>`<td><select data-path="align.${j}" aria-label="Alignment of column ${j+1}">${[['','Align left'],['center','Centre'],['right','Align right']].map(([v,t])=>`<option value="${v}"${(al[j]||'')===v?' selected':''}>${t}</option>`).join('')}</select></td>`).join('')}<td></td></tr>${b.cells.map((r,i)=>`<tr>${r.map((v,j)=>`<td><input type="text" data-path="cells.${i}.${j}" value="${esc(v)}" aria-label="Row ${i+1}, column ${j+1}"${i===0&&b.header?' class="hcell"':''}></td>`).join('')}<td class="trow-tools"><button type="button" class="icon" data-act="trowup" data-i="${i}" aria-label="Move row ${i+1} up" title="Move row up">${ICON.up}</button><button type="button" class="icon" data-act="trowdown" data-i="${i}" aria-label="Move row ${i+1} down" title="Move row down">${ICON.down}</button><button type="button" class="icon del" data-act="trowdel" data-i="${i}" aria-label="Remove row ${i+1}" title="Remove row">${ICON.x}</button></td></tr>`).join('')}</table></div><p class="hint">Tip: copy cells from a spreadsheet and paste them into any cell here.</p><div class="btnrow"><button type="button" class="add" data-act="addtrow">Add row</button><button type="button" class="add" data-act="addcol">Add column</button><button type="button" class="add" data-act="delcol">Remove last column</button></div>`},
		  wiki:b=>{const out=[`{| class="wikitable${b.sortable?' sortable':''}"`];if(b.caption.trim())out.push('|+ '+b.caption.trim());const al=b.align||[];
		    b.cells.forEach((r,i)=>{if(i>0)out.push('|-');out.push(i===0&&b.header?'! '+r.map(x=>x.trim()).join(' !! '):'| '+r.map((x,j)=>al[j]?`style="text-align:${al[j]}" | ${x.trim()}`:x.trim()).join(' || '))});
		    out.push('|}');return out.join('\n')},
		  html:(b,c)=>{const al=b.align||[];return `<div class="tablewrap"><table class="wikitable">${b.caption.trim()?`<caption>${c.inl(b.caption)}</caption>`:''}${b.cells.map((r,i)=>`<tr>${r.map((x,j)=>i===0&&b.header?`<th>${c.inl(x)}</th>`:`<td${al[j]?` style="text-align:${al[j]}"`:''}>${c.inl(x)}</td>`).join('')}</tr>`).join('')}</table></div>`}},

		template:{label:'Template',tok:'{\u007B }}',desc:'Any template from your wiki',
		  init:()=>({name:'',params:[{k:'',v:''}]}),
		  sum:b=>b.name,
		  form:templateForm,
		  wiki:b=>{const ps=b.params.filter(p=>(p.k.trim()||p.v.trim())&&!(p.td&&!p.v.trim()));const n=b.name.trim()||'Template name';
		    if(ps.filter(p=>p.k.trim()).length>=3)return `{\u007B${n}\n${ps.map(p=>`| ${p.k.trim()?p.k.trim()+' = ':''}${p.v.trim()}`).join('\n')}\n}}`;
		    return `{\u007B${n}${ps.map(p=>'|'+(p.k.trim()?p.k.trim()+'=':'')+p.v.trim()).join('')}}}`},
		  html:b=>`<div class="tpl"><code>{\u007B${esc(b.name.trim()||'Template name')}}}</code> renders here once the page is saved on your wiki.</div>`},

		note:{label:'Note box',tok:'<div>',desc:'A coloured box for tips, warnings or notices',
		  init:()=>({kind:'info',title:'',text:''}),
		  sum:b=>b.title||b.text,
		  form:b=>`<div class="row r-wide">${fText('Title (optional)','title',b.title,'Heads up')}${fSelect('Colour','kind',b.kind,[['info','Blue'],['success','Green'],['warning','Yellow'],['danger','Red'],['plain','Grey']])}</div>${fmtBar()}${fArea('Text','text',b.text,'What the box says',3)}`,
		  wiki:b=>{const k=NOTE_STYLES[b.kind]?b.kind:'info',t=b.title.trim(),x=b.text.trim();return `<div class="pb-note pb-note-${k}" style="${noteStyle(k)}">${t?`'''${t}'''\n\n`:''}${x}</div>`},
		  html:(b,c)=>`<div style="${noteStyle(b.kind)}">${b.title.trim()?`<p><b>${c.inl(b.title)}</b></p>`:''}${b.text.trim().split(/\n\s*\n/).map(x=>`<p>${c.inl(x)}</p>`).join('')}</div>`},

		collapsible:{label:'Collapsible section',tok:'mw-collapsible',desc:'A box readers can open and close',
		  init:()=>({title:'More details',text:'',collapsed:true}),
		  sum:b=>b.title,
		  form:b=>`${fText('Title','title',b.title,'Shown when the box is closed')}${fCheck('Start closed','collapsed',b.collapsed)}${fmtBar()}${fArea('Content','text',b.text,'Anything: text, lists, images, tables…',4)}`,
		  wiki:b=>`<div class="mw-collapsible${b.collapsed?' mw-collapsed':''}" style="${COLL_STYLE}">\n<div style="font-weight:bold">${b.title.trim()||'Details'}</div>\n<div class="mw-collapsible-content">\n${b.text.trim()}\n</div>\n</div>`,
		  html:(b,c)=>`<div class="coll"><div class="coll-title">${c.inl(b.title.trim()||'Details')} <span class="muted">[${b.collapsed?'show':'hide'}]</span></div>${b.collapsed?'':b.text.trim().split(/\n\s*\n/).map(x=>`<p>${c.inl(x)}</p>`).join('')}</div>`},

		hatnote:{label:'Main article link',tok:":''Main''",desc:'Points readers to a more detailed page',
		  init:()=>({kind:'Main',target:'',markup:'plain'}),
		  sum:b=>b.target,
		  form:b=>`<div class="row">${fSelect('Type','kind',b.kind,[['Main','Main article'],['See also','See also'],['Further','Further information']])}${fText('Page name','target',b.target,'Detailed page')}</div>${fSelect('Markup','markup',b.markup,[['plain','Plain wikitext (works now)'],['template','{\u007BMain}} / {\u007BSee also}} / {\u007BFurther}} (needs those templates)']])}`,
		  wiki:b=>{const t=b.target.trim()||'Page name';return b.markup==='template'?`{\u007B${b.kind}|${t}}}`:`:''${({Main:'Main article','See also':'See also',Further:'Further information'})[b.kind]}: [\u005B${t}]]''`},
		  html:b=>`<div class="hatnote">${{Main:'Main article','See also':'See also',Further:'Further information'}[b.kind]}: <a class="wl">${esc(b.target.trim()||'Page name')}</a></div>`},

		toc:{label:'Contents box',tok:'__TOC__',desc:'Control the table of contents',
		  init:()=>({mode:'__TOC__'}),
		  sum:b=>({__TOC__:'Placed here',__FORCETOC__:'Always shown',__NOTOC__:'Hidden'})[b.mode],
		  form:b=>fSelect('Behaviour','mode',b.mode,[['__TOC__','Put the contents box here'],['__FORCETOC__','Always show it, even with few sections'],['__NOTOC__','Hide the contents box']]),
		  wiki:b=>b.mode,
		  html:(b,c)=>{if(b.mode==='__NOTOC__')return '';const ct=[0,0,0,0];
		    const items=c.heads.map(h=>{const i=Math.max(0,Math.min(3,(+h.level||2)-2));ct[i]++;for(let j=i+1;j<4;j++)ct[j]=0;
		      return `<li style="padding-left:${i*1.1}em"><span class="n">${ct.slice(0,i+1).map(n=>n||1).join('.')}</span>${esc(h.text.trim()||'Section')}</li>`}).join('');
		    return `<div class="toc"><div class="toctitle">Contents</div>${items?`<ul>${items}</ul>`:'<span class="muted">No sections yet</span>'}</div>`}},

		refs:{label:'References',tok:'<references/>',desc:'Lists every <ref> on the page',
		  init:()=>({heading:true,style:'references'}),
		  sum:b=>b.style==='reflist'?'{\u007BReflist}}':'<references />',
		  form:b=>`<div class="row">${fSelect('Markup','style',b.style,[['references','<references /> (any wiki)'],['reflist','{\u007BReflist}} (needs that template)']])}<div class="field"><span>&nbsp;</span>${fCheck('Add a “References” heading','heading',b.heading)}</div></div>`,
		  wiki:b=>`${b.heading?'== References ==\n':''}${b.style==='reflist'?'{\u007BReflist}}':'<references />'}`,
		  html:(b,c)=>`${b.heading?'<h2>References</h2>':''}${c.refs.length?`<ol class="refs">${c.refs.map(r=>`<li>${fmt(r,c)}</li>`).join('')}</ol>`:'<p class="muted">Add references to paragraphs with the Reference button.</p>'}`},

		categories:{label:'Categories',tok:'[\u005BCategory:]]',desc:'File the page under categories',
		  init:()=>({items:''}),
		  sum:b=>lines(b.items).join(', '),
		  form:b=>fArea('Categories','items',b.items,'One category per line',3),
		  wiki:b=>lines(b.items).map(l=>`[\u005BCategory:${l.trim().replace(/^Category:/i,'')}]]`).join('\n'),
		  html:(b,c)=>{lines(b.items).forEach(l=>c.cats.push(l.trim().replace(/^Category:/i,'')));return ''}},

		cattree:{label:'Category tree',tok:'<categorytree>',desc:'An expandable tree of a category and what is in it',
		  init:()=>({cat:'',mode:'categories',depth:'1',hideroot:false}),
		  sum:b=>b.cat,
		  form:b=>`<div class="row r-3">${fText('Category','cat',b.cat,'Category name')}${fSelect('Show','mode',b.mode,[['categories','Subcategories only'],['pages','Subcategories and pages'],['all','Everything, including files'],['parents','Parent categories']])}${fSelect('Depth','depth',b.depth,[['0','0'],['1','1'],['2','2'],['3','3']])}</div>${fCheck('Hide the top category','hideroot',b.hideroot)}`,
		  wiki:b=>`<categorytree mode="${b.mode}"${b.depth!=='1'?` depth="${b.depth}"`:''}${b.hideroot?' hideroot="on"':''}>${b.cat.trim().replace(/^Category:/i,'')||'Category name'}</categorytree>`,
		  html:b=>`<div class="cattree"><span class="tw">&#9656;</span> <a class="wl">${esc(b.cat.trim().replace(/^Category:/i,'')||'Category name')}</a> <span class="muted">(expands on your wiki)</span></div>`},

		raw:{label:'Raw wikitext',tok:'</>',desc:'Any wikitext, kept exactly as written',
		  init:()=>({text:''}),
		  sum:b=>b.text.trim().split('\n')[0],
		  form:b=>fArea('Wikitext','text',b.text,'Anything the other blocks don’t cover. It goes into the page exactly as written.',5,true),
		  wiki:b=>b.text.replace(/^\n+/,'').replace(/\s+$/,''),
		  html:b=>b.text.trim()?`<div class="rawbox"><pre>${esc(b.text.replace(/^\n+/,'').replace(/\s+$/,''))}</pre></div>`:''},

		rule:{label:'Horizontal rule',tok:'----',desc:'A dividing line',
		  init:()=>({}),
		  sum:()=>'',
		  form:()=>`<p class="note">Adds a horizontal line across the page.</p>`,
		  wiki:()=>'----',
		  html:()=>'<hr>'}
		};

		let state={title:'',blocks:[]};
		const collapsed=new Set(),openTpl=new Set();
		const mk=(type,props)=>Object.assign({id:uid(),type},DEFS[type].init(),props||{});

		function example(){
		  return {title:'Sourdough bread',blocks:[
		    mk('infobox',{template:'Infobox',name:'Sourdough bread',image:'Sourdough loaf.jpg',caption:'A round country loaf',
		      rows:[{k:'Type',v:'Bread'},{k:'Main ingredient',v:'Flour, water, salt'},{k:'Leavening',v:'Wild yeast and [\u005Blactic acid bacteria]]'}]}),
		    mk('paragraph',{text:"'''Sourdough''' is a [\u005Bbread]] leavened by a naturally fermented starter rather than commercial [\u005Byeast]].<ref>Replace this with your source.</ref> The bacteria in the starter give the loaf its mildly sour flavour and help it keep longer."}),
		    mk('toc'),
		    mk('heading',{text:'History'}),
		    mk('paragraph',{text:'Naturally leavened bread is one of the oldest forms of raised bread, and was the usual way of baking until baker’s yeast became widely available.'}),
		    mk('heading',{text:'Method'}),
		    mk('image',{file:'Sourdough crumb.jpg',caption:'The open crumb of a well-proofed loaf'}),
		    mk('numbers',{items:'Feed the starter until it doubles\nMix starter with flour, water and salt\nLeave the dough to rise\n*Fold it every 30 minutes for the first two hours\nShape, proof overnight and bake in a hot oven'}),
		    mk('heading',{text:'Hydration',level:'3'}),
		    mk('table',{caption:'Typical hydration levels',sortable:true,cells:[['Style','Hydration','Crumb'],['Sandwich loaf','65%','Tight'],['Country loaf','75%','Open'],['Ciabatta','80%+','Very open']]}),
		    mk('heading',{text:'See also'}),
		    mk('bullets',{items:'[\u005BRye bread]]\n[\u005BSoda bread]]'}),
		    mk('refs'),
		    mk('categories',{items:'Breads\nFermented foods'})
		  ]};
		}

		const BUILTIN_PRESETS=[
		  {name:'Person',description:'An infobox with a few details, About and Friends sections, and the People category.',
		   wikitext:"{\u007BInfobox\n| title   = $TITLE\n| image   = \n| caption = \n| label1  = Pronouns\n| data1   = \n| label2  = Location\n| data2   = \n| label3  = Interests\n| data3   = \n}}\n'''$TITLE''' is …\n\n== About ==\nWrite a few lines about them here.\n\n== Friends ==\n* [\u005BName of a friend]]\n\n[\u005BCategory:People]]"},
		  {name:'Article',description:'An introduction, Overview and History sections, See also, References and a category.',
		   wikitext:"'''$TITLE''' is …\n\n== Overview ==\nWhat it is and why it matters.\n\n== History ==\nHow it came about.\n\n== See also ==\n* [\u005BRelated page]]\n\n== References ==\n<references />\n\n[\u005BCategory:Category name]]"}
		];
		const EXAMPLE_PRESET={name:'Example: Sourdough bread',description:'A finished page that uses most kinds of blocks.',build:example};

		function save(){}
		function load(){return null}

		function blockText(b){let w;try{w=DEFS[b.type].wiki(b)}catch(e){w=''}return b.src!=null&&w===b._g?b.src:w}
		function gen(){
		  let out='',prev=null;
		  state.blocks.forEach(b=>{const w=blockText(b);if(!w||!w.trim())return;
		    if(out)out+=prev&&b.prevId===prev.id&&b.sep!=null?b.sep:'\n\n';
		    out+=w;prev=b});
		  return out;
		}

		function fmt(s,c){
		  return s
		    .replace(/'''''(.+?)'''''/g,'<b><i>$1</i></b>')
		    .replace(/'''(.+?)'''/g,'<b>$1</b>')
		    .replace(/''(.+?)''/g,'<i>$1</i>')
		    .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g,'<a class="wl">$2</a>')
		    .replace(/\[\[([^\]]+)\]\]/g,'<a class="wl">$1</a>')
		    .replace(/\[(https?:\/\/[^\s\]]+) ([^\]]+)\]/g,'<a class="ext">$2</a>')
		    .replace(/\[(https?:\/\/[^\s\]]+)\]/g,()=>`<a class="ext">[${++c.ext}]</a>`)
		    .replace(/\{\{([^{}]+)\}\}/g,'<span class="tpl-inline">{\u007B$1}}</span>')
		    .replace(/&lt;(\/?)(u|s|del|ins|sup|sub|small|big|code|mark|b|i)&gt;/gi,'<$1$2>')
		    .replace(/&lt;br\s*\/?&gt;/gi,'<br>')
		    .replace(/&lt;span style=&quot;([^&]*)&quot;&gt;/gi,(m,st)=>{const ok=safeStyle(st);return `<span${ok?` style="${ok}"`:''}>`})
		    .replace(/&lt;\/span&gt;/gi,'</span>')
		    .replace(/\u0000(\d+)\u0000/g,(m,n)=>(c.nw||[])[n]||'');
		}
		function safeStyle(st){return st.split(';').map(x=>x.trim()).filter(x=>/^(color|background-color|background)\s*:\s*(#[0-9a-f]{3,8}|[a-z]+|rgba?\([\d\s.,%]+\))$/i.test(x)).join(';')}
		function inl(t,c){
		  let s=esc(t);
		  c.nw=c.nw||[];
		  s=s.replace(/&lt;nowiki&gt;([\s\S]*?)&lt;\/nowiki&gt;/gi,(m,x)=>{c.nw.push(x);return '\u0000'+(c.nw.length-1)+'\u0000'});
		  s=s.replace(/&lt;ref\s(?:(?!&gt;).)*\/&gt;/gi,'');
		  s=s.replace(/&lt;ref(?:\s(?:(?!&gt;).)*)?&gt;([\s\S]*?)&lt;\/ref&gt;/gi,(m,x)=>{c.refs.push(x);return `<sup class="ref">[${c.refs.length}]</sup>`});
		  return fmt(s,c);
		}
		function renderPreview(){
		  const c={refs:[],cats:[],ext:0,nw:[],heads:state.blocks.filter(b=>b.type==='heading')};
		  c.inl=t=>inl(t,c);
		  const body=state.blocks.map(b=>{try{return DEFS[b.type].html(b,c)}catch(e){return ''}}).join('');
		  $('#pb-preview').innerHTML=`<div class="ptitle" role="heading" aria-level="2">${esc(state.title.trim()||'Untitled page')}</div><div class="from">From your wiki</div>${body||'<p class="muted">Add blocks to see the page here.</p>'}${c.cats.length?`<div class="catlinks"><a class="wl">Categories</a>: ${c.cats.map(x=>`<a class="wl">${esc(x)}</a>`).join(' | ')}</div>`:''}`;
		}
		function updateOutput(){
		  const w=gen();
		  $('#pb-code').textContent=w;
		  const n=w?w.split('\n').length:0;
		  $('#pb-meta').textContent=n?`${n} line${n===1?'':'s'}`:'';
		  renderPreview();renderOutline();scheduleChecks();
		  if(hooks.onChange)hooks.onChange(w);
		}

		function renderPalette(){
		  $('#pb-palette').innerHTML=GROUPS.map(([id,name,types])=>`<div class="pal-group"><h2>${name}</h2><div class="pal-list">${types.map(t=>`<div class="pal-item" role="button" tabindex="0" draggable="true" data-type="${t}" title="${esc(DEFS[t].desc)}"><span class="tok">${esc(DEFS[t].tok)}</span><span>${DEFS[t].label}</span></div>`).join('')}</div></div>`).join('');
		}

		const blocksEl=$('#pb-blocks'),canvasEl=$('#pb-canvas');
		function cardHtml(b){
		  const d=DEFS[b.type];const isC=collapsed.has(b.id);
		  return `<div class="block${isC?' collapsed':''}" data-id="${b.id}">
		  <div class="block-head">
		    <span class="grip" title="Drag to reorder">${ICON.grip}</span>
		    <button type="button" class="b-title" data-act="toggle" aria-expanded="${!isC}"><span class="name">${d.label}</span><span class="summary">${esc(String(d.sum(b)||'').slice(0,80))}</span><span class="foldnote"></span></button>
		    <span class="tools">${b.type==='heading'?`
		      <button type="button" class="icon" data-act="secup" aria-label="Move this section up" title="Move this section up">${ICON.up2}</button>
		      <button type="button" class="icon" data-act="secdown" aria-label="Move this section down" title="Move this section down">${ICON.down2}</button>
		      <button type="button" class="icon" data-act="fold" aria-pressed="${folded.has(b.id)}" aria-label="${folded.has(b.id)?'Show':'Hide'} the blocks in this section" title="${folded.has(b.id)?'Show':'Hide'} the blocks in this section">${folded.has(b.id)?ICON.unfold:ICON.fold}</button>`:''}
		      <button type="button" class="icon" data-act="up" aria-label="Move up" title="Move up">${ICON.up}</button>
		      <button type="button" class="icon" data-act="down" aria-label="Move down" title="Move down">${ICON.down}</button>
		      <button type="button" class="icon" data-act="dup" aria-label="Duplicate" title="Duplicate">${ICON.dup}</button>
		      <button type="button" class="icon del" data-act="del" aria-label="Delete" title="Delete">${ICON.x}</button>
		    </span>
		  </div>
		  <div class="block-body">${d.form(b)}</div>
		</div>`;
		}
		function renderCanvas(){
		  blocksEl.innerHTML=state.blocks.length?state.blocks.map(cardHtml).join(''):
		    `<div class="empty"><strong>This page is empty</strong><p>Drag a block from the list into this space, or click one to add it at the end.</p></div>`;
		  blocksEl.querySelectorAll('[data-thumbs]').forEach(t=>fillThumbs(t.closest('.block')));
		  applyFold();
		}

		const folded=new Set();
		const lvlOf=b=>b.type==='heading'?(+b.level||2):(b.type==='refs'&&b.heading?2:null);
		function sectionEnd(i){const L=lvlOf(state.blocks[i])||2;for(let j=i+1;j<state.blocks.length;j++){const l=lvlOf(state.blocks[j]);if(l!=null&&l<=L)return j}return state.blocks.length}
		function applyFold(){
		  let cur=null;const counts={};
		  state.blocks.forEach(b=>{const l=lvlOf(b);if(cur&&l!=null&&l<=cur.l)cur=null;
		    const card=cardOf(b.id);
		    if(cur){counts[cur.id]++;if(card)card.classList.add('fold-hid')}else if(card)card.classList.remove('fold-hid');
		    if(!cur&&b.type==='heading'&&folded.has(b.id)){cur={id:b.id,l};counts[b.id]=0}});
		  [...folded].forEach(id=>{if(!state.blocks.some(b=>b.id===id))folded.delete(id)});
		  blocksEl.querySelectorAll('.block').forEach(c=>{const s=c.querySelector('.foldnote');if(!s)return;const n=folded.has(c.dataset.id)?counts[c.dataset.id]||0:0;s.textContent=n?`${n} block${n===1?'':'s'} hidden`:''});
		}
		function revealBlock(id){const idx=state.blocks.findIndex(b=>b.id===id);let changed=false;
		  state.blocks.forEach((b,i)=>{if(b.type==='heading'&&folded.has(b.id)&&i<idx&&sectionEnd(i)>idx){folded.delete(b.id);changed=true;const c=cardOf(b.id);if(c){const t=document.createElement('div');t.innerHTML=cardHtml(b);c.replaceWith(t.firstElementChild)}}});
		  if(changed)applyFold()}
		function moveSection(i,dir){const L=lvlOf(state.blocks[i])||2,end=sectionEnd(i);
		  if(dir<0){let p=-1;for(let j=i-1;j>=0;j--){const l=lvlOf(state.blocks[j]);if(l!=null&&l<=L){if(l===L&&state.blocks[j].type==='heading')p=j;break}}
		    if(p<0){showToast('This is already the first section at its level.');return false}
		    const mine=state.blocks.splice(i,end-i);state.blocks.splice(p,0,...mine)}
		  else{const nb=state.blocks[end];if(!nb||nb.type!=='heading'||lvlOf(nb)!==L){showToast('This is already the last section at its level.');return false}
		    const theirs=state.blocks.splice(end,sectionEnd(end)-end);state.blocks.splice(i,0,...theirs)}
		  return true}
		function commit(){renderCanvas();updateOutput();save();record()}

		const hist={stack:[],i:-1,timer:null};
		function record(){clearTimeout(hist.timer);hist.timer=null;const s=JSON.stringify(state);
		  if(hist.stack[hist.i]!==s){hist.stack=hist.stack.slice(0,hist.i+1);hist.stack.push(s);if(hist.stack.length>200)hist.stack.shift();hist.i=hist.stack.length-1}
		  histButtons()}
		function recordSoon(){clearTimeout(hist.timer);hist.timer=setTimeout(record,500);histButtons()}
		function resetHistory(){clearTimeout(hist.timer);hist.timer=null;hist.stack=[];hist.i=-1;record()}
		function histButtons(){const u=$('#pb-undoBtn'),r=$('#pb-redoBtn');if(u)u.disabled=!(hist.i>0||hist.timer);if(r)r.disabled=hist.i>=hist.stack.length-1||!!hist.timer}
		const cssq=x=>window.CSS&&CSS.escape?CSS.escape(x):String(x).replace(/["\\]/g,'\\$&');
		function focusKey(){const a=document.activeElement;if(!a||!blocksEl.contains(a))return null;const c=a.closest('.block');if(!c)return null;
		  const sel=a.dataset.path?`[data-path="${cssq(a.dataset.path)}"]`:a.dataset.tdkey!=null?`[data-tdkey="${cssq(a.dataset.tdkey)}"]`:a.dataset.act?`[data-act="${cssq(a.dataset.act)}"]`:null;
		  return sel?{id:c.dataset.id,sel,pos:a.selectionStart,end:a.selectionEnd}:null}
		function refocus(k){if(!k)return;const c=cardOf(k.id);const f=c&&c.querySelector(k.sel);if(!f)return;f.focus({preventScroll:true});
		  if(k.pos!=null&&f.setSelectionRange){try{f.setSelectionRange(Math.min(k.pos,f.value.length),Math.min(k.end,f.value.length))}catch(e){}}}
		function restoreFrom(s){const k=focusKey();state=JSON.parse(s);titleEl.value=state.title;renderCanvas();updateOutput();save();refocus(k);histButtons()}
		function undo(){acHide();if(hist.timer)record();if(hist.i>0){hist.i--;restoreFrom(hist.stack[hist.i])}}
		function redo(){acHide();if(hist.timer)record();if(hist.i<hist.stack.length-1){hist.i++;restoreFrom(hist.stack[hist.i])}}
		function rerenderCard(id){const c=cardOf(id),b=state.blocks.find(x=>x.id===id);if(!c||!b)return;const k=focusKey();
		  const t=document.createElement('div');t.innerHTML=cardHtml(b);const n=t.firstElementChild;c.replaceWith(n);if(k&&k.id===id)refocus(k);fillThumbs(n);applyFold()}

		function fillThumbs(card){if(!card||!hooks.thumb)return;const box=card.querySelector('[data-thumbs]');if(!box)return;
		  const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
		  const files=(b.type==='image'?[b.file]:lines(b.items).map(l=>l.split('|')[0])).map(f=>stripFile(f).trim()).filter(Boolean).slice(0,12);
		  box.innerHTML=files.map(f=>`<figure class="tb" data-file="${esc(f)}"><span class="wait">Loading…</span><figcaption>${esc(f)}</figcaption></figure>`).join('');
		  box.querySelectorAll('.tb').forEach(fig=>{hooks.thumb(fig.dataset.file).then(r=>{const slot=fig.firstElementChild;if(!slot||slot.tagName==='FIGCAPTION')return;
		      if(r&&r.url){const img=document.createElement('img');img.src=r.url;img.alt='';img.loading='lazy';slot.replaceWith(img)}
		      else{slot.className='miss';slot.textContent='Not on this wiki'}},
		    ()=>{const slot=fig.firstElementChild;if(slot&&slot.tagName!=='FIGCAPTION'){slot.className='miss';slot.textContent='Couldn’t load'}})})}

		const smooth=()=>window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth';
		function renderOutline(){const el=$('#pb-outline');if(!el)return;
		  const hs=state.blocks.filter(b=>b.type==='heading'||(b.type==='refs'&&b.heading));
		  el.innerHTML=hs.length?hs.map(b=>{const t=b.type==='refs'?'References':(b.text.trim()||'Untitled section');const l=b.type==='refs'?2:Math.min(5,Math.max(2,+b.level||2));
		    return `<li class="l${l}"><button type="button" data-goto="${b.id}" title="${esc(t)}">${esc(t)}</button></li>`}).join(''):'<li><p class="outline-empty">Headings you add show up here.</p></li>'}
		function gotoBlock(id){revealBlock(id);const c=cardOf(id);if(!c)return;
		  if(collapsed.has(id)){collapsed.delete(id);c.classList.remove('collapsed');const t=c.querySelector('[data-act="toggle"]');if(t)t.setAttribute('aria-expanded','true')}
		  if(c.scrollIntoView)c.scrollIntoView({block:'start',behavior:smooth()});flash(id);
		  const f=c.querySelector('.block-body [data-path], .block-body [data-tdkey]');if(f)f.focus({preventScroll:true})}
		const cardOf=id=>blocksEl.querySelector(`.block[data-id="${id}"]`);
		function flash(id){const c=cardOf(id);if(!c)return;c.classList.remove('fresh');void c.offsetWidth;c.classList.add('fresh');setTimeout(()=>c.classList.remove('fresh'),1200)}

		function addBlock(type,idx){
		  const b=mk(type);state.blocks.splice(idx,0,b);commit();revealBlock(b.id);
		  const c=cardOf(b.id);
		  if(c){c.scrollIntoView({block:'nearest',behavior:'smooth'});flash(b.id);
		    const f=c.querySelector('.block-body [data-path]');if(f)f.focus({preventScroll:true});}
		}

		function setPath(o,path,v){const p=path.split('.');let t=o;for(let i=0;i<p.length-1;i++){if(t[p[i]]==null)t[p[i]]=/^\d+$/.test(p[i+1])?[]:{};t=t[p[i]]}t[p[p.length-1]]=v}

		function onField(e){
		  const el=e.target.closest('[data-path]');if(!el)return;
		  const card=el.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
		  setPath(b,el.dataset.path,el.type==='checkbox'?el.checked:el.value);
		  if(el.dataset.rerender){renderCanvas();const n=cardOf(b.id);const f=n&&n.querySelector(`[data-path="${el.dataset.path}"]`);if(f)f.focus()}
		  else{const s=card.querySelector('.summary');if(s)s.textContent=String(DEFS[b.type].sum(b)||'').slice(0,80);
		    const tc=card.querySelector('[data-tplcode]');if(tc)tc.textContent=infoboxTemplate(b);
		    const tn=card.querySelector('[data-tplname]');if(tn)tn.textContent='Template:'+(tdKey(b.template)||'Infobox');
		    const sb=card.querySelector('[data-act="savetpl"]');if(sb&&!sb.classList.contains('armed'))sb.textContent='Save to Template:'+(tdKey(b.template)||'Infobox')}
		  updateOutput();save();recordSoon();
		}
		blocksEl.addEventListener('input',onField);
		blocksEl.addEventListener('input',e=>{const el=e.target;if(!el.dataset||el.dataset.tdkey==null)return;
		  const card=el.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
		  const k=el.dataset.tdkey,cur=b.params.find(p=>p.k.trim()===k);
		  if(cur){cur.v=el.value;cur.td=true}
		  else if(el.value.trim()){const e=tdFor(b.name),order=e&&e.data?e.data.order:[],oi=order.indexOf(k);let at=b.params.length;
		    if(oi>=0)for(let j=0;j<b.params.length;j++){if(order.indexOf(b.params[j].k.trim())>oi){at=j;break}}
		    b.params.splice(at,0,{k,v:el.value,td:true});if(at<b.params.length-1)setTimeout(()=>rerenderCard(b.id),0)}
		  const s=card.querySelector('.summary');if(s)s.textContent=String(DEFS[b.type].sum(b)||'').slice(0,80);
		  updateOutput();save();recordSoon()});
		blocksEl.addEventListener('change',e=>{const el=e.target;if(!el.dataset||!el.dataset.path)return;
		  const card=el.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
		  if(hooks.templateData&&((b.type==='template'&&el.dataset.path==='name')||(b.type==='infobox'&&el.dataset.path==='template')))setTimeout(()=>rerenderCard(b.id),0);
		  else if((b.type==='image'&&el.dataset.path==='file')||(b.type==='gallery'&&el.dataset.path==='items'))fillThumbs(card);
		  else if(b.type==='infobox'&&b.mode==='dedicated'&&/^rows\.\d+\.k$/.test(el.dataset.path))setTimeout(()=>rerenderCard(b.id),0)});
		blocksEl.addEventListener('change',e=>{if(e.target.matches('select,input[type=checkbox]'))onField(e)});

		let lastField=null;
		const FIELD_SEL='textarea[data-path],input[type=text][data-path]';
		blocksEl.addEventListener('focusin',e=>{if(e.target.matches&&e.target.matches(FIELD_SEL))lastField=e.target});
		function fieldFor(card){
		  if(lastField&&lastField.isConnected&&card.contains(lastField))return lastField;
		  return card.querySelector('.block-body textarea[data-path]')||card.querySelector('.block-body input[type=text][data-path]');
		}
		const WRAP={bold:["'''","'''",'bold text'],italic:["''","''",'italic text'],under:['<u>','</u>','underlined text'],strike:['<s>','</s>','struck-out text'],
		  sup:['<sup>','</sup>','2'],sub:['<sub>','</sub>','2'],small:['<small>','</small>','small text'],big:['<big>','</big>','big text'],code:['<code>','</code>','code'],
		  link:['[\u005B',']]','Page name'],ext:['[https://example.com ',']','link text'],ref:['<ref>','</ref>','Source details'],tpl:['{\u007B','}}','Template name'],nowiki:['<nowiki>','</nowiki>','text']};
		function applyFmt(card,kind,arg){
		  if(kind==='ref'){openCite(card);return}
		  const f=fieldFor(card);if(!f)return;
		  const v=f.value,s=f.selectionStart==null?v.length:f.selectionStart,e=f.selectionEnd==null?v.length:f.selectionEnd,sel=v.slice(s,e);
		  let nv,ns,ne;
		  if(kind==='br'||kind==='sym'){const ins=kind==='br'?'<br />':arg;nv=v.slice(0,s)+ins+v.slice(e);ns=ne=s+ins.length}
		  else if(kind==='clear'){
		    if(!sel){f.focus();return}
		    const c=sel.replace(/'{2,}/g,'').replace(/<\/?(u|s|del|ins|sup|sub|small|big|code|span|nowiki|b|i|mark)\b[^>]*>/gi,'');
		    nv=v.slice(0,s)+c+v.slice(e);ns=s;ne=s+c.length}
		  else{
		    const [a,z,d]=kind==='color'?[`<span style="color:${arg}">`,'</span>','coloured text']:kind==='bg'?[`<span style="background-color:${arg}">`,'</span>','highlighted text']:WRAP[kind];
		    const q=a[0]==="'";
		    const around=v.slice(s-a.length,s)===a&&v.slice(e,e+z.length)===z&&!(q&&(v[s-a.length-1]==="'"||v[e+z.length]==="'"));
		    const within=sel.length>=a.length+z.length&&sel.startsWith(a)&&sel.endsWith(z)&&!(q&&(sel[a.length]==="'"||sel[sel.length-z.length-1]==="'"));
		    if(around){nv=v.slice(0,s-a.length)+sel+v.slice(e+z.length);ns=s-a.length;ne=ns+sel.length}
		    else if(within){const inner=sel.slice(a.length,sel.length-z.length);nv=v.slice(0,s)+inner+v.slice(e);ns=s;ne=s+inner.length}
		    else{const inner=sel||d;nv=v.slice(0,s)+a+inner+z+v.slice(e);ns=s+a.length;ne=ns+inner.length}
		  }
		  f.value=nv;f.focus();try{f.setSelectionRange(ns,ne)}catch(x){}
		  f.dispatchEvent(new Event('input',{bubbles:true}));
		}
		blocksEl.addEventListener('change',e=>{const t=e.target;const card=t.closest&&t.closest('.block');if(!card)return;
		  if(t.matches('input[data-fmtcolor]'))applyFmt(card,t.dataset.fmtcolor,t.value);
		  else if(t.matches('select[data-fmtsym]')){if(t.value)applyFmt(card,'sym',t.value);t.value=''}});
		blocksEl.addEventListener('keydown',e=>{
		  if(!(e.ctrlKey||e.metaKey)||e.altKey||e.shiftKey)return;
		  const f=e.target;if(!f.matches||!f.matches(FIELD_SEL))return;
		  const card=f.closest('.block');if(!card||!card.querySelector('.fmtbar'))return;
		  const k={b:'bold',i:'italic',u:'under',k:'link'}[e.key.toLowerCase()];if(!k)return;
		  e.preventDefault();lastField=f;applyFmt(card,k);
		});

		const AC_KIND={image:{file:'file'},infobox:{template:'template',image:'file'},template:{name:'template'},hatnote:{target:'page'},cattree:{cat:'category'},gallery:{items:'fileline'},categories:{items:'catline'}};
		const acEl=$('#pb-acList');let ac=null,acSeq=0,acTimer=null,acSkip=false;
		function acContext(f){
		  const card=f.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return null;
		  const v=f.value,c=f.selectionStart;if(c==null||c!==f.selectionEnd)return null;
		  const before=v.slice(0,c),rest=v.slice(c);
		  const kind=f.dataset.ac||(AC_KIND[b.type]||{})[f.dataset.path];
		  if(kind==='file'||kind==='template'||kind==='page'||kind==='category')
		    return {kind,q:before.replace(/^\s*(File|Image|Category|Template)\s*:/i,'').trim(),from:0,to:v.length,after:''};
		  if(kind==='fileline'||kind==='catline'){
		    const ls=before.lastIndexOf('\n')+1,lb=before.slice(ls);if(kind==='fileline'&&lb.includes('|'))return null;
		    const le=v.indexOf('\n',c),end=le<0?v.length:le;let to=end;
		    if(kind==='fileline'){const pi=v.slice(ls,end).indexOf('|');if(pi>=0)to=ls+pi}
		    return {kind:kind==='fileline'?'file':'category',q:lb.replace(/^\s*(File|Image|Category)\s*:/i,'').trim(),from:ls,to,after:''}}
		  let m=before.match(/\[\[([^\[\]|\n{}]*)$/);
		  if(m){let q=m[1],k='page',skip=0;const ns=q.match(/^\s*:?\s*(File|Image|Media|Category)\s*:\s*/i);
		    if(ns){k=/^c/i.test(ns[1])?'category':'file';skip=ns[0].length}
		    const tail=rest.match(/^[^\[\]\n|#]*/)[0];
		    return {kind:k,q:q.slice(skip).trim(),from:c-m[1].length+skip,to:c+tail.length,after:/^[^\[\]\n|#]*(\]\]|\||#)/.test(rest)?'':']]'}}
		  m=before.match(/\{\{([^{}|\n]*)$/);
		  if(m&&!/[:#]/.test(m[1])){const tail=rest.match(/^[^{}\n|]*/)[0];
		    return {kind:'template',q:m[1].trim(),from:c-m[1].length,to:c+tail.length,after:/^[^{}\n|]*(\}\}|\|)/.test(rest)?'':'}}'}}
		  return null;
		}
		function acHide(){if(acEl)acEl.hidden=true;if(ac&&ac.f){ac.f.removeAttribute('aria-activedescendant');ac.f.setAttribute('aria-expanded','false')}ac=null}
		function acQuery(f){
		  if(!hooks.search||!acEl)return;
		  const ctx=acContext(f);if(!ctx||!ctx.q){acHide();return}
		  const my=++acSeq;clearTimeout(acTimer);
		  acTimer=setTimeout(()=>{hooks.search(ctx.kind,ctx.q).then(items=>{
		    if(my!==acSeq||document.activeElement!==f)return;
		    items=(items||[]).slice(0,8);if(!items.length){acHide();return}
		    acShow(f,ctx,items)},()=>{})},180);
		}
		function acShow(f,ctx,items){
		  ac={f,ctx,items,i:0};
		  acEl.innerHTML=items.map((t,i)=>`<div class="ac-opt${i?'':' on'}" role="option" id="${acEl.id}-o${i}" data-i="${i}" aria-selected="${i===0}">${esc(t)}</div>`).join('');
		  acEl.hidden=false;
		  const r=f.getBoundingClientRect(),w=Math.max(220,Math.min(420,r.width));
		  acEl.style.width=w+'px';acEl.style.left=Math.max(8,Math.min(r.left,window.innerWidth-w-8))+'px';
		  const h=acEl.offsetHeight;acEl.style.top=(r.bottom+2+h>window.innerHeight&&r.top-h-2>0?r.top-h-2:r.bottom+2)+'px';
		  f.setAttribute('aria-expanded','true');f.setAttribute('aria-controls',acEl.id);f.setAttribute('aria-autocomplete','list');f.setAttribute('aria-activedescendant',acEl.id+'-o0');
		}
		function acMove(d){if(!ac)return;ac.i=(ac.i+d+ac.items.length)%ac.items.length;
		  acEl.querySelectorAll('.ac-opt').forEach((o,i)=>{o.classList.toggle('on',i===ac.i);o.setAttribute('aria-selected',String(i===ac.i))});
		  const on=acEl.querySelector('.ac-opt.on');if(on){if(on.scrollIntoView)on.scrollIntoView({block:'nearest'});ac.f.setAttribute('aria-activedescendant',on.id)}}
		function acAccept(i){if(!ac)return;const f=ac.f,ctx=ac.ctx,t=ac.items[i==null?ac.i:i];acHide();
		  const v=f.value,ins=t+ctx.after;f.value=v.slice(0,ctx.from)+ins+v.slice(ctx.to);const pos=ctx.from+ins.length;
		  f.focus();try{f.setSelectionRange(pos,pos)}catch(e){}
		  acSkip=true;f.dispatchEvent(new Event('input',{bubbles:true}));f.dispatchEvent(new Event('change',{bubbles:true}));acSkip=false}
		blocksEl.addEventListener('input',e=>{if(acSkip)return;const f=e.target;if(f.matches&&(f.matches(FIELD_SEL)||f.matches('[data-tdkey]')))acQuery(f)});
		blocksEl.addEventListener('keydown',e=>{if(!ac||e.target!==ac.f)return;
		  if(e.key==='ArrowDown'){e.preventDefault();acMove(1)}
		  else if(e.key==='ArrowUp'){e.preventDefault();acMove(-1)}
		  else if(e.key==='Enter'||e.key==='Tab'){e.preventDefault();acAccept()}
		  else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();acHide()}},true);
		blocksEl.addEventListener('focusout',()=>setTimeout(()=>{if(ac&&document.activeElement!==ac.f)acHide()},120));
		if(acEl){acEl.addEventListener('mousedown',e=>e.preventDefault());
		  acEl.addEventListener('click',e=>{const o=e.target.closest('.ac-opt');if(o)acAccept(+o.dataset.i)})}
		document.addEventListener('scroll',e=>{if(ac&&e.target!==acEl)acHide()},true);
		window.addEventListener('resize',()=>acHide());

		let checkTimer=null,checkSeq=0;const existCache=new Map();
		function scheduleChecks(){clearTimeout(checkTimer);checkTimer=setTimeout(runChecks,700)}
		function extractTitles(text){
		  const t=text.replace(/<nowiki>[\s\S]*?<\/nowiki>/gi,'').replace(/<!--[\s\S]*?-->/g,'').replace(/\{\{\{[^{}]*\}\}\}/g,'');
		  const pages=new Set(),files=new Set(),cats=new Set(),tpls=new Set();
		  t.replace(/\[\[\s*:?\s*([^\[\]|#\n{}]+?)\s*(?=\||#|\]\])/g,(m,name)=>{const n=name.trim();if(!n)return m;
		    const ns=n.match(/^(File|Image|Media|Category)\s*:\s*(.+)$/i);
		    if(ns)(/^c/i.test(ns[1])?cats:files).add(ns[2].trim());else pages.add(n);return m});
		  t.replace(/\{\{\s*([^{}|\n]+?)\s*(?=\||\}\})/g,(m,name)=>{const n=name.trim();if(n&&!/[:#]/.test(n)&&!/^[A-Z0-9 _!]+$/.test(n))tpls.add(n);return m});
		  return {pages:[...pages],files:[...files].map(f=>'File:'+f),cats:[...cats].map(c=>'Category:'+c),tpls:[...tpls].map(x=>'Template:'+x)};
		}
		function localIssues(text){
		  const out=[];
		  const listed=/<references\b|\{\{\s*reflist/i.test(text);
		  const refs=/<ref\b/i.test(text.replace(/<references\b[^>]*>/gi,''));
		  if(refs&&!listed)out.push({warn:true,msg:'This page has references but nowhere to list them. Add a References block.'});
		  if(listed&&!refs){const r=state.blocks.find(b=>b.type==='refs');out.push({msg:'There’s a References block, but no references on the page yet.',id:r&&r.id})}
		  let last=1;
		  state.blocks.forEach(b=>{
		    if(b.type==='heading'){const l=+b.level||2;
		      if(!b.text.trim())out.push({warn:true,msg:'A heading has no text.',id:b.id});
		      else if(l>last+1)out.push({msg:`“${b.text.trim()}” skips a heading level. The contents box and screen readers work best when levels go one step at a time.`,id:b.id});
		      last=l}
		    if(b.type==='refs'&&b.heading)last=2;
		    if(b.type==='image'&&!stripFile(b.file).trim())out.push({warn:true,msg:'An image block has no file name.',id:b.id});
		    if(b.type==='template'&&!b.name.trim())out.push({warn:true,msg:'A template block has no template name.',id:b.id});
		    if(b.type==='paragraph'&&!b.text.trim())out.push({msg:'A paragraph block is empty, so it’s left out of the page.',id:b.id});
		  });
		  return out;
		}
		function renderChecks(issues,note,pending){
		  const el=$('#pb-checks');if(!el)return;
		  issues.sort((a,b)=>(b.warn?1:0)-(a.warn?1:0));
		  const n=issues.filter(x=>x.warn).length;const tab=$('#pb-tab-chk');if(tab)tab.textContent=n?`Checks (${n})`:'Checks';
		  const link=t=>hooks.url?`<a href="${esc(hooks.url(t))}" target="_blank" rel="noopener">${esc(t)}</a>`:esc(t);
		  el.innerHTML=(issues.length?`<ul class="issues">${issues.map(x=>`<li class="issue${x.warn?' warn':''}"><p>${esc(x.msg)}</p>${x.titles?`<p class="titles">${x.titles.map(link).join(', ')}</p>`:''}${x.id?`<button type="button" class="linkbtn" data-goto="${x.id}">Show the block</button>`:''}</li>`).join('')}</ul>`:(pending?'':'<p class="checks-ok">No problems found.</p>'))
		    +(pending?'<p class="checks-ok">Checking links against your wiki…</p>':'')+(note?`<p class="checks-ok">${esc(note)}</p>`:'');
		}
		function runChecks(){
		  const text=gen(),issues=localIssues(text),my=++checkSeq;
		  if(!hooks.exists){renderChecks(issues,'Red links, missing images and missing templates are checked when the builder runs on your wiki.');return}
		  const T=extractTitles(text);
		  const all=[].concat(T.pages,T.files,T.cats,T.tpls);
		  const need=all.filter(t=>!existCache.has(t)).slice(0,500);
		  const finish=()=>{if(my!==checkSeq)return;const miss=a=>a.filter(t=>existCache.get(t)===false);
		    const mf=miss(T.files),mt=miss(T.tpls),mp=miss(T.pages),mc=miss(T.cats);
		    if(mf.length)issues.push({warn:true,msg:mf.length===1?'An image that isn’t uploaded to this wiki:':'Images that aren’t uploaded to this wiki:',titles:mf});
		    if(mt.length)issues.push({warn:true,msg:mt.length===1?'A template that doesn’t exist on this wiki yet:':'Templates that don’t exist on this wiki yet:',titles:mt});
		    if(mp.length)issues.push({msg:'Links to pages that don’t exist yet (red links):',titles:mp});
		    if(mc.length)issues.push({msg:'Category pages that don’t exist yet. The page is still filed under them:',titles:mc});
		    renderChecks(issues)};
		  if(!need.length){finish();return}
		  renderChecks(issues.slice(),null,true);
		  hooks.exists(need).then(map=>{need.forEach(t=>existCache.set(t,!map||map[t]!==false));finish()},
		    ()=>{if(my===checkSeq)renderChecks(issues,'Couldn’t check links against your wiki just now.')});
		}

		let toastTimer=null,undoFn=null;
		function showToast(msg,undo){
		  $('#pb-toastMsg').textContent=msg;undoFn=undo||null;$('#pb-toastUndo').hidden=!undo;$('#pb-toast').hidden=false;
		  clearTimeout(toastTimer);toastTimer=setTimeout(()=>{$('#pb-toast').hidden=true;undoFn=null},6000);
		}
		$('#pb-toastUndo').addEventListener('click',()=>{const f=undoFn;undoFn=null;$('#pb-toast').hidden=true;if(f)f()});

		blocksEl.addEventListener('mousedown',e=>{if(e.target.closest('.fmtbar button'))e.preventDefault()});
		blocksEl.addEventListener('toggle',e=>{const d=e.target;if(!d.matches||!d.matches('details.tplcode'))return;const id=d.closest('.block').dataset.id;d.open?openTpl.add(id):openTpl.delete(id)},true);
		blocksEl.addEventListener('click',e=>{
		  const btn=e.target.closest('[data-act]');if(!btn)return;
		  const card=btn.closest('.block');if(!card)return;
		  const id=card.dataset.id,i=state.blocks.findIndex(x=>x.id===id),b=state.blocks[i];if(!b)return;
		  const act=btn.dataset.act;
		  const refocus=(sel)=>{const c=cardOf(id);const f=c&&c.querySelector(sel);if(f)f.focus()};
		  switch(act){
		    case 'toggle':collapsed.has(id)?collapsed.delete(id):collapsed.add(id);card.classList.toggle('collapsed');btn.setAttribute('aria-expanded',String(!collapsed.has(id)));return;
		    case 'up':if(i>0){[state.blocks[i-1],state.blocks[i]]=[state.blocks[i],state.blocks[i-1]];commit();refocus('[data-act="up"]')}return;
		    case 'down':if(i<state.blocks.length-1){[state.blocks[i+1],state.blocks[i]]=[state.blocks[i],state.blocks[i+1]];commit();refocus('[data-act="down"]')}return;
		    case 'dup':{const c=JSON.parse(JSON.stringify(b));c.id=uid();state.blocks.splice(i+1,0,c);commit();flash(c.id);return}
		    case 'del':state.blocks.splice(i,1);collapsed.delete(id);commit();showToast(`${DEFS[b.type].label} deleted`,()=>{state.blocks.splice(Math.min(i,state.blocks.length),0,b);commit();flash(b.id)});return;
		    case 'fmt':applyFmt(card,btn.dataset.fmt);return;
		    case 'copytpl':copyText(infoboxTemplate(b)).then(ok=>{btn.textContent=ok?'Copied':'Select the code above to copy it';setTimeout(()=>btn.textContent='Copy template code',1800)});return;
		    case 'addrow':b.rows.push({k:'',v:''});commit();refocus(`[data-path="rows.${b.rows.length-1}.k"]`);return;
		    case 'secup':case 'secdown':if(moveSection(i,act==='secup'?-1:1)){commit();flash(id);refocus(`[data-act="${act}"]`)}return;
		    case 'fold':folded.has(id)?folded.delete(id):folded.add(id);rerenderCard(id);refocus('[data-act="fold"]');return;
		    case 'savetpl':saveTemplatePage(b,btn);return;
		    case 'trowup':{const r=+btn.dataset.i;if(r>0){[b.cells[r-1],b.cells[r]]=[b.cells[r],b.cells[r-1]];commit();refocus(`[data-act="trowup"][data-i="${r-1}"]`)}return}
		    case 'trowdown':{const r=+btn.dataset.i;if(r<b.cells.length-1){[b.cells[r+1],b.cells[r]]=[b.cells[r],b.cells[r+1]];commit();refocus(`[data-act="trowdown"][data-i="${r+1}"]`)}return}
		    case 'trowdel':if(b.cells.length>1){b.cells.splice(+btn.dataset.i,1);commit()}return;
		    case 'tdfill':{const m=tdMissing(b);if(m.length){b.rows=b.rows.filter(r=>r.k.trim()||r.v.trim());m.forEach(k=>b.rows.push({k,v:''}));commit()}return}
		    case 'delrow':b.rows.splice(+btn.dataset.i,1);if(!b.rows.length)b.rows.push({k:'',v:''});commit();return;
		    case 'addparam':b.params.push({k:'',v:''});commit();refocus(`[data-path="params.${b.params.length-1}.k"]`);return;
		    case 'delparam':b.params.splice(+btn.dataset.i,1);if(!b.params.length)b.params.push({k:'',v:''});commit();return;
		    case 'addtrow':b.cells.push(Array((b.cells[0]||['']).length).fill(''));commit();refocus(`[data-path="cells.${b.cells.length-1}.0"]`);return;
		    case 'addcol':b.cells.forEach(r=>r.push(''));commit();refocus(`[data-path="cells.0.${b.cells[0].length-1}"]`);return;
		    case 'deltrow':if(b.cells.length>1){b.cells.pop();commit()}return;
		    case 'delcol':if(b.cells[0].length>1){b.cells.forEach(r=>r.pop());if(b.align&&b.align.length>=b.cells[0].length+1)b.align.length=b.cells[0].length;commit()}return;
		  }
		});

		let drag=null,line=null;
		const pal=$('#pb-palette');
		pal.addEventListener('click',e=>{const it=e.target.closest('.pal-item');if(it)addBlock(it.dataset.type,state.blocks.length)});
		pal.addEventListener('keydown',e=>{const it=e.target.closest('.pal-item');if(it&&(e.key==='Enter'||e.key===' ')){e.preventDefault();addBlock(it.dataset.type,state.blocks.length)}});
		pal.addEventListener('dragstart',e=>{const it=e.target.closest('.pal-item');if(!it)return;drag={kind:'new',type:it.dataset.type};e.dataTransfer.effectAllowed='copy';e.dataTransfer.setData('text/plain',it.dataset.type)});

		blocksEl.addEventListener('pointerdown',e=>{const g=e.target.closest('.grip');if(g)g.closest('.block').draggable=true});
		document.addEventListener('pointerup',()=>blocksEl.querySelectorAll('.block[draggable="true"]:not(.dragging)').forEach(c=>c.draggable=false));
		blocksEl.addEventListener('dragstart',e=>{
		  const card=e.target.closest&&e.target.closest('.block');if(!card||!card.draggable||e.target!==card)return;
		  drag={kind:'move',id:card.dataset.id};e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',card.dataset.id);
		  requestAnimationFrame(()=>card.classList.add('dragging'));
		});
		document.addEventListener('dragend',()=>{
		  blocksEl.querySelectorAll('.block').forEach(c=>{c.classList.remove('dragging');c.draggable=false});
		  hideLine();drag=null;
		});
		function targetFor(y){
		  for(const c of blocksEl.querySelectorAll('.block')){const r=c.getBoundingClientRect();if(y<r.top+r.height/2)return c.dataset.id}
		  return null;
		}
		function showLine(beforeId){
		  if(!line){line=document.createElement('div');line.className='drop-line'}
		  const ref=beforeId?cardOf(beforeId):null;
		  if(ref){if(line.nextSibling!==ref)blocksEl.insertBefore(line,ref)}
		  else if(blocksEl.lastChild!==line)blocksEl.appendChild(line);
		  canvasEl.classList.add('over');
		}
		function hideLine(){if(line&&line.parentNode)line.remove();canvasEl.classList.remove('over')}
		canvasEl.addEventListener('dragover',e=>{if(!drag)return;e.preventDefault();e.dataTransfer.dropEffect=drag.kind==='new'?'copy':'move';showLine(targetFor(e.clientY))});
		canvasEl.addEventListener('dragleave',e=>{if(!canvasEl.contains(e.relatedTarget))hideLine()});
		canvasEl.addEventListener('drop',e=>{
		  if(!drag)return;e.preventDefault();
		  const before=targetFor(e.clientY);hideLine();const d=drag;drag=null;
		  if(d.kind==='new'){addBlock(d.type,before?state.blocks.findIndex(b=>b.id===before):state.blocks.length);return}
		  if(before===d.id)return;
		  const from=state.blocks.findIndex(b=>b.id===d.id);if(from<0)return;
		  const [m]=state.blocks.splice(from,1);
		  const to=before?state.blocks.findIndex(b=>b.id===before):state.blocks.length;
		  state.blocks.splice(to<0?state.blocks.length:to,0,m);commit();flash(m.id);
		});

		const LANGS=['text','bash','c','cpp','css','html','java','javascript','json','lua','php','python','ruby','sql','xml','yaml'];
		function splitTop(s){const out=[];let cur='',d=0;
		  for(let i=0;i<s.length;){const two=s.substr(i,2);
		    if(two==='{\u007B'||two==='[\u005B'){d++;cur+=two;i+=2}
		    else if((two==='}}'||two===']]')&&d>0){d--;cur+=two;i+=2}
		    else if(s[i]==='|'&&d===0){out.push(cur);cur='';i++}
		    else{cur+=s[i];i++}}
		  out.push(cur);return out}
		function topEq(s){let d=0;for(let i=0;i<s.length;i++){const two=s.substr(i,2);
		  if(two==='{\u007B'||two==='[\u005B'){d++;i++}else if((two==='}}'||two===']]')&&d>0){d--;i++}else if(s[i]==='='&&d===0)return i}return -1}
		function matchPair(s,start,open,close){let d=0;for(let i=start;i<s.length;){
		  if(s.startsWith(open,i)){d++;i+=2}else if(s.startsWith(close,i)){d--;i+=2;if(d===0)return i}else i++}return -1}
		function isBalanced(s){const c=re=>(s.match(re)||[]).length;
		  const refOpen=(s.match(/<ref\b[^>]*>/gi)||[]).filter(x=>!/\/\s*>$/.test(x)).length;
		  return c(/\{\{/g)<=c(/\}\}/g)&&refOpen<=c(/<\/ref\s*>/gi)&&c(/<!--/g)<=c(/-->/g)}
		const attrsOf=s=>{const o={};String(s||'').replace(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g,(m,k,a,b,c)=>{o[k.toLowerCase()]=a!=null?a:b!=null?b:c!=null?c:true;return ''});return o};
		const BLOCK_TAGS='div|center|table|noinclude|includeonly|onlyinclude|nowiki|math|poem|timeline|imagemap|inputbox|templatestyles|references|section|mapframe|score|hiero|chem|ce|graph|tabber|ul|ol|dl|p|h[1-6]|indicator|categorytree|gallery|syntaxhighlight|source|pre|blockquote';

		function templateBlock(inner){
		  const parts=splitTop(inner);const name=parts[0].trim();
		  if(!name||/[:#{}<>\n|=\[\]]/.test(name))return null;
		  const params=parts.slice(1).map(p=>{const e=topEq(p);return e>=0?{k:p.slice(0,e).trim(),v:p.slice(e+1).trim()}:{k:'',v:p.trim()}});
		  if(params.some(p=>p.v.includes('\n')||p.k.includes('\n')))return null;
		  const lname=name.toLowerCase().replace(/_/g,' ');const pos=params.filter(p=>!p.k);
		  const get=k=>(params.find(p=>p.k===k)||{v:''}).v;
		  if(/^infobox\b/.test(lname)){
		    if(params.some(p=>/^(label|data)\d+$/.test(p.k))){
		      if(pos.length||params.some(p=>!/^(title|image|caption|(label|data)\d+)$/.test(p.k)))return ['template',{name,params}];
		      const nums=[...new Set(params.map(p=>(p.k.match(/^(?:label|data)(\d+)$/)||[])[1]).filter(Boolean).map(Number))].sort((a,b)=>a-b);
		      return ['infobox',{mode:'generic',template:name,name:get('title'),image:get('image'),caption:get('caption'),rows:nums.map(n=>({k:get('label'+n),v:get('data'+n)}))}];
		    }
		    if(pos.length)return ['template',{name,params}];
		    const rows=params.filter(p=>!['title','image','caption'].includes(p.k)).map(p=>({k:p.k,v:p.v}));
		    return ['infobox',{mode:'dedicated',template:name,name:get('title'),image:get('image'),caption:get('caption'),rows:rows.length?rows:[{k:'',v:''}]}];
		  }
		  const HAT={main:'Main','see also':'See also',further:'Further'};
		  if(own(HAT,lname)&&params.length===1&&!params[0].k)return ['hatnote',{kind:HAT[lname],target:params[0].v,markup:'template'}];
		  if(lname==='quote'&&params.length&&params.every(p=>p.k==='text'||p.k==='author'||(!p.k&&pos.indexOf(p)<2)))
		    return ['quote',{fmt:'template',text:get('text')||(pos[0]||{}).v||'',source:get('author')||(pos[1]||{}).v||''}];
		  if(lname==='reflist'&&!params.length)return ['refs',{style:'reflist',heading:false}];
		  return ['template',{name,params:params.length?params:[{k:'',v:''}]}];
		}
		function imageBlock(inner){
		  const parts=splitTop(inner);const file=parts[0].replace(/^\s*(File|Image)\s*:\s*/i,'').trim();
		  if(!file||parts.some(p=>p.includes('\n')))return null;
		  const p={file,caption:'',frame:'',align:'',size:''};
		  const FR={thumb:'thumb',thumbnail:'thumb',frame:'frame',framed:'frame',frameless:'frameless'},AL={left:'left',right:'right',center:'center',centre:'center',none:'none'};
		  let cap=null;
		  for(const part of parts.slice(1)){const o=part.trim(),lo=o.toLowerCase();
		    if(own(FR,lo)){if(p.frame)return null;p.frame=FR[lo]}
		    else if(own(AL,lo)){if(p.align)return null;p.align=AL[lo]}
		    else if(/^\d*x?\d+px$/i.test(o))p.size=/^\d+px$/i.test(o)?o.replace(/px$/i,''):o;
		    else if(/^(upright|border|baseline|middle|sub|super|text-top|text-bottom|top|bottom)$/i.test(lo)||/^(link|alt|page|class|lang|upright)\s*=/i.test(o))return null;
		    else{if(cap!==null)return null;cap=o}}
		  p.caption=cap||'';return p;
		}
		function parseTable(ls){
		  const m=ls[0].trim().slice(2).trim().match(/^class\s*=\s*"([^"]*)"$/i);if(!m)return null;
		  const cls=m[1].trim().split(/\s+/);if(!cls.includes('wikitable')||cls.some(c=>c!=='wikitable'&&c!=='sortable'))return null;
		  if(ls[ls.length-1].trim()!=='|}')return null;
		  let caption='';const rows=[];let cur=null;
		  for(const line of ls.slice(1,-1)){const l=line.trim();if(!l)continue;
		    if(l.startsWith('|+')){if(rows.length||caption)return null;caption=l.slice(2).trim();if(splitTop(caption).length>1)return null;continue}
		    if(l.startsWith('|-')){if(l.slice(2).trim())return null;cur=null;continue}
		    if(l[0]==='!'||l[0]==='|'){const h=l[0]==='!';const cells=l.slice(1).split(h?/!!|\|\|/:/\|\|/);
		      if(!cur){cur={c:[],h:[],a:[]};rows.push(cur)}
		      for(const c of cells){const parts=splitTop(c);if(parts.length>2)return null;
		        if(parts.length===2){const am=parts[0].trim().match(/^style\s*=\s*"\s*text-align\s*:\s*(left|center|right)\s*;?\s*"$/i);if(!am||h)return null;cur.c.push(parts[1].trim());cur.a.push(am[1].toLowerCase()==='left'?'':am[1].toLowerCase())}
		        else{cur.c.push(c.trim());cur.a.push('')}
		        cur.h.push(h)}continue}
		    return null}
		  if(!rows.length)return null;
		  const header=rows[0].h.every(Boolean);
		  if(!header&&rows[0].h.some(Boolean))return null;
		  if(rows.slice(1).some(r=>r.h.some(Boolean)))return null;
		  const w=Math.max(...rows.map(r=>r.c.length));const align=[];
		  for(let j=0;j<w;j++){const vals=new Set(rows.slice(header?1:0).map(r=>r.a[j]||''));if(vals.size>1)return null;align.push(vals.size?[...vals][0]:'')}
		  return {caption,header,sortable:cls.includes('sortable'),cells:rows.map(r=>r.c.concat(Array(w-r.c.length).fill(''))),align};
		}
		function tagBlock(tag,a,inner,attrText){
		  const keys=Object.keys(a);const trimNl=s=>s.replace(/^\n/,'').replace(/\n\s*$/,'');
		  switch(tag){
		    case 'gallery':{if(keys.some(k=>!['mode','heights','caption'].includes(k)))return null;const mode=a.mode&&a.mode!==true?String(a.mode).toLowerCase():'';
		      if(mode&&!GALLERY_MODES.some(x=>x[0]===mode))return null;
		      return ['gallery',{items:lines(inner).map(l=>l.trim()).join('\n'),mode,heights:a.heights&&a.heights!==true?String(a.heights):'',caption:a.caption&&a.caption!==true?String(a.caption).replace(/&quot;/g,'"'):''}]}
		    case 'syntaxhighlight':case 'source':
		      if(!a.lang||a.lang===true||keys.some(k=>k!=='lang'&&k!=='line'))return null;
		      return ['code',{fmt:'syntaxhighlight',lang:String(a.lang),line:'line' in a,code:trimNl(inner)}];
		    case 'pre':return attrText?null:['code',{fmt:'pre',lang:'text',code:trimNl(inner)}];
		    case 'blockquote':{if(attrText)return null;const q=inner.match(/^([\s\S]*?)\s*<br\s*\/?>\s*(?:—|&mdash;)\s*([^\n<]+?)\s*$/i);
		      return ['quote',{fmt:'blockquote',text:(q?q[1]:inner).trim(),source:q?q[2]:''}]}
		    case 'categorytree':{if(keys.some(k=>!['mode','depth','hideroot'].includes(k))||inner.includes('\n'))return null;
		      const mode=String(a.mode||'categories').toLowerCase(),depth=String(a.depth==null?'1':a.depth);
		      if(!['categories','pages','all','parents'].includes(mode)||!/^[0-3]$/.test(depth))return null;
		      return ['cattree',{cat:inner.trim(),mode,depth,hideroot:!!a.hideroot&&a.hideroot!=='off'}]}
		    case 'references':return attrText||inner.trim()?null:['refs',{style:'references',heading:false}];
		  }
		  return null;
		}

		function parseWikitext(src){
		  const L=String(src).replace(/\r\n?/g,'\n').split('\n');
		  const out=[];let i=0;
		  const add=(type,props)=>out.push(mk(type,props));
		  const raw=t=>{if(t.trim())add('raw',{text:t.replace(/^\n+/,'').replace(/\s+$/,'')})};
		  const rest=()=>L.slice(i).join('\n');
		  const take=n=>{const s=L.slice(i,i+n).join('\n');i+=n;return s};

		  const span=(r,end)=>{if(end<0)return 0;if(r.slice(end).split('\n')[0].trim())return 0;return r.slice(0,end).split('\n').length};
		  const tagSpan=(r,name)=>{const re=new RegExp(`<(/?)(${name})\\b[^>]*?(/?)>`,'gi');let d=0,m;
		    while((m=re.exec(r))){if(m[1])d--;else if(!m[3])d++;if(d<=0)return re.lastIndex}return -1};
		  const standalone=k=>{const l=L[k];const r=L.slice(k).join('\n');
		    if(l.startsWith('{\u007B'))return span(r,matchPair(r,0,'{\u007B','}}'))>0;
		    if(/^\[\[\s*(File|Image)\s*:/i.test(l))return span(r,matchPair(r,0,'[\u005B',']]'))>0;
		    return false};
		  const blockTagRe=new RegExp(`^<(${BLOCK_TAGS})\\b`,'i');
		  const startsBlock=k=>/^(={2,6}[^=]|[*#:;\s]|\{\||-{4}|__(NO|FORCE)?TOC__\s*$|\[\[\s*Category\s*:|<!--)/i.test(L[k])||blockTagRe.test(L[k])||standalone(k);
		  const catLine=/^(\[\[\s*Category\s*:[^\]]*\]\]\s*)+$/i;

		  function step(){
		    const line=L[i],t=line.trim();let m,n,r,e;
		    if(t.startsWith('<!--')){r=rest();e=r.indexOf('-->');n=e<0?0:span(r,e+3);if(n){raw(take(n));return}}
		    if(/^[ \t]/.test(line)){let k=i;while(k<L.length&&/^[ \t]/.test(L[k])&&L[k].trim())k++;raw(take(k-i));return}
		    if(/^#redirect/i.test(t)){raw(take(1));return}
		    if((m=line.match(/^(={2,5})\s*([^=].*?)\s*\1\s*$/))){add('heading',{text:m[2],level:String(m[1].length)});i++;return}
		    if(/^__(NO|FORCE)?TOC__$/.test(t)){add('toc',{mode:t});i++;return}
		    if(/^-{4,}$/.test(t)){add('rule');i++;return}
		    if(catLine.test(t)){const names=[];while(i<L.length&&catLine.test(L[i].trim())){L[i].replace(/\[\[\s*Category\s*:\s*([^\]]*?)\s*\]\]/gi,(x,nm)=>{names.push(nm);return ''});i++}
		      add('categories',{items:names.join('\n')});return}
		    if((m=t.match(/^:\s*''\s*(Main article|See also|Further information)\s*:\s*\[\[([^\]|]+)\]\]\s*''$/))){
		      add('hatnote',{kind:{'Main article':'Main','See also':'See also','Further information':'Further'}[m[1]],target:m[2].trim(),markup:'plain'});i++;return}
		    if(/^[*#:;]/.test(line)){const c0=line[0],grp=c0==='*'||c0==='#'?x=>x[0]===c0:x=>x[0]===':'||x[0]===';';let k=i;while(k<L.length&&/^[*#:;]/.test(L[k])&&grp(L[k]))k++;const ls=L.slice(i,k);
		      const marks=ls.map(l=>l.match(/^[*#:;]+/)[0]);const ch=marks[0][0];
		      if((ch==='*'||ch==='#')&&marks.every(x=>[...x].every(c=>c===ch))){i=k;
		        add(ch==='*'?'bullets':'numbers',{items:ls.map(l=>{const mm=l.match(/^([*#]+)\s*(.*)$/);return '*'.repeat(mm[1].length-1)+mm[2]}).join('\n')})}
		      else raw(take(k-i));
		      return}
		    if(line.startsWith('{|')){let d=0,k=i;for(;k<L.length;k++){const l=L[k].trim();if(l.startsWith('{|'))d++;else if(l.startsWith('|}')){d--;if(d===0)break}}
		      if(k>=L.length){raw(take(L.length-i));return}
		      const ls=L.slice(i,k+1);const p=parseTable(ls);i=k+1;if(p)add('table',p);else raw(ls.join('\n'));return}
		    if((m=line.match(/^<(gallery|syntaxhighlight|source|pre|blockquote|categorytree|references)\b([^>]*)>/i))){
		      const tag=m[1].toLowerCase(),self=/\/\s*$/.test(m[2]),attr=m[2].replace(/\/\s*$/,'');r=rest();let innerEnd=m[0].length;
		      if(self)e=m[0].length;else{const cm=new RegExp(`</${tag}\\s*>`,'i').exec(r);e=cm?cm.index+cm[0].length:-1;if(cm)innerEnd=cm.index}
		      n=span(r,e);
		      if(n){const inner=r.slice(m[0].length,innerEnd);const whole=take(n);const blk=tagBlock(tag,attrsOf(attr),inner,attr.trim());if(blk)add(blk[0],blk[1]);else raw(whole);return}
		    }
		    if(/^<div class="(pb-note |mw-collapsible)/.test(line)){r=rest();e=tagSpan(r,'div');n=span(r,e);
		      if(n){const blk=divBlock(r.slice(0,e));if(blk){i+=n;add(blk[0],blk[1]);return}}}
		    if((m=line.match(blockTagRe))){r=rest();e=tagSpan(r,m[1]);n=span(r,e);if(n){raw(take(n));return}if(e<0){raw(take(1));return}}
		    if(line.startsWith('{\u007B')){r=rest();e=matchPair(r,0,'{\u007B','}}');n=span(r,e);
		      if(n){const whole=r.slice(0,e);i+=n;const blk=templateBlock(whole.slice(2,-2));if(blk)add(blk[0],blk[1]);else raw(whole);return}}
		    if(/^\[\[\s*(File|Image)\s*:/i.test(line)){r=rest();e=matchPair(r,0,'[\u005B',']]');n=span(r,e);
		      if(n){const whole=r.slice(0,e);i+=n;const p=imageBlock(whole.slice(2,-2));if(p)add('image',p);else raw(whole);return}}
		    const start=i;i++;
		    while(i<L.length){if(isBalanced(L.slice(start,i).join('\n'))&&(!L[i].trim()||startsBlock(i)))break;i++}
		    add('paragraph',{text:L.slice(start,i).join('\n').trim()});
		  }
		  while(i<L.length){
		    if(!L[i].trim()){i++;continue}
		    const s0=i,n0=out.length;step();
		    for(let k=n0;k<out.length;k++){out[k]._s=s0;out[k]._e=i}
		  }

		  const res=[];
		  for(const b of out){const prev=res[res.length-1];
		    if(prev&&b.type==='raw'&&prev.type==='raw'){prev._e=b._e;continue}
		    if(prev&&b.type==='categories'&&prev.type==='categories'){prev.items+='\n'+b.items;prev._e=b._e;continue}
		    if(prev&&b.type==='refs'&&!b.heading&&prev.type==='heading'&&prev.level==='2'&&prev.text.trim()==='References'){res.pop();b.heading=true;b._s=prev._s}
		    res.push(b)}
		  let before=null;
		  res.forEach(b=>{
		    b.src=L.slice(b._s,b._e).join('\n');
		    if(b.type==='raw')b.text=b.src;
		    try{b._g=DEFS[b.type].wiki(b)}catch(e){b._g=null}
		    if(before){b.sep='\n'+L.slice(before._e,b._s).map(x=>x+'\n').join('');b.prevId=before.id}
		    before=b;
		  });
		  res.forEach(b=>{delete b._s;delete b._e});
		  return res;
		}

		const titleEl=$('#pb-pageTitle');
		titleEl.addEventListener('input',()=>{state.title=titleEl.value;renderPreview();save();recordSoon()});
		$('#pb-undoBtn').addEventListener('click',undo);
		$('#pb-redoBtn').addEventListener('click',redo);
		$('.app').addEventListener('keydown',e=>{if(!(e.ctrlKey||e.metaKey)||e.altKey)return;const k=e.key.toLowerCase();
		  if(k==='z'&&!e.shiftKey){e.preventDefault();undo()}
		  else if((k==='z'&&e.shiftKey)||k==='y'){e.preventDefault();redo()}});
		$('#pb-outline').addEventListener('click',e=>{const b=e.target.closest('[data-goto]');if(b)gotoBlock(b.dataset.goto)});
		$('#pb-checks').addEventListener('click',e=>{const b=e.target.closest('[data-goto]');if(b)gotoBlock(b.dataset.goto)});
		$('#pb-collapseAll').addEventListener('click',()=>{state.blocks.forEach(b=>collapsed.add(b.id));renderCanvas()});
		$('#pb-expandAll').addEventListener('click',()=>{collapsed.clear();renderCanvas()});
		function showUndoReplace(prev,msg){
		  showToast(msg,()=>{state=prev;titleEl.value=state.title;commit()});
		}
		let armTimer=null;
		const clearBtn=$('#pb-clearPage');
		clearBtn.addEventListener('click',()=>{
		  if(!clearBtn.classList.contains('armed')){
		    clearBtn.classList.add('armed');clearBtn.textContent='Click again to clear';
		    armTimer=setTimeout(()=>{clearBtn.classList.remove('armed');clearBtn.textContent='Clear page'},3000);return;
		  }
		  clearTimeout(armTimer);clearBtn.classList.remove('armed');clearBtn.textContent='Clear page';
		  const prev=JSON.parse(JSON.stringify(state));
		  state={title:'',blocks:[]};collapsed.clear();titleEl.value='';commit();
		  showUndoReplace(prev,'Page cleared');
		});

		const tabs=[[$('#pb-tab-code'),$('#pb-panel-code')],[$('#pb-tab-prev'),$('#pb-panel-prev')],[$('#pb-tab-chk'),$('#pb-panel-chk')]];
		tabs.forEach(([t])=>t.addEventListener('click',()=>tabs.forEach(([x,p])=>{const on=x===t;x.setAttribute('aria-selected',String(on));p.hidden=!on})));
		$('.tabs').addEventListener('keydown',e=>{
		  if(e.key!=='ArrowRight'&&e.key!=='ArrowLeft')return;
		  const i=tabs.findIndex(([t])=>t.getAttribute('aria-selected')==='true');const n=tabs[(i+(e.key==='ArrowRight'?1:tabs.length-1))%tabs.length][0];n.click();n.focus();
		});

		function copyText(t){
		  const fallback=()=>{const ta=document.createElement('textarea');ta.value=t;ta.setAttribute('readonly','');ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();
		    let ok=false;try{ok=document.execCommand('copy')}catch(e){}ta.remove();return ok};
		  try{if(navigator.clipboard&&navigator.clipboard.writeText)return navigator.clipboard.writeText(t).then(()=>true,fallback)}catch(e){}
		  return Promise.resolve(fallback());
		}
		$('#pb-tab-chk').addEventListener('click',runChecks);
		const copyBtn=$('#pb-copyBtn');
		function copied(label){copyBtn.textContent=label;setTimeout(()=>copyBtn.textContent='Copy wikitext',1800)}
		copyBtn.addEventListener('click',()=>{
		  const t=gen();if(!t){copied('Nothing to copy');return}
		  copyText(t).then(ok=>{if(ok){copied('Copied');return}
		    tabs[0][0].click();const r=document.createRange();r.selectNodeContents($('#pb-code'));const s=getSelection();s.removeAllRanges();s.addRange(r);
		    copied('Selected — press Ctrl+C')});
		});

		const dlg=$('#pb-importDlg'),impText=$('#pb-importText'),impFile=$('#pb-importFile'),impTitle=$('#pb-importTitleInput'),impErr=$('#pb-importErr');
		const closeDlg=()=>{try{dlg.close()}catch(e){dlg.removeAttribute('open')}};
		$('#pb-importBtn').addEventListener('click',()=>{impErr.textContent='';impTitle.value='';
		  try{dlg.showModal()}catch(e){dlg.setAttribute('open','')}impText.focus()});
		$('#pb-importCancel').addEventListener('click',closeDlg);
		impFile.addEventListener('change',()=>{const f=impFile.files&&impFile.files[0];if(!f)return;
		  f.text().then(txt=>{impText.value=txt;$('#pb-importFileName').textContent=f.name;impErr.textContent='';
		    if(!impTitle.value.trim())impTitle.value=f.name.replace(/\.[^.]+$/,'').replace(/_/g,' ')},
		  ()=>{impErr.textContent='That file couldn’t be read. Open it in a text editor and paste its contents instead.'});
		  impFile.value=''});
		$('#pb-importGo').addEventListener('click',()=>{
		  const src=impText.value;if(!src.trim()){impErr.textContent='Paste some wikitext or open a file first.';impText.focus();return}
		  let blocks;try{blocks=parseWikitext(src)}catch(e){impErr.textContent='Part of that text couldn’t be read. Try importing a smaller section of it.';return}
		  if(!blocks.length){impErr.textContent='No page content was found in that text.';return}
		  const prev=JSON.parse(JSON.stringify(state));
		  const mode=(dlg.querySelector('input[name=importMode]:checked')||{}).value;
		  if(mode==='append')state.blocks=state.blocks.concat(blocks);else{state.blocks=blocks;collapsed.clear()}
		  if(impTitle.value.trim()){state.title=impTitle.value.trim();titleEl.value=state.title}
		  commit();closeDlg();impText.value='';$('#pb-importFileName').textContent='';
		  const nr=blocks.filter(b=>b.type==='raw').length;
		  showToast(`Imported ${blocks.length} block${blocks.length===1?'':'s'}${nr?`, ${nr} kept as raw wikitext`:''}`,()=>{state=prev;titleEl.value=state.title;commit()});
		  const first=cardOf(blocks[0].id);if(first&&first.scrollIntoView)first.scrollIntoView({block:'nearest'});
		});

		const showDlg=d=>{try{d.showModal()}catch(e){d.setAttribute('open','')}};
		const hideDlg=d=>{try{d.close()}catch(e){d.removeAttribute('open')}};

		let citeTarget=null;
		function citeKindChanged(){const k=$('#pb-citeKind').value;$('#pb-citeDlg').querySelectorAll('[data-for]').forEach(el=>{el.hidden=!el.dataset.for.split(' ').includes(k)});
		  $('#pb-citeWorkLabel').textContent=k==='book'?'Publisher':'Website';$('#pb-citeFmtWrap').hidden=k==='other'}
		function openCite(card){const f=fieldFor(card);if(!f)return;
		  citeTarget={f,pos:f.selectionEnd==null?f.value.length:f.selectionEnd};
		  [$('#pb-citeTitle'),$('#pb-citeUrl'),$('#pb-citeAuthor'),$('#pb-citeWork'),$('#pb-citeDate'),$('#pb-citeText'),$('#pb-citeName')].forEach(x=>{x.value=''});
		  $('#pb-citeAccess').value=new Date().toISOString().slice(0,10);$('#pb-citeErr').textContent='';
		  const names=[];gen().replace(/<ref\s+name\s*=\s*"?([^"\/>]+?)"?\s*>/gi,(m,n)=>{n=n.trim();if(n&&!names.includes(n))names.push(n);return m});
		  $('#pb-citeReuse').innerHTML=names.length?`<p class="sub">Use a reference that’s already on the page</p><div class="cite-reuse">${names.map(n=>`<button type="button" class="btn" data-reuse="${esc(n)}">${esc(n)}</button>`).join('')}</div><p class="sub">Or add a new one</p>`:'';
		  citeKindChanged();showDlg($('#pb-citeDlg'));$('#pb-citeTitle').focus()}
		function citeInsert(t){if(!citeTarget)return;const {f,pos}=citeTarget;citeTarget=null;hideDlg($('#pb-citeDlg'));
		  const v=f.value;f.value=v.slice(0,pos)+t+v.slice(pos);f.focus();try{f.setSelectionRange(pos+t.length,pos+t.length)}catch(e){}
		  f.dispatchEvent(new Event('input',{bubbles:true}))}
		function citeWikitext(){
		  const k=$('#pb-citeKind').value,tpl=$('#pb-citeFmt').value==='tpl',g=x=>$(x).value.trim();
		  const title=g('#pb-citeTitle'),url=g('#pb-citeUrl'),author=g('#pb-citeAuthor'),work=g('#pb-citeWork'),date=g('#pb-citeDate'),access=g('#pb-citeAccess'),text=g('#pb-citeText');
		  let body;
		  if(k==='other'){if(!text)return {err:'Write the reference text first.'};body=text}
		  else{
		    if(k==='web'&&!title&&!url)return {err:'Add a title or a link.'};
		    if(k==='book'&&!title)return {err:'Add the book’s title.'};
		    if(tpl){const ps=[['title',title],['url',k==='web'?url:''],['author',author],[k==='web'?'website':'publisher',work],['date',date],['access-date',k==='web'&&url?access:'']].filter(x=>x[1]);
		      body=`{\u007B${k==='web'?'cite web':'cite book'}${ps.map(([a,v])=>`|${a}=${v}`).join('')}}}`}
		    else{const parts=[];if(author)parts.push(author.replace(/\.$/,'')+'.');
		      if(k==='web')parts.push(url?(title?`"[${url} ${title}]".`:`[${url}].`):`"${title}".`);else parts.push(`''${title}''.`);
		      if(work)parts.push(k==='web'?`''${work}''.`:`${work}.`);if(date)parts.push(date+'.');if(k==='web'&&url&&access)parts.push(`Retrieved ${access}.`);
		      body=parts.join(' ')}}
		  const nm=g('#pb-citeName').replace(/["<>\/]/g,'');
		  return {text:nm?`<ref name="${nm}">${body}</ref>`:`<ref>${body}</ref>`};
		}
		$('#pb-citeKind').addEventListener('change',citeKindChanged);
		$('#pb-citeCancel').addEventListener('click',()=>{citeTarget=null;hideDlg($('#pb-citeDlg'))});
		$('#pb-citeGo').addEventListener('click',()=>{const r=citeWikitext();if(r.err){$('#pb-citeErr').textContent=r.err;return}citeInsert(r.text)});
		$('#pb-citeReuse').addEventListener('click',e=>{const b=e.target.closest('[data-reuse]');if(b)citeInsert(`<ref name="${b.dataset.reuse}" />`)});

		let upTarget=null,upFiles=[];
		const objUrl=f=>window.URL&&URL.createObjectURL?URL.createObjectURL(f):'';
		const cleanName=n=>String(n).replace(/[#<>\[\]|{}:\/\\]+/g,'-').replace(/\s+/g,' ').trim();
		function openUpload(id,files){if(!hooks.upload||!files.length)return;
		  upTarget=id;upFiles=files.map(f=>({file:f,url:objUrl(f),name:cleanName(f.name),msg:'',bad:false}));
		  $('#pb-upErr').textContent='';$('#pb-upGo').disabled=false;$('#pb-upGo').textContent=upFiles.length>1?`Upload ${upFiles.length} pictures`:'Upload';
		  renderUpList();showDlg($('#pb-upDlg'));const i=$('#pb-upList input');if(i)i.focus()}
		function renderUpList(){$('#pb-upList').innerHTML=upFiles.map((u,i)=>`<div class="upitem">${u.url?`<img src="${u.url}" alt="">`:''}<div class="upfields"><label class="field"><span>File name</span><input type="text" data-upi="${i}" value="${esc(u.name)}"${u.done?' disabled':''}></label><p class="upstate${u.bad?' bad':''}">${esc(u.msg)}</p>${u.dup&&!u.done?`<button type="button" class="linkbtn" data-usedup="${i}">Use File:${esc(u.dup)} instead</button>`:''}${u.canForce&&!u.done?`<button type="button" class="linkbtn" data-force="${i}">Upload anyway</button>`:''}</div></div>`).join('')}
		function uploadProblem(u,err){const w=(err&&err.warnings)||{};u.bad=true;u.dup=null;u.canForce=false;
		  if(w.exists||w['exists-normalized']||w['page-exists'])u.msg=`A file called ${u.name} is already on the wiki. Change the name to upload yours.`;
		  else if(w.duplicate){u.dup=String([].concat(w.duplicate)[0]).replace(/_/g,' ');u.msg='This picture is already on the wiki.'}
		  else if(w.badfilename){u.name=String(w.badfilename).replace(/_/g,' ');u.msg=`Your wiki needs a different name, so it suggests ${u.name}.`;u.canForce=true}
		  else if(w['was-deleted']||w['duplicate-archive']){u.msg='A file with this name or content was deleted from the wiki before.';u.canForce=true}
		  else if(Object.keys(w).length){u.msg='Your wiki warned about this file: '+Object.keys(w).join(', ')+'.';u.canForce=true}
		  else u.msg=(err&&err.info)||'The upload didn’t work.'}
		function runUploads(){const go=$('#pb-upGo');go.disabled=true;go.textContent='Uploading…';const desc=$('#pb-upDesc').value.trim();
		  let chain=Promise.resolve();
		  upFiles.filter(u=>!u.done).forEach(u=>{chain=chain.then(()=>{u.msg='Uploading…';u.bad=false;renderUpList();
		    return Promise.resolve(hooks.upload(u.file,u.name,desc,!!u.force)).then(r=>{u.done=true;u.final=r.filename;u.msg='Uploaded'},err=>uploadProblem(u,err)).then(renderUpList)})});
		  chain.then(()=>{go.disabled=false;go.textContent='Try again';if(upFiles.every(u=>u.done))finishUploads()})}
		function finishUploads(){const b=state.blocks.find(x=>x.id===upTarget);const names=upFiles.filter(u=>u.done).map(u=>u.final);
		  hideDlg($('#pb-upDlg'));upFiles.forEach(u=>{if(u.url&&window.URL&&URL.revokeObjectURL)URL.revokeObjectURL(u.url)});upFiles=[];
		  if(!b||!names.length)return;
		  if(b.type==='image')b.file=names[0];else b.items=lines(b.items).concat(names).join('\n');
		  commit();revealBlock(b.id);flash(b.id);showToast(names.length===1?`Added ${names[0]}`:`Added ${names.length} pictures`)}
		$('#pb-upGo').addEventListener('click',runUploads);
		$('#pb-upCancel').addEventListener('click',()=>{if(upFiles.some(u=>u.done))finishUploads();else{hideDlg($('#pb-upDlg'));upFiles=[]}});
		$('#pb-upList').addEventListener('input',e=>{const i=e.target.dataset.upi;if(i!=null&&upFiles[+i])upFiles[+i].name=e.target.value});
		$('#pb-upList').addEventListener('click',e=>{const d=e.target.closest('[data-usedup]'),f=e.target.closest('[data-force]');
		  if(d){const u=upFiles[+d.dataset.usedup];u.done=true;u.final=u.dup;u.bad=false;u.msg='Using the file that’s already on the wiki';renderUpList();if(upFiles.every(x=>x.done))finishUploads()}
		  if(f){upFiles[+f.dataset.force].force=true;runUploads()}});
		blocksEl.addEventListener('change',e=>{const inp=e.target;if(!inp.matches||!inp.matches('input[data-upload]'))return;const card=inp.closest('.block');
		  openUpload(card.dataset.id,[...inp.files].filter(f=>/^image\//.test(f.type)).slice(0,20));inp.value=''});
		const hasFiles=e=>!!(e.dataTransfer&&[...(e.dataTransfer.types||[])].includes('Files'));
		const clearDropMarks=()=>blocksEl.querySelectorAll('.block.dropfile').forEach(c=>c.classList.remove('dropfile'));
		canvasEl.addEventListener('dragover',e=>{if(drag||!hooks.upload||!hasFiles(e))return;e.preventDefault();e.dataTransfer.dropEffect='copy';
		  const card=e.target.closest&&e.target.closest('.block');const t=card&&state.blocks.find(b=>b.id===card.dataset.id);clearDropMarks();
		  if(t&&(t.type==='image'||t.type==='gallery')){card.classList.add('dropfile');hideLine()}else showLine(targetFor(e.clientY))});
		canvasEl.addEventListener('dragleave',e=>{if(!canvasEl.contains(e.relatedTarget))clearDropMarks()});
		canvasEl.addEventListener('drop',e=>{if(drag||!hooks.upload||!hasFiles(e))return;e.preventDefault();hideLine();clearDropMarks();
		  const files=[...e.dataTransfer.files].filter(f=>/^image\//.test(f.type)).slice(0,20);if(!files.length){showToast('Only pictures can be uploaded here.');return}
		  const card=e.target.closest&&e.target.closest('.block');const t=card&&state.blocks.find(b=>b.id===card.dataset.id);
		  if(t&&(t.type==='image'||t.type==='gallery')){openUpload(t.id,t.type==='image'?files.slice(0,1):files);return}
		  const before=targetFor(e.clientY);const idx=before?state.blocks.findIndex(b=>b.id===before):state.blocks.length;
		  const nb=mk(files.length>1?'gallery':'image',files.length>1?{items:''}:{});state.blocks.splice(idx,0,nb);commit();openUpload(nb.id,files)});

		function saveTemplatePage(b,btn){const name=tdKey(b.template)||'Infobox',label=`Save to Template:${name}`;
		  const reset=()=>{btn.disabled=false;btn.classList.remove('armed');btn.textContent=label};
		  const go=()=>{btn.disabled=true;btn.textContent='Saving…';
		    Promise.resolve(hooks.saveTemplate(name,infoboxTemplate(b))).then(()=>{reset();tdCache.delete(name);existCache.set('Template:'+name,true);scheduleChecks();showToast(`Saved Template:${name}`)},
		      err=>{reset();showToast(`Couldn’t save the template: ${(err&&err.info)||err||'unknown error'}`)})};
		  if(btn.classList.contains('armed')){go();return}
		  Promise.resolve(hooks.exists(['Template:'+name])).then(m=>{if(m&&m['Template:'+name]===false)go();
		    else{btn.classList.add('armed');btn.textContent=`Replace the existing Template:${name}?`;setTimeout(()=>{if(btn.isConnected&&!btn.disabled&&btn.classList.contains('armed'))reset()},5000)}},go)}

		blocksEl.addEventListener('paste',e=>{const f=e.target;const m=f.dataset&&f.dataset.path&&f.dataset.path.match(/^cells\.(\d+)\.(\d+)$/);if(!m)return;
		  const txt=((e.clipboardData||window.clipboardData)||{getData:()=>''}).getData('text');if(!txt)return;
		  const body=txt.replace(/\r/g,'').replace(/\n+$/,'');if(!/[\t\n]/.test(body))return;e.preventDefault();
		  const card=f.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
		  const rows=body.split('\n').map(l=>l.split('\t').map(c=>c.replace(/^"([\s\S]*)"$/,'$1').replace(/""/g,'"').replace(/\|/g,'&#124;').trim()));
		  const r0=+m[1],c0=+m[2],width=Math.max(b.cells[0].length,c0+Math.max(...rows.map(r=>r.length)));
		  b.cells.forEach(r=>{while(r.length<width)r.push('')});
		  rows.forEach((cells,ri)=>{const R=r0+ri;while(b.cells.length<=R)b.cells.push(Array(width).fill(''));cells.forEach((v,ci)=>{b.cells[R][c0+ci]=v})});
		  commit();showToast(`Pasted ${rows.length} row${rows.length===1?'':'s'}`)});

		let qi=null;
		const qiBox=$('#pb-qiBox'),qiInput=$('#pb-qiInput'),qiList=$('#pb-qiList');
		function qiOpen(at,replace,anchor){qi={at,replace,anchor,i:0,items:[]};qiBox.hidden=false;qiInput.value='';qiFilter();
		  const r=(anchor||canvasEl).getBoundingClientRect(),W=Math.min(320,window.innerWidth-16);
		  qiBox.style.left=Math.max(8,Math.min(r.left+(anchor?0:24),window.innerWidth-W-8))+'px';
		  qiBox.style.top=Math.max(8,Math.min(anchor?r.bottom+4:r.top+24,window.innerHeight-320))+'px';qiInput.focus()}
		function qiFilter(){if(!qi)return;const q=qiInput.value.trim().toLowerCase();
		  qi.items=GROUPS.reduce((a,g)=>a.concat(g[2]),[]).filter(t=>!q||(DEFS[t].label+' '+DEFS[t].desc+' '+t).toLowerCase().includes(q));qi.i=0;
		  qiList.innerHTML=qi.items.length?qi.items.map((t,i)=>`<div class="ac-opt qi-opt${i?'':' on'}" role="option" id="${qiList.id}-o${i}" data-t="${t}" aria-selected="${i===0}"><span class="tok">${esc(DEFS[t].tok)}</span>${esc(DEFS[t].label)}</div>`).join(''):'<p class="note">No block matches that.</p>';
		  if(qi.items.length)qiInput.setAttribute('aria-activedescendant',qiList.id+'-o0');else qiInput.removeAttribute('aria-activedescendant')}
		function qiMove(d){if(!qi||!qi.items.length)return;qi.i=(qi.i+d+qi.items.length)%qi.items.length;
		  qiList.querySelectorAll('.qi-opt').forEach((o,i)=>{o.classList.toggle('on',i===qi.i);o.setAttribute('aria-selected',String(i===qi.i))});
		  const on=qiList.querySelector('.qi-opt.on');if(on){if(on.scrollIntoView)on.scrollIntoView({block:'nearest'});qiInput.setAttribute('aria-activedescendant',on.id)}}
		function qiClose(back){if(!qi)return;const a=qi.anchor;qi=null;qiBox.hidden=true;if(back&&a&&a.isConnected)a.focus()}
		function qiPick(t){if(!qi)return;const at=qi.at,rp=qi.replace;qiClose(false);
		  if(rp){const i=state.blocks.findIndex(b=>b.id===rp);if(i>=0){state.blocks.splice(i,1);addBlock(t,i);return}}
		  addBlock(t,Math.min(at,state.blocks.length))}
		qiInput.addEventListener('input',qiFilter);
		qiInput.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();qiMove(1)}else if(e.key==='ArrowUp'){e.preventDefault();qiMove(-1)}
		  else if(e.key==='Enter'){e.preventDefault();if(qi&&qi.items[qi.i])qiPick(qi.items[qi.i])}else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();qiClose(true)}});
		qiInput.addEventListener('blur',()=>setTimeout(()=>{if(qi&&document.activeElement!==qiInput)qiClose(false)},150));
		qiList.addEventListener('mousedown',e=>e.preventDefault());
		qiList.addEventListener('click',e=>{const o=e.target.closest('.qi-opt');if(o)qiPick(o.dataset.t)});
		$('.app').addEventListener('keydown',e=>{if(e.key!=='/'||e.ctrlKey||e.metaKey||e.altKey||qi)return;
		  const t=e.target;if(t.closest&&t.closest('input,textarea,select,[contenteditable="true"],dialog'))return;
		  e.preventDefault();const card=document.activeElement&&document.activeElement.closest&&document.activeElement.closest('.block');
		  const idx=card?state.blocks.findIndex(b=>b.id===card.dataset.id)+1:state.blocks.length;qiOpen(idx,null,null)});
		blocksEl.addEventListener('input',e=>{const f=e.target;if(qi||!f.matches||!f.matches('textarea[data-path="text"]')||f.value!=='/')return;
		  const card=f.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b||b.type!=='paragraph')return;
		  qiOpen(state.blocks.indexOf(b),b.id,f)});

		let presetCache=null;
		function loadPresets(){
		  if(!hooks.presets)return Promise.resolve(BUILTIN_PRESETS.concat([EXAMPLE_PRESET]));
		  return hooks.presets().then(l=>l&&l.length?l:BUILTIN_PRESETS,()=>BUILTIN_PRESETS);
		}
		const presetDlg=$('#pb-presetDlg');
		const closePresets=()=>{try{presetDlg.close()}catch(e){presetDlg.removeAttribute('open')}};
		function openPresets(fresh){
		  $('#pb-presetNote').textContent=fresh?'This page is empty. Pick a layout to start from, or start with a blank page.':'Pick a layout to start from. It replaces what’s on the page now, and you can undo it.';
		  $('#pb-presetCancel').textContent=fresh?'Start blank':'Cancel';
		  $('#pb-presetList').innerHTML='<p class="note">Loading presets…</p>';
		  try{presetDlg.showModal()}catch(e){presetDlg.setAttribute('open','')}
		  (presetCache?Promise.resolve(presetCache):loadPresets()).then(list=>{presetCache=list;
		    $('#pb-presetList').innerHTML=list.map((p,i)=>`<button type="button" class="preset" data-i="${i}"><strong>${esc(p.name)}</strong>${p.description?`<span>${esc(p.description)}</span>`:''}</button>`).join('');
		    const f=$('#pb-presetList .preset');if(f)f.focus()});
		}
		function applyPreset(p){
		  const prev=JSON.parse(JSON.stringify(state));let blocks;
		  if(p.build){const ex=p.build();blocks=ex.blocks;if(!state.title.trim()){state.title=ex.title;titleEl.value=state.title}}
		  else blocks=parseWikitext(String(p.wikitext).replace(/\$TITLE/g,state.title.trim()||'Page name'));
		  closePresets();state.blocks=blocks;collapsed.clear();commit();
		  if(prev.blocks.length)showToast(`${p.name} loaded`,()=>{state=prev;titleEl.value=state.title;commit()});
		}
		$('#pb-presetsBtn').addEventListener('click',()=>openPresets(false));
		$('#pb-presetCancel').addEventListener('click',closePresets);
		$('#pb-presetList').addEventListener('click',e=>{const b=e.target.closest('.preset');if(b&&presetCache)applyPreset(presetCache[+b.dataset.i])});

		renderPalette();renderCanvas();
		return {
		  load(text,title){
		    let blocks;
		    try{blocks=parseWikitext(text)}catch(e){blocks=text.trim()?[mk('raw',{text})]:[]}
		    state={title:title||'',blocks};collapsed.clear();titleEl.value=state.title;commit();resetHistory();
		  },
		  gen,
		  showToast,
		  offerPresets(){openPresets(true)},
		  replace(text){let blocks;try{blocks=parseWikitext(text)}catch(e){blocks=[mk('raw',{text})]}state.blocks=blocks;collapsed.clear();commit()},
		  hideMenus(){acHide()}
		};

	}

	function createBuilder( box ) {
		const $box = $( box );
		const root = document.createElement( 'div' );
		root.className = 'pb-root';
		root.hidden = true;
		root.setAttribute( 'role', 'dialog' );
		root.setAttribute( 'aria-modal', 'true' );
		root.setAttribute( 'aria-label', 'Page builder' );
		root.innerHTML = MARKUP;
		document.body.appendChild( root );

		const q = ( sel ) => root.querySelector( sel );
		const live = q( '#pb-live' );
		const rough = q( '#pb-preview' );
		const status = q( '#pb-liveStatus' );
		const panelPrev = q( '#pb-panel-prev' );
		const doneBtn = q( '#pb-doneBuilder' );
		const cancelBtn = q( '#pb-cancelBuilder' );
		const pageTitle = mw.config.get( 'wgPageName' );
		const lang = mw.config.get( 'wgPageContentLanguage' );
		live.setAttribute( 'lang', lang );
		live.setAttribute( 'dir', $( 'html' ).attr( 'dir' ) || 'ltr' );

		let baseline = '';
		let opener = null;
		let seq = 0;
		let timer = null;
		let lastRendered = null;
		let cancelArmed = null;

		const api = new mw.Api();
		const NS = { page: 0, file: 6, category: 14, template: 10 };
		const userLang = mw.config.get( 'wgUserLanguage' );
		const pick = ( v ) => v === null || v === undefined ? '' : typeof v === 'string' ? v : ( v[ userLang ] || v.en || Object.keys( v ).map( ( k ) => v[ k ] )[ 0 ] || '' );
		const thumbCache = new Map();

		const hooks = {
			onChange: () => {
				scheduleLive();
				saveDraftSoon();
			},
			upload( file, name, desc, force ) {
				return new Promise( ( resolve, reject ) => {
					const params = { action: 'upload', filename: name, file: file, comment: desc || 'Uploaded with the page builder', formatversion: 2 };
					if ( desc ) {
						params.text = desc;
					}
					if ( force ) {
						params.ignorewarnings = 1;
					}
					api.postWithEditToken( params, { contentType: 'multipart/form-data' } ).then( ( d ) => {
						const u = d.upload || {};
						if ( u.result === 'Success' ) {
							resolve( { filename: String( u.filename || name ).replace( /_/g, ' ' ) } );
						} else {
							reject( { warnings: u.warnings || {}, info: u.result || 'The upload didn’t work.' } );
						}
					}, ( code, d ) => reject( { code: code, info: ( d && d.error && d.error.info ) || code } ) );
				} );
			},
			saveTemplate( name, code ) {
				return new Promise( ( resolve, reject ) => {
					api.postWithEditToken( { action: 'edit', title: 'Template:' + name, text: code, summary: 'Infobox template made with the page builder', formatversion: 2 } ).then( ( d ) => {
						if ( d.edit && d.edit.result === 'Success' ) {
							resolve( true );
						} else {
							reject( { info: ( d.edit && d.edit.info ) || 'Your wiki didn’t accept the edit.' } );
						}
					}, ( code, d ) => reject( { info: ( d && d.error && d.error.info ) || code } ) );
				} );
			},
			url: ( t ) => mw.util.getUrl( t ),
			search( kind, query ) {
				return api.get( { action: 'query', list: 'prefixsearch', pssearch: query, psnamespace: NS[ kind ], pslimit: 8, formatversion: 2 } )
					.then( ( d ) => ( ( d.query && d.query.prefixsearch ) || [] ).map( ( x ) => kind === 'page' ? x.title : x.title.replace( /^[^:]+:/, '' ) ) );
			},
			exists( titles ) {
				const out = {};
				const batches = [];
				for ( let i = 0; i < titles.length; i += 50 ) {
					batches.push( titles.slice( i, i + 50 ) );
				}
				return Promise.all( batches.map( ( batch ) => api.post( { action: 'query', titles: batch.join( '|' ), formatversion: 2 } ).then( ( d ) => {
					const res = d.query || {};
					const norm = {};
					( res.normalized || [] ).forEach( ( n ) => {
						norm[ n.from ] = n.to;
					} );
					const pages = {};
					( res.pages || [] ).forEach( ( pg ) => {
						pages[ pg.title ] = pg;
					} );
					batch.forEach( ( t ) => {
						const pg = pages[ norm[ t ] || t ];
						out[ t ] = !pg || !( pg.invalid || ( pg.missing && !pg.known ) );
					} );
				} ) ) ).then( () => out );
			},
			templateData( name ) {
				return api.get( { action: 'templatedata', titles: 'Template:' + name, redirects: 1, lang: userLang, formatversion: 2 } ).then( ( d ) => {
					const pages = d.pages || {};
					const t = Object.keys( pages ).map( ( k ) => pages[ k ] )[ 0 ];
					if ( !t || t.missing || t.notemplatedata || !t.params ) {
						return null;
					}
					const params = {};
					Object.keys( t.params ).forEach( ( k ) => {
						const pr = t.params[ k ];
						params[ k ] = { label: pick( pr.label ), description: pick( pr.description ), required: !!pr.required, suggested: !!pr.suggested, deprecated: !!pr.deprecated, type: pr.type || 'unknown' };
					} );
					const order = ( t.paramOrder || Object.keys( params ) ).filter( ( k ) => params[ k ] );
					return { description: pick( t.description ), params: params, order: order };
				} );
			},
			thumb( file ) {
				if ( !thumbCache.has( file ) ) {
					thumbCache.set( file, api.get( { action: 'query', titles: 'File:' + file, prop: 'imageinfo', iiprop: 'url', iiurlwidth: 200, formatversion: 2 } ).then( ( d ) => {
						const pg = ( ( d.query && d.query.pages ) || [] )[ 0 ];
						const ii = pg && pg.imageinfo && pg.imageinfo[ 0 ];
						return ii ? { url: ii.thumburl || ii.url } : { missing: true };
					} ) );
				}
				return thumbCache.get( file );
			},
			presets() {
				return api.get( { action: 'query', prop: 'revisions', titles: 'MediaWiki:Gadget-PageBuilder-presets.json', rvprop: 'content', rvslots: 'main', formatversion: 2 } ).then( ( d ) => {
					const pg = ( ( d.query && d.query.pages ) || [] )[ 0 ];
					if ( !pg || pg.missing || !pg.revisions ) {
						return null;
					}
					const list = JSON.parse( pg.revisions[ 0 ].slots.main.content );
					return Array.isArray( list ) ? list.filter( ( x ) => x && typeof x.name === 'string' && typeof x.wikitext === 'string' ) : null;
				} );
			}
		};
		const app = createApp( root, hooks );

		function scheduleLive() {
			if ( root.hidden || panelPrev.hidden ) {
				return;
			}
			clearTimeout( timer );
			timer = setTimeout( renderLive, 600 );
		}

		function renderLive() {
			const text = app.gen();
			if ( text === lastRendered ) {
				return;
			}
			const my = ++seq;
			status.textContent = 'Updating preview…';
			new mw.Api().post( {
				action: 'parse',
				title: pageTitle,
				text: text,
				contentmodel: 'wikitext',
				prop: 'text|modules|jsconfigvars|categorieshtml|displaytitle',
				pst: true,
				preview: true,
				disableeditsection: true,
				disablelimitreport: true,
				formatversion: 2
			} ).then( ( data ) => {
				if ( my !== seq ) {
					return;
				}
				const p = data.parse || {};
				lastRendered = text;
				if ( p.jsconfigvars ) {
					mw.config.set( p.jsconfigvars );
				}
				mw.loader.load( ( p.modulestyles || [] ).concat( p.modules || [] ) );
				live.innerHTML = '<div class="pb-ptitle">' + ( p.displaytitle || mw.html.escape( mw.config.get( 'wgTitle' ) ) ) + '</div>' +
					( p.text || '' ) + ( p.categorieshtml || '' );
				live.hidden = false;
				rough.hidden = true;
				status.textContent = '';
				mw.hook( 'wikipage.content' ).fire( $( live ) );
			}, ( code ) => {
				if ( my !== seq ) {
					return;
				}
				live.hidden = true;
				rough.hidden = false;
				status.textContent = 'Your wiki couldn’t render the preview (' + code + '), so this is a rough version. It will try again when you make a change.';
			} );
		}

		q( '#pb-tab-prev' ).addEventListener( 'click', scheduleLive );

		function disarmCancel() {
			clearTimeout( cancelArmed );
			cancelArmed = null;
			cancelBtn.classList.remove( 'armed' );
			cancelBtn.textContent = 'Cancel';
		}

		function close() {
			disarmCancel();
			clearTimeout( timer );
			root.hidden = true;
			document.documentElement.classList.remove( 'pb-open' );
			if ( opener ) {
				opener.focus();
			}
		}

		cancelBtn.addEventListener( 'click', () => {
			if ( app.gen() === baseline || cancelArmed ) {
				clearTimeout( draftTimer );
				draftTimer = null;
				drafts.clear();
				close();
				return;
			}
			cancelBtn.classList.add( 'armed' );
			cancelBtn.textContent = 'Discard changes?';
			cancelArmed = setTimeout( disarmCancel, 3000 );
		} );

		doneBtn.addEventListener( 'click', () => {
			const out = app.gen();
			const changed = out !== baseline;
			if ( changed ) {
				$box.textSelection( 'setContents', out );
				box.dispatchEvent( new Event( 'input', { bubbles: true } ) );
				box.dispatchEvent( new Event( 'change', { bubbles: true } ) );
			}
			close();
			if ( changed ) {
				mw.notify( 'Your changes are in the edit box. Preview and save the page as usual.', { title: 'Page builder' } );
			}
		} );

		const saveBtn = q( '#pb-saveBuilder' );
		const saveDlg = q( '#pb-saveDlg' );
		const saveSummary = q( '#pb-saveSummary' );
		const saveMinor = q( '#pb-saveMinor' );
		const saveWatch = q( '#pb-saveWatch' );
		const saveReview = q( '#pb-saveReview' );
		const saveDiff = q( '#pb-saveDiff' );
		const saveErr = q( '#pb-saveErr' );
		const saveGo = q( '#pb-saveGo' );
		const form = box.form || document.getElementById( 'editform' );
		const field = ( name ) => form ? form.querySelector( '[name="' + name + '"]' ) : null;
		const section = ( field( 'wpSection' ) || { value: '' } ).value || '';
		let diffFor = null;
		if ( section === 'new' ) {
			saveBtn.hidden = true;
		}

		function closeSave() {
			try {
				saveDlg.close();
			} catch ( e ) {
				saveDlg.removeAttribute( 'open' );
			}
		}

		saveBtn.addEventListener( 'click', () => {
			app.hideMenus();
			saveErr.textContent = '';
			const sum = field( 'wpSummary' );
			if ( !saveSummary.value && sum ) {
				saveSummary.value = sum.value;
			}
			const minor = field( 'wpMinoredit' );
			q( '#pb-saveMinorWrap' ).hidden = !minor;
			saveMinor.checked = !!( minor && minor.checked );
			const watch = field( 'wpWatchthis' );
			saveWatch.checked = !!( watch && watch.checked );
			saveReview.open = false;
			diffFor = null;
			saveGo.disabled = false;
			saveGo.textContent = 'Save page';
			try {
				saveDlg.showModal();
			} catch ( e ) {
				saveDlg.setAttribute( 'open', '' );
			}
			saveSummary.focus();
		} );
		q( '#pb-saveCancel' ).addEventListener( 'click', closeSave );

		saveReview.addEventListener( 'toggle', () => {
			if ( !saveReview.open ) {
				return;
			}
			const text = app.gen();
			if ( diffFor === text ) {
				return;
			}
			diffFor = text;
			const from = box.defaultValue;
			if ( from.replace( /\s+$/, '' ) === text ) {
				saveDiff.innerHTML = '<p>There are no changes.</p>';
				return;
			}
			saveDiff.textContent = 'Loading your changes…';
			mw.loader.load( 'mediawiki.diff.styles' );
			api.post( {
				action: 'compare',
				fromtitle: pageTitle,
				totitle: pageTitle,
				fromslots: 'main',
				'fromtext-main': from,
				'fromcontentmodel-main': 'wikitext',
				toslots: 'main',
				'totext-main': text,
				'tocontentmodel-main': 'wikitext',
				prop: 'diff',
				formatversion: 2
			} ).then( ( d ) => {
				if ( diffFor !== text ) {
					return;
				}
				const body = d.compare && d.compare.body;
				saveDiff.innerHTML = body ?
					'<table class="diff diff-contentalign-left diff-editfont-monospace"><colgroup><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content"></colgroup>' + body + '</table>' :
					'<p>There are no changes.</p>';
			}, () => {
				if ( diffFor === text ) {
					diffFor = null;
					saveDiff.textContent = 'Couldn’t load your changes. You can still save, or click Done and use Show changes in the editor.';
				}
			} );
		} );

		saveGo.addEventListener( 'click', () => {
			const text = app.gen();
			const fail = ( msg ) => {
				saveErr.textContent = msg;
				saveGo.disabled = false;
				saveGo.textContent = 'Save page';
			};
			saveErr.textContent = '';
			saveGo.disabled = true;
			saveGo.textContent = 'Saving…';
			const params = {
				action: 'edit',
				title: pageTitle,
				text: text,
				summary: saveSummary.value,
				watchlist: saveWatch.checked ? 'watch' : 'nochange',
				formatversion: 2
			};
			if ( saveMinor.checked && !q( '#pb-saveMinorWrap' ).hidden ) {
				params.minor = true;
			}
			if ( section ) {
				params.section = section;
			}
			const base = field( 'wpEdittime' );
			const started = field( 'wpStarttime' );
			if ( base && base.value ) {
				params.basetimestamp = base.value;
			}
			if ( started && started.value ) {
				params.starttimestamp = started.value;
			}
			if ( !mw.config.get( 'wgArticleId' ) ) {
				params.createonly = true;
			}
			api.postWithEditToken( params ).then( ( d ) => {
				const r = d.edit || {};
				if ( r.result !== 'Success' ) {
					fail( r.info || 'Your wiki didn’t accept the edit.' );
					return;
				}
				drafts.clear();
				box.value = box.defaultValue;
				$( window ).off( 'beforeunload' );
				window.onbeforeunload = null;
				saveGo.textContent = 'Saved';
				location.href = mw.util.getUrl( pageTitle );
			}, ( code, d ) => {
				if ( code === 'editconflict' ) {
					fail( 'Someone else saved this page after you started editing. Click Done, then save from the editor so you can see and merge their changes.' );
				} else {
					fail( ( d && d.error && d.error.info ) || ( 'Your wiki returned an error: ' + code ) );
				}
			} );
		} );

		const banner = q( '#pb-draftBanner' );
		const draftMsg = q( '#pb-draftMsg' );
		const draftKey = 'pb-draft:' + ( mw.config.get( 'wgWikiID' ) || '' ) + ':' + pageTitle + ':' + section;
		const hashText = ( t ) => {
			let h = 2166136261;
			for ( let i = 0; i < t.length; i++ ) {
				h ^= t.charCodeAt( i );
				h = Math.imul( h, 16777619 );
			}
			return ( h >>> 0 ).toString( 16 ) + ':' + t.length;
		};
		const drafts = {
			get() {
				try {
					return JSON.parse( localStorage.getItem( draftKey ) || 'null' );
				} catch ( e ) {
					return null;
				}
			},
			set( v ) {
				try {
					localStorage.setItem( draftKey, JSON.stringify( v ) );
				} catch ( e ) {}
			},
			clear() {
				try {
					localStorage.removeItem( draftKey );
				} catch ( e ) {}
			}
		};
		let draftTimer = null;
		let pendingDraft = null;

		function saveDraftNow() {
			clearTimeout( draftTimer );
			draftTimer = null;
			if ( root.hidden ) {
				return;
			}
			const text = app.gen();
			if ( text === baseline ) {
				drafts.clear();
				return;
			}
			drafts.set( { text: text, base: hashText( box.defaultValue ), time: Date.now() } );
		}
		function saveDraftSoon() {
			clearTimeout( draftTimer );
			draftTimer = setTimeout( saveDraftNow, 1000 );
		}
		window.addEventListener( 'pagehide', () => {
			if ( draftTimer ) {
				saveDraftNow();
			}
		} );
		const whenText = ( t ) => {
			const m = Math.round( ( Date.now() - t ) / 60000 );
			if ( m < 1 ) {
				return 'a moment ago';
			}
			if ( m < 60 ) {
				return m + ( m === 1 ? ' minute ago' : ' minutes ago' );
			}
			const hrs = Math.round( m / 60 );
			if ( hrs < 24 ) {
				return hrs + ( hrs === 1 ? ' hour ago' : ' hours ago' );
			}
			return 'on ' + new Date( t ).toLocaleString();
		};
		q( '#pb-draftRestore' ).addEventListener( 'click', () => {
			if ( pendingDraft ) {
				app.replace( pendingDraft.text );
				app.showToast( 'Draft restored. Undo puts the page back the way it was.' );
			}
			pendingDraft = null;
			banner.hidden = true;
		} );
		q( '#pb-draftDiscard' ).addEventListener( 'click', () => {
			pendingDraft = null;
			banner.hidden = true;
			drafts.clear();
		} );

		return {
			open( from ) {
				opener = from || null;
				const text = $box.textSelection( 'getContents' );
				app.load( text, mw.config.get( 'wgTitle' ) );
				baseline = app.gen();
				lastRendered = null;
				live.innerHTML = '';
				root.hidden = false;
				document.documentElement.classList.add( 'pb-open' );
				const first = root.querySelector( '.pal-item' );
				if ( first ) {
					first.focus();
				}
				scheduleLive();
				pendingDraft = null;
				banner.hidden = true;
				const draft = drafts.get();
				if ( draft && typeof draft.text === 'string' ) {
					if ( draft.base !== hashText( box.defaultValue ) ) {
						drafts.clear();
					} else if ( draft.text !== baseline ) {
						pendingDraft = draft;
						draftMsg.textContent = 'You have unsaved changes to this page from ' + whenText( draft.time ) + '. Do you want to carry on where you left off?';
						banner.hidden = false;
					}
				}
				if ( !text.trim() && !pendingDraft ) {
					app.offerPresets();
				}
			}
		};
	}

	$( () => {
		const box = document.getElementById( 'wpTextbox1' );
		if ( !box || box.readOnly ) {
			return;
		}
		let builder = null;
		const launch = document.createElement( 'button' );
		launch.type = 'button';
		launch.className = 'pb-launch';
		launch.textContent = 'Open page builder';
		launch.addEventListener( 'click', () => {
			if ( !builder ) {
				builder = createBuilder( box );
			}
			builder.open( launch );
		} );
		const anchor = box.closest( '.wikiEditor-ui' ) || box;
		anchor.parentNode.insertBefore( launch, anchor );
		mw.hook( 'pagebuilder.open' ).add( () => launch.click() );
		if ( mw.util.getParamValue( 'pagebuilder' ) ) {
			launch.click();
		}
	} );
}() );