MediaWiki:Gadget-PageBuilder.js: Difference between revisions
Appearance
Created page with "( 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=\"t..." |
m Protected "MediaWiki:Gadget-PageBuilder.js" ([Edit=Allow only administrators] (indefinite) [Move=Allow only administrators] (indefinite)) |
(No difference)
| |
Revision as of 15:58, 24 October 2026
( function () {
'use strict';
if ( [ 'edit', 'submit' ].indexOf( mw.config.get( 'wgAction' ) ) === -1 ) {
return;
}
if ( mw.config.get( 'wgPageContentModel' ) !== 'wikitext' ) {
return;
}
const MARKUP = "<div class=\"app\">\n <header class=\"top\">\n <h1 class=\"brand\">Page builder</h1>\n <div class=\"title-field\">\n <label for=\"pb-pageTitle\">Page</label>\n <input type=\"text\" id=\"pb-pageTitle\" readonly>\n </div>\n <div class=\"top-actions\">\n <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 | title = … }} '''Page''' is … == 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
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'],[' ','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===' '?'␣':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> </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">▸</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(/<(\/?)(u|s|del|ins|sup|sub|small|big|code|mark|b|i)>/gi,'<$1$2>')
.replace(/<br\s*\/?>/gi,'<br>')
.replace(/<span style="([^&]*)">/gi,(m,st)=>{const ok=safeStyle(st);return `<span${ok?` style="${ok}"`:''}>`})
.replace(/<\/span>/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(/<nowiki>([\s\S]*?)<\/nowiki>/gi,(m,x)=>{c.nw.push(x);return '\u0000'+(c.nw.length-1)+'\u0000'});
s=s.replace(/<ref\s(?:(?!>).)*\/>/gi,'');
s=s.replace(/<ref(?:\s(?:(?!>).)*)?>([\s\S]*?)<\/ref>/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)&¶ms.length===1&&!params[0].k)return ['hatnote',{kind:HAT[lname],target:params[0].v,markup:'template'}];
if(lname==='quote'&¶ms.length&¶ms.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*(?:—|—)\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 );
} );
}() );