Jump to content

MediaWiki:Gadget-PageBuilder.js

Revision as of 15:58, 24 October 2026 by Luhte (talk | contribs) (Protected "MediaWiki:Gadget-PageBuilder.js" ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite)))

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      <button class=\"btn\" id=\"pb-importBtn\" type=\"button\">Import</button>\n      \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 primary\" id=\"pb-doneBuilder\" type=\"button\">Done</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    </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        </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    </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<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)=>`<div class="kv-list">${arr.map((r,i)=>`<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 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><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>`,
		  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),
		  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:b=>`${fText('Template name','name',b.name,'Citation needed')}<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>`,
		  wiki:b=>{const ps=b.params.filter(p=>p.k.trim()||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'})
		  ]};
		}

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

		function gen(){return state.blocks.map(b=>{try{return DEFS[b.type].wiki(b)}catch(e){return ''}}).filter(s=>s&&s.trim()).join('\n\n')}

		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();
		  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>`;
		}
		function commit(){renderCanvas();updateOutput();save()}
		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();
		}
		blocksEl.addEventListener('input',onField);
		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);
		});

		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 '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;

		  while(i<L.length){
		    const line=L[i],t=line.trim();let m,n,r,e;
		    if(!t){i++;continue}
		    if(t.startsWith('<!--')){r=rest();e=r.indexOf('-->');n=e<0?0:span(r,e+3);if(n){raw(take(n));continue}}
		    if(/^[ \t]/.test(line)){let k=i;while(k<L.length&&/^[ \t]/.test(L[k])&&L[k].trim())k++;raw(take(k-i));continue}
		    if(/^#redirect/i.test(t)){raw(take(1));continue}
		    if((m=line.match(/^(={2,5})\s*([^=].*?)\s*\1\s*$/))){add('heading',{text:m[2],level:String(m[1].length)});i++;continue}
		    if(/^__(NO|FORCE)?TOC__$/.test(t)){add('toc',{mode:t});i++;continue}
		    if(/^-{4,}$/.test(t)){add('rule');i++;continue}
		    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')});continue}
		    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++;continue}
		    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));
		      continue}
		    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));continue}
		      const ls=L.slice(i,k+1);const p=parseTable(ls);i=k+1;if(p)add('table',p);else raw(ls.join('\n'));continue}
		    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);continue}
		    }
		    if((m=line.match(blockTagRe))){r=rest();e=tagSpan(r,m[1]);n=span(r,e);if(n){raw(take(n));continue}if(e<0){raw(take(1));continue}}
		    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);continue}}
		    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);continue}}
		    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()});
		  }

		  const res=[];
		  for(const b of out){const prev=res[res.length-1];
		    if(prev&&b.type==='raw'&&prev.type==='raw'){prev.text+='\n\n'+b.text;continue}
		    if(prev&&b.type==='categories'&&prev.type==='categories'){prev.items+='\n'+b.items;continue}
		    if(prev&&b.type==='refs'&&!b.heading&&prev.type==='heading'&&prev.level==='2'&&prev.text.trim()==='References'){res.pop();b.heading=true}
		    res.push(b)}
		  return res;
		}

		const titleEl=$('#pb-pageTitle');
		titleEl.addEventListener('input',()=>{state.title=titleEl.value;renderPreview();save()});
		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')]];
		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+1)%2][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());
		}
		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'});
		});

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

	}

	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 hooks = { onChange: () => scheduleLive() };
		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' } );
			}
		} );

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

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