Jump to content

MediaWiki:Gadget-PageBuilder.js

Revision as of 16:33, 24 October 2026 by Luhte (talk | contribs)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
( 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.</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>";

	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>',
		  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 infoboxTemplate(b){
		  if(b.mode!=='dedicated'){
		    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++)out.push(`  <data source="data${i}"><label>{{{label${i}|}}}</label></data>`);
		    out.push('</infobox>');
		    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'}};
		  rows.forEach(r=>{params[r.k.trim()]={label:niceKey(r.k),type:'string'}});
		  return ['<infobox>','  <title source="title"><default>{{PAGENAME}}</default></title>','  <image source="image">','    <caption source="caption"/>','  </image>',
		    ...rows.map(r=>`  <data source="${esc(r.k.trim())}"><label>${esc(niceKey(r.k))}</label></data>`),
		    '</infobox><noinclude>','<templatedata>',JSON.stringify({params,format:'block'},null,2),'</templatedata>','</noinclude>'].join('\n');
		}

		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','{{ }}','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 GROUPS=[['text','Text',['heading','paragraph','bullets','numbers','quote','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','{{Quote}} (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>`},
		  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:''}]}),
		  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>`;
		    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>`},
		  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 [`{{${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>`},

		image:{label:'Image',tok:'[[File:]]',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>${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 `[[${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 of images',
		  init:()=>({items:'Example.jpg|First caption\nExample2.jpg|Second caption'}),
		  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,
		  wiki:b=>`<gallery>\n${lines(b.items).map(l=>l.trim()).join('\n')}\n</gallery>`,
		  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>`},

		table:{label:'Table',tok:'{| |}',desc:'A wikitable with rows and columns',
		  init:()=>({caption:'',header:true,sortable:false,cells:[['Header 1','Header 2'],['','']]}),
		  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>`,
		  wiki:b=>{const out=[`{| class="wikitable${b.sortable?' sortable':''}"`];if(b.caption.trim())out.push('|+ '+b.caption.trim());
		    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(' || '))});
		    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>`},

		template:{label:'Template',tok:'{{ }}',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 `{{${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('')}}}`},
		  html:b=>`<div class="tpl"><code>{{${esc(b.name.trim()||'Template name')}}}</code> renders here once the page is saved on your wiki.</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','{{Main}} / {{See also}} / {{Further}} (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}]]''`},
		  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'?'{{Reflist}}':'<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>`,
		  wiki:b=>`${b.heading?'== References ==\n':''}${b.style==='reflist'?'{{Reflist}}':'<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:'[[Category:]]',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=>`[[Category:${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 [[lactic 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('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:'[[Rye bread]]\n[[Soda 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:"{{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]]"},
		  {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]]"}
		];
		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">{{$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></button>
		    <span class="tools">
		      <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')));
		}
		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)}

		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){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();
		  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++)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:'+(b.template.trim()||'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)});
		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:['[[',']]','Page name'],ext:['[https://example.com ',']','link text'],ref:['<ref>','</ref>','Source details'],tpl:['{{','}}','Template name'],nowiki:['<nowiki>','</nowiki>','text']};
		function applyFmt(card,kind,arg){
		  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 '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());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==='{{'||two==='[['){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==='{{'||two==='[['){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:[]};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}
		    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));
		  return {caption,header,sortable:cls.includes('sortable'),cells:rows.map(r=>r.c.concat(Array(w-r.c.length).fill('')))};
		}
		function tagBlock(tag,a,inner,attrText){
		  const keys=Object.keys(a);const trimNl=s=>s.replace(/^\n/,'').replace(/\n\s*$/,'');
		  switch(tag){
		    case 'gallery':return attrText?null:['gallery',{items:lines(inner).map(l=>l.trim()).join('\n')}];
		    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('{{'))return span(r,matchPair(r,0,'{{','}}'))>0;
		    if(/^\[\[\s*(File|Image)\s*:/i.test(l))return span(r,matchPair(r,0,'[[',']]'))>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((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(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(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'});
		});

		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)},
		  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(),
			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 ) {
				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;
				}
				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 ) );
				}
			} );
		} );

		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();
				if ( !text.trim() ) {
					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();
		}
	} );
}() );