MediaWiki:Gadget-PageBuilder.js: Difference between revisions
Appearance
No edit summary |
No edit summary |
||
| (2 intermediate revisions by the same user not shown) | |||
| Line 9: | Line 9: | ||
} | } | ||
const MARKUP = "<div class=\"app\">\n <header class=\"top\">\n <h1 class=\"brand\">Page builder</h1>\n <div class=\"title-field\">\n <label for=\"pb-pageTitle\">Page</label>\n <input type=\"text\" id=\"pb-pageTitle\" readonly>\n </div>\n <div class=\"top-actions\">\n <button class=\"btn\" id=\"pb- | const MARKUP = "<div class=\"app\">\n <header class=\"top\">\n <h1 class=\"brand\">Page builder</h1>\n <div class=\"title-field\">\n <label for=\"pb-pageTitle\">Page</label>\n <input type=\"text\" id=\"pb-pageTitle\" readonly>\n </div>\n <div class=\"top-actions\">\n <span class=\"hist\"><button class=\"btn\" id=\"pb-undoBtn\" type=\"button\" title=\"Undo (Ctrl+Z)\" disabled>Undo</button><button class=\"btn\" id=\"pb-redoBtn\" type=\"button\" title=\"Redo (Ctrl+Y)\" disabled>Redo</button></span>\n <button class=\"btn\" id=\"pb-importBtn\" type=\"button\">Import</button>\n <button class=\"btn\" id=\"pb-presetsBtn\" type=\"button\">Presets</button>\n <button class=\"btn danger\" id=\"pb-clearPage\" type=\"button\">Clear page</button>\n <button class=\"btn\" id=\"pb-cancelBuilder\" type=\"button\">Cancel</button>\n <button class=\"btn\" id=\"pb-doneBuilder\" type=\"button\" title=\"Put your changes in the edit box and go back to the editor\">Done</button>\n <button class=\"btn primary\" id=\"pb-saveBuilder\" type=\"button\">Save page…</button>\n </div>\n </header>\n\n <div class=\"main\">\n <aside class=\"pane palette\" aria-label=\"Blocks\">\n <div class=\"pal-groups\" id=\"pb-palette\"></div>\n <p class=\"pal-hint\">Drag a block onto the page, or click it to add it at the end. Press / to find one by name.</p>\n <nav class=\"outline\" aria-labelledby=\"pb-outlineHeading\">\n <div class=\"outline-head\"><h2 id=\"pb-outlineHeading\">Outline</h2><span class=\"outline-tools\"><button type=\"button\" class=\"linkbtn\" id=\"pb-collapseAll\">Collapse all</button><button type=\"button\" class=\"linkbtn\" id=\"pb-expandAll\">Expand all</button></span></div>\n <ul class=\"outline-list\" id=\"pb-outline\"></ul>\n </nav>\n </aside>\n\n <main class=\"pane canvas\" id=\"pb-canvas\" aria-label=\"Page layout\">\n <div class=\"canvas-inner\">\n <div class=\"banner\" id=\"pb-draftBanner\" role=\"status\" hidden><p id=\"pb-draftMsg\"></p><span class=\"banner-actions\"><button class=\"btn primary\" id=\"pb-draftRestore\" type=\"button\">Restore it</button><button class=\"btn\" id=\"pb-draftDiscard\" type=\"button\">Discard it</button></span></div>\n <div class=\"blocks\" id=\"pb-blocks\"></div>\n </div>\n </main>\n\n <section class=\"pane output\" aria-label=\"Output\">\n <div class=\"out-head\">\n <div class=\"tabs\" role=\"tablist\">\n <button class=\"tab\" role=\"tab\" id=\"pb-tab-code\" aria-controls=\"pb-panel-code\" aria-selected=\"true\" type=\"button\">Wikitext</button>\n <button class=\"tab\" role=\"tab\" id=\"pb-tab-prev\" aria-controls=\"pb-panel-prev\" aria-selected=\"false\" type=\"button\">Preview</button>\n <button class=\"tab\" role=\"tab\" id=\"pb-tab-chk\" aria-controls=\"pb-panel-chk\" aria-selected=\"false\" type=\"button\">Checks</button>\n </div>\n <div class=\"out-tools\">\n <span class=\"meta\" id=\"pb-meta\"></span>\n <button class=\"btn\" id=\"pb-copyBtn\" type=\"button\">Copy wikitext</button>\n </div>\n </div>\n <div class=\"panel\" id=\"pb-panel-code\" role=\"tabpanel\" aria-labelledby=\"pb-tab-code\"><pre class=\"wikitext\" id=\"pb-code\"></pre></div>\n <div class=\"panel\" id=\"pb-panel-prev\" role=\"tabpanel\" aria-labelledby=\"pb-tab-prev\" hidden><p class=\"pb-status\" id=\"pb-liveStatus\" role=\"status\"></p><div class=\"pb-live mw-body-content\" id=\"pb-live\"></div><div class=\"wiki\" id=\"pb-preview\" hidden></div></div>\n <div class=\"panel\" id=\"pb-panel-chk\" role=\"tabpanel\" aria-labelledby=\"pb-tab-chk\" hidden><div class=\"checks-panel\" id=\"pb-checks\"></div></div>\n </section>\n </div>\n</div>\n<dialog class=\"dlg\" id=\"pb-importDlg\" aria-labelledby=\"pb-importHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-importHeading\">Import wikitext</h2>\n <p class=\"note\">Paste wikitext from another page or open a file. Anything the editor doesn’t have a block for is kept as raw wikitext, so nothing is lost.</p>\n <textarea id=\"pb-importText\" class=\"mono\" spellcheck=\"false\" aria-label=\"Wikitext to import\" placeholder=\"{\u007BInfobox | 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<dialog class=\"dlg\" id=\"pb-presetDlg\" aria-labelledby=\"pb-presetHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-presetHeading\">Start from a preset</h2>\n <p class=\"note\" id=\"pb-presetNote\"></p>\n <div class=\"presets\" id=\"pb-presetList\"></div>\n <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-presetCancel\" type=\"button\">Cancel</button></div>\n </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-citeDlg\" aria-labelledby=\"pb-citeHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-citeHeading\">Add a reference</h2>\n <div id=\"pb-citeReuse\"></div>\n <div class=\"row\"><label class=\"field\"><span>Kind of source</span><select id=\"pb-citeKind\"><option value=\"web\">Web page</option><option value=\"book\">Book</option><option value=\"other\">Something else</option></select></label><label class=\"field\" id=\"pb-citeFmtWrap\"><span>Format</span><select id=\"pb-citeFmt\"><option value=\"plain\">Plain text (works on any wiki)</option><option value=\"tpl\">Cite templates (cite web, cite book)</option></select></label></div>\n <label class=\"field\" data-for=\"web book\"><span>Title</span><input type=\"text\" id=\"pb-citeTitle\"></label>\n <label class=\"field\" data-for=\"web\"><span>Link</span><input type=\"text\" id=\"pb-citeUrl\" placeholder=\"https://\"></label>\n <div class=\"row\" data-for=\"web book\"><label class=\"field\"><span>Author</span><input type=\"text\" id=\"pb-citeAuthor\"></label><label class=\"field\"><span id=\"pb-citeWorkLabel\">Website</span><input type=\"text\" id=\"pb-citeWork\"></label></div>\n <div class=\"row\" data-for=\"web book\"><label class=\"field\"><span>Published</span><input type=\"text\" id=\"pb-citeDate\" placeholder=\"2026-10-24\"></label><label class=\"field\" data-for=\"web\"><span>Accessed</span><input type=\"text\" id=\"pb-citeAccess\"></label></div>\n <label class=\"field\" data-for=\"other\"><span>Reference text</span><textarea id=\"pb-citeText\" rows=\"3\"></textarea></label>\n <label class=\"field\"><span>Name (optional)</span><input type=\"text\" id=\"pb-citeName\" placeholder=\"Lets you use this reference again elsewhere on the page\"></label>\n <p class=\"dlg-err\" id=\"pb-citeErr\" role=\"alert\"></p>\n <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-citeCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-citeGo\" type=\"button\">Add reference</button></div>\n </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-upDlg\" aria-labelledby=\"pb-upHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-upHeading\">Upload to your wiki</h2>\n <div id=\"pb-upList\"></div>\n <label class=\"field\"><span>Description</span><input type=\"text\" id=\"pb-upDesc\" placeholder=\"What the picture shows, and where it came from\"></label>\n <p class=\"dlg-err\" id=\"pb-upErr\" role=\"alert\"></p>\n <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-upCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-upGo\" type=\"button\">Upload</button></div>\n </div>\n</dialog>\n<div class=\"qi\" id=\"pb-qiBox\" role=\"dialog\" aria-label=\"Add a block\" hidden><input type=\"text\" id=\"pb-qiInput\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"pb-qiList\" placeholder=\"Type to find a block\" autocomplete=\"off\"><div class=\"qi-list\" id=\"pb-qiList\" role=\"listbox\" aria-label=\"Blocks\"></div></div>\n<dialog class=\"dlg\" id=\"pb-saveDlg\" aria-labelledby=\"pb-saveHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-saveHeading\">Save page</h2>\n <label class=\"field\"><span>Summary</span><input type=\"text\" id=\"pb-saveSummary\" maxlength=\"500\" placeholder=\"Briefly describe your changes\"></label>\n <div class=\"checks\"><label class=\"check\" id=\"pb-saveMinorWrap\"><input type=\"checkbox\" id=\"pb-saveMinor\"><span>This is a minor edit</span></label><label class=\"check\"><input type=\"checkbox\" id=\"pb-saveWatch\"><span>Watch this page</span></label></div>\n <details class=\"tplcode\" id=\"pb-saveReview\"><summary>Review your changes</summary><div class=\"pb-live pb-diff\" id=\"pb-saveDiff\"></div></details>\n <p class=\"dlg-err\" id=\"pb-saveErr\" role=\"alert\"></p>\n <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-saveCancel\" type=\"button\">Keep editing</button><button class=\"btn primary\" id=\"pb-saveGo\" type=\"button\">Save page</button></div>\n </div>\n</dialog>\n<div class=\"ac\" id=\"pb-acList\" role=\"listbox\" aria-label=\"Suggestions\" hidden></div>\n<div class=\"toast\" id=\"pb-toast\" role=\"status\" hidden><span id=\"pb-toastMsg\"></span><button type=\"button\" id=\"pb-toastUndo\">Undo</button></div>"; | ||
function createApp( root, hooks ) { | function createApp( root, hooks ) { | ||
| Line 27: | Line 27: | ||
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 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 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 kvRows=(arr,listKey,delAct,kPh,vPh,keep)=>`<div class="kv-list">${arr.map((r,i)=>keep&&!keep(r)?'':`<div class="kv"><input type="text" data-path="${listKey}.${i}.k" value="${esc(r.k)}" placeholder="${esc(kPh)}" aria-label="Name ${i+1}"><input type="text" data-path="${listKey}.${i}.v" value="${esc(r.v)}" placeholder="${esc(vPh)}" aria-label="Value ${i+1}"><button type="button" class="icon del" data-act="${delAct}" data-i="${i}" aria-label="Remove row ${i+1}">${ICON.x}</button></div>`).join('')}</div>`; | ||
const ICON={ | const ICON={ | ||
| Line 35: | Line 35: | ||
dup:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5" y="5" width="8.5" height="8.5" rx="1"/><path d="M3 10.5V3.5a1 1 0 0 1 1-1h6.5"/></svg>', | dup:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5" y="5" width="8.5" height="8.5" rx="1"/><path d="M3 10.5V3.5a1 1 0 0 1 1-1h6.5"/></svg>', | ||
x:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>', | x:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>', | ||
up2:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 8.5 8 4l4.5 4.5M3.5 12.5 8 8l4.5 4.5"/></svg>', | |||
down2:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 3.5 8 8l4.5-4.5M3.5 7.5 8 12l4.5-4.5"/></svg>', | |||
fold:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1"/><path d="M5 8h6"/></svg>', | |||
unfold:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1"/><path d="M5 8h6M8 5v6"/></svg>', | |||
pic:'<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3" y="6" width="26" height="20" rx="1.5"/><circle cx="11" cy="13" r="2.5"/><path d="M3 23l8-7 6 5 4-3 8 6"/></svg>' | pic:'<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3" y="6" width="26" height="20" rx="1.5"/><circle cx="11" cy="13" r="2.5"/><path d="M3 23l8-7 6 5 4-3 8 6"/></svg>' | ||
}; | }; | ||
const ph=f=>`<div class="ph">${ICON.pic}<span>${esc(f||'Example.jpg')}</span></div>`; | const ph=f=>`<div class="ph">${ICON.pic}<span>${esc(f||'Example.jpg')}</span></div>`; | ||
function infoboxAttrs(b){let a='';if(b.layout==='stacked')a+=' layout="stacked"';if(b.accentOn&&b.accent)a+=` accent-color-default="${esc(b.accent)}"`;return a} | |||
function infoboxTemplate(b){ | function infoboxTemplate(b){ | ||
const head=[`<infobox${infoboxAttrs(b)}>`,' <title source="title"><default>{\u007BPAGENAME}}</default></title>',' <image source="image">',' <caption source="caption"/>',' </image>']; | |||
if(b.mode!=='dedicated'){ | if(b.mode!=='dedicated'){ | ||
const n=Math.max(20,b.rows.length); | const n=Math.max(20,b.rows.length); | ||
for(let i=1;i<=n;i++)head.push(` <data source="data${i}"><label>{\u007B{label${i}|}}}</label></data>`); | |||
for(let i=1;i<=n;i++) | head.push('</infobox>'); | ||
return head.join('\n'); | |||
return | |||
} | } | ||
const params={title:{label:'Title',description:'Box title. Leave blank to use the page name.',type:'string'},image:{label:'Image',description:'File name, without File:',type:'wiki-file-name'},caption:{label:'Caption',description:'Text under the image',type:'string'}}; | const params={title:{label:'Title',description:'Box title. Leave blank to use the page name.',type:'string'},image:{label:'Image',description:'File name, without File:',type:'wiki-file-name'},caption:{label:'Caption',description:'Text under the image',type:'string'}}; | ||
rows.forEach(r=>{params[r.k.trim()]={label: | const body=[];let open=false; | ||
b.rows.filter(r=>r.k.trim()).forEach(r=>{const lab=String(r.label||'').trim()||niceKey(r.k);params[r.k.trim()]={label:lab,type:'string'}; | |||
. | const h=String(r.header||'').trim(); | ||
if(h){if(open)body.push(' </group>');body.push(' <group>',` <header>${esc(h)}</header>`);open=true} | |||
body.push(`${open?' ':' '}<data source="${esc(r.k.trim())}"><label>${esc(lab)}</label></data>`)}); | |||
if(open)body.push(' </group>'); | |||
return head.concat(body,['</infobox><noinclude>','<templatedata>',JSON.stringify({params,format:'block'},null,2),'</templatedata>','</noinclude>']).join('\n'); | |||
} | |||
function infoboxDesigner(b){ | |||
const g=b.mode!=='dedicated'; | |||
let h=`<div class="row">${fSelect('Layout','layout',b.layout||'',[['','Labels beside values'],['stacked','Labels above values']])}<div class="field"><span>Accent colour</span><span class="colorrow">${fCheck('Use one','accentOn',!!b.accentOn,true)}${b.accentOn?`<input type="color" data-path="accent" value="${esc(b.accent||'#3366cc')}" aria-label="Accent colour">`:''}</span></div></div>`; | |||
if(!g){const rows=b.rows.map((r,i)=>r.k.trim()?`<tr><td><code>${esc(r.k.trim())}</code></td><td><input type="text" data-path="rows.${i}.label" value="${esc(r.label||'')}" placeholder="${esc(niceKey(r.k))}" aria-label="Label for ${esc(r.k.trim())}"></td><td><input type="text" data-path="rows.${i}.header" value="${esc(r.header||'')}" placeholder="No new section" aria-label="Section that starts at ${esc(r.k.trim())}"></td></tr>`:'').join(''); | |||
if(rows)h+=`<div class="grid-wrap"><table class="design"><thead><tr><th scope="col">Field</th><th scope="col">Label shown</th><th scope="col">Starts a section called</th></tr></thead><tbody>${rows}</tbody></table></div>`} | |||
return h; | |||
} | } | ||
| Line 73: | Line 87: | ||
+`<span class="fgrp">${FB('bold','Bold','<b>B</b>','Bold (Ctrl+B)')}${FB('italic','Italic','<i>I</i>','Italic (Ctrl+I)')}${FB('under','Underline','<u>U</u>','Underline (Ctrl+U)')}${FB('strike','Strikethrough','<s>S</s>')}${FB('sup','Superscript','x<sup>2</sup>')}${FB('sub','Subscript','x<sub>2</sub>')}${FB('small','Small text','<small>Small</small>')}${FB('big','Big text','<span style="font-size:1.15em">Big</span>')}${FB('code','Inline code','<code>code</code>')}</span>` | +`<span class="fgrp">${FB('bold','Bold','<b>B</b>','Bold (Ctrl+B)')}${FB('italic','Italic','<i>I</i>','Italic (Ctrl+I)')}${FB('under','Underline','<u>U</u>','Underline (Ctrl+U)')}${FB('strike','Strikethrough','<s>S</s>')}${FB('sup','Superscript','x<sup>2</sup>')}${FB('sub','Subscript','x<sub>2</sub>')}${FB('small','Small text','<small>Small</small>')}${FB('big','Big text','<span style="font-size:1.15em">Big</span>')}${FB('code','Inline code','<code>code</code>')}</span>` | ||
+`<span class="fgrp"><label class="fcolor" title="Text colour"><span>Colour</span><input type="color" data-fmtcolor="color" value="#d73333" aria-label="Text colour"></label><label class="fcolor" title="Highlight"><span>Highlight</span><input type="color" data-fmtcolor="bg" value="#fef6c8" aria-label="Highlight colour"></label></span>` | +`<span class="fgrp"><label class="fcolor" title="Text colour"><span>Colour</span><input type="color" data-fmtcolor="color" value="#d73333" aria-label="Text colour"></label><label class="fcolor" title="Highlight"><span>Highlight</span><input type="color" data-fmtcolor="bg" value="#fef6c8" aria-label="Highlight colour"></label></span>` | ||
+`<span class="fgrp">${FB('link','Wiki link','Wiki link','Wiki link (Ctrl+K)')}${FB('ext','Web link','Web link')}${FB('ref','Reference','Reference')}${FB('tpl','Template','{ | +`<span class="fgrp">${FB('link','Wiki link','Wiki link','Wiki link (Ctrl+K)')}${FB('ext','Web link','Web link')}${FB('ref','Reference','Reference')}${FB('tpl','Template','{\u007B }}','Template')}</span>` | ||
+`<span class="fgrp">${FB('br','Line break','Line break')}${FB('nowiki','No wiki','No wiki','Show markup as plain text')}${FB('clear','Clear formatting','Clear','Remove formatting from the selection')}<select data-fmtsym aria-label="Insert a symbol"><option value="">Symbol</option>${SYMS.map(([v,l])=>`<option value="${esc(v)}">${v===' '?'␣':esc(v)} ${esc(l)}</option>`).join('')}</select></span></div>`} | +`<span class="fgrp">${FB('br','Line break','Line break')}${FB('nowiki','No wiki','No wiki','Show markup as plain text')}${FB('clear','Clear formatting','Clear','Remove formatting from the selection')}<select data-fmtsym aria-label="Insert a symbol"><option value="">Symbol</option>${SYMS.map(([v,l])=>`<option value="${esc(v)}">${v===' '?'␣':esc(v)} ${esc(l)}</option>`).join('')}</select></span></div>`} | ||
const GROUPS=[['text','Text',['heading','paragraph','bullets','numbers','quote','code']], | const TD_AC={'wiki-file-name':'file','wiki-page-name':'page','wiki-template-name':'template','wiki-user-name':'page'}; | ||
const tdCache=new Map(); | |||
const tdKey=n=>{const s=String(n||'').trim().replace(/_/g,' ').replace(/^Template\s*:/i,'').trim();return s?s.charAt(0).toUpperCase()+s.slice(1):''}; | |||
function tdFor(name){ | |||
if(!hooks.templateData)return null; | |||
const k=tdKey(name);if(!k)return null; | |||
let e=tdCache.get(k); | |||
if(!e){e={state:'loading',data:null};tdCache.set(k,e); | |||
hooks.templateData(k).then(d=>{e.state=d&&d.order&&d.order.length?'ok':'none';e.data=d;tdRefresh(k)},()=>{e.state='none';tdRefresh(k)})} | |||
return e; | |||
} | |||
function tdRefresh(k){state.blocks.forEach(b=>{if((b.type==='template'&&tdKey(b.name)===k)||(b.type==='infobox'&&b.mode==='dedicated'&&tdKey(b.template)===k))rerenderCard(b.id)})} | |||
function tdMissing(b){const e=tdFor(b.template);if(!e||e.state!=='ok')return [];const have=new Set(b.rows.map(r=>r.k.trim()).concat(['title','image','caption']));return e.data.order.filter(k=>!have.has(k)&&e.data.params[k]&&!e.data.params[k].deprecated)} | |||
function tdFillBtn(b){const m=tdMissing(b);return m.length?`<div><button type="button" class="add" data-act="tdfill">Add ${m.length} documented field${m.length===1?'':'s'} from the template</button></div>`:''} | |||
function templateForm(b){ | |||
const e=tdFor(b.name),d=e&&e.state==='ok'?e.data:null; | |||
let h=fText('Template name','name',b.name,'Citation needed'); | |||
if(e&&e.state==='loading')h+='<p class="note">Looking up this template’s parameters…</p>'; | |||
if(!d)return h+`<p class="sub">Parameters (leave the name blank for a numbered parameter)</p>${kvRows(b.params,'params','delparam','name','value')}<div><button type="button" class="add" data-act="addparam">Add parameter</button></div>`; | |||
const known=new Set(d.order); | |||
const keys=d.order.filter(k=>d.params[k]&&!(d.params[k].deprecated&&!b.params.some(p=>p.k.trim()===k&&p.v.trim()))); | |||
if(d.description)h+=`<p class="note">${esc(d.description)}</p>`; | |||
h+=`<div class="tdfields">${keys.map(k=>{const p=d.params[k],cur=b.params.find(x=>x.k.trim()===k),v=cur?cur.v:''; | |||
const ac=TD_AC[p.type]?` data-ac="${TD_AC[p.type]}"`:''; | |||
const input=p.type==='content'||p.type==='unbalanced-wikitext'?`<textarea data-tdkey="${esc(k)}" rows="3"${ac}>${esc(v)}</textarea>`:`<input type="text" data-tdkey="${esc(k)}" value="${esc(v)}"${ac}>`; | |||
return `<label class="field"><span>${esc(p.label||k)}${p.required?'<em class="req">required</em>':''}</span>${input}${p.description?`<small class="hint">${esc(p.description)}</small>`:''}</label>`}).join('')}</div>`; | |||
return h+`<p class="sub">Other parameters</p>${kvRows(b.params,'params','delparam','name','value',r=>!known.has(r.k.trim()))}<div><button type="button" class="add" data-act="addparam">Add parameter</button></div>`; | |||
} | |||
const THUMBS='<div class="thumbs" data-thumbs></div>'; | |||
const UPBTN=multi=>`<div class="uprow"><label class="btn filebtn">${multi?'Upload pictures':'Upload a picture'}<input type="file" accept="image/*"${multi?' multiple':''} class="sr-only" data-upload></label><span class="hint">or drop ${multi?'them':'one'} onto this block</span></div>`; | |||
const GALLERY_MODES=[['','Grid'],['packed','Packed rows'],['packed-hover','Packed, captions on hover'],['packed-overlay','Packed, captions over pictures'],['nolines','Grid without borders'],['slideshow','Slideshow']]; | |||
const NOTE_STYLES={info:['#3366cc','#eaf3ff'],success:['#14866d','#d5fdf4'],warning:['#edab00','#fef6e7'],danger:['#d73333','#fee7e6'],plain:['#a2a9b1','#f8f9fa']}; | |||
const noteStyle=k=>{const [bd,bg]=NOTE_STYLES[k]||NOTE_STYLES.info;return `border:1px solid ${bd};border-left:4px solid ${bd};background:${bg};color:#202122;padding:8px 12px;margin:1em 0`}; | |||
const COLL_STYLE='border:1px solid #a2a9b1;padding:6px 10px;margin:1em 0'; | |||
const reEsc=x=>x.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); | |||
const COLL_RE=new RegExp('^<div class="mw-collapsible( mw-collapsed)?" style="'+reEsc(COLL_STYLE)+'">\\n<div style="font-weight:bold">(.*)</div>\\n<div class="mw-collapsible-content">\\n([\\s\\S]*)\\n</div>\\n</div>$'); | |||
function divBlock(w){ | |||
let m=w.match(/^<div class="pb-note pb-note-(\w+)" style="([^"]*)">([\s\S]*)<\/div>$/); | |||
if(m){if(!NOTE_STYLES[m[1]]||m[2]!==noteStyle(m[1]))return null;const t=m[3].match(/^'''([^\n]+?)'''\n\n([\s\S]*)$/);return ['note',{kind:m[1],title:t?t[1]:'',text:t?t[2]:m[3]}]} | |||
m=w.match(COLL_RE);if(m)return ['collapsible',{collapsed:!!m[1],title:m[2],text:m[3]}]; | |||
return null; | |||
} | |||
const GROUPS=[['text','Text',['heading','paragraph','bullets','numbers','quote','note','collapsible','code']], | |||
['media','Media and data',['infobox','image','gallery','table']], | ['media','Media and data',['infobox','image','gallery','table']], | ||
['wiki','Wiki features',['template','hatnote','toc','refs','categories','cattree','rule','raw']]]; | ['wiki','Wiki features',['template','hatnote','toc','refs','categories','cattree','rule','raw']]]; | ||
| Line 112: | Line 169: | ||
init:()=>({text:'',source:'',fmt:'blockquote'}), | init:()=>({text:'',source:'',fmt:'blockquote'}), | ||
sum:b=>b.text, | sum:b=>b.text, | ||
form:b=>`${fmtBar()}${fArea('Quotation','text',b.text,'',3)}<div class="row">${fText('Source (optional)','source',b.source,'Who said it')}${fSelect('Markup','fmt',b.fmt,[['blockquote','<blockquote> (any wiki)'],['template','{ | form:b=>`${fmtBar()}${fArea('Quotation','text',b.text,'',3)}<div class="row">${fText('Source (optional)','source',b.source,'Who said it')}${fSelect('Markup','fmt',b.fmt,[['blockquote','<blockquote> (any wiki)'],['template','{\u007BQuote}} (needs that template)']])}</div>`, | ||
wiki:b=>{const t=b.text.trim(),s=b.source.trim();return b.fmt==='template'?`{ | wiki:b=>{const t=b.text.trim(),s=b.source.trim();return b.fmt==='template'?`{\u007BQuote|text=${t}${s?`|author=${s}`:''}}}`:`<blockquote>${t}${s?`<br />— ${s}`:''}</blockquote>`}, | ||
html:(b,c)=>`<blockquote><p>${c.inl(b.text)}</p>${b.source.trim()?`<p class="qsrc">— ${c.inl(b.source)}</p>`:''}</blockquote>`}, | html:(b,c)=>`<blockquote><p>${c.inl(b.text)}</p>${b.source.trim()?`<p class="qsrc">— ${c.inl(b.source)}</p>`:''}</blockquote>`}, | ||
| Line 124: | Line 181: | ||
infobox:{label:'Infobox',tok:'<infobox>',desc:'A Portable Infobox with any labels you like', | infobox:{label:'Infobox',tok:'<infobox>',desc:'A Portable Infobox with any labels you like', | ||
init:()=>({mode:'generic',template:'Infobox',name:'',image:'',caption:'',rows:[{k:'',v:''}]}), | init:()=>({mode:'generic',template:'Infobox',name:'',image:'',caption:'',rows:[{k:'',v:''}],layout:'',accentOn:false,accent:'#3366cc'}), | ||
sum:b=>b.name||b.template, | sum:b=>b.name||b.template, | ||
form:b=>{const g=b.mode!=='dedicated';const tn=`<b data-tplname>Template:${esc( | form:b=>{const g=b.mode!=='dedicated';const nm=tdKey(b.template)||'Infobox';const tn=`<b data-tplname>Template:${esc(nm)}</b>`; | ||
return `<div class="row">${fSelect('Kind','mode',b.mode,[['generic','Generic: any labels'],['dedicated','Dedicated: fixed field names']],true)}${fText('Template name','template',b.template,g?'Infobox':'Infobox character')}</div><div class="row">${fText('Title','name',b.name,'Leave blank to use the page name')}${fText('Image file','image',b.image,'Example.jpg')}</div>${fText('Image caption','caption',b.caption)}${fmtBar()}<p class="sub">${g?'Rows: type any label you like':'Fields'}</p>${kvRows(b.rows,'rows','delrow',g?'Label':'field_name',g?'Value':'value')}<div><button type="button" class="add" data-act="addrow">${g?'Add row':'Add field'}</button></div><details class="tplcode"${openTpl.has(b.id)?' open':''}><summary> | return `<div class="row">${fSelect('Kind','mode',b.mode,[['generic','Generic: any labels'],['dedicated','Dedicated: fixed field names']],true)}${fText('Template name','template',b.template,g?'Infobox':'Infobox character')}</div><div class="row">${fText('Title','name',b.name,'Leave blank to use the page name')}${fText('Image file','image',b.image,'Example.jpg')}</div>${fText('Image caption','caption',b.caption)}${fmtBar()}<p class="sub">${g?'Rows: type any label you like':'Fields'}</p>${kvRows(b.rows,'rows','delrow',g?'Label':'field_name',g?'Value':'value')}<div><button type="button" class="add" data-act="addrow">${g?'Add row':'Add field'}</button></div>${g?'':tdFillBtn(b)}<details class="tplcode"${openTpl.has(b.id)?' open':''}><summary>Design the template</summary><p class="note">${g?`This is the code for ${tn}. Save it to the wiki once, and any page can use the box with its own labels. It holds ${Math.max(20,b.rows.length)} rows, and grows if a page ever needs more.`:`This is the code for ${tn}. Save it to the wiki once and every page of this kind can use it. Its TemplateData gives VisualEditor and this builder a form for the fields.`}</p>${infoboxDesigner(b)}<pre class="mini" data-tplcode>${esc(infoboxTemplate(b))}</pre><div class="btnrow"><button type="button" class="btn" data-act="copytpl">Copy template code</button>${hooks.saveTemplate?`<button type="button" class="btn primary" data-act="savetpl">Save to Template:${esc(nm)}</button>`:''}</div></details>`}, | ||
wiki:b=>{ | wiki:b=>{ | ||
const base=[['title',b.name],['image',stripFile(b.image)],['caption',b.caption]].filter(p=>String(p[1]).trim()); | const base=[['title',b.name],['image',stripFile(b.image)],['caption',b.caption]].filter(p=>String(p[1]).trim()); | ||
| Line 135: | Line 192: | ||
const pairs=base.concat(rest); | const pairs=base.concat(rest); | ||
const w=pairs.reduce((m,p)=>Math.max(m,p[0].length),0); | const w=pairs.reduce((m,p)=>Math.max(m,p[0].length),0); | ||
return [`{ | return [`{\u007B${b.template.trim()||'Infobox'}`,...pairs.map(([k,v])=>`| ${k.padEnd(w)} = ${String(v).trim()}`),'}}'].join('\n')}, | ||
html:(b,c)=>`< | html:(b,c)=>{const g=b.mode!=='dedicated'; | ||
const rows=g?b.rows.filter(r=>r.v.trim()).map(r=>`<tr><th scope="row">${c.inl(r.k)}</th><td>${c.inl(r.v)}</td></tr>`).join('') | |||
:b.rows.filter(r=>r.k.trim()).map(r=>`${String(r.header||'').trim()?`<tr><th colspan="2" class="ib-head">${esc(r.header.trim())}</th></tr>`:''}<tr><th scope="row">${esc(String(r.label||'').trim()||niceKey(r.k))}</th><td>${c.inl(r.v)}</td></tr>`).join(''); | |||
return `<table class="infobox"${b.accentOn&&b.accent?` style="border-top:4px solid ${esc(b.accent)}"`:''}><caption>${b.name.trim()?c.inl(b.name):esc(state.title.trim()||'Page name')}</caption>${b.image.trim()?`<tr><td colspan="2" class="ib-img">${ph(stripFile(b.image))}${b.caption.trim()?`<div class="ib-cap">${c.inl(b.caption)}</div>`:''}</td></tr>`:''}${rows}</table>`}}, | |||
image:{label:'Image',tok:'[ | image:{label:'Image',tok:'[\u005BFile:]]',desc:'A picture with an optional caption', | ||
init:()=>({file:'',caption:'',frame:'thumb',align:'',size:''}), | init:()=>({file:'',caption:'',frame:'thumb',align:'',size:''}), | ||
sum:b=>stripFile(b.file), | sum:b=>stripFile(b.file), | ||
form:b=>`<div class="row">${fText('File name','file',b.file,'Example.jpg')}${fText('Caption','caption',b.caption)}</div><div class="row r-3">${fSelect('Style','frame',b.frame,[['thumb','Thumbnail'],['frame','Framed'],['frameless','Frameless'],['','Plain']])}${fSelect('Position','align',b.align,[['','Default'],['right','Right'],['left','Left'],['center','Centre'],['none','None']])}${fText('Width (px)','size',b.size,'250')}</div>`, | form:b=>`<div class="row">${fText('File name','file',b.file,'Example.jpg')}${fText('Caption','caption',b.caption)}</div><div class="row r-3">${fSelect('Style','frame',b.frame,[['thumb','Thumbnail'],['frame','Framed'],['frameless','Frameless'],['','Plain']])}${fSelect('Position','align',b.align,[['','Default'],['right','Right'],['left','Left'],['center','Centre'],['none','None']])}${fText('Width (px)','size',b.size,'250')}</div>${hooks.upload?UPBTN(false):''}${THUMBS}`, | ||
wiki:b=>{const p=['File:'+(stripFile(b.file)||'Example.jpg')];if(b.frame)p.push(b.frame);if(b.align)p.push(b.align); | wiki:b=>{const p=['File:'+(stripFile(b.file)||'Example.jpg')];if(b.frame)p.push(b.frame);if(b.align)p.push(b.align); | ||
const s=b.size.trim().replace(/px$/i,'');if(s)p.push(/^\d+$/.test(s)?s+'px':b.size.trim());if(b.caption.trim())p.push(b.caption.trim());return `[ | const s=b.size.trim().replace(/px$/i,'');if(s)p.push(/^\d+$/.test(s)?s+'px':b.size.trim());if(b.caption.trim())p.push(b.caption.trim());return `[\u005B${p.join('|')}]]`}, | ||
html:(b,c)=>{const framed=b.frame==='thumb'||b.frame==='frame';const al=b.align||(framed?'right':'');const w=(parseInt(b.size,10)||(b.frame==='frame'?300:220))+(framed?8:0); | html:(b,c)=>{const framed=b.frame==='thumb'||b.frame==='frame';const al=b.align||(framed?'right':'');const w=(parseInt(b.size,10)||(b.frame==='frame'?300:220))+(framed?8:0); | ||
return `<figure class="thumb${framed?' framed':''} al-${al||'inline'}" style="width:${w}px">${ph(stripFile(b.file))}${framed&&b.caption.trim()?`<figcaption>${c.inl(b.caption)}</figcaption>`:''}</figure>`}}, | return `<figure class="thumb${framed?' framed':''} al-${al||'inline'}" style="width:${w}px">${ph(stripFile(b.file))}${framed&&b.caption.trim()?`<figcaption>${c.inl(b.caption)}</figcaption>`:''}</figure>`}}, | ||
gallery:{label:'Gallery',tok:'<gallery>',desc:'A grid of images', | gallery:{label:'Gallery',tok:'<gallery>',desc:'A grid or slideshow of images', | ||
init:()=>({items:'Example.jpg|First caption\nExample2.jpg|Second caption'}), | init:()=>({items:'Example.jpg|First caption\nExample2.jpg|Second caption',mode:'',heights:'',caption:''}), | ||
sum:b=>lines(b.items).length+' images', | sum:b=>lines(b.items).length+' images', | ||
form:b=>fArea('Images','items',b.items,'One image per line: File name.jpg|Caption',4,true), | form:b=>`<div class="row r-3">${fSelect('Style','mode',b.mode||'',GALLERY_MODES)}${fText('Caption (optional)','caption',b.caption||'')}${fText('Row height (px)','heights',b.heights||'','150')}</div>`+fArea('Images','items',b.items,'One image per line: File name.jpg|Caption',4,true)+(hooks.upload?UPBTN(true):'')+THUMBS, | ||
wiki:b=>`<gallery>\n${lines(b.items).map(l=>l.trim()).join('\n')}\n</gallery>`, | wiki:b=>{let a='';if(b.mode)a+=` mode="${b.mode}"`;const h=String(b.heights||'').trim().replace(/px$/i,'');if(h)a+=` heights="${/^\d+$/.test(h)?h+'px':h}"`; | ||
html:(b,c)=>`<div class="gallery">${lines(b.items).map(l=>{const [f,...cap]=l.split('|');return `<figure>${ph(stripFile(f))}<figcaption>${c.inl(cap.join('|'))}</figcaption></figure>`}).join('')}</div>`}, | const cap=String(b.caption||'').trim();if(cap)a+=` caption="${cap.replace(/"/g,'"')}"`; | ||
return `<gallery${a}>\n${lines(b.items).map(l=>l.trim()).join('\n')}\n</gallery>`}, | |||
html:(b,c)=>`${String(b.caption||'').trim()?`<p class="gallery-cap">${c.inl(b.caption)}</p>`:''}<div class="gallery">${lines(b.items).map(l=>{const [f,...cap]=l.split('|');return `<figure>${ph(stripFile(f))}<figcaption>${c.inl(cap.join('|'))}</figcaption></figure>`}).join('')}</div>`}, | |||
table:{label:'Table',tok:'{| |}',desc:'A wikitable with rows and columns', | table:{label:'Table',tok:'{| |}',desc:'A wikitable with rows and columns', | ||
init:()=>({caption:'',header:true,sortable:false,cells:[['Header 1','Header 2'],['','']]}), | init:()=>({caption:'',header:true,sortable:false,cells:[['Header 1','Header 2'],['','']],align:[]}), | ||
sum:b=>b.caption||`${b.cells.length} rows, ${(b.cells[0]||[]).length} columns`, | sum:b=>b.caption||`${b.cells.length} rows, ${(b.cells[0]||[]).length} columns`, | ||
form:b=>`${fText('Caption (optional)','caption',b.caption)}<div class="checks">${fCheck('First row is a header','header',b.header,true)}${fCheck('Sortable columns','sortable',b.sortable)}</div>${fmtBar()}<div class="grid-wrap"><table class="cells">${b.cells.map((r,i)=>`<tr>${r.map((v,j)=>`<td><input type="text" data-path="cells.${i}.${j}" value="${esc(v)}" aria-label="Row ${i+1}, column ${j+1}"${i===0&&b.header?' class="hcell"':''}></td>`).join('')}< | form:b=>{const w=(b.cells[0]||[]).length,al=b.align||[]; | ||
wiki:b=>{const out=[`{| class="wikitable${b.sortable?' sortable':''}"`];if(b.caption.trim())out.push('|+ '+b.caption.trim()); | return `${fText('Caption (optional)','caption',b.caption)}<div class="checks">${fCheck('First row is a header','header',b.header,true)}${fCheck('Sortable columns','sortable',b.sortable)}</div>${fmtBar()}<div class="grid-wrap"><table class="cells"><tr class="alignrow">${Array.from({length:w},(x,j)=>`<td><select data-path="align.${j}" aria-label="Alignment of column ${j+1}">${[['','Align left'],['center','Centre'],['right','Align right']].map(([v,t])=>`<option value="${v}"${(al[j]||'')===v?' selected':''}>${t}</option>`).join('')}</select></td>`).join('')}<td></td></tr>${b.cells.map((r,i)=>`<tr>${r.map((v,j)=>`<td><input type="text" data-path="cells.${i}.${j}" value="${esc(v)}" aria-label="Row ${i+1}, column ${j+1}"${i===0&&b.header?' class="hcell"':''}></td>`).join('')}<td class="trow-tools"><button type="button" class="icon" data-act="trowup" data-i="${i}" aria-label="Move row ${i+1} up" title="Move row up">${ICON.up}</button><button type="button" class="icon" data-act="trowdown" data-i="${i}" aria-label="Move row ${i+1} down" title="Move row down">${ICON.down}</button><button type="button" class="icon del" data-act="trowdel" data-i="${i}" aria-label="Remove row ${i+1}" title="Remove row">${ICON.x}</button></td></tr>`).join('')}</table></div><p class="hint">Tip: copy cells from a spreadsheet and paste them into any cell here.</p><div class="btnrow"><button type="button" class="add" data-act="addtrow">Add row</button><button type="button" class="add" data-act="addcol">Add column</button><button type="button" class="add" data-act="delcol">Remove last column</button></div>`}, | ||
b.cells.forEach((r,i)=>{if(i>0)out.push('|-');out.push(i===0&&b.header?'! '+r.map(x=>x.trim()).join(' !! '):'| '+r.map(x=>x.trim()).join(' || '))}); | wiki:b=>{const out=[`{| class="wikitable${b.sortable?' sortable':''}"`];if(b.caption.trim())out.push('|+ '+b.caption.trim());const al=b.align||[]; | ||
b.cells.forEach((r,i)=>{if(i>0)out.push('|-');out.push(i===0&&b.header?'! '+r.map(x=>x.trim()).join(' !! '):'| '+r.map((x,j)=>al[j]?`style="text-align:${al[j]}" | ${x.trim()}`:x.trim()).join(' || '))}); | |||
out.push('|}');return out.join('\n')}, | out.push('|}');return out.join('\n')}, | ||
html:(b,c)=>`<div class="tablewrap"><table class="wikitable">${b.caption.trim()?`<caption>${c.inl(b.caption)}</caption>`:''}${b.cells.map((r,i)=>`<tr>${r.map(x=>i===0&&b.header?`<th>${c.inl(x)}</th>`:`<td>${c.inl(x)}</td>`).join('')}</tr>`).join('')}</table></div>`}, | html:(b,c)=>{const al=b.align||[];return `<div class="tablewrap"><table class="wikitable">${b.caption.trim()?`<caption>${c.inl(b.caption)}</caption>`:''}${b.cells.map((r,i)=>`<tr>${r.map((x,j)=>i===0&&b.header?`<th>${c.inl(x)}</th>`:`<td${al[j]?` style="text-align:${al[j]}"`:''}>${c.inl(x)}</td>`).join('')}</tr>`).join('')}</table></div>`}}, | ||
template:{label:'Template',tok:'{ | template:{label:'Template',tok:'{\u007B }}',desc:'Any template from your wiki', | ||
init:()=>({name:'',params:[{k:'',v:''}]}), | init:()=>({name:'',params:[{k:'',v:''}]}), | ||
sum:b=>b.name, | sum:b=>b.name, | ||
form: | form:templateForm, | ||
wiki:b=>{const ps=b.params.filter(p=>p.k.trim()||p.v.trim());const n=b.name.trim()||'Template name'; | wiki:b=>{const ps=b.params.filter(p=>(p.k.trim()||p.v.trim())&&!(p.td&&!p.v.trim()));const n=b.name.trim()||'Template name'; | ||
if(ps.filter(p=>p.k.trim()).length>=3)return `{ | if(ps.filter(p=>p.k.trim()).length>=3)return `{\u007B${n}\n${ps.map(p=>`| ${p.k.trim()?p.k.trim()+' = ':''}${p.v.trim()}`).join('\n')}\n}}`; | ||
return `{ | return `{\u007B${n}${ps.map(p=>'|'+(p.k.trim()?p.k.trim()+'=':'')+p.v.trim()).join('')}}}`}, | ||
html:b=>`<div class="tpl"><code>{ | html:b=>`<div class="tpl"><code>{\u007B${esc(b.name.trim()||'Template name')}}}</code> renders here once the page is saved on your wiki.</div>`}, | ||
note:{label:'Note box',tok:'<div>',desc:'A coloured box for tips, warnings or notices', | |||
init:()=>({kind:'info',title:'',text:''}), | |||
sum:b=>b.title||b.text, | |||
form:b=>`<div class="row r-wide">${fText('Title (optional)','title',b.title,'Heads up')}${fSelect('Colour','kind',b.kind,[['info','Blue'],['success','Green'],['warning','Yellow'],['danger','Red'],['plain','Grey']])}</div>${fmtBar()}${fArea('Text','text',b.text,'What the box says',3)}`, | |||
wiki:b=>{const k=NOTE_STYLES[b.kind]?b.kind:'info',t=b.title.trim(),x=b.text.trim();return `<div class="pb-note pb-note-${k}" style="${noteStyle(k)}">${t?`'''${t}'''\n\n`:''}${x}</div>`}, | |||
html:(b,c)=>`<div style="${noteStyle(b.kind)}">${b.title.trim()?`<p><b>${c.inl(b.title)}</b></p>`:''}${b.text.trim().split(/\n\s*\n/).map(x=>`<p>${c.inl(x)}</p>`).join('')}</div>`}, | |||
collapsible:{label:'Collapsible section',tok:'mw-collapsible',desc:'A box readers can open and close', | |||
init:()=>({title:'More details',text:'',collapsed:true}), | |||
sum:b=>b.title, | |||
form:b=>`${fText('Title','title',b.title,'Shown when the box is closed')}${fCheck('Start closed','collapsed',b.collapsed)}${fmtBar()}${fArea('Content','text',b.text,'Anything: text, lists, images, tables…',4)}`, | |||
wiki:b=>`<div class="mw-collapsible${b.collapsed?' mw-collapsed':''}" style="${COLL_STYLE}">\n<div style="font-weight:bold">${b.title.trim()||'Details'}</div>\n<div class="mw-collapsible-content">\n${b.text.trim()}\n</div>\n</div>`, | |||
html:(b,c)=>`<div class="coll"><div class="coll-title">${c.inl(b.title.trim()||'Details')} <span class="muted">[${b.collapsed?'show':'hide'}]</span></div>${b.collapsed?'':b.text.trim().split(/\n\s*\n/).map(x=>`<p>${c.inl(x)}</p>`).join('')}</div>`}, | |||
hatnote:{label:'Main article link',tok:":''Main''",desc:'Points readers to a more detailed page', | hatnote:{label:'Main article link',tok:":''Main''",desc:'Points readers to a more detailed page', | ||
init:()=>({kind:'Main',target:'',markup:'plain'}), | init:()=>({kind:'Main',target:'',markup:'plain'}), | ||
sum:b=>b.target, | sum:b=>b.target, | ||
form:b=>`<div class="row">${fSelect('Type','kind',b.kind,[['Main','Main article'],['See also','See also'],['Further','Further information']])}${fText('Page name','target',b.target,'Detailed page')}</div>${fSelect('Markup','markup',b.markup,[['plain','Plain wikitext (works now)'],['template','{ | form:b=>`<div class="row">${fSelect('Type','kind',b.kind,[['Main','Main article'],['See also','See also'],['Further','Further information']])}${fText('Page name','target',b.target,'Detailed page')}</div>${fSelect('Markup','markup',b.markup,[['plain','Plain wikitext (works now)'],['template','{\u007BMain}} / {\u007BSee also}} / {\u007BFurther}} (needs those templates)']])}`, | ||
wiki:b=>{const t=b.target.trim()||'Page name';return b.markup==='template'?`{ | wiki:b=>{const t=b.target.trim()||'Page name';return b.markup==='template'?`{\u007B${b.kind}|${t}}}`:`:''${({Main:'Main article','See also':'See also',Further:'Further information'})[b.kind]}: [\u005B${t}]]''`}, | ||
html:b=>`<div class="hatnote">${{Main:'Main article','See also':'See also',Further:'Further information'}[b.kind]}: <a class="wl">${esc(b.target.trim()||'Page name')}</a></div>`}, | html:b=>`<div class="hatnote">${{Main:'Main article','See also':'See also',Further:'Further information'}[b.kind]}: <a class="wl">${esc(b.target.trim()||'Page name')}</a></div>`}, | ||
| Line 191: | Line 268: | ||
refs:{label:'References',tok:'<references/>',desc:'Lists every <ref> on the page', | refs:{label:'References',tok:'<references/>',desc:'Lists every <ref> on the page', | ||
init:()=>({heading:true,style:'references'}), | init:()=>({heading:true,style:'references'}), | ||
sum:b=>b.style==='reflist'?'{ | sum:b=>b.style==='reflist'?'{\u007BReflist}}':'<references />', | ||
form:b=>`<div class="row">${fSelect('Markup','style',b.style,[['references','<references /> (any wiki)'],['reflist','{ | form:b=>`<div class="row">${fSelect('Markup','style',b.style,[['references','<references /> (any wiki)'],['reflist','{\u007BReflist}} (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'?'{ | wiki:b=>`${b.heading?'== References ==\n':''}${b.style==='reflist'?'{\u007BReflist}}':'<references />'}`, | ||
html:(b,c)=>`${b.heading?'<h2>References</h2>':''}${c.refs.length?`<ol class="refs">${c.refs.map(r=>`<li>${fmt(r,c)}</li>`).join('')}</ol>`:'<p class="muted">Add references to paragraphs with the Reference button.</p>'}`}, | html:(b,c)=>`${b.heading?'<h2>References</h2>':''}${c.refs.length?`<ol class="refs">${c.refs.map(r=>`<li>${fmt(r,c)}</li>`).join('')}</ol>`:'<p class="muted">Add references to paragraphs with the Reference button.</p>'}`}, | ||
categories:{label:'Categories',tok:'[ | categories:{label:'Categories',tok:'[\u005BCategory:]]',desc:'File the page under categories', | ||
init:()=>({items:''}), | init:()=>({items:''}), | ||
sum:b=>lines(b.items).join(', '), | sum:b=>lines(b.items).join(', '), | ||
form:b=>fArea('Categories','items',b.items,'One category per line',3), | form:b=>fArea('Categories','items',b.items,'One category per line',3), | ||
wiki:b=>lines(b.items).map(l=>`[ | wiki:b=>lines(b.items).map(l=>`[\u005BCategory:${l.trim().replace(/^Category:/i,'')}]]`).join('\n'), | ||
html:(b,c)=>{lines(b.items).forEach(l=>c.cats.push(l.trim().replace(/^Category:/i,'')));return ''}}, | html:(b,c)=>{lines(b.items).forEach(l=>c.cats.push(l.trim().replace(/^Category:/i,'')));return ''}}, | ||
| Line 232: | Line 309: | ||
return {title:'Sourdough bread',blocks:[ | return {title:'Sourdough bread',blocks:[ | ||
mk('infobox',{template:'Infobox',name:'Sourdough bread',image:'Sourdough loaf.jpg',caption:'A round country loaf', | mk('infobox',{template:'Infobox',name:'Sourdough bread',image:'Sourdough loaf.jpg',caption:'A round country loaf', | ||
rows:[{k:'Type',v:'Bread'},{k:'Main ingredient',v:'Flour, water, salt'},{k:'Leavening',v:'Wild yeast and [ | rows:[{k:'Type',v:'Bread'},{k:'Main ingredient',v:'Flour, water, salt'},{k:'Leavening',v:'Wild yeast and [\u005Blactic acid bacteria]]'}]}), | ||
mk('paragraph',{text:"'''Sourdough''' is a [ | mk('paragraph',{text:"'''Sourdough''' is a [\u005Bbread]] leavened by a naturally fermented starter rather than commercial [\u005Byeast]].<ref>Replace this with your source.</ref> The bacteria in the starter give the loaf its mildly sour flavour and help it keep longer."}), | ||
mk('toc'), | mk('toc'), | ||
mk('heading',{text:'History'}), | mk('heading',{text:'History'}), | ||
| Line 243: | Line 320: | ||
mk('table',{caption:'Typical hydration levels',sortable:true,cells:[['Style','Hydration','Crumb'],['Sandwich loaf','65%','Tight'],['Country loaf','75%','Open'],['Ciabatta','80%+','Very open']]}), | mk('table',{caption:'Typical hydration levels',sortable:true,cells:[['Style','Hydration','Crumb'],['Sandwich loaf','65%','Tight'],['Country loaf','75%','Open'],['Ciabatta','80%+','Very open']]}), | ||
mk('heading',{text:'See also'}), | mk('heading',{text:'See also'}), | ||
mk('bullets',{items:'[ | mk('bullets',{items:'[\u005BRye bread]]\n[\u005BSoda bread]]'}), | ||
mk('refs'), | mk('refs'), | ||
mk('categories',{items:'Breads\nFermented foods'}) | mk('categories',{items:'Breads\nFermented foods'}) | ||
]}; | ]}; | ||
} | } | ||
const BUILTIN_PRESETS=[ | |||
{name:'Person',description:'An infobox with a few details, About and Friends sections, and the People category.', | |||
wikitext:"{\u007BInfobox\n| title = $TITLE\n| image = \n| caption = \n| label1 = Pronouns\n| data1 = \n| label2 = Location\n| data2 = \n| label3 = Interests\n| data3 = \n}}\n'''$TITLE''' is …\n\n== About ==\nWrite a few lines about them here.\n\n== Friends ==\n* [\u005BName of a friend]]\n\n[\u005BCategory:People]]"}, | |||
{name:'Article',description:'An introduction, Overview and History sections, See also, References and a category.', | |||
wikitext:"'''$TITLE''' is …\n\n== Overview ==\nWhat it is and why it matters.\n\n== History ==\nHow it came about.\n\n== See also ==\n* [\u005BRelated page]]\n\n== References ==\n<references />\n\n[\u005BCategory:Category name]]"} | |||
]; | |||
const EXAMPLE_PRESET={name:'Example: Sourdough bread',description:'A finished page that uses most kinds of blocks.',build:example}; | |||
function save(){} | function save(){} | ||
function load(){return null} | function load(){return null} | ||
function | function blockText(b){let w;try{w=DEFS[b.type].wiki(b)}catch(e){w=''}return b.src!=null&&w===b._g?b.src:w} | ||
function gen(){ | |||
let out='',prev=null; | |||
state.blocks.forEach(b=>{const w=blockText(b);if(!w||!w.trim())return; | |||
if(out)out+=prev&&b.prevId===prev.id&&b.sep!=null?b.sep:'\n\n'; | |||
out+=w;prev=b}); | |||
return out; | |||
} | |||
function fmt(s,c){ | function fmt(s,c){ | ||
| Line 263: | Line 355: | ||
.replace(/\[(https?:\/\/[^\s\]]+) ([^\]]+)\]/g,'<a class="ext">$2</a>') | .replace(/\[(https?:\/\/[^\s\]]+) ([^\]]+)\]/g,'<a class="ext">$2</a>') | ||
.replace(/\[(https?:\/\/[^\s\]]+)\]/g,()=>`<a class="ext">[${++c.ext}]</a>`) | .replace(/\[(https?:\/\/[^\s\]]+)\]/g,()=>`<a class="ext">[${++c.ext}]</a>`) | ||
.replace(/\{\{([^{}]+)\}\}/g,'<span class="tpl-inline">{ | .replace(/\{\{([^{}]+)\}\}/g,'<span class="tpl-inline">{\u007B$1}}</span>') | ||
.replace(/<(\/?)(u|s|del|ins|sup|sub|small|big|code|mark|b|i)>/gi,'<$1$2>') | .replace(/<(\/?)(u|s|del|ins|sup|sub|small|big|code|mark|b|i)>/gi,'<$1$2>') | ||
.replace(/<br\s*\/?>/gi,'<br>') | .replace(/<br\s*\/?>/gi,'<br>') | ||
| Line 290: | Line 382: | ||
const n=w?w.split('\n').length:0; | const n=w?w.split('\n').length:0; | ||
$('#pb-meta').textContent=n?`${n} line${n===1?'':'s'}`:''; | $('#pb-meta').textContent=n?`${n} line${n===1?'':'s'}`:''; | ||
renderPreview(); | renderPreview();renderOutline();scheduleChecks(); | ||
if(hooks.onChange)hooks.onChange(w); | if(hooks.onChange)hooks.onChange(w); | ||
} | } | ||
| Line 304: | Line 396: | ||
<div class="block-head"> | <div class="block-head"> | ||
<span class="grip" title="Drag to reorder">${ICON.grip}</span> | <span class="grip" title="Drag to reorder">${ICON.grip}</span> | ||
<button type="button" class="b-title" data-act="toggle" aria-expanded="${!isC}"><span class="name">${d.label}</span><span class="summary">${esc(String(d.sum(b)||'').slice(0,80))}</span></button> | <button type="button" class="b-title" data-act="toggle" aria-expanded="${!isC}"><span class="name">${d.label}</span><span class="summary">${esc(String(d.sum(b)||'').slice(0,80))}</span><span class="foldnote"></span></button> | ||
<span class="tools"> | <span class="tools">${b.type==='heading'?` | ||
<button type="button" class="icon" data-act="secup" aria-label="Move this section up" title="Move this section up">${ICON.up2}</button> | |||
<button type="button" class="icon" data-act="secdown" aria-label="Move this section down" title="Move this section down">${ICON.down2}</button> | |||
<button type="button" class="icon" data-act="fold" aria-pressed="${folded.has(b.id)}" aria-label="${folded.has(b.id)?'Show':'Hide'} the blocks in this section" title="${folded.has(b.id)?'Show':'Hide'} the blocks in this section">${folded.has(b.id)?ICON.unfold:ICON.fold}</button>`:''} | |||
<button type="button" class="icon" data-act="up" aria-label="Move up" title="Move up">${ICON.up}</button> | <button type="button" class="icon" data-act="up" aria-label="Move up" title="Move up">${ICON.up}</button> | ||
<button type="button" class="icon" data-act="down" aria-label="Move down" title="Move down">${ICON.down}</button> | <button type="button" class="icon" data-act="down" aria-label="Move down" title="Move down">${ICON.down}</button> | ||
| Line 318: | Line 413: | ||
blocksEl.innerHTML=state.blocks.length?state.blocks.map(cardHtml).join(''): | 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>`; | `<div class="empty"><strong>This page is empty</strong><p>Drag a block from the list into this space, or click one to add it at the end.</p></div>`; | ||
blocksEl.querySelectorAll('[data-thumbs]').forEach(t=>fillThumbs(t.closest('.block'))); | |||
applyFold(); | |||
} | } | ||
function commit(){renderCanvas();updateOutput();save()} | |||
const folded=new Set(); | |||
const lvlOf=b=>b.type==='heading'?(+b.level||2):(b.type==='refs'&&b.heading?2:null); | |||
function sectionEnd(i){const L=lvlOf(state.blocks[i])||2;for(let j=i+1;j<state.blocks.length;j++){const l=lvlOf(state.blocks[j]);if(l!=null&&l<=L)return j}return state.blocks.length} | |||
function applyFold(){ | |||
let cur=null;const counts={}; | |||
state.blocks.forEach(b=>{const l=lvlOf(b);if(cur&&l!=null&&l<=cur.l)cur=null; | |||
const card=cardOf(b.id); | |||
if(cur){counts[cur.id]++;if(card)card.classList.add('fold-hid')}else if(card)card.classList.remove('fold-hid'); | |||
if(!cur&&b.type==='heading'&&folded.has(b.id)){cur={id:b.id,l};counts[b.id]=0}}); | |||
[...folded].forEach(id=>{if(!state.blocks.some(b=>b.id===id))folded.delete(id)}); | |||
blocksEl.querySelectorAll('.block').forEach(c=>{const s=c.querySelector('.foldnote');if(!s)return;const n=folded.has(c.dataset.id)?counts[c.dataset.id]||0:0;s.textContent=n?`${n} block${n===1?'':'s'} hidden`:''}); | |||
} | |||
function revealBlock(id){const idx=state.blocks.findIndex(b=>b.id===id);let changed=false; | |||
state.blocks.forEach((b,i)=>{if(b.type==='heading'&&folded.has(b.id)&&i<idx&§ionEnd(i)>idx){folded.delete(b.id);changed=true;const c=cardOf(b.id);if(c){const t=document.createElement('div');t.innerHTML=cardHtml(b);c.replaceWith(t.firstElementChild)}}}); | |||
if(changed)applyFold()} | |||
function moveSection(i,dir){const L=lvlOf(state.blocks[i])||2,end=sectionEnd(i); | |||
if(dir<0){let p=-1;for(let j=i-1;j>=0;j--){const l=lvlOf(state.blocks[j]);if(l!=null&&l<=L){if(l===L&&state.blocks[j].type==='heading')p=j;break}} | |||
if(p<0){showToast('This is already the first section at its level.');return false} | |||
const mine=state.blocks.splice(i,end-i);state.blocks.splice(p,0,...mine)} | |||
else{const nb=state.blocks[end];if(!nb||nb.type!=='heading'||lvlOf(nb)!==L){showToast('This is already the last section at its level.');return false} | |||
const theirs=state.blocks.splice(end,sectionEnd(end)-end);state.blocks.splice(i,0,...theirs)} | |||
return true} | |||
function commit(){renderCanvas();updateOutput();save();record()} | |||
const hist={stack:[],i:-1,timer:null}; | |||
function record(){clearTimeout(hist.timer);hist.timer=null;const s=JSON.stringify(state); | |||
if(hist.stack[hist.i]!==s){hist.stack=hist.stack.slice(0,hist.i+1);hist.stack.push(s);if(hist.stack.length>200)hist.stack.shift();hist.i=hist.stack.length-1} | |||
histButtons()} | |||
function recordSoon(){clearTimeout(hist.timer);hist.timer=setTimeout(record,500);histButtons()} | |||
function resetHistory(){clearTimeout(hist.timer);hist.timer=null;hist.stack=[];hist.i=-1;record()} | |||
function histButtons(){const u=$('#pb-undoBtn'),r=$('#pb-redoBtn');if(u)u.disabled=!(hist.i>0||hist.timer);if(r)r.disabled=hist.i>=hist.stack.length-1||!!hist.timer} | |||
const cssq=x=>window.CSS&&CSS.escape?CSS.escape(x):String(x).replace(/["\\]/g,'\\$&'); | |||
function focusKey(){const a=document.activeElement;if(!a||!blocksEl.contains(a))return null;const c=a.closest('.block');if(!c)return null; | |||
const sel=a.dataset.path?`[data-path="${cssq(a.dataset.path)}"]`:a.dataset.tdkey!=null?`[data-tdkey="${cssq(a.dataset.tdkey)}"]`:a.dataset.act?`[data-act="${cssq(a.dataset.act)}"]`:null; | |||
return sel?{id:c.dataset.id,sel,pos:a.selectionStart,end:a.selectionEnd}:null} | |||
function refocus(k){if(!k)return;const c=cardOf(k.id);const f=c&&c.querySelector(k.sel);if(!f)return;f.focus({preventScroll:true}); | |||
if(k.pos!=null&&f.setSelectionRange){try{f.setSelectionRange(Math.min(k.pos,f.value.length),Math.min(k.end,f.value.length))}catch(e){}}} | |||
function restoreFrom(s){const k=focusKey();state=JSON.parse(s);titleEl.value=state.title;renderCanvas();updateOutput();save();refocus(k);histButtons()} | |||
function undo(){acHide();if(hist.timer)record();if(hist.i>0){hist.i--;restoreFrom(hist.stack[hist.i])}} | |||
function redo(){acHide();if(hist.timer)record();if(hist.i<hist.stack.length-1){hist.i++;restoreFrom(hist.stack[hist.i])}} | |||
function rerenderCard(id){const c=cardOf(id),b=state.blocks.find(x=>x.id===id);if(!c||!b)return;const k=focusKey(); | |||
const t=document.createElement('div');t.innerHTML=cardHtml(b);const n=t.firstElementChild;c.replaceWith(n);if(k&&k.id===id)refocus(k);fillThumbs(n);applyFold()} | |||
function fillThumbs(card){if(!card||!hooks.thumb)return;const box=card.querySelector('[data-thumbs]');if(!box)return; | |||
const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b)return; | |||
const files=(b.type==='image'?[b.file]:lines(b.items).map(l=>l.split('|')[0])).map(f=>stripFile(f).trim()).filter(Boolean).slice(0,12); | |||
box.innerHTML=files.map(f=>`<figure class="tb" data-file="${esc(f)}"><span class="wait">Loading…</span><figcaption>${esc(f)}</figcaption></figure>`).join(''); | |||
box.querySelectorAll('.tb').forEach(fig=>{hooks.thumb(fig.dataset.file).then(r=>{const slot=fig.firstElementChild;if(!slot||slot.tagName==='FIGCAPTION')return; | |||
if(r&&r.url){const img=document.createElement('img');img.src=r.url;img.alt='';img.loading='lazy';slot.replaceWith(img)} | |||
else{slot.className='miss';slot.textContent='Not on this wiki'}}, | |||
()=>{const slot=fig.firstElementChild;if(slot&&slot.tagName!=='FIGCAPTION'){slot.className='miss';slot.textContent='Couldn’t load'}})})} | |||
const smooth=()=>window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'; | |||
function renderOutline(){const el=$('#pb-outline');if(!el)return; | |||
const hs=state.blocks.filter(b=>b.type==='heading'||(b.type==='refs'&&b.heading)); | |||
el.innerHTML=hs.length?hs.map(b=>{const t=b.type==='refs'?'References':(b.text.trim()||'Untitled section');const l=b.type==='refs'?2:Math.min(5,Math.max(2,+b.level||2)); | |||
return `<li class="l${l}"><button type="button" data-goto="${b.id}" title="${esc(t)}">${esc(t)}</button></li>`}).join(''):'<li><p class="outline-empty">Headings you add show up here.</p></li>'} | |||
function gotoBlock(id){revealBlock(id);const c=cardOf(id);if(!c)return; | |||
if(collapsed.has(id)){collapsed.delete(id);c.classList.remove('collapsed');const t=c.querySelector('[data-act="toggle"]');if(t)t.setAttribute('aria-expanded','true')} | |||
if(c.scrollIntoView)c.scrollIntoView({block:'start',behavior:smooth()});flash(id); | |||
const f=c.querySelector('.block-body [data-path], .block-body [data-tdkey]');if(f)f.focus({preventScroll:true})} | |||
const cardOf=id=>blocksEl.querySelector(`.block[data-id="${id}"]`); | 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 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){ | function addBlock(type,idx){ | ||
const b=mk(type);state.blocks.splice(idx,0,b);commit(); | const b=mk(type);state.blocks.splice(idx,0,b);commit();revealBlock(b.id); | ||
const c=cardOf(b.id); | const c=cardOf(b.id); | ||
if(c){c.scrollIntoView({block:'nearest',behavior:'smooth'});flash(b.id); | if(c){c.scrollIntoView({block:'nearest',behavior:'smooth'});flash(b.id); | ||
| Line 330: | Line 488: | ||
} | } | ||
function setPath(o,path,v){const p=path.split('.');let t=o;for(let i=0;i<p.length-1;i++)t=t[p[i]];t[p[p.length-1]]=v} | function setPath(o,path,v){const p=path.split('.');let t=o;for(let i=0;i<p.length-1;i++){if(t[p[i]]==null)t[p[i]]=/^\d+$/.test(p[i+1])?[]:{};t=t[p[i]]}t[p[p.length-1]]=v} | ||
function onField(e){ | function onField(e){ | ||
| Line 339: | Line 497: | ||
else{const s=card.querySelector('.summary');if(s)s.textContent=String(DEFS[b.type].sum(b)||'').slice(0,80); | else{const s=card.querySelector('.summary');if(s)s.textContent=String(DEFS[b.type].sum(b)||'').slice(0,80); | ||
const tc=card.querySelector('[data-tplcode]');if(tc)tc.textContent=infoboxTemplate(b); | const tc=card.querySelector('[data-tplcode]');if(tc)tc.textContent=infoboxTemplate(b); | ||
const tn=card.querySelector('[data-tplname]');if(tn)tn.textContent='Template:'+(b.template. | const tn=card.querySelector('[data-tplname]');if(tn)tn.textContent='Template:'+(tdKey(b.template)||'Infobox'); | ||
updateOutput();save(); | const sb=card.querySelector('[data-act="savetpl"]');if(sb&&!sb.classList.contains('armed'))sb.textContent='Save to Template:'+(tdKey(b.template)||'Infobox')} | ||
updateOutput();save();recordSoon(); | |||
} | } | ||
blocksEl.addEventListener('input',onField); | blocksEl.addEventListener('input',onField); | ||
blocksEl.addEventListener('input',e=>{const el=e.target;if(!el.dataset||el.dataset.tdkey==null)return; | |||
const card=el.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return; | |||
const k=el.dataset.tdkey,cur=b.params.find(p=>p.k.trim()===k); | |||
if(cur){cur.v=el.value;cur.td=true} | |||
else if(el.value.trim()){const e=tdFor(b.name),order=e&&e.data?e.data.order:[],oi=order.indexOf(k);let at=b.params.length; | |||
if(oi>=0)for(let j=0;j<b.params.length;j++){if(order.indexOf(b.params[j].k.trim())>oi){at=j;break}} | |||
b.params.splice(at,0,{k,v:el.value,td:true});if(at<b.params.length-1)setTimeout(()=>rerenderCard(b.id),0)} | |||
const s=card.querySelector('.summary');if(s)s.textContent=String(DEFS[b.type].sum(b)||'').slice(0,80); | |||
updateOutput();save();recordSoon()}); | |||
blocksEl.addEventListener('change',e=>{const el=e.target;if(!el.dataset||!el.dataset.path)return; | |||
const card=el.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return; | |||
if(hooks.templateData&&((b.type==='template'&&el.dataset.path==='name')||(b.type==='infobox'&&el.dataset.path==='template')))setTimeout(()=>rerenderCard(b.id),0); | |||
else if((b.type==='image'&&el.dataset.path==='file')||(b.type==='gallery'&&el.dataset.path==='items'))fillThumbs(card); | |||
else if(b.type==='infobox'&&b.mode==='dedicated'&&/^rows\.\d+\.k$/.test(el.dataset.path))setTimeout(()=>rerenderCard(b.id),0)}); | |||
blocksEl.addEventListener('change',e=>{if(e.target.matches('select,input[type=checkbox]'))onField(e)}); | blocksEl.addEventListener('change',e=>{if(e.target.matches('select,input[type=checkbox]'))onField(e)}); | ||
| Line 354: | Line 527: | ||
const WRAP={bold:["'''","'''",'bold text'],italic:["''","''",'italic text'],under:['<u>','</u>','underlined text'],strike:['<s>','</s>','struck-out text'], | const WRAP={bold:["'''","'''",'bold text'],italic:["''","''",'italic text'],under:['<u>','</u>','underlined text'],strike:['<s>','</s>','struck-out text'], | ||
sup:['<sup>','</sup>','2'],sub:['<sub>','</sub>','2'],small:['<small>','</small>','small text'],big:['<big>','</big>','big text'],code:['<code>','</code>','code'], | sup:['<sup>','</sup>','2'],sub:['<sub>','</sub>','2'],small:['<small>','</small>','small text'],big:['<big>','</big>','big text'],code:['<code>','</code>','code'], | ||
link:['[ | link:['[\u005B',']]','Page name'],ext:['[https://example.com ',']','link text'],ref:['<ref>','</ref>','Source details'],tpl:['{\u007B','}}','Template name'],nowiki:['<nowiki>','</nowiki>','text']}; | ||
function applyFmt(card,kind,arg){ | function applyFmt(card,kind,arg){ | ||
if(kind==='ref'){openCite(card);return} | |||
const f=fieldFor(card);if(!f)return; | const f=fieldFor(card);if(!f)return; | ||
const v=f.value,s=f.selectionStart==null?v.length:f.selectionStart,e=f.selectionEnd==null?v.length:f.selectionEnd,sel=v.slice(s,e); | const v=f.value,s=f.selectionStart==null?v.length:f.selectionStart,e=f.selectionEnd==null?v.length:f.selectionEnd,sel=v.slice(s,e); | ||
| Line 386: | Line 560: | ||
e.preventDefault();lastField=f;applyFmt(card,k); | e.preventDefault();lastField=f;applyFmt(card,k); | ||
}); | }); | ||
const AC_KIND={image:{file:'file'},infobox:{template:'template',image:'file'},template:{name:'template'},hatnote:{target:'page'},cattree:{cat:'category'},gallery:{items:'fileline'},categories:{items:'catline'}}; | |||
const acEl=$('#pb-acList');let ac=null,acSeq=0,acTimer=null,acSkip=false; | |||
function acContext(f){ | |||
const card=f.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return null; | |||
const v=f.value,c=f.selectionStart;if(c==null||c!==f.selectionEnd)return null; | |||
const before=v.slice(0,c),rest=v.slice(c); | |||
const kind=f.dataset.ac||(AC_KIND[b.type]||{})[f.dataset.path]; | |||
if(kind==='file'||kind==='template'||kind==='page'||kind==='category') | |||
return {kind,q:before.replace(/^\s*(File|Image|Category|Template)\s*:/i,'').trim(),from:0,to:v.length,after:''}; | |||
if(kind==='fileline'||kind==='catline'){ | |||
const ls=before.lastIndexOf('\n')+1,lb=before.slice(ls);if(kind==='fileline'&&lb.includes('|'))return null; | |||
const le=v.indexOf('\n',c),end=le<0?v.length:le;let to=end; | |||
if(kind==='fileline'){const pi=v.slice(ls,end).indexOf('|');if(pi>=0)to=ls+pi} | |||
return {kind:kind==='fileline'?'file':'category',q:lb.replace(/^\s*(File|Image|Category)\s*:/i,'').trim(),from:ls,to,after:''}} | |||
let m=before.match(/\[\[([^\[\]|\n{}]*)$/); | |||
if(m){let q=m[1],k='page',skip=0;const ns=q.match(/^\s*:?\s*(File|Image|Media|Category)\s*:\s*/i); | |||
if(ns){k=/^c/i.test(ns[1])?'category':'file';skip=ns[0].length} | |||
const tail=rest.match(/^[^\[\]\n|#]*/)[0]; | |||
return {kind:k,q:q.slice(skip).trim(),from:c-m[1].length+skip,to:c+tail.length,after:/^[^\[\]\n|#]*(\]\]|\||#)/.test(rest)?'':']]'}} | |||
m=before.match(/\{\{([^{}|\n]*)$/); | |||
if(m&&!/[:#]/.test(m[1])){const tail=rest.match(/^[^{}\n|]*/)[0]; | |||
return {kind:'template',q:m[1].trim(),from:c-m[1].length,to:c+tail.length,after:/^[^{}\n|]*(\}\}|\|)/.test(rest)?'':'}}'}} | |||
return null; | |||
} | |||
function acHide(){if(acEl)acEl.hidden=true;if(ac&&ac.f){ac.f.removeAttribute('aria-activedescendant');ac.f.setAttribute('aria-expanded','false')}ac=null} | |||
function acQuery(f){ | |||
if(!hooks.search||!acEl)return; | |||
const ctx=acContext(f);if(!ctx||!ctx.q){acHide();return} | |||
const my=++acSeq;clearTimeout(acTimer); | |||
acTimer=setTimeout(()=>{hooks.search(ctx.kind,ctx.q).then(items=>{ | |||
if(my!==acSeq||document.activeElement!==f)return; | |||
items=(items||[]).slice(0,8);if(!items.length){acHide();return} | |||
acShow(f,ctx,items)},()=>{})},180); | |||
} | |||
function acShow(f,ctx,items){ | |||
ac={f,ctx,items,i:0}; | |||
acEl.innerHTML=items.map((t,i)=>`<div class="ac-opt${i?'':' on'}" role="option" id="${acEl.id}-o${i}" data-i="${i}" aria-selected="${i===0}">${esc(t)}</div>`).join(''); | |||
acEl.hidden=false; | |||
const r=f.getBoundingClientRect(),w=Math.max(220,Math.min(420,r.width)); | |||
acEl.style.width=w+'px';acEl.style.left=Math.max(8,Math.min(r.left,window.innerWidth-w-8))+'px'; | |||
const h=acEl.offsetHeight;acEl.style.top=(r.bottom+2+h>window.innerHeight&&r.top-h-2>0?r.top-h-2:r.bottom+2)+'px'; | |||
f.setAttribute('aria-expanded','true');f.setAttribute('aria-controls',acEl.id);f.setAttribute('aria-autocomplete','list');f.setAttribute('aria-activedescendant',acEl.id+'-o0'); | |||
} | |||
function acMove(d){if(!ac)return;ac.i=(ac.i+d+ac.items.length)%ac.items.length; | |||
acEl.querySelectorAll('.ac-opt').forEach((o,i)=>{o.classList.toggle('on',i===ac.i);o.setAttribute('aria-selected',String(i===ac.i))}); | |||
const on=acEl.querySelector('.ac-opt.on');if(on){if(on.scrollIntoView)on.scrollIntoView({block:'nearest'});ac.f.setAttribute('aria-activedescendant',on.id)}} | |||
function acAccept(i){if(!ac)return;const f=ac.f,ctx=ac.ctx,t=ac.items[i==null?ac.i:i];acHide(); | |||
const v=f.value,ins=t+ctx.after;f.value=v.slice(0,ctx.from)+ins+v.slice(ctx.to);const pos=ctx.from+ins.length; | |||
f.focus();try{f.setSelectionRange(pos,pos)}catch(e){} | |||
acSkip=true;f.dispatchEvent(new Event('input',{bubbles:true}));f.dispatchEvent(new Event('change',{bubbles:true}));acSkip=false} | |||
blocksEl.addEventListener('input',e=>{if(acSkip)return;const f=e.target;if(f.matches&&(f.matches(FIELD_SEL)||f.matches('[data-tdkey]')))acQuery(f)}); | |||
blocksEl.addEventListener('keydown',e=>{if(!ac||e.target!==ac.f)return; | |||
if(e.key==='ArrowDown'){e.preventDefault();acMove(1)} | |||
else if(e.key==='ArrowUp'){e.preventDefault();acMove(-1)} | |||
else if(e.key==='Enter'||e.key==='Tab'){e.preventDefault();acAccept()} | |||
else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();acHide()}},true); | |||
blocksEl.addEventListener('focusout',()=>setTimeout(()=>{if(ac&&document.activeElement!==ac.f)acHide()},120)); | |||
if(acEl){acEl.addEventListener('mousedown',e=>e.preventDefault()); | |||
acEl.addEventListener('click',e=>{const o=e.target.closest('.ac-opt');if(o)acAccept(+o.dataset.i)})} | |||
document.addEventListener('scroll',e=>{if(ac&&e.target!==acEl)acHide()},true); | |||
window.addEventListener('resize',()=>acHide()); | |||
let checkTimer=null,checkSeq=0;const existCache=new Map(); | |||
function scheduleChecks(){clearTimeout(checkTimer);checkTimer=setTimeout(runChecks,700)} | |||
function extractTitles(text){ | |||
const t=text.replace(/<nowiki>[\s\S]*?<\/nowiki>/gi,'').replace(/<!--[\s\S]*?-->/g,'').replace(/\{\{\{[^{}]*\}\}\}/g,''); | |||
const pages=new Set(),files=new Set(),cats=new Set(),tpls=new Set(); | |||
t.replace(/\[\[\s*:?\s*([^\[\]|#\n{}]+?)\s*(?=\||#|\]\])/g,(m,name)=>{const n=name.trim();if(!n)return m; | |||
const ns=n.match(/^(File|Image|Media|Category)\s*:\s*(.+)$/i); | |||
if(ns)(/^c/i.test(ns[1])?cats:files).add(ns[2].trim());else pages.add(n);return m}); | |||
t.replace(/\{\{\s*([^{}|\n]+?)\s*(?=\||\}\})/g,(m,name)=>{const n=name.trim();if(n&&!/[:#]/.test(n)&&!/^[A-Z0-9 _!]+$/.test(n))tpls.add(n);return m}); | |||
return {pages:[...pages],files:[...files].map(f=>'File:'+f),cats:[...cats].map(c=>'Category:'+c),tpls:[...tpls].map(x=>'Template:'+x)}; | |||
} | |||
function localIssues(text){ | |||
const out=[]; | |||
const listed=/<references\b|\{\{\s*reflist/i.test(text); | |||
const refs=/<ref\b/i.test(text.replace(/<references\b[^>]*>/gi,'')); | |||
if(refs&&!listed)out.push({warn:true,msg:'This page has references but nowhere to list them. Add a References block.'}); | |||
if(listed&&!refs){const r=state.blocks.find(b=>b.type==='refs');out.push({msg:'There’s a References block, but no references on the page yet.',id:r&&r.id})} | |||
let last=1; | |||
state.blocks.forEach(b=>{ | |||
if(b.type==='heading'){const l=+b.level||2; | |||
if(!b.text.trim())out.push({warn:true,msg:'A heading has no text.',id:b.id}); | |||
else if(l>last+1)out.push({msg:`“${b.text.trim()}” skips a heading level. The contents box and screen readers work best when levels go one step at a time.`,id:b.id}); | |||
last=l} | |||
if(b.type==='refs'&&b.heading)last=2; | |||
if(b.type==='image'&&!stripFile(b.file).trim())out.push({warn:true,msg:'An image block has no file name.',id:b.id}); | |||
if(b.type==='template'&&!b.name.trim())out.push({warn:true,msg:'A template block has no template name.',id:b.id}); | |||
if(b.type==='paragraph'&&!b.text.trim())out.push({msg:'A paragraph block is empty, so it’s left out of the page.',id:b.id}); | |||
}); | |||
return out; | |||
} | |||
function renderChecks(issues,note,pending){ | |||
const el=$('#pb-checks');if(!el)return; | |||
issues.sort((a,b)=>(b.warn?1:0)-(a.warn?1:0)); | |||
const n=issues.filter(x=>x.warn).length;const tab=$('#pb-tab-chk');if(tab)tab.textContent=n?`Checks (${n})`:'Checks'; | |||
const link=t=>hooks.url?`<a href="${esc(hooks.url(t))}" target="_blank" rel="noopener">${esc(t)}</a>`:esc(t); | |||
el.innerHTML=(issues.length?`<ul class="issues">${issues.map(x=>`<li class="issue${x.warn?' warn':''}"><p>${esc(x.msg)}</p>${x.titles?`<p class="titles">${x.titles.map(link).join(', ')}</p>`:''}${x.id?`<button type="button" class="linkbtn" data-goto="${x.id}">Show the block</button>`:''}</li>`).join('')}</ul>`:(pending?'':'<p class="checks-ok">No problems found.</p>')) | |||
+(pending?'<p class="checks-ok">Checking links against your wiki…</p>':'')+(note?`<p class="checks-ok">${esc(note)}</p>`:''); | |||
} | |||
function runChecks(){ | |||
const text=gen(),issues=localIssues(text),my=++checkSeq; | |||
if(!hooks.exists){renderChecks(issues,'Red links, missing images and missing templates are checked when the builder runs on your wiki.');return} | |||
const T=extractTitles(text); | |||
const all=[].concat(T.pages,T.files,T.cats,T.tpls); | |||
const need=all.filter(t=>!existCache.has(t)).slice(0,500); | |||
const finish=()=>{if(my!==checkSeq)return;const miss=a=>a.filter(t=>existCache.get(t)===false); | |||
const mf=miss(T.files),mt=miss(T.tpls),mp=miss(T.pages),mc=miss(T.cats); | |||
if(mf.length)issues.push({warn:true,msg:mf.length===1?'An image that isn’t uploaded to this wiki:':'Images that aren’t uploaded to this wiki:',titles:mf}); | |||
if(mt.length)issues.push({warn:true,msg:mt.length===1?'A template that doesn’t exist on this wiki yet:':'Templates that don’t exist on this wiki yet:',titles:mt}); | |||
if(mp.length)issues.push({msg:'Links to pages that don’t exist yet (red links):',titles:mp}); | |||
if(mc.length)issues.push({msg:'Category pages that don’t exist yet. The page is still filed under them:',titles:mc}); | |||
renderChecks(issues)}; | |||
if(!need.length){finish();return} | |||
renderChecks(issues.slice(),null,true); | |||
hooks.exists(need).then(map=>{need.forEach(t=>existCache.set(t,!map||map[t]!==false));finish()}, | |||
()=>{if(my===checkSeq)renderChecks(issues,'Couldn’t check links against your wiki just now.')}); | |||
} | |||
let toastTimer=null,undoFn=null; | let toastTimer=null,undoFn=null; | ||
| Line 411: | Line 704: | ||
case 'copytpl':copyText(infoboxTemplate(b)).then(ok=>{btn.textContent=ok?'Copied':'Select the code above to copy it';setTimeout(()=>btn.textContent='Copy template code',1800)});return; | case 'copytpl':copyText(infoboxTemplate(b)).then(ok=>{btn.textContent=ok?'Copied':'Select the code above to copy it';setTimeout(()=>btn.textContent='Copy template code',1800)});return; | ||
case 'addrow':b.rows.push({k:'',v:''});commit();refocus(`[data-path="rows.${b.rows.length-1}.k"]`);return; | case 'addrow':b.rows.push({k:'',v:''});commit();refocus(`[data-path="rows.${b.rows.length-1}.k"]`);return; | ||
case 'secup':case 'secdown':if(moveSection(i,act==='secup'?-1:1)){commit();flash(id);refocus(`[data-act="${act}"]`)}return; | |||
case 'fold':folded.has(id)?folded.delete(id):folded.add(id);rerenderCard(id);refocus('[data-act="fold"]');return; | |||
case 'savetpl':saveTemplatePage(b,btn);return; | |||
case 'trowup':{const r=+btn.dataset.i;if(r>0){[b.cells[r-1],b.cells[r]]=[b.cells[r],b.cells[r-1]];commit();refocus(`[data-act="trowup"][data-i="${r-1}"]`)}return} | |||
case 'trowdown':{const r=+btn.dataset.i;if(r<b.cells.length-1){[b.cells[r+1],b.cells[r]]=[b.cells[r],b.cells[r+1]];commit();refocus(`[data-act="trowdown"][data-i="${r+1}"]`)}return} | |||
case 'trowdel':if(b.cells.length>1){b.cells.splice(+btn.dataset.i,1);commit()}return; | |||
case 'tdfill':{const m=tdMissing(b);if(m.length){b.rows=b.rows.filter(r=>r.k.trim()||r.v.trim());m.forEach(k=>b.rows.push({k,v:''}));commit()}return} | |||
case 'delrow':b.rows.splice(+btn.dataset.i,1);if(!b.rows.length)b.rows.push({k:'',v:''});commit();return; | case 'delrow':b.rows.splice(+btn.dataset.i,1);if(!b.rows.length)b.rows.push({k:'',v:''});commit();return; | ||
case 'addparam':b.params.push({k:'',v:''});commit();refocus(`[data-path="params.${b.params.length-1}.k"]`);return; | case 'addparam':b.params.push({k:'',v:''});commit();refocus(`[data-path="params.${b.params.length-1}.k"]`);return; | ||
| Line 417: | Line 717: | ||
case 'addcol':b.cells.forEach(r=>r.push(''));commit();refocus(`[data-path="cells.0.${b.cells[0].length-1}"]`);return; | case 'addcol':b.cells.forEach(r=>r.push(''));commit();refocus(`[data-path="cells.0.${b.cells[0].length-1}"]`);return; | ||
case 'deltrow':if(b.cells.length>1){b.cells.pop();commit()}return; | case 'deltrow':if(b.cells.length>1){b.cells.pop();commit()}return; | ||
case 'delcol':if(b.cells[0].length>1){b.cells.forEach(r=>r.pop());commit()}return; | case 'delcol':if(b.cells[0].length>1){b.cells.forEach(r=>r.pop());if(b.align&&b.align.length>=b.cells[0].length+1)b.align.length=b.cells[0].length;commit()}return; | ||
} | } | ||
}); | }); | ||
| Line 466: | Line 766: | ||
function splitTop(s){const out=[];let cur='',d=0; | function splitTop(s){const out=[];let cur='',d=0; | ||
for(let i=0;i<s.length;){const two=s.substr(i,2); | for(let i=0;i<s.length;){const two=s.substr(i,2); | ||
if(two==='{ | if(two==='{\u007B'||two==='[\u005B'){d++;cur+=two;i+=2} | ||
else if((two==='}}'||two===']]')&&d>0){d--;cur+=two;i+=2} | else if((two==='}}'||two===']]')&&d>0){d--;cur+=two;i+=2} | ||
else if(s[i]==='|'&&d===0){out.push(cur);cur='';i++} | else if(s[i]==='|'&&d===0){out.push(cur);cur='';i++} | ||
| Line 472: | Line 772: | ||
out.push(cur);return out} | out.push(cur);return out} | ||
function topEq(s){let d=0;for(let i=0;i<s.length;i++){const two=s.substr(i,2); | function topEq(s){let d=0;for(let i=0;i<s.length;i++){const two=s.substr(i,2); | ||
if(two==='{ | if(two==='{\u007B'||two==='[\u005B'){d++;i++}else if((two==='}}'||two===']]')&&d>0){d--;i++}else if(s[i]==='='&&d===0)return i}return -1} | ||
function matchPair(s,start,open,close){let d=0;for(let i=start;i<s.length;){ | function matchPair(s,start,open,close){let d=0;for(let i=start;i<s.length;){ | ||
if(s.startsWith(open,i)){d++;i+=2}else if(s.startsWith(close,i)){d--;i+=2;if(d===0)return i}else i++}return -1} | if(s.startsWith(open,i)){d++;i+=2}else if(s.startsWith(close,i)){d--;i+=2;if(d===0)return i}else i++}return -1} | ||
| Line 528: | Line 828: | ||
if(l.startsWith('|-')){if(l.slice(2).trim())return null;cur=null;continue} | if(l.startsWith('|-')){if(l.slice(2).trim())return null;cur=null;continue} | ||
if(l[0]==='!'||l[0]==='|'){const h=l[0]==='!';const cells=l.slice(1).split(h?/!!|\|\|/:/\|\|/); | if(l[0]==='!'||l[0]==='|'){const h=l[0]==='!';const cells=l.slice(1).split(h?/!!|\|\|/:/\|\|/); | ||
if(!cur){cur={c:[],h:[]};rows.push(cur)} | if(!cur){cur={c:[],h:[],a:[]};rows.push(cur)} | ||
for(const c of cells){ | for(const c of cells){const parts=splitTop(c);if(parts.length>2)return null; | ||
if(parts.length===2){const am=parts[0].trim().match(/^style\s*=\s*"\s*text-align\s*:\s*(left|center|right)\s*;?\s*"$/i);if(!am||h)return null;cur.c.push(parts[1].trim());cur.a.push(am[1].toLowerCase()==='left'?'':am[1].toLowerCase())} | |||
else{cur.c.push(c.trim());cur.a.push('')} | |||
cur.h.push(h)}continue} | |||
return null} | return null} | ||
if(!rows.length)return null; | if(!rows.length)return null; | ||
| Line 535: | Line 838: | ||
if(!header&&rows[0].h.some(Boolean))return null; | if(!header&&rows[0].h.some(Boolean))return null; | ||
if(rows.slice(1).some(r=>r.h.some(Boolean)))return null; | if(rows.slice(1).some(r=>r.h.some(Boolean)))return null; | ||
const w=Math.max(...rows.map(r=>r.c.length)); | const w=Math.max(...rows.map(r=>r.c.length));const align=[]; | ||
return {caption,header,sortable:cls.includes('sortable'),cells:rows.map(r=>r.c.concat(Array(w-r.c.length).fill('')))}; | for(let j=0;j<w;j++){const vals=new Set(rows.slice(header?1:0).map(r=>r.a[j]||''));if(vals.size>1)return null;align.push(vals.size?[...vals][0]:'')} | ||
return {caption,header,sortable:cls.includes('sortable'),cells:rows.map(r=>r.c.concat(Array(w-r.c.length).fill(''))),align}; | |||
} | } | ||
function tagBlock(tag,a,inner,attrText){ | function tagBlock(tag,a,inner,attrText){ | ||
const keys=Object.keys(a);const trimNl=s=>s.replace(/^\n/,'').replace(/\n\s*$/,''); | const keys=Object.keys(a);const trimNl=s=>s.replace(/^\n/,'').replace(/\n\s*$/,''); | ||
switch(tag){ | switch(tag){ | ||
case 'gallery':return | case 'gallery':{if(keys.some(k=>!['mode','heights','caption'].includes(k)))return null;const mode=a.mode&&a.mode!==true?String(a.mode).toLowerCase():''; | ||
if(mode&&!GALLERY_MODES.some(x=>x[0]===mode))return null; | |||
return ['gallery',{items:lines(inner).map(l=>l.trim()).join('\n'),mode,heights:a.heights&&a.heights!==true?String(a.heights):'',caption:a.caption&&a.caption!==true?String(a.caption).replace(/"/g,'"'):''}]} | |||
case 'syntaxhighlight':case 'source': | case 'syntaxhighlight':case 'source': | ||
if(!a.lang||a.lang===true||keys.some(k=>k!=='lang'&&k!=='line'))return null; | if(!a.lang||a.lang===true||keys.some(k=>k!=='lang'&&k!=='line'))return null; | ||
| Line 569: | Line 875: | ||
while((m=re.exec(r))){if(m[1])d--;else if(!m[3])d++;if(d<=0)return re.lastIndex}return -1}; | while((m=re.exec(r))){if(m[1])d--;else if(!m[3])d++;if(d<=0)return re.lastIndex}return -1}; | ||
const standalone=k=>{const l=L[k];const r=L.slice(k).join('\n'); | const standalone=k=>{const l=L[k];const r=L.slice(k).join('\n'); | ||
if(l.startsWith('{ | if(l.startsWith('{\u007B'))return span(r,matchPair(r,0,'{\u007B','}}'))>0; | ||
if(/^\[\[\s*(File|Image)\s*:/i.test(l))return span(r,matchPair(r,0,'[ | if(/^\[\[\s*(File|Image)\s*:/i.test(l))return span(r,matchPair(r,0,'[\u005B',']]'))>0; | ||
return false}; | return false}; | ||
const blockTagRe=new RegExp(`^<(${BLOCK_TAGS})\\b`,'i'); | const blockTagRe=new RegExp(`^<(${BLOCK_TAGS})\\b`,'i'); | ||
| Line 576: | Line 882: | ||
const catLine=/^(\[\[\s*Category\s*:[^\]]*\]\]\s*)+$/i; | const catLine=/^(\[\[\s*Category\s*:[^\]]*\]\]\s*)+$/i; | ||
function step(){ | |||
const line=L[i],t=line.trim();let m,n,r,e; | const line=L[i],t=line.trim();let m,n,r,e; | ||
if(t.startsWith('<!--')){r=rest();e=r.indexOf('-->');n=e<0?0:span(r,e+3);if(n){raw(take(n));return}} | |||
if(t.startsWith('<!--')){r=rest();e=r.indexOf('-->');n=e<0?0:span(r,e+3);if(n){raw(take(n)); | if(/^[ \t]/.test(line)){let k=i;while(k<L.length&&/^[ \t]/.test(L[k])&&L[k].trim())k++;raw(take(k-i));return} | ||
if(/^[ \t]/.test(line)){let k=i;while(k<L.length&&/^[ \t]/.test(L[k])&&L[k].trim())k++;raw(take(k-i)); | if(/^#redirect/i.test(t)){raw(take(1));return} | ||
if(/^#redirect/i.test(t)){raw(take(1)); | if((m=line.match(/^(={2,5})\s*([^=].*?)\s*\1\s*$/))){add('heading',{text:m[2],level:String(m[1].length)});i++;return} | ||
if((m=line.match(/^(={2,5})\s*([^=].*?)\s*\1\s*$/))){add('heading',{text:m[2],level:String(m[1].length)});i++; | if(/^__(NO|FORCE)?TOC__$/.test(t)){add('toc',{mode:t});i++;return} | ||
if(/^__(NO|FORCE)?TOC__$/.test(t)){add('toc',{mode:t});i++; | if(/^-{4,}$/.test(t)){add('rule');i++;return} | ||
if(/^-{4,}$/.test(t)){add('rule');i++; | |||
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++} | 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')}); | add('categories',{items:names.join('\n')});return} | ||
if((m=t.match(/^:\s*''\s*(Main article|See also|Further information)\s*:\s*\[\[([^\]|]+)\]\]\s*''$/))){ | 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++; | add('hatnote',{kind:{'Main article':'Main','See also':'See also','Further information':'Further'}[m[1]],target:m[2].trim(),markup:'plain'});i++;return} | ||
if(/^[*#:;]/.test(line)){const c0=line[0],grp=c0==='*'||c0==='#'?x=>x[0]===c0:x=>x[0]===':'||x[0]===';';let k=i;while(k<L.length&&/^[*#:;]/.test(L[k])&&grp(L[k]))k++;const ls=L.slice(i,k); | 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]; | const marks=ls.map(l=>l.match(/^[*#:;]+/)[0]);const ch=marks[0][0]; | ||
| Line 594: | Line 899: | ||
add(ch==='*'?'bullets':'numbers',{items:ls.map(l=>{const mm=l.match(/^([*#]+)\s*(.*)$/);return '*'.repeat(mm[1].length-1)+mm[2]}).join('\n')})} | 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)); | else raw(take(k-i)); | ||
return} | |||
if(line.startsWith('{|')){let d=0,k=i;for(;k<L.length;k++){const l=L[k].trim();if(l.startsWith('{|'))d++;else if(l.startsWith('|}')){d--;if(d===0)break}} | if(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)); | if(k>=L.length){raw(take(L.length-i));return} | ||
const ls=L.slice(i,k+1);const p=parseTable(ls);i=k+1;if(p)add('table',p);else raw(ls.join('\n')); | const ls=L.slice(i,k+1);const p=parseTable(ls);i=k+1;if(p)add('table',p);else raw(ls.join('\n'));return} | ||
if((m=line.match(/^<(gallery|syntaxhighlight|source|pre|blockquote|categorytree|references)\b([^>]*)>/i))){ | 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; | 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} | 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); | 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); | if(n){const inner=r.slice(m[0].length,innerEnd);const whole=take(n);const blk=tagBlock(tag,attrsOf(attr),inner,attr.trim());if(blk)add(blk[0],blk[1]);else raw(whole);return} | ||
} | } | ||
if((m=line.match(blockTagRe))){r=rest();e=tagSpan(r,m[1]);n=span(r,e);if(n){raw(take(n)); | if(/^<div class="(pb-note |mw-collapsible)/.test(line)){r=rest();e=tagSpan(r,'div');n=span(r,e); | ||
if(line.startsWith('{ | if(n){const blk=divBlock(r.slice(0,e));if(blk){i+=n;add(blk[0],blk[1]);return}}} | ||
if(n){const whole=r.slice(0,e);i+=n;const blk=templateBlock(whole.slice(2,-2));if(blk)add(blk[0],blk[1]);else raw(whole); | if((m=line.match(blockTagRe))){r=rest();e=tagSpan(r,m[1]);n=span(r,e);if(n){raw(take(n));return}if(e<0){raw(take(1));return}} | ||
if(/^\[\[\s*(File|Image)\s*:/i.test(line)){r=rest();e=matchPair(r,0,'[ | if(line.startsWith('{\u007B')){r=rest();e=matchPair(r,0,'{\u007B','}}');n=span(r,e); | ||
if(n){const whole=r.slice(0,e);i+=n;const p=imageBlock(whole.slice(2,-2));if(p)add('image',p);else raw(whole); | if(n){const whole=r.slice(0,e);i+=n;const blk=templateBlock(whole.slice(2,-2));if(blk)add(blk[0],blk[1]);else raw(whole);return}} | ||
if(/^\[\[\s*(File|Image)\s*:/i.test(line)){r=rest();e=matchPair(r,0,'[\u005B',']]');n=span(r,e); | |||
if(n){const whole=r.slice(0,e);i+=n;const p=imageBlock(whole.slice(2,-2));if(p)add('image',p);else raw(whole);return}} | |||
const start=i;i++; | const start=i;i++; | ||
while(i<L.length){if(isBalanced(L.slice(start,i).join('\n'))&&(!L[i].trim()||startsBlock(i)))break;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()}); | add('paragraph',{text:L.slice(start,i).join('\n').trim()}); | ||
} | |||
while(i<L.length){ | |||
if(!L[i].trim()){i++;continue} | |||
const s0=i,n0=out.length;step(); | |||
for(let k=n0;k<out.length;k++){out[k]._s=s0;out[k]._e=i} | |||
} | } | ||
const res=[]; | const res=[]; | ||
for(const b of out){const prev=res[res.length-1]; | for(const b of out){const prev=res[res.length-1]; | ||
if(prev&&b.type==='raw'&&prev.type==='raw'){prev. | if(prev&&b.type==='raw'&&prev.type==='raw'){prev._e=b._e;continue} | ||
if(prev&&b.type==='categories'&&prev.type==='categories'){prev.items+='\n'+b.items;continue} | if(prev&&b.type==='categories'&&prev.type==='categories'){prev.items+='\n'+b.items;prev._e=b._e;continue} | ||
if(prev&&b.type==='refs'&&!b.heading&&prev.type==='heading'&&prev.level==='2'&&prev.text.trim()==='References'){res.pop();b.heading=true} | if(prev&&b.type==='refs'&&!b.heading&&prev.type==='heading'&&prev.level==='2'&&prev.text.trim()==='References'){res.pop();b.heading=true;b._s=prev._s} | ||
res.push(b)} | res.push(b)} | ||
let before=null; | |||
res.forEach(b=>{ | |||
b.src=L.slice(b._s,b._e).join('\n'); | |||
if(b.type==='raw')b.text=b.src; | |||
try{b._g=DEFS[b.type].wiki(b)}catch(e){b._g=null} | |||
if(before){b.sep='\n'+L.slice(before._e,b._s).map(x=>x+'\n').join('');b.prevId=before.id} | |||
before=b; | |||
}); | |||
res.forEach(b=>{delete b._s;delete b._e}); | |||
return res; | return res; | ||
} | } | ||
const titleEl=$('#pb-pageTitle'); | const titleEl=$('#pb-pageTitle'); | ||
titleEl.addEventListener('input',()=>{state.title=titleEl.value;renderPreview();save()}); | titleEl.addEventListener('input',()=>{state.title=titleEl.value;renderPreview();save();recordSoon()}); | ||
$('#pb-undoBtn').addEventListener('click',undo); | |||
$('#pb-redoBtn').addEventListener('click',redo); | |||
$('.app').addEventListener('keydown',e=>{if(!(e.ctrlKey||e.metaKey)||e.altKey)return;const k=e.key.toLowerCase(); | |||
if(k==='z'&&!e.shiftKey){e.preventDefault();undo()} | |||
else if((k==='z'&&e.shiftKey)||k==='y'){e.preventDefault();redo()}}); | |||
$('#pb-outline').addEventListener('click',e=>{const b=e.target.closest('[data-goto]');if(b)gotoBlock(b.dataset.goto)}); | |||
$('#pb-checks').addEventListener('click',e=>{const b=e.target.closest('[data-goto]');if(b)gotoBlock(b.dataset.goto)}); | |||
$('#pb-collapseAll').addEventListener('click',()=>{state.blocks.forEach(b=>collapsed.add(b.id));renderCanvas()}); | |||
$('#pb-expandAll').addEventListener('click',()=>{collapsed.clear();renderCanvas()}); | |||
function showUndoReplace(prev,msg){ | function showUndoReplace(prev,msg){ | ||
showToast(msg,()=>{state=prev;titleEl.value=state.title;commit()}); | showToast(msg,()=>{state=prev;titleEl.value=state.title;commit()}); | ||
| Line 641: | Line 971: | ||
}); | }); | ||
const tabs=[[$('#pb-tab-code'),$('#pb-panel-code')],[$('#pb-tab-prev'),$('#pb-panel-prev')]]; | const tabs=[[$('#pb-tab-code'),$('#pb-panel-code')],[$('#pb-tab-prev'),$('#pb-panel-prev')],[$('#pb-tab-chk'),$('#pb-panel-chk')]]; | ||
tabs.forEach(([t])=>t.addEventListener('click',()=>tabs.forEach(([x,p])=>{const on=x===t;x.setAttribute('aria-selected',String(on));p.hidden=!on}))); | tabs.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=>{ | $('.tabs').addEventListener('keydown',e=>{ | ||
if(e.key!=='ArrowRight'&&e.key!=='ArrowLeft')return; | if(e.key!=='ArrowRight'&&e.key!=='ArrowLeft')return; | ||
const i=tabs.findIndex(([t])=>t.getAttribute('aria-selected')==='true');const n=tabs[(i+1)% | const i=tabs.findIndex(([t])=>t.getAttribute('aria-selected')==='true');const n=tabs[(i+(e.key==='ArrowRight'?1:tabs.length-1))%tabs.length][0];n.click();n.focus(); | ||
}); | }); | ||
| Line 654: | Line 984: | ||
return Promise.resolve(fallback()); | return Promise.resolve(fallback()); | ||
} | } | ||
$('#pb-tab-chk').addEventListener('click',runChecks); | |||
const copyBtn=$('#pb-copyBtn'); | const copyBtn=$('#pb-copyBtn'); | ||
function copied(label){copyBtn.textContent=label;setTimeout(()=>copyBtn.textContent='Copy wikitext',1800)} | function copied(label){copyBtn.textContent=label;setTimeout(()=>copyBtn.textContent='Copy wikitext',1800)} | ||
| Line 686: | Line 1,017: | ||
const first=cardOf(blocks[0].id);if(first&&first.scrollIntoView)first.scrollIntoView({block:'nearest'}); | const first=cardOf(blocks[0].id);if(first&&first.scrollIntoView)first.scrollIntoView({block:'nearest'}); | ||
}); | }); | ||
const showDlg=d=>{try{d.showModal()}catch(e){d.setAttribute('open','')}}; | |||
const hideDlg=d=>{try{d.close()}catch(e){d.removeAttribute('open')}}; | |||
let citeTarget=null; | |||
function citeKindChanged(){const k=$('#pb-citeKind').value;$('#pb-citeDlg').querySelectorAll('[data-for]').forEach(el=>{el.hidden=!el.dataset.for.split(' ').includes(k)}); | |||
$('#pb-citeWorkLabel').textContent=k==='book'?'Publisher':'Website';$('#pb-citeFmtWrap').hidden=k==='other'} | |||
function openCite(card){const f=fieldFor(card);if(!f)return; | |||
citeTarget={f,pos:f.selectionEnd==null?f.value.length:f.selectionEnd}; | |||
[$('#pb-citeTitle'),$('#pb-citeUrl'),$('#pb-citeAuthor'),$('#pb-citeWork'),$('#pb-citeDate'),$('#pb-citeText'),$('#pb-citeName')].forEach(x=>{x.value=''}); | |||
$('#pb-citeAccess').value=new Date().toISOString().slice(0,10);$('#pb-citeErr').textContent=''; | |||
const names=[];gen().replace(/<ref\s+name\s*=\s*"?([^"\/>]+?)"?\s*>/gi,(m,n)=>{n=n.trim();if(n&&!names.includes(n))names.push(n);return m}); | |||
$('#pb-citeReuse').innerHTML=names.length?`<p class="sub">Use a reference that’s already on the page</p><div class="cite-reuse">${names.map(n=>`<button type="button" class="btn" data-reuse="${esc(n)}">${esc(n)}</button>`).join('')}</div><p class="sub">Or add a new one</p>`:''; | |||
citeKindChanged();showDlg($('#pb-citeDlg'));$('#pb-citeTitle').focus()} | |||
function citeInsert(t){if(!citeTarget)return;const {f,pos}=citeTarget;citeTarget=null;hideDlg($('#pb-citeDlg')); | |||
const v=f.value;f.value=v.slice(0,pos)+t+v.slice(pos);f.focus();try{f.setSelectionRange(pos+t.length,pos+t.length)}catch(e){} | |||
f.dispatchEvent(new Event('input',{bubbles:true}))} | |||
function citeWikitext(){ | |||
const k=$('#pb-citeKind').value,tpl=$('#pb-citeFmt').value==='tpl',g=x=>$(x).value.trim(); | |||
const title=g('#pb-citeTitle'),url=g('#pb-citeUrl'),author=g('#pb-citeAuthor'),work=g('#pb-citeWork'),date=g('#pb-citeDate'),access=g('#pb-citeAccess'),text=g('#pb-citeText'); | |||
let body; | |||
if(k==='other'){if(!text)return {err:'Write the reference text first.'};body=text} | |||
else{ | |||
if(k==='web'&&!title&&!url)return {err:'Add a title or a link.'}; | |||
if(k==='book'&&!title)return {err:'Add the book’s title.'}; | |||
if(tpl){const ps=[['title',title],['url',k==='web'?url:''],['author',author],[k==='web'?'website':'publisher',work],['date',date],['access-date',k==='web'&&url?access:'']].filter(x=>x[1]); | |||
body=`{\u007B${k==='web'?'cite web':'cite book'}${ps.map(([a,v])=>`|${a}=${v}`).join('')}}}`} | |||
else{const parts=[];if(author)parts.push(author.replace(/\.$/,'')+'.'); | |||
if(k==='web')parts.push(url?(title?`"[${url} ${title}]".`:`[${url}].`):`"${title}".`);else parts.push(`''${title}''.`); | |||
if(work)parts.push(k==='web'?`''${work}''.`:`${work}.`);if(date)parts.push(date+'.');if(k==='web'&&url&&access)parts.push(`Retrieved ${access}.`); | |||
body=parts.join(' ')}} | |||
const nm=g('#pb-citeName').replace(/["<>\/]/g,''); | |||
return {text:nm?`<ref name="${nm}">${body}</ref>`:`<ref>${body}</ref>`}; | |||
} | |||
$('#pb-citeKind').addEventListener('change',citeKindChanged); | |||
$('#pb-citeCancel').addEventListener('click',()=>{citeTarget=null;hideDlg($('#pb-citeDlg'))}); | |||
$('#pb-citeGo').addEventListener('click',()=>{const r=citeWikitext();if(r.err){$('#pb-citeErr').textContent=r.err;return}citeInsert(r.text)}); | |||
$('#pb-citeReuse').addEventListener('click',e=>{const b=e.target.closest('[data-reuse]');if(b)citeInsert(`<ref name="${b.dataset.reuse}" />`)}); | |||
let upTarget=null,upFiles=[]; | |||
const objUrl=f=>window.URL&&URL.createObjectURL?URL.createObjectURL(f):''; | |||
const cleanName=n=>String(n).replace(/[#<>\[\]|{}:\/\\]+/g,'-').replace(/\s+/g,' ').trim(); | |||
function openUpload(id,files){if(!hooks.upload||!files.length)return; | |||
upTarget=id;upFiles=files.map(f=>({file:f,url:objUrl(f),name:cleanName(f.name),msg:'',bad:false})); | |||
$('#pb-upErr').textContent='';$('#pb-upGo').disabled=false;$('#pb-upGo').textContent=upFiles.length>1?`Upload ${upFiles.length} pictures`:'Upload'; | |||
renderUpList();showDlg($('#pb-upDlg'));const i=$('#pb-upList input');if(i)i.focus()} | |||
function renderUpList(){$('#pb-upList').innerHTML=upFiles.map((u,i)=>`<div class="upitem">${u.url?`<img src="${u.url}" alt="">`:''}<div class="upfields"><label class="field"><span>File name</span><input type="text" data-upi="${i}" value="${esc(u.name)}"${u.done?' disabled':''}></label><p class="upstate${u.bad?' bad':''}">${esc(u.msg)}</p>${u.dup&&!u.done?`<button type="button" class="linkbtn" data-usedup="${i}">Use File:${esc(u.dup)} instead</button>`:''}${u.canForce&&!u.done?`<button type="button" class="linkbtn" data-force="${i}">Upload anyway</button>`:''}</div></div>`).join('')} | |||
function uploadProblem(u,err){const w=(err&&err.warnings)||{};u.bad=true;u.dup=null;u.canForce=false; | |||
if(w.exists||w['exists-normalized']||w['page-exists'])u.msg=`A file called ${u.name} is already on the wiki. Change the name to upload yours.`; | |||
else if(w.duplicate){u.dup=String([].concat(w.duplicate)[0]).replace(/_/g,' ');u.msg='This picture is already on the wiki.'} | |||
else if(w.badfilename){u.name=String(w.badfilename).replace(/_/g,' ');u.msg=`Your wiki needs a different name, so it suggests ${u.name}.`;u.canForce=true} | |||
else if(w['was-deleted']||w['duplicate-archive']){u.msg='A file with this name or content was deleted from the wiki before.';u.canForce=true} | |||
else if(Object.keys(w).length){u.msg='Your wiki warned about this file: '+Object.keys(w).join(', ')+'.';u.canForce=true} | |||
else u.msg=(err&&err.info)||'The upload didn’t work.'} | |||
function runUploads(){const go=$('#pb-upGo');go.disabled=true;go.textContent='Uploading…';const desc=$('#pb-upDesc').value.trim(); | |||
let chain=Promise.resolve(); | |||
upFiles.filter(u=>!u.done).forEach(u=>{chain=chain.then(()=>{u.msg='Uploading…';u.bad=false;renderUpList(); | |||
return Promise.resolve(hooks.upload(u.file,u.name,desc,!!u.force)).then(r=>{u.done=true;u.final=r.filename;u.msg='Uploaded'},err=>uploadProblem(u,err)).then(renderUpList)})}); | |||
chain.then(()=>{go.disabled=false;go.textContent='Try again';if(upFiles.every(u=>u.done))finishUploads()})} | |||
function finishUploads(){const b=state.blocks.find(x=>x.id===upTarget);const names=upFiles.filter(u=>u.done).map(u=>u.final); | |||
hideDlg($('#pb-upDlg'));upFiles.forEach(u=>{if(u.url&&window.URL&&URL.revokeObjectURL)URL.revokeObjectURL(u.url)});upFiles=[]; | |||
if(!b||!names.length)return; | |||
if(b.type==='image')b.file=names[0];else b.items=lines(b.items).concat(names).join('\n'); | |||
commit();revealBlock(b.id);flash(b.id);showToast(names.length===1?`Added ${names[0]}`:`Added ${names.length} pictures`)} | |||
$('#pb-upGo').addEventListener('click',runUploads); | |||
$('#pb-upCancel').addEventListener('click',()=>{if(upFiles.some(u=>u.done))finishUploads();else{hideDlg($('#pb-upDlg'));upFiles=[]}}); | |||
$('#pb-upList').addEventListener('input',e=>{const i=e.target.dataset.upi;if(i!=null&&upFiles[+i])upFiles[+i].name=e.target.value}); | |||
$('#pb-upList').addEventListener('click',e=>{const d=e.target.closest('[data-usedup]'),f=e.target.closest('[data-force]'); | |||
if(d){const u=upFiles[+d.dataset.usedup];u.done=true;u.final=u.dup;u.bad=false;u.msg='Using the file that’s already on the wiki';renderUpList();if(upFiles.every(x=>x.done))finishUploads()} | |||
if(f){upFiles[+f.dataset.force].force=true;runUploads()}}); | |||
blocksEl.addEventListener('change',e=>{const inp=e.target;if(!inp.matches||!inp.matches('input[data-upload]'))return;const card=inp.closest('.block'); | |||
openUpload(card.dataset.id,[...inp.files].filter(f=>/^image\//.test(f.type)).slice(0,20));inp.value=''}); | |||
const hasFiles=e=>!!(e.dataTransfer&&[...(e.dataTransfer.types||[])].includes('Files')); | |||
const clearDropMarks=()=>blocksEl.querySelectorAll('.block.dropfile').forEach(c=>c.classList.remove('dropfile')); | |||
canvasEl.addEventListener('dragover',e=>{if(drag||!hooks.upload||!hasFiles(e))return;e.preventDefault();e.dataTransfer.dropEffect='copy'; | |||
const card=e.target.closest&&e.target.closest('.block');const t=card&&state.blocks.find(b=>b.id===card.dataset.id);clearDropMarks(); | |||
if(t&&(t.type==='image'||t.type==='gallery')){card.classList.add('dropfile');hideLine()}else showLine(targetFor(e.clientY))}); | |||
canvasEl.addEventListener('dragleave',e=>{if(!canvasEl.contains(e.relatedTarget))clearDropMarks()}); | |||
canvasEl.addEventListener('drop',e=>{if(drag||!hooks.upload||!hasFiles(e))return;e.preventDefault();hideLine();clearDropMarks(); | |||
const files=[...e.dataTransfer.files].filter(f=>/^image\//.test(f.type)).slice(0,20);if(!files.length){showToast('Only pictures can be uploaded here.');return} | |||
const card=e.target.closest&&e.target.closest('.block');const t=card&&state.blocks.find(b=>b.id===card.dataset.id); | |||
if(t&&(t.type==='image'||t.type==='gallery')){openUpload(t.id,t.type==='image'?files.slice(0,1):files);return} | |||
const before=targetFor(e.clientY);const idx=before?state.blocks.findIndex(b=>b.id===before):state.blocks.length; | |||
const nb=mk(files.length>1?'gallery':'image',files.length>1?{items:''}:{});state.blocks.splice(idx,0,nb);commit();openUpload(nb.id,files)}); | |||
function saveTemplatePage(b,btn){const name=tdKey(b.template)||'Infobox',label=`Save to Template:${name}`; | |||
const reset=()=>{btn.disabled=false;btn.classList.remove('armed');btn.textContent=label}; | |||
const go=()=>{btn.disabled=true;btn.textContent='Saving…'; | |||
Promise.resolve(hooks.saveTemplate(name,infoboxTemplate(b))).then(()=>{reset();tdCache.delete(name);existCache.set('Template:'+name,true);scheduleChecks();showToast(`Saved Template:${name}`)}, | |||
err=>{reset();showToast(`Couldn’t save the template: ${(err&&err.info)||err||'unknown error'}`)})}; | |||
if(btn.classList.contains('armed')){go();return} | |||
Promise.resolve(hooks.exists(['Template:'+name])).then(m=>{if(m&&m['Template:'+name]===false)go(); | |||
else{btn.classList.add('armed');btn.textContent=`Replace the existing Template:${name}?`;setTimeout(()=>{if(btn.isConnected&&!btn.disabled&&btn.classList.contains('armed'))reset()},5000)}},go)} | |||
blocksEl.addEventListener('paste',e=>{const f=e.target;const m=f.dataset&&f.dataset.path&&f.dataset.path.match(/^cells\.(\d+)\.(\d+)$/);if(!m)return; | |||
const txt=((e.clipboardData||window.clipboardData)||{getData:()=>''}).getData('text');if(!txt)return; | |||
const body=txt.replace(/\r/g,'').replace(/\n+$/,'');if(!/[\t\n]/.test(body))return;e.preventDefault(); | |||
const card=f.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b)return; | |||
const rows=body.split('\n').map(l=>l.split('\t').map(c=>c.replace(/^"([\s\S]*)"$/,'$1').replace(/""/g,'"').replace(/\|/g,'|').trim())); | |||
const r0=+m[1],c0=+m[2],width=Math.max(b.cells[0].length,c0+Math.max(...rows.map(r=>r.length))); | |||
b.cells.forEach(r=>{while(r.length<width)r.push('')}); | |||
rows.forEach((cells,ri)=>{const R=r0+ri;while(b.cells.length<=R)b.cells.push(Array(width).fill(''));cells.forEach((v,ci)=>{b.cells[R][c0+ci]=v})}); | |||
commit();showToast(`Pasted ${rows.length} row${rows.length===1?'':'s'}`)}); | |||
let qi=null; | |||
const qiBox=$('#pb-qiBox'),qiInput=$('#pb-qiInput'),qiList=$('#pb-qiList'); | |||
function qiOpen(at,replace,anchor){qi={at,replace,anchor,i:0,items:[]};qiBox.hidden=false;qiInput.value='';qiFilter(); | |||
const r=(anchor||canvasEl).getBoundingClientRect(),W=Math.min(320,window.innerWidth-16); | |||
qiBox.style.left=Math.max(8,Math.min(r.left+(anchor?0:24),window.innerWidth-W-8))+'px'; | |||
qiBox.style.top=Math.max(8,Math.min(anchor?r.bottom+4:r.top+24,window.innerHeight-320))+'px';qiInput.focus()} | |||
function qiFilter(){if(!qi)return;const q=qiInput.value.trim().toLowerCase(); | |||
qi.items=GROUPS.reduce((a,g)=>a.concat(g[2]),[]).filter(t=>!q||(DEFS[t].label+' '+DEFS[t].desc+' '+t).toLowerCase().includes(q));qi.i=0; | |||
qiList.innerHTML=qi.items.length?qi.items.map((t,i)=>`<div class="ac-opt qi-opt${i?'':' on'}" role="option" id="${qiList.id}-o${i}" data-t="${t}" aria-selected="${i===0}"><span class="tok">${esc(DEFS[t].tok)}</span>${esc(DEFS[t].label)}</div>`).join(''):'<p class="note">No block matches that.</p>'; | |||
if(qi.items.length)qiInput.setAttribute('aria-activedescendant',qiList.id+'-o0');else qiInput.removeAttribute('aria-activedescendant')} | |||
function qiMove(d){if(!qi||!qi.items.length)return;qi.i=(qi.i+d+qi.items.length)%qi.items.length; | |||
qiList.querySelectorAll('.qi-opt').forEach((o,i)=>{o.classList.toggle('on',i===qi.i);o.setAttribute('aria-selected',String(i===qi.i))}); | |||
const on=qiList.querySelector('.qi-opt.on');if(on){if(on.scrollIntoView)on.scrollIntoView({block:'nearest'});qiInput.setAttribute('aria-activedescendant',on.id)}} | |||
function qiClose(back){if(!qi)return;const a=qi.anchor;qi=null;qiBox.hidden=true;if(back&&a&&a.isConnected)a.focus()} | |||
function qiPick(t){if(!qi)return;const at=qi.at,rp=qi.replace;qiClose(false); | |||
if(rp){const i=state.blocks.findIndex(b=>b.id===rp);if(i>=0){state.blocks.splice(i,1);addBlock(t,i);return}} | |||
addBlock(t,Math.min(at,state.blocks.length))} | |||
qiInput.addEventListener('input',qiFilter); | |||
qiInput.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();qiMove(1)}else if(e.key==='ArrowUp'){e.preventDefault();qiMove(-1)} | |||
else if(e.key==='Enter'){e.preventDefault();if(qi&&qi.items[qi.i])qiPick(qi.items[qi.i])}else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();qiClose(true)}}); | |||
qiInput.addEventListener('blur',()=>setTimeout(()=>{if(qi&&document.activeElement!==qiInput)qiClose(false)},150)); | |||
qiList.addEventListener('mousedown',e=>e.preventDefault()); | |||
qiList.addEventListener('click',e=>{const o=e.target.closest('.qi-opt');if(o)qiPick(o.dataset.t)}); | |||
$('.app').addEventListener('keydown',e=>{if(e.key!=='/'||e.ctrlKey||e.metaKey||e.altKey||qi)return; | |||
const t=e.target;if(t.closest&&t.closest('input,textarea,select,[contenteditable="true"],dialog'))return; | |||
e.preventDefault();const card=document.activeElement&&document.activeElement.closest&&document.activeElement.closest('.block'); | |||
const idx=card?state.blocks.findIndex(b=>b.id===card.dataset.id)+1:state.blocks.length;qiOpen(idx,null,null)}); | |||
blocksEl.addEventListener('input',e=>{const f=e.target;if(qi||!f.matches||!f.matches('textarea[data-path="text"]')||f.value!=='/')return; | |||
const card=f.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b||b.type!=='paragraph')return; | |||
qiOpen(state.blocks.indexOf(b),b.id,f)}); | |||
let presetCache=null; | |||
function loadPresets(){ | |||
if(!hooks.presets)return Promise.resolve(BUILTIN_PRESETS.concat([EXAMPLE_PRESET])); | |||
return hooks.presets().then(l=>l&&l.length?l:BUILTIN_PRESETS,()=>BUILTIN_PRESETS); | |||
} | |||
const presetDlg=$('#pb-presetDlg'); | |||
const closePresets=()=>{try{presetDlg.close()}catch(e){presetDlg.removeAttribute('open')}}; | |||
function openPresets(fresh){ | |||
$('#pb-presetNote').textContent=fresh?'This page is empty. Pick a layout to start from, or start with a blank page.':'Pick a layout to start from. It replaces what’s on the page now, and you can undo it.'; | |||
$('#pb-presetCancel').textContent=fresh?'Start blank':'Cancel'; | |||
$('#pb-presetList').innerHTML='<p class="note">Loading presets…</p>'; | |||
try{presetDlg.showModal()}catch(e){presetDlg.setAttribute('open','')} | |||
(presetCache?Promise.resolve(presetCache):loadPresets()).then(list=>{presetCache=list; | |||
$('#pb-presetList').innerHTML=list.map((p,i)=>`<button type="button" class="preset" data-i="${i}"><strong>${esc(p.name)}</strong>${p.description?`<span>${esc(p.description)}</span>`:''}</button>`).join(''); | |||
const f=$('#pb-presetList .preset');if(f)f.focus()}); | |||
} | |||
function applyPreset(p){ | |||
const prev=JSON.parse(JSON.stringify(state));let blocks; | |||
if(p.build){const ex=p.build();blocks=ex.blocks;if(!state.title.trim()){state.title=ex.title;titleEl.value=state.title}} | |||
else blocks=parseWikitext(String(p.wikitext).replace(/\$TITLE/g,state.title.trim()||'Page name')); | |||
closePresets();state.blocks=blocks;collapsed.clear();commit(); | |||
if(prev.blocks.length)showToast(`${p.name} loaded`,()=>{state=prev;titleEl.value=state.title;commit()}); | |||
} | |||
$('#pb-presetsBtn').addEventListener('click',()=>openPresets(false)); | |||
$('#pb-presetCancel').addEventListener('click',closePresets); | |||
$('#pb-presetList').addEventListener('click',e=>{const b=e.target.closest('.preset');if(b&&presetCache)applyPreset(presetCache[+b.dataset.i])}); | |||
renderPalette();renderCanvas(); | renderPalette();renderCanvas(); | ||
| Line 692: | Line 1,184: | ||
let blocks; | let blocks; | ||
try{blocks=parseWikitext(text)}catch(e){blocks=text.trim()?[mk('raw',{text})]:[]} | try{blocks=parseWikitext(text)}catch(e){blocks=text.trim()?[mk('raw',{text})]:[]} | ||
state={title:title||'',blocks};collapsed.clear();titleEl.value=state.title;commit(); | state={title:title||'',blocks};collapsed.clear();titleEl.value=state.title;commit();resetHistory(); | ||
}, | }, | ||
gen, | gen, | ||
showToast | showToast, | ||
offerPresets(){openPresets(true)}, | |||
replace(text){let blocks;try{blocks=parseWikitext(text)}catch(e){blocks=[mk('raw',{text})]}state.blocks=blocks;collapsed.clear();commit()}, | |||
hideMenus(){acHide()} | |||
}; | }; | ||
| Line 730: | Line 1,225: | ||
let cancelArmed = null; | let cancelArmed = null; | ||
const hooks = { onChange: () => scheduleLive() }; | const api = new mw.Api(); | ||
const NS = { page: 0, file: 6, category: 14, template: 10 }; | |||
const userLang = mw.config.get( 'wgUserLanguage' ); | |||
const pick = ( v ) => v === null || v === undefined ? '' : typeof v === 'string' ? v : ( v[ userLang ] || v.en || Object.keys( v ).map( ( k ) => v[ k ] )[ 0 ] || '' ); | |||
const thumbCache = new Map(); | |||
const hooks = { | |||
onChange: () => { | |||
scheduleLive(); | |||
saveDraftSoon(); | |||
}, | |||
upload( file, name, desc, force ) { | |||
return new Promise( ( resolve, reject ) => { | |||
const params = { action: 'upload', filename: name, file: file, comment: desc || 'Uploaded with the page builder', formatversion: 2 }; | |||
if ( desc ) { | |||
params.text = desc; | |||
} | |||
if ( force ) { | |||
params.ignorewarnings = 1; | |||
} | |||
api.postWithEditToken( params, { contentType: 'multipart/form-data' } ).then( ( d ) => { | |||
const u = d.upload || {}; | |||
if ( u.result === 'Success' ) { | |||
resolve( { filename: String( u.filename || name ).replace( /_/g, ' ' ) } ); | |||
} else { | |||
reject( { warnings: u.warnings || {}, info: u.result || 'The upload didn’t work.' } ); | |||
} | |||
}, ( code, d ) => reject( { code: code, info: ( d && d.error && d.error.info ) || code } ) ); | |||
} ); | |||
}, | |||
saveTemplate( name, code ) { | |||
return new Promise( ( resolve, reject ) => { | |||
api.postWithEditToken( { action: 'edit', title: 'Template:' + name, text: code, summary: 'Infobox template made with the page builder', formatversion: 2 } ).then( ( d ) => { | |||
if ( d.edit && d.edit.result === 'Success' ) { | |||
resolve( true ); | |||
} else { | |||
reject( { info: ( d.edit && d.edit.info ) || 'Your wiki didn’t accept the edit.' } ); | |||
} | |||
}, ( code, d ) => reject( { info: ( d && d.error && d.error.info ) || code } ) ); | |||
} ); | |||
}, | |||
url: ( t ) => mw.util.getUrl( t ), | |||
search( kind, query ) { | |||
return api.get( { action: 'query', list: 'prefixsearch', pssearch: query, psnamespace: NS[ kind ], pslimit: 8, formatversion: 2 } ) | |||
.then( ( d ) => ( ( d.query && d.query.prefixsearch ) || [] ).map( ( x ) => kind === 'page' ? x.title : x.title.replace( /^[^:]+:/, '' ) ) ); | |||
}, | |||
exists( titles ) { | |||
const out = {}; | |||
const batches = []; | |||
for ( let i = 0; i < titles.length; i += 50 ) { | |||
batches.push( titles.slice( i, i + 50 ) ); | |||
} | |||
return Promise.all( batches.map( ( batch ) => api.post( { action: 'query', titles: batch.join( '|' ), formatversion: 2 } ).then( ( d ) => { | |||
const res = d.query || {}; | |||
const norm = {}; | |||
( res.normalized || [] ).forEach( ( n ) => { | |||
norm[ n.from ] = n.to; | |||
} ); | |||
const pages = {}; | |||
( res.pages || [] ).forEach( ( pg ) => { | |||
pages[ pg.title ] = pg; | |||
} ); | |||
batch.forEach( ( t ) => { | |||
const pg = pages[ norm[ t ] || t ]; | |||
out[ t ] = !pg || !( pg.invalid || ( pg.missing && !pg.known ) ); | |||
} ); | |||
} ) ) ).then( () => out ); | |||
}, | |||
templateData( name ) { | |||
return api.get( { action: 'templatedata', titles: 'Template:' + name, redirects: 1, lang: userLang, formatversion: 2 } ).then( ( d ) => { | |||
const pages = d.pages || {}; | |||
const t = Object.keys( pages ).map( ( k ) => pages[ k ] )[ 0 ]; | |||
if ( !t || t.missing || t.notemplatedata || !t.params ) { | |||
return null; | |||
} | |||
const params = {}; | |||
Object.keys( t.params ).forEach( ( k ) => { | |||
const pr = t.params[ k ]; | |||
params[ k ] = { label: pick( pr.label ), description: pick( pr.description ), required: !!pr.required, suggested: !!pr.suggested, deprecated: !!pr.deprecated, type: pr.type || 'unknown' }; | |||
} ); | |||
const order = ( t.paramOrder || Object.keys( params ) ).filter( ( k ) => params[ k ] ); | |||
return { description: pick( t.description ), params: params, order: order }; | |||
} ); | |||
}, | |||
thumb( file ) { | |||
if ( !thumbCache.has( file ) ) { | |||
thumbCache.set( file, api.get( { action: 'query', titles: 'File:' + file, prop: 'imageinfo', iiprop: 'url', iiurlwidth: 200, formatversion: 2 } ).then( ( d ) => { | |||
const pg = ( ( d.query && d.query.pages ) || [] )[ 0 ]; | |||
const ii = pg && pg.imageinfo && pg.imageinfo[ 0 ]; | |||
return ii ? { url: ii.thumburl || ii.url } : { missing: true }; | |||
} ) ); | |||
} | |||
return thumbCache.get( file ); | |||
}, | |||
presets() { | |||
return api.get( { action: 'query', prop: 'revisions', titles: 'MediaWiki:Gadget-PageBuilder-presets.json', rvprop: 'content', rvslots: 'main', formatversion: 2 } ).then( ( d ) => { | |||
const pg = ( ( d.query && d.query.pages ) || [] )[ 0 ]; | |||
if ( !pg || pg.missing || !pg.revisions ) { | |||
return null; | |||
} | |||
const list = JSON.parse( pg.revisions[ 0 ].slots.main.content ); | |||
return Array.isArray( list ) ? list.filter( ( x ) => x && typeof x.name === 'string' && typeof x.wikitext === 'string' ) : null; | |||
} ); | |||
} | |||
}; | |||
const app = createApp( root, hooks ); | const app = createApp( root, hooks ); | ||
| Line 806: | Line 1,405: | ||
cancelBtn.addEventListener( 'click', () => { | cancelBtn.addEventListener( 'click', () => { | ||
if ( app.gen() === baseline || cancelArmed ) { | if ( app.gen() === baseline || cancelArmed ) { | ||
clearTimeout( draftTimer ); | |||
draftTimer = null; | |||
drafts.clear(); | |||
close(); | close(); | ||
return; | return; | ||
| Line 826: | Line 1,428: | ||
mw.notify( 'Your changes are in the edit box. Preview and save the page as usual.', { title: 'Page builder' } ); | mw.notify( 'Your changes are in the edit box. Preview and save the page as usual.', { title: 'Page builder' } ); | ||
} | } | ||
} ); | |||
const saveBtn = q( '#pb-saveBuilder' ); | |||
const saveDlg = q( '#pb-saveDlg' ); | |||
const saveSummary = q( '#pb-saveSummary' ); | |||
const saveMinor = q( '#pb-saveMinor' ); | |||
const saveWatch = q( '#pb-saveWatch' ); | |||
const saveReview = q( '#pb-saveReview' ); | |||
const saveDiff = q( '#pb-saveDiff' ); | |||
const saveErr = q( '#pb-saveErr' ); | |||
const saveGo = q( '#pb-saveGo' ); | |||
const form = box.form || document.getElementById( 'editform' ); | |||
const field = ( name ) => form ? form.querySelector( '[name="' + name + '"]' ) : null; | |||
const section = ( field( 'wpSection' ) || { value: '' } ).value || ''; | |||
let diffFor = null; | |||
if ( section === 'new' ) { | |||
saveBtn.hidden = true; | |||
} | |||
function closeSave() { | |||
try { | |||
saveDlg.close(); | |||
} catch ( e ) { | |||
saveDlg.removeAttribute( 'open' ); | |||
} | |||
} | |||
saveBtn.addEventListener( 'click', () => { | |||
app.hideMenus(); | |||
saveErr.textContent = ''; | |||
const sum = field( 'wpSummary' ); | |||
if ( !saveSummary.value && sum ) { | |||
saveSummary.value = sum.value; | |||
} | |||
const minor = field( 'wpMinoredit' ); | |||
q( '#pb-saveMinorWrap' ).hidden = !minor; | |||
saveMinor.checked = !!( minor && minor.checked ); | |||
const watch = field( 'wpWatchthis' ); | |||
saveWatch.checked = !!( watch && watch.checked ); | |||
saveReview.open = false; | |||
diffFor = null; | |||
saveGo.disabled = false; | |||
saveGo.textContent = 'Save page'; | |||
try { | |||
saveDlg.showModal(); | |||
} catch ( e ) { | |||
saveDlg.setAttribute( 'open', '' ); | |||
} | |||
saveSummary.focus(); | |||
} ); | |||
q( '#pb-saveCancel' ).addEventListener( 'click', closeSave ); | |||
saveReview.addEventListener( 'toggle', () => { | |||
if ( !saveReview.open ) { | |||
return; | |||
} | |||
const text = app.gen(); | |||
if ( diffFor === text ) { | |||
return; | |||
} | |||
diffFor = text; | |||
const from = box.defaultValue; | |||
if ( from.replace( /\s+$/, '' ) === text ) { | |||
saveDiff.innerHTML = '<p>There are no changes.</p>'; | |||
return; | |||
} | |||
saveDiff.textContent = 'Loading your changes…'; | |||
mw.loader.load( 'mediawiki.diff.styles' ); | |||
api.post( { | |||
action: 'compare', | |||
fromtitle: pageTitle, | |||
totitle: pageTitle, | |||
fromslots: 'main', | |||
'fromtext-main': from, | |||
'fromcontentmodel-main': 'wikitext', | |||
toslots: 'main', | |||
'totext-main': text, | |||
'tocontentmodel-main': 'wikitext', | |||
prop: 'diff', | |||
formatversion: 2 | |||
} ).then( ( d ) => { | |||
if ( diffFor !== text ) { | |||
return; | |||
} | |||
const body = d.compare && d.compare.body; | |||
saveDiff.innerHTML = body ? | |||
'<table class="diff diff-contentalign-left diff-editfont-monospace"><colgroup><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content"></colgroup>' + body + '</table>' : | |||
'<p>There are no changes.</p>'; | |||
}, () => { | |||
if ( diffFor === text ) { | |||
diffFor = null; | |||
saveDiff.textContent = 'Couldn’t load your changes. You can still save, or click Done and use Show changes in the editor.'; | |||
} | |||
} ); | |||
} ); | |||
saveGo.addEventListener( 'click', () => { | |||
const text = app.gen(); | |||
const fail = ( msg ) => { | |||
saveErr.textContent = msg; | |||
saveGo.disabled = false; | |||
saveGo.textContent = 'Save page'; | |||
}; | |||
saveErr.textContent = ''; | |||
saveGo.disabled = true; | |||
saveGo.textContent = 'Saving…'; | |||
const params = { | |||
action: 'edit', | |||
title: pageTitle, | |||
text: text, | |||
summary: saveSummary.value, | |||
watchlist: saveWatch.checked ? 'watch' : 'nochange', | |||
formatversion: 2 | |||
}; | |||
if ( saveMinor.checked && !q( '#pb-saveMinorWrap' ).hidden ) { | |||
params.minor = true; | |||
} | |||
if ( section ) { | |||
params.section = section; | |||
} | |||
const base = field( 'wpEdittime' ); | |||
const started = field( 'wpStarttime' ); | |||
if ( base && base.value ) { | |||
params.basetimestamp = base.value; | |||
} | |||
if ( started && started.value ) { | |||
params.starttimestamp = started.value; | |||
} | |||
if ( !mw.config.get( 'wgArticleId' ) ) { | |||
params.createonly = true; | |||
} | |||
api.postWithEditToken( params ).then( ( d ) => { | |||
const r = d.edit || {}; | |||
if ( r.result !== 'Success' ) { | |||
fail( r.info || 'Your wiki didn’t accept the edit.' ); | |||
return; | |||
} | |||
drafts.clear(); | |||
box.value = box.defaultValue; | |||
$( window ).off( 'beforeunload' ); | |||
window.onbeforeunload = null; | |||
saveGo.textContent = 'Saved'; | |||
location.href = mw.util.getUrl( pageTitle ); | |||
}, ( code, d ) => { | |||
if ( code === 'editconflict' ) { | |||
fail( 'Someone else saved this page after you started editing. Click Done, then save from the editor so you can see and merge their changes.' ); | |||
} else { | |||
fail( ( d && d.error && d.error.info ) || ( 'Your wiki returned an error: ' + code ) ); | |||
} | |||
} ); | |||
} ); | |||
const banner = q( '#pb-draftBanner' ); | |||
const draftMsg = q( '#pb-draftMsg' ); | |||
const draftKey = 'pb-draft:' + ( mw.config.get( 'wgWikiID' ) || '' ) + ':' + pageTitle + ':' + section; | |||
const hashText = ( t ) => { | |||
let h = 2166136261; | |||
for ( let i = 0; i < t.length; i++ ) { | |||
h ^= t.charCodeAt( i ); | |||
h = Math.imul( h, 16777619 ); | |||
} | |||
return ( h >>> 0 ).toString( 16 ) + ':' + t.length; | |||
}; | |||
const drafts = { | |||
get() { | |||
try { | |||
return JSON.parse( localStorage.getItem( draftKey ) || 'null' ); | |||
} catch ( e ) { | |||
return null; | |||
} | |||
}, | |||
set( v ) { | |||
try { | |||
localStorage.setItem( draftKey, JSON.stringify( v ) ); | |||
} catch ( e ) {} | |||
}, | |||
clear() { | |||
try { | |||
localStorage.removeItem( draftKey ); | |||
} catch ( e ) {} | |||
} | |||
}; | |||
let draftTimer = null; | |||
let pendingDraft = null; | |||
function saveDraftNow() { | |||
clearTimeout( draftTimer ); | |||
draftTimer = null; | |||
if ( root.hidden ) { | |||
return; | |||
} | |||
const text = app.gen(); | |||
if ( text === baseline ) { | |||
drafts.clear(); | |||
return; | |||
} | |||
drafts.set( { text: text, base: hashText( box.defaultValue ), time: Date.now() } ); | |||
} | |||
function saveDraftSoon() { | |||
clearTimeout( draftTimer ); | |||
draftTimer = setTimeout( saveDraftNow, 1000 ); | |||
} | |||
window.addEventListener( 'pagehide', () => { | |||
if ( draftTimer ) { | |||
saveDraftNow(); | |||
} | |||
} ); | |||
const whenText = ( t ) => { | |||
const m = Math.round( ( Date.now() - t ) / 60000 ); | |||
if ( m < 1 ) { | |||
return 'a moment ago'; | |||
} | |||
if ( m < 60 ) { | |||
return m + ( m === 1 ? ' minute ago' : ' minutes ago' ); | |||
} | |||
const hrs = Math.round( m / 60 ); | |||
if ( hrs < 24 ) { | |||
return hrs + ( hrs === 1 ? ' hour ago' : ' hours ago' ); | |||
} | |||
return 'on ' + new Date( t ).toLocaleString(); | |||
}; | |||
q( '#pb-draftRestore' ).addEventListener( 'click', () => { | |||
if ( pendingDraft ) { | |||
app.replace( pendingDraft.text ); | |||
app.showToast( 'Draft restored. Undo puts the page back the way it was.' ); | |||
} | |||
pendingDraft = null; | |||
banner.hidden = true; | |||
} ); | |||
q( '#pb-draftDiscard' ).addEventListener( 'click', () => { | |||
pendingDraft = null; | |||
banner.hidden = true; | |||
drafts.clear(); | |||
} ); | } ); | ||
| Line 843: | Line 1,678: | ||
} | } | ||
scheduleLive(); | scheduleLive(); | ||
pendingDraft = null; | |||
banner.hidden = true; | |||
const draft = drafts.get(); | |||
if ( draft && typeof draft.text === 'string' ) { | |||
if ( draft.base !== hashText( box.defaultValue ) ) { | |||
drafts.clear(); | |||
} else if ( draft.text !== baseline ) { | |||
pendingDraft = draft; | |||
draftMsg.textContent = 'You have unsaved changes to this page from ' + whenText( draft.time ) + '. Do you want to carry on where you left off?'; | |||
banner.hidden = false; | |||
} | |||
} | |||
if ( !text.trim() && !pendingDraft ) { | |||
app.offerPresets(); | |||
} | |||
} | } | ||
}; | }; | ||
Latest revision as of 07:56, 26 October 2026
( function () {
'use strict';
if ( [ 'edit', 'submit' ].indexOf( mw.config.get( 'wgAction' ) ) === -1 ) {
return;
}
if ( mw.config.get( 'wgPageContentModel' ) !== 'wikitext' ) {
return;
}
const MARKUP = "<div class=\"app\">\n <header class=\"top\">\n <h1 class=\"brand\">Page builder</h1>\n <div class=\"title-field\">\n <label for=\"pb-pageTitle\">Page</label>\n <input type=\"text\" id=\"pb-pageTitle\" readonly>\n </div>\n <div class=\"top-actions\">\n <span class=\"hist\"><button class=\"btn\" id=\"pb-undoBtn\" type=\"button\" title=\"Undo (Ctrl+Z)\" disabled>Undo</button><button class=\"btn\" id=\"pb-redoBtn\" type=\"button\" title=\"Redo (Ctrl+Y)\" disabled>Redo</button></span>\n <button class=\"btn\" id=\"pb-importBtn\" type=\"button\">Import</button>\n <button class=\"btn\" id=\"pb-presetsBtn\" type=\"button\">Presets</button>\n <button class=\"btn danger\" id=\"pb-clearPage\" type=\"button\">Clear page</button>\n <button class=\"btn\" id=\"pb-cancelBuilder\" type=\"button\">Cancel</button>\n <button class=\"btn\" id=\"pb-doneBuilder\" type=\"button\" title=\"Put your changes in the edit box and go back to the editor\">Done</button>\n <button class=\"btn primary\" id=\"pb-saveBuilder\" type=\"button\">Save page…</button>\n </div>\n </header>\n\n <div class=\"main\">\n <aside class=\"pane palette\" aria-label=\"Blocks\">\n <div class=\"pal-groups\" id=\"pb-palette\"></div>\n <p class=\"pal-hint\">Drag a block onto the page, or click it to add it at the end. Press / to find one by name.</p>\n <nav class=\"outline\" aria-labelledby=\"pb-outlineHeading\">\n <div class=\"outline-head\"><h2 id=\"pb-outlineHeading\">Outline</h2><span class=\"outline-tools\"><button type=\"button\" class=\"linkbtn\" id=\"pb-collapseAll\">Collapse all</button><button type=\"button\" class=\"linkbtn\" id=\"pb-expandAll\">Expand all</button></span></div>\n <ul class=\"outline-list\" id=\"pb-outline\"></ul>\n </nav>\n </aside>\n\n <main class=\"pane canvas\" id=\"pb-canvas\" aria-label=\"Page layout\">\n <div class=\"canvas-inner\">\n <div class=\"banner\" id=\"pb-draftBanner\" role=\"status\" hidden><p id=\"pb-draftMsg\"></p><span class=\"banner-actions\"><button class=\"btn primary\" id=\"pb-draftRestore\" type=\"button\">Restore it</button><button class=\"btn\" id=\"pb-draftDiscard\" type=\"button\">Discard it</button></span></div>\n <div class=\"blocks\" id=\"pb-blocks\"></div>\n </div>\n </main>\n\n <section class=\"pane output\" aria-label=\"Output\">\n <div class=\"out-head\">\n <div class=\"tabs\" role=\"tablist\">\n <button class=\"tab\" role=\"tab\" id=\"pb-tab-code\" aria-controls=\"pb-panel-code\" aria-selected=\"true\" type=\"button\">Wikitext</button>\n <button class=\"tab\" role=\"tab\" id=\"pb-tab-prev\" aria-controls=\"pb-panel-prev\" aria-selected=\"false\" type=\"button\">Preview</button>\n <button class=\"tab\" role=\"tab\" id=\"pb-tab-chk\" aria-controls=\"pb-panel-chk\" aria-selected=\"false\" type=\"button\">Checks</button>\n </div>\n <div class=\"out-tools\">\n <span class=\"meta\" id=\"pb-meta\"></span>\n <button class=\"btn\" id=\"pb-copyBtn\" type=\"button\">Copy wikitext</button>\n </div>\n </div>\n <div class=\"panel\" id=\"pb-panel-code\" role=\"tabpanel\" aria-labelledby=\"pb-tab-code\"><pre class=\"wikitext\" id=\"pb-code\"></pre></div>\n <div class=\"panel\" id=\"pb-panel-prev\" role=\"tabpanel\" aria-labelledby=\"pb-tab-prev\" hidden><p class=\"pb-status\" id=\"pb-liveStatus\" role=\"status\"></p><div class=\"pb-live mw-body-content\" id=\"pb-live\"></div><div class=\"wiki\" id=\"pb-preview\" hidden></div></div>\n <div class=\"panel\" id=\"pb-panel-chk\" role=\"tabpanel\" aria-labelledby=\"pb-tab-chk\" hidden><div class=\"checks-panel\" id=\"pb-checks\"></div></div>\n </section>\n </div>\n</div>\n<dialog class=\"dlg\" id=\"pb-importDlg\" aria-labelledby=\"pb-importHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-importHeading\">Import wikitext</h2>\n <p class=\"note\">Paste wikitext from another page or open a file. Anything the editor doesn’t have a block for is kept as raw wikitext, so nothing is lost.</p>\n <textarea id=\"pb-importText\" class=\"mono\" spellcheck=\"false\" aria-label=\"Wikitext to import\" placeholder=\"{\u007BInfobox | 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<dialog class=\"dlg\" id=\"pb-presetDlg\" aria-labelledby=\"pb-presetHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-presetHeading\">Start from a preset</h2>\n <p class=\"note\" id=\"pb-presetNote\"></p>\n <div class=\"presets\" id=\"pb-presetList\"></div>\n <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-presetCancel\" type=\"button\">Cancel</button></div>\n </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-citeDlg\" aria-labelledby=\"pb-citeHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-citeHeading\">Add a reference</h2>\n <div id=\"pb-citeReuse\"></div>\n <div class=\"row\"><label class=\"field\"><span>Kind of source</span><select id=\"pb-citeKind\"><option value=\"web\">Web page</option><option value=\"book\">Book</option><option value=\"other\">Something else</option></select></label><label class=\"field\" id=\"pb-citeFmtWrap\"><span>Format</span><select id=\"pb-citeFmt\"><option value=\"plain\">Plain text (works on any wiki)</option><option value=\"tpl\">Cite templates (cite web, cite book)</option></select></label></div>\n <label class=\"field\" data-for=\"web book\"><span>Title</span><input type=\"text\" id=\"pb-citeTitle\"></label>\n <label class=\"field\" data-for=\"web\"><span>Link</span><input type=\"text\" id=\"pb-citeUrl\" placeholder=\"https://\"></label>\n <div class=\"row\" data-for=\"web book\"><label class=\"field\"><span>Author</span><input type=\"text\" id=\"pb-citeAuthor\"></label><label class=\"field\"><span id=\"pb-citeWorkLabel\">Website</span><input type=\"text\" id=\"pb-citeWork\"></label></div>\n <div class=\"row\" data-for=\"web book\"><label class=\"field\"><span>Published</span><input type=\"text\" id=\"pb-citeDate\" placeholder=\"2026-10-24\"></label><label class=\"field\" data-for=\"web\"><span>Accessed</span><input type=\"text\" id=\"pb-citeAccess\"></label></div>\n <label class=\"field\" data-for=\"other\"><span>Reference text</span><textarea id=\"pb-citeText\" rows=\"3\"></textarea></label>\n <label class=\"field\"><span>Name (optional)</span><input type=\"text\" id=\"pb-citeName\" placeholder=\"Lets you use this reference again elsewhere on the page\"></label>\n <p class=\"dlg-err\" id=\"pb-citeErr\" role=\"alert\"></p>\n <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-citeCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-citeGo\" type=\"button\">Add reference</button></div>\n </div>\n</dialog>\n<dialog class=\"dlg\" id=\"pb-upDlg\" aria-labelledby=\"pb-upHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-upHeading\">Upload to your wiki</h2>\n <div id=\"pb-upList\"></div>\n <label class=\"field\"><span>Description</span><input type=\"text\" id=\"pb-upDesc\" placeholder=\"What the picture shows, and where it came from\"></label>\n <p class=\"dlg-err\" id=\"pb-upErr\" role=\"alert\"></p>\n <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-upCancel\" type=\"button\">Cancel</button><button class=\"btn primary\" id=\"pb-upGo\" type=\"button\">Upload</button></div>\n </div>\n</dialog>\n<div class=\"qi\" id=\"pb-qiBox\" role=\"dialog\" aria-label=\"Add a block\" hidden><input type=\"text\" id=\"pb-qiInput\" role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"true\" aria-controls=\"pb-qiList\" placeholder=\"Type to find a block\" autocomplete=\"off\"><div class=\"qi-list\" id=\"pb-qiList\" role=\"listbox\" aria-label=\"Blocks\"></div></div>\n<dialog class=\"dlg\" id=\"pb-saveDlg\" aria-labelledby=\"pb-saveHeading\">\n <div class=\"dlg-body\">\n <h2 id=\"pb-saveHeading\">Save page</h2>\n <label class=\"field\"><span>Summary</span><input type=\"text\" id=\"pb-saveSummary\" maxlength=\"500\" placeholder=\"Briefly describe your changes\"></label>\n <div class=\"checks\"><label class=\"check\" id=\"pb-saveMinorWrap\"><input type=\"checkbox\" id=\"pb-saveMinor\"><span>This is a minor edit</span></label><label class=\"check\"><input type=\"checkbox\" id=\"pb-saveWatch\"><span>Watch this page</span></label></div>\n <details class=\"tplcode\" id=\"pb-saveReview\"><summary>Review your changes</summary><div class=\"pb-live pb-diff\" id=\"pb-saveDiff\"></div></details>\n <p class=\"dlg-err\" id=\"pb-saveErr\" role=\"alert\"></p>\n <div class=\"dlg-actions\"><button class=\"btn\" id=\"pb-saveCancel\" type=\"button\">Keep editing</button><button class=\"btn primary\" id=\"pb-saveGo\" type=\"button\">Save page</button></div>\n </div>\n</dialog>\n<div class=\"ac\" id=\"pb-acList\" role=\"listbox\" aria-label=\"Suggestions\" hidden></div>\n<div class=\"toast\" id=\"pb-toast\" role=\"status\" hidden><span id=\"pb-toastMsg\"></span><button type=\"button\" id=\"pb-toastUndo\">Undo</button></div>";
function createApp( root, hooks ) {
'use strict';
const KEY='mw-page-builder-v1';
const $=s=>root.querySelector(s);
const own=(o,k)=>Object.prototype.hasOwnProperty.call(o,k);
const esc=s=>String(s==null?'':s).replace(/&/g,'&').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,keep)=>`<div class="kv-list">${arr.map((r,i)=>keep&&!keep(r)?'':`<div class="kv"><input type="text" data-path="${listKey}.${i}.k" value="${esc(r.k)}" placeholder="${esc(kPh)}" aria-label="Name ${i+1}"><input type="text" data-path="${listKey}.${i}.v" value="${esc(r.v)}" placeholder="${esc(vPh)}" aria-label="Value ${i+1}"><button type="button" class="icon del" data-act="${delAct}" data-i="${i}" aria-label="Remove row ${i+1}">${ICON.x}</button></div>`).join('')}</div>`;
const ICON={
grip:'<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><circle cx="5.5" cy="3.5" r="1.4"/><circle cx="10.5" cy="3.5" r="1.4"/><circle cx="5.5" cy="8" r="1.4"/><circle cx="10.5" cy="8" r="1.4"/><circle cx="5.5" cy="12.5" r="1.4"/><circle cx="10.5" cy="12.5" r="1.4"/></svg>',
up:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M3.5 10 8 5.5 12.5 10"/></svg>',
down:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M3.5 6 8 10.5 12.5 6"/></svg>',
dup:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="5" y="5" width="8.5" height="8.5" rx="1"/><path d="M3 10.5V3.5a1 1 0 0 1 1-1h6.5"/></svg>',
x:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
up2:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 8.5 8 4l4.5 4.5M3.5 12.5 8 8l4.5 4.5"/></svg>',
down2:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 3.5 8 8l4.5-4.5M3.5 7.5 8 12l4.5-4.5"/></svg>',
fold:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1"/><path d="M5 8h6"/></svg>',
unfold:'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="2.5" width="11" height="11" rx="1"/><path d="M5 8h6M8 5v6"/></svg>',
pic:'<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="3" y="6" width="26" height="20" rx="1.5"/><circle cx="11" cy="13" r="2.5"/><path d="M3 23l8-7 6 5 4-3 8 6"/></svg>'
};
const ph=f=>`<div class="ph">${ICON.pic}<span>${esc(f||'Example.jpg')}</span></div>`;
function infoboxAttrs(b){let a='';if(b.layout==='stacked')a+=' layout="stacked"';if(b.accentOn&&b.accent)a+=` accent-color-default="${esc(b.accent)}"`;return a}
function infoboxTemplate(b){
const head=[`<infobox${infoboxAttrs(b)}>`,' <title source="title"><default>{\u007BPAGENAME}}</default></title>',' <image source="image">',' <caption source="caption"/>',' </image>'];
if(b.mode!=='dedicated'){
const n=Math.max(20,b.rows.length);
for(let i=1;i<=n;i++)head.push(` <data source="data${i}"><label>{\u007B{label${i}|}}}</label></data>`);
head.push('</infobox>');
return head.join('\n');
}
const params={title:{label:'Title',description:'Box title. Leave blank to use the page name.',type:'string'},image:{label:'Image',description:'File name, without File:',type:'wiki-file-name'},caption:{label:'Caption',description:'Text under the image',type:'string'}};
const body=[];let open=false;
b.rows.filter(r=>r.k.trim()).forEach(r=>{const lab=String(r.label||'').trim()||niceKey(r.k);params[r.k.trim()]={label:lab,type:'string'};
const h=String(r.header||'').trim();
if(h){if(open)body.push(' </group>');body.push(' <group>',` <header>${esc(h)}</header>`);open=true}
body.push(`${open?' ':' '}<data source="${esc(r.k.trim())}"><label>${esc(lab)}</label></data>`)});
if(open)body.push(' </group>');
return head.concat(body,['</infobox><noinclude>','<templatedata>',JSON.stringify({params,format:'block'},null,2),'</templatedata>','</noinclude>']).join('\n');
}
function infoboxDesigner(b){
const g=b.mode!=='dedicated';
let h=`<div class="row">${fSelect('Layout','layout',b.layout||'',[['','Labels beside values'],['stacked','Labels above values']])}<div class="field"><span>Accent colour</span><span class="colorrow">${fCheck('Use one','accentOn',!!b.accentOn,true)}${b.accentOn?`<input type="color" data-path="accent" value="${esc(b.accent||'#3366cc')}" aria-label="Accent colour">`:''}</span></div></div>`;
if(!g){const rows=b.rows.map((r,i)=>r.k.trim()?`<tr><td><code>${esc(r.k.trim())}</code></td><td><input type="text" data-path="rows.${i}.label" value="${esc(r.label||'')}" placeholder="${esc(niceKey(r.k))}" aria-label="Label for ${esc(r.k.trim())}"></td><td><input type="text" data-path="rows.${i}.header" value="${esc(r.header||'')}" placeholder="No new section" aria-label="Section that starts at ${esc(r.k.trim())}"></td></tr>`:'').join('');
if(rows)h+=`<div class="grid-wrap"><table class="design"><thead><tr><th scope="col">Field</th><th scope="col">Label shown</th><th scope="col">Starts a section called</th></tr></thead><tbody>${rows}</tbody></table></div>`}
return h;
}
function listWiki(items,ch){
return lines(items).map(l=>{const m=l.match(/^\s*([*#]*)\s*(.*)$/);return ch.repeat(m[1].length+1)+' '+m[2]}).join('\n');
}
function listHtml(items,tag,c){
let out='',depth=0;
lines(items).forEach(l=>{
const m=l.match(/^\s*([*#]*)\s*(.*)$/);const d=m[1].length+1;const t=c.inl(m[2]);
if(d>depth){while(depth<d){out+=`<${tag}>`;if(depth+1<d)out+='<li>';depth++}out+='<li>'+t}
else{out+='</li>';while(depth>d){out+=`</${tag}></li>`;depth--}out+='<li>'+t}
});
if(depth){out+='</li>';while(depth>0){out+=`</${tag}>`;depth--;if(depth>0)out+='</li>'}}
return out;
}
const SYMS=[['—','Em dash'],['–','En dash'],['…','Ellipsis'],['·','Middle dot'],['°','Degree'],['×','Multiply'],['±','Plus or minus'],['→','Right arrow'],['←','Left arrow'],['©','Copyright'],['®','Registered'],['™','Trademark'],['½','Half'],[' ','Non-breaking space']];
const FB=(f,label,html,title)=>`<button type="button" data-act="fmt" data-fmt="${f}" aria-label="${label}" title="${title||label}">${html}</button>`;
function fmtBar(){return `<div class="fmtbar" role="toolbar" aria-label="Formatting">`
+`<span class="fgrp">${FB('bold','Bold','<b>B</b>','Bold (Ctrl+B)')}${FB('italic','Italic','<i>I</i>','Italic (Ctrl+I)')}${FB('under','Underline','<u>U</u>','Underline (Ctrl+U)')}${FB('strike','Strikethrough','<s>S</s>')}${FB('sup','Superscript','x<sup>2</sup>')}${FB('sub','Subscript','x<sub>2</sub>')}${FB('small','Small text','<small>Small</small>')}${FB('big','Big text','<span style="font-size:1.15em">Big</span>')}${FB('code','Inline code','<code>code</code>')}</span>`
+`<span class="fgrp"><label class="fcolor" title="Text colour"><span>Colour</span><input type="color" data-fmtcolor="color" value="#d73333" aria-label="Text colour"></label><label class="fcolor" title="Highlight"><span>Highlight</span><input type="color" data-fmtcolor="bg" value="#fef6c8" aria-label="Highlight colour"></label></span>`
+`<span class="fgrp">${FB('link','Wiki link','Wiki link','Wiki link (Ctrl+K)')}${FB('ext','Web link','Web link')}${FB('ref','Reference','Reference')}${FB('tpl','Template','{\u007B }}','Template')}</span>`
+`<span class="fgrp">${FB('br','Line break','Line break')}${FB('nowiki','No wiki','No wiki','Show markup as plain text')}${FB('clear','Clear formatting','Clear','Remove formatting from the selection')}<select data-fmtsym aria-label="Insert a symbol"><option value="">Symbol</option>${SYMS.map(([v,l])=>`<option value="${esc(v)}">${v===' '?'␣':esc(v)} ${esc(l)}</option>`).join('')}</select></span></div>`}
const TD_AC={'wiki-file-name':'file','wiki-page-name':'page','wiki-template-name':'template','wiki-user-name':'page'};
const tdCache=new Map();
const tdKey=n=>{const s=String(n||'').trim().replace(/_/g,' ').replace(/^Template\s*:/i,'').trim();return s?s.charAt(0).toUpperCase()+s.slice(1):''};
function tdFor(name){
if(!hooks.templateData)return null;
const k=tdKey(name);if(!k)return null;
let e=tdCache.get(k);
if(!e){e={state:'loading',data:null};tdCache.set(k,e);
hooks.templateData(k).then(d=>{e.state=d&&d.order&&d.order.length?'ok':'none';e.data=d;tdRefresh(k)},()=>{e.state='none';tdRefresh(k)})}
return e;
}
function tdRefresh(k){state.blocks.forEach(b=>{if((b.type==='template'&&tdKey(b.name)===k)||(b.type==='infobox'&&b.mode==='dedicated'&&tdKey(b.template)===k))rerenderCard(b.id)})}
function tdMissing(b){const e=tdFor(b.template);if(!e||e.state!=='ok')return [];const have=new Set(b.rows.map(r=>r.k.trim()).concat(['title','image','caption']));return e.data.order.filter(k=>!have.has(k)&&e.data.params[k]&&!e.data.params[k].deprecated)}
function tdFillBtn(b){const m=tdMissing(b);return m.length?`<div><button type="button" class="add" data-act="tdfill">Add ${m.length} documented field${m.length===1?'':'s'} from the template</button></div>`:''}
function templateForm(b){
const e=tdFor(b.name),d=e&&e.state==='ok'?e.data:null;
let h=fText('Template name','name',b.name,'Citation needed');
if(e&&e.state==='loading')h+='<p class="note">Looking up this template’s parameters…</p>';
if(!d)return h+`<p class="sub">Parameters (leave the name blank for a numbered parameter)</p>${kvRows(b.params,'params','delparam','name','value')}<div><button type="button" class="add" data-act="addparam">Add parameter</button></div>`;
const known=new Set(d.order);
const keys=d.order.filter(k=>d.params[k]&&!(d.params[k].deprecated&&!b.params.some(p=>p.k.trim()===k&&p.v.trim())));
if(d.description)h+=`<p class="note">${esc(d.description)}</p>`;
h+=`<div class="tdfields">${keys.map(k=>{const p=d.params[k],cur=b.params.find(x=>x.k.trim()===k),v=cur?cur.v:'';
const ac=TD_AC[p.type]?` data-ac="${TD_AC[p.type]}"`:'';
const input=p.type==='content'||p.type==='unbalanced-wikitext'?`<textarea data-tdkey="${esc(k)}" rows="3"${ac}>${esc(v)}</textarea>`:`<input type="text" data-tdkey="${esc(k)}" value="${esc(v)}"${ac}>`;
return `<label class="field"><span>${esc(p.label||k)}${p.required?'<em class="req">required</em>':''}</span>${input}${p.description?`<small class="hint">${esc(p.description)}</small>`:''}</label>`}).join('')}</div>`;
return h+`<p class="sub">Other parameters</p>${kvRows(b.params,'params','delparam','name','value',r=>!known.has(r.k.trim()))}<div><button type="button" class="add" data-act="addparam">Add parameter</button></div>`;
}
const THUMBS='<div class="thumbs" data-thumbs></div>';
const UPBTN=multi=>`<div class="uprow"><label class="btn filebtn">${multi?'Upload pictures':'Upload a picture'}<input type="file" accept="image/*"${multi?' multiple':''} class="sr-only" data-upload></label><span class="hint">or drop ${multi?'them':'one'} onto this block</span></div>`;
const GALLERY_MODES=[['','Grid'],['packed','Packed rows'],['packed-hover','Packed, captions on hover'],['packed-overlay','Packed, captions over pictures'],['nolines','Grid without borders'],['slideshow','Slideshow']];
const NOTE_STYLES={info:['#3366cc','#eaf3ff'],success:['#14866d','#d5fdf4'],warning:['#edab00','#fef6e7'],danger:['#d73333','#fee7e6'],plain:['#a2a9b1','#f8f9fa']};
const noteStyle=k=>{const [bd,bg]=NOTE_STYLES[k]||NOTE_STYLES.info;return `border:1px solid ${bd};border-left:4px solid ${bd};background:${bg};color:#202122;padding:8px 12px;margin:1em 0`};
const COLL_STYLE='border:1px solid #a2a9b1;padding:6px 10px;margin:1em 0';
const reEsc=x=>x.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
const COLL_RE=new RegExp('^<div class="mw-collapsible( mw-collapsed)?" style="'+reEsc(COLL_STYLE)+'">\\n<div style="font-weight:bold">(.*)</div>\\n<div class="mw-collapsible-content">\\n([\\s\\S]*)\\n</div>\\n</div>$');
function divBlock(w){
let m=w.match(/^<div class="pb-note pb-note-(\w+)" style="([^"]*)">([\s\S]*)<\/div>$/);
if(m){if(!NOTE_STYLES[m[1]]||m[2]!==noteStyle(m[1]))return null;const t=m[3].match(/^'''([^\n]+?)'''\n\n([\s\S]*)$/);return ['note',{kind:m[1],title:t?t[1]:'',text:t?t[2]:m[3]}]}
m=w.match(COLL_RE);if(m)return ['collapsible',{collapsed:!!m[1],title:m[2],text:m[3]}];
return null;
}
const GROUPS=[['text','Text',['heading','paragraph','bullets','numbers','quote','note','collapsible','code']],
['media','Media and data',['infobox','image','gallery','table']],
['wiki','Wiki features',['template','hatnote','toc','refs','categories','cattree','rule','raw']]];
const DEFS={
heading:{label:'Heading',tok:'== ==',desc:'A section heading',
init:()=>({text:'New section',level:'2'}),
sum:b=>b.text,
form:b=>`<div class="row r-wide">${fText('Heading text','text',b.text,'Section title')}${fSelect('Level','level',b.level,[['2','Section'],['3','Subsection'],['4','Level 4'],['5','Level 5']])}</div>`,
wiki:b=>{const e='='.repeat(+b.level||2);return `${e} ${b.text.trim()||'Section'} ${e}`},
html:(b,c)=>{const l=Math.min(6,+b.level||2);return `<h${l}>${c.inl(b.text.trim()||'Section')}</h${l}>`}},
paragraph:{label:'Paragraph',tok:'Abc',desc:'Body text with bold, links and references',
init:()=>({text:''}),
sum:b=>b.text,
form:b=>`${fmtBar()}${fArea('Text','text',b.text,'Write your text. Select words and use the buttons above to format them. A blank line starts a new paragraph.',5)}`,
wiki:b=>b.text.trim(),
html:(b,c)=>b.text.trim()?b.text.trim().split(/\n\s*\n/).map(p=>`<p>${c.inl(p)}</p>`).join(''):''},
bullets:{label:'Bulleted list',tok:'* ',desc:'One item per line',
init:()=>({items:'First item\nSecond item'}),
sum:b=>lines(b.items).length+' items',
form:b=>fmtBar()+fArea('Items','items',b.items,'One item per line. Start a line with * to indent it a level.',4),
wiki:b=>listWiki(b.items,'*'),
html:(b,c)=>listHtml(b.items,'ul',c)},
numbers:{label:'Numbered list',tok:'# ',desc:'One step per line',
init:()=>({items:'First step\nSecond step'}),
sum:b=>lines(b.items).length+' items',
form:b=>fmtBar()+fArea('Items','items',b.items,'One item per line. Start a line with * to indent it a level.',4),
wiki:b=>listWiki(b.items,'#'),
html:(b,c)=>listHtml(b.items,'ol',c)},
quote:{label:'Quote',tok:'<blockquote>',desc:'An indented quotation',
init:()=>({text:'',source:'',fmt:'blockquote'}),
sum:b=>b.text,
form:b=>`${fmtBar()}${fArea('Quotation','text',b.text,'',3)}<div class="row">${fText('Source (optional)','source',b.source,'Who said it')}${fSelect('Markup','fmt',b.fmt,[['blockquote','<blockquote> (any wiki)'],['template','{\u007BQuote}} (needs that template)']])}</div>`,
wiki:b=>{const t=b.text.trim(),s=b.source.trim();return b.fmt==='template'?`{\u007BQuote|text=${t}${s?`|author=${s}`:''}}}`:`<blockquote>${t}${s?`<br />— ${s}`:''}</blockquote>`},
html:(b,c)=>`<blockquote><p>${c.inl(b.text)}</p>${b.source.trim()?`<p class="qsrc">— ${c.inl(b.source)}</p>`:''}</blockquote>`},
code:{label:'Code block',tok:'<syntaxhighlight>',desc:'Syntax-highlighted code',
init:()=>({lang:'python',fmt:'syntaxhighlight',code:'',line:false}),
sum:b=>b.fmt==='pre'?'pre':b.lang,
form:b=>`<div class="row">${fSelect('Markup','fmt',b.fmt,[['syntaxhighlight','<syntaxhighlight>'],['pre','<pre> (any wiki)']])}${fSelect('Language','lang',b.lang,LANGS.concat(LANGS.includes(b.lang)?[]:[b.lang]).map(x=>[x,x]))}</div>${fCheck('Show line numbers','line',b.line)}${fArea('Code','code',b.code,'',6,true)}`,
wiki:b=>b.fmt==='pre'?`<pre>\n${b.code}\n</pre>`:`<syntaxhighlight lang="${b.lang}"${b.line?' line':''}>\n${b.code}\n</syntaxhighlight>`,
html:b=>`<pre class="code">${esc(b.code)}</pre>`},
infobox:{label:'Infobox',tok:'<infobox>',desc:'A Portable Infobox with any labels you like',
init:()=>({mode:'generic',template:'Infobox',name:'',image:'',caption:'',rows:[{k:'',v:''}],layout:'',accentOn:false,accent:'#3366cc'}),
sum:b=>b.name||b.template,
form:b=>{const g=b.mode!=='dedicated';const nm=tdKey(b.template)||'Infobox';const tn=`<b data-tplname>Template:${esc(nm)}</b>`;
return `<div class="row">${fSelect('Kind','mode',b.mode,[['generic','Generic: any labels'],['dedicated','Dedicated: fixed field names']],true)}${fText('Template name','template',b.template,g?'Infobox':'Infobox character')}</div><div class="row">${fText('Title','name',b.name,'Leave blank to use the page name')}${fText('Image file','image',b.image,'Example.jpg')}</div>${fText('Image caption','caption',b.caption)}${fmtBar()}<p class="sub">${g?'Rows: type any label you like':'Fields'}</p>${kvRows(b.rows,'rows','delrow',g?'Label':'field_name',g?'Value':'value')}<div><button type="button" class="add" data-act="addrow">${g?'Add row':'Add field'}</button></div>${g?'':tdFillBtn(b)}<details class="tplcode"${openTpl.has(b.id)?' open':''}><summary>Design the template</summary><p class="note">${g?`This is the code for ${tn}. Save it to the wiki once, and any page can use the box with its own labels. It holds ${Math.max(20,b.rows.length)} rows, and grows if a page ever needs more.`:`This is the code for ${tn}. Save it to the wiki once and every page of this kind can use it. Its TemplateData gives VisualEditor and this builder a form for the fields.`}</p>${infoboxDesigner(b)}<pre class="mini" data-tplcode>${esc(infoboxTemplate(b))}</pre><div class="btnrow"><button type="button" class="btn" data-act="copytpl">Copy template code</button>${hooks.saveTemplate?`<button type="button" class="btn primary" data-act="savetpl">Save to Template:${esc(nm)}</button>`:''}</div></details>`},
wiki:b=>{
const base=[['title',b.name],['image',stripFile(b.image)],['caption',b.caption]].filter(p=>String(p[1]).trim());
const rest=b.mode!=='dedicated'
?b.rows.filter(r=>r.k.trim()||r.v.trim()).flatMap((r,i)=>[[`label${i+1}`,r.k],[`data${i+1}`,r.v]])
:b.rows.filter(r=>r.k.trim()).map(r=>[r.k.trim(),r.v]);
const pairs=base.concat(rest);
const w=pairs.reduce((m,p)=>Math.max(m,p[0].length),0);
return [`{\u007B${b.template.trim()||'Infobox'}`,...pairs.map(([k,v])=>`| ${k.padEnd(w)} = ${String(v).trim()}`),'}}'].join('\n')},
html:(b,c)=>{const g=b.mode!=='dedicated';
const rows=g?b.rows.filter(r=>r.v.trim()).map(r=>`<tr><th scope="row">${c.inl(r.k)}</th><td>${c.inl(r.v)}</td></tr>`).join('')
:b.rows.filter(r=>r.k.trim()).map(r=>`${String(r.header||'').trim()?`<tr><th colspan="2" class="ib-head">${esc(r.header.trim())}</th></tr>`:''}<tr><th scope="row">${esc(String(r.label||'').trim()||niceKey(r.k))}</th><td>${c.inl(r.v)}</td></tr>`).join('');
return `<table class="infobox"${b.accentOn&&b.accent?` style="border-top:4px solid ${esc(b.accent)}"`:''}><caption>${b.name.trim()?c.inl(b.name):esc(state.title.trim()||'Page name')}</caption>${b.image.trim()?`<tr><td colspan="2" class="ib-img">${ph(stripFile(b.image))}${b.caption.trim()?`<div class="ib-cap">${c.inl(b.caption)}</div>`:''}</td></tr>`:''}${rows}</table>`}},
image:{label:'Image',tok:'[\u005BFile:]]',desc:'A picture with an optional caption',
init:()=>({file:'',caption:'',frame:'thumb',align:'',size:''}),
sum:b=>stripFile(b.file),
form:b=>`<div class="row">${fText('File name','file',b.file,'Example.jpg')}${fText('Caption','caption',b.caption)}</div><div class="row r-3">${fSelect('Style','frame',b.frame,[['thumb','Thumbnail'],['frame','Framed'],['frameless','Frameless'],['','Plain']])}${fSelect('Position','align',b.align,[['','Default'],['right','Right'],['left','Left'],['center','Centre'],['none','None']])}${fText('Width (px)','size',b.size,'250')}</div>${hooks.upload?UPBTN(false):''}${THUMBS}`,
wiki:b=>{const p=['File:'+(stripFile(b.file)||'Example.jpg')];if(b.frame)p.push(b.frame);if(b.align)p.push(b.align);
const s=b.size.trim().replace(/px$/i,'');if(s)p.push(/^\d+$/.test(s)?s+'px':b.size.trim());if(b.caption.trim())p.push(b.caption.trim());return `[\u005B${p.join('|')}]]`},
html:(b,c)=>{const framed=b.frame==='thumb'||b.frame==='frame';const al=b.align||(framed?'right':'');const w=(parseInt(b.size,10)||(b.frame==='frame'?300:220))+(framed?8:0);
return `<figure class="thumb${framed?' framed':''} al-${al||'inline'}" style="width:${w}px">${ph(stripFile(b.file))}${framed&&b.caption.trim()?`<figcaption>${c.inl(b.caption)}</figcaption>`:''}</figure>`}},
gallery:{label:'Gallery',tok:'<gallery>',desc:'A grid or slideshow of images',
init:()=>({items:'Example.jpg|First caption\nExample2.jpg|Second caption',mode:'',heights:'',caption:''}),
sum:b=>lines(b.items).length+' images',
form:b=>`<div class="row r-3">${fSelect('Style','mode',b.mode||'',GALLERY_MODES)}${fText('Caption (optional)','caption',b.caption||'')}${fText('Row height (px)','heights',b.heights||'','150')}</div>`+fArea('Images','items',b.items,'One image per line: File name.jpg|Caption',4,true)+(hooks.upload?UPBTN(true):'')+THUMBS,
wiki:b=>{let a='';if(b.mode)a+=` mode="${b.mode}"`;const h=String(b.heights||'').trim().replace(/px$/i,'');if(h)a+=` heights="${/^\d+$/.test(h)?h+'px':h}"`;
const cap=String(b.caption||'').trim();if(cap)a+=` caption="${cap.replace(/"/g,'"')}"`;
return `<gallery${a}>\n${lines(b.items).map(l=>l.trim()).join('\n')}\n</gallery>`},
html:(b,c)=>`${String(b.caption||'').trim()?`<p class="gallery-cap">${c.inl(b.caption)}</p>`:''}<div class="gallery">${lines(b.items).map(l=>{const [f,...cap]=l.split('|');return `<figure>${ph(stripFile(f))}<figcaption>${c.inl(cap.join('|'))}</figcaption></figure>`}).join('')}</div>`},
table:{label:'Table',tok:'{| |}',desc:'A wikitable with rows and columns',
init:()=>({caption:'',header:true,sortable:false,cells:[['Header 1','Header 2'],['','']],align:[]}),
sum:b=>b.caption||`${b.cells.length} rows, ${(b.cells[0]||[]).length} columns`,
form:b=>{const w=(b.cells[0]||[]).length,al=b.align||[];
return `${fText('Caption (optional)','caption',b.caption)}<div class="checks">${fCheck('First row is a header','header',b.header,true)}${fCheck('Sortable columns','sortable',b.sortable)}</div>${fmtBar()}<div class="grid-wrap"><table class="cells"><tr class="alignrow">${Array.from({length:w},(x,j)=>`<td><select data-path="align.${j}" aria-label="Alignment of column ${j+1}">${[['','Align left'],['center','Centre'],['right','Align right']].map(([v,t])=>`<option value="${v}"${(al[j]||'')===v?' selected':''}>${t}</option>`).join('')}</select></td>`).join('')}<td></td></tr>${b.cells.map((r,i)=>`<tr>${r.map((v,j)=>`<td><input type="text" data-path="cells.${i}.${j}" value="${esc(v)}" aria-label="Row ${i+1}, column ${j+1}"${i===0&&b.header?' class="hcell"':''}></td>`).join('')}<td class="trow-tools"><button type="button" class="icon" data-act="trowup" data-i="${i}" aria-label="Move row ${i+1} up" title="Move row up">${ICON.up}</button><button type="button" class="icon" data-act="trowdown" data-i="${i}" aria-label="Move row ${i+1} down" title="Move row down">${ICON.down}</button><button type="button" class="icon del" data-act="trowdel" data-i="${i}" aria-label="Remove row ${i+1}" title="Remove row">${ICON.x}</button></td></tr>`).join('')}</table></div><p class="hint">Tip: copy cells from a spreadsheet and paste them into any cell here.</p><div class="btnrow"><button type="button" class="add" data-act="addtrow">Add row</button><button type="button" class="add" data-act="addcol">Add column</button><button type="button" class="add" data-act="delcol">Remove last column</button></div>`},
wiki:b=>{const out=[`{| class="wikitable${b.sortable?' sortable':''}"`];if(b.caption.trim())out.push('|+ '+b.caption.trim());const al=b.align||[];
b.cells.forEach((r,i)=>{if(i>0)out.push('|-');out.push(i===0&&b.header?'! '+r.map(x=>x.trim()).join(' !! '):'| '+r.map((x,j)=>al[j]?`style="text-align:${al[j]}" | ${x.trim()}`:x.trim()).join(' || '))});
out.push('|}');return out.join('\n')},
html:(b,c)=>{const al=b.align||[];return `<div class="tablewrap"><table class="wikitable">${b.caption.trim()?`<caption>${c.inl(b.caption)}</caption>`:''}${b.cells.map((r,i)=>`<tr>${r.map((x,j)=>i===0&&b.header?`<th>${c.inl(x)}</th>`:`<td${al[j]?` style="text-align:${al[j]}"`:''}>${c.inl(x)}</td>`).join('')}</tr>`).join('')}</table></div>`}},
template:{label:'Template',tok:'{\u007B }}',desc:'Any template from your wiki',
init:()=>({name:'',params:[{k:'',v:''}]}),
sum:b=>b.name,
form:templateForm,
wiki:b=>{const ps=b.params.filter(p=>(p.k.trim()||p.v.trim())&&!(p.td&&!p.v.trim()));const n=b.name.trim()||'Template name';
if(ps.filter(p=>p.k.trim()).length>=3)return `{\u007B${n}\n${ps.map(p=>`| ${p.k.trim()?p.k.trim()+' = ':''}${p.v.trim()}`).join('\n')}\n}}`;
return `{\u007B${n}${ps.map(p=>'|'+(p.k.trim()?p.k.trim()+'=':'')+p.v.trim()).join('')}}}`},
html:b=>`<div class="tpl"><code>{\u007B${esc(b.name.trim()||'Template name')}}}</code> renders here once the page is saved on your wiki.</div>`},
note:{label:'Note box',tok:'<div>',desc:'A coloured box for tips, warnings or notices',
init:()=>({kind:'info',title:'',text:''}),
sum:b=>b.title||b.text,
form:b=>`<div class="row r-wide">${fText('Title (optional)','title',b.title,'Heads up')}${fSelect('Colour','kind',b.kind,[['info','Blue'],['success','Green'],['warning','Yellow'],['danger','Red'],['plain','Grey']])}</div>${fmtBar()}${fArea('Text','text',b.text,'What the box says',3)}`,
wiki:b=>{const k=NOTE_STYLES[b.kind]?b.kind:'info',t=b.title.trim(),x=b.text.trim();return `<div class="pb-note pb-note-${k}" style="${noteStyle(k)}">${t?`'''${t}'''\n\n`:''}${x}</div>`},
html:(b,c)=>`<div style="${noteStyle(b.kind)}">${b.title.trim()?`<p><b>${c.inl(b.title)}</b></p>`:''}${b.text.trim().split(/\n\s*\n/).map(x=>`<p>${c.inl(x)}</p>`).join('')}</div>`},
collapsible:{label:'Collapsible section',tok:'mw-collapsible',desc:'A box readers can open and close',
init:()=>({title:'More details',text:'',collapsed:true}),
sum:b=>b.title,
form:b=>`${fText('Title','title',b.title,'Shown when the box is closed')}${fCheck('Start closed','collapsed',b.collapsed)}${fmtBar()}${fArea('Content','text',b.text,'Anything: text, lists, images, tables…',4)}`,
wiki:b=>`<div class="mw-collapsible${b.collapsed?' mw-collapsed':''}" style="${COLL_STYLE}">\n<div style="font-weight:bold">${b.title.trim()||'Details'}</div>\n<div class="mw-collapsible-content">\n${b.text.trim()}\n</div>\n</div>`,
html:(b,c)=>`<div class="coll"><div class="coll-title">${c.inl(b.title.trim()||'Details')} <span class="muted">[${b.collapsed?'show':'hide'}]</span></div>${b.collapsed?'':b.text.trim().split(/\n\s*\n/).map(x=>`<p>${c.inl(x)}</p>`).join('')}</div>`},
hatnote:{label:'Main article link',tok:":''Main''",desc:'Points readers to a more detailed page',
init:()=>({kind:'Main',target:'',markup:'plain'}),
sum:b=>b.target,
form:b=>`<div class="row">${fSelect('Type','kind',b.kind,[['Main','Main article'],['See also','See also'],['Further','Further information']])}${fText('Page name','target',b.target,'Detailed page')}</div>${fSelect('Markup','markup',b.markup,[['plain','Plain wikitext (works now)'],['template','{\u007BMain}} / {\u007BSee also}} / {\u007BFurther}} (needs those templates)']])}`,
wiki:b=>{const t=b.target.trim()||'Page name';return b.markup==='template'?`{\u007B${b.kind}|${t}}}`:`:''${({Main:'Main article','See also':'See also',Further:'Further information'})[b.kind]}: [\u005B${t}]]''`},
html:b=>`<div class="hatnote">${{Main:'Main article','See also':'See also',Further:'Further information'}[b.kind]}: <a class="wl">${esc(b.target.trim()||'Page name')}</a></div>`},
toc:{label:'Contents box',tok:'__TOC__',desc:'Control the table of contents',
init:()=>({mode:'__TOC__'}),
sum:b=>({__TOC__:'Placed here',__FORCETOC__:'Always shown',__NOTOC__:'Hidden'})[b.mode],
form:b=>fSelect('Behaviour','mode',b.mode,[['__TOC__','Put the contents box here'],['__FORCETOC__','Always show it, even with few sections'],['__NOTOC__','Hide the contents box']]),
wiki:b=>b.mode,
html:(b,c)=>{if(b.mode==='__NOTOC__')return '';const ct=[0,0,0,0];
const items=c.heads.map(h=>{const i=Math.max(0,Math.min(3,(+h.level||2)-2));ct[i]++;for(let j=i+1;j<4;j++)ct[j]=0;
return `<li style="padding-left:${i*1.1}em"><span class="n">${ct.slice(0,i+1).map(n=>n||1).join('.')}</span>${esc(h.text.trim()||'Section')}</li>`}).join('');
return `<div class="toc"><div class="toctitle">Contents</div>${items?`<ul>${items}</ul>`:'<span class="muted">No sections yet</span>'}</div>`}},
refs:{label:'References',tok:'<references/>',desc:'Lists every <ref> on the page',
init:()=>({heading:true,style:'references'}),
sum:b=>b.style==='reflist'?'{\u007BReflist}}':'<references />',
form:b=>`<div class="row">${fSelect('Markup','style',b.style,[['references','<references /> (any wiki)'],['reflist','{\u007BReflist}} (needs that template)']])}<div class="field"><span> </span>${fCheck('Add a “References” heading','heading',b.heading)}</div></div>`,
wiki:b=>`${b.heading?'== References ==\n':''}${b.style==='reflist'?'{\u007BReflist}}':'<references />'}`,
html:(b,c)=>`${b.heading?'<h2>References</h2>':''}${c.refs.length?`<ol class="refs">${c.refs.map(r=>`<li>${fmt(r,c)}</li>`).join('')}</ol>`:'<p class="muted">Add references to paragraphs with the Reference button.</p>'}`},
categories:{label:'Categories',tok:'[\u005BCategory:]]',desc:'File the page under categories',
init:()=>({items:''}),
sum:b=>lines(b.items).join(', '),
form:b=>fArea('Categories','items',b.items,'One category per line',3),
wiki:b=>lines(b.items).map(l=>`[\u005BCategory:${l.trim().replace(/^Category:/i,'')}]]`).join('\n'),
html:(b,c)=>{lines(b.items).forEach(l=>c.cats.push(l.trim().replace(/^Category:/i,'')));return ''}},
cattree:{label:'Category tree',tok:'<categorytree>',desc:'An expandable tree of a category and what is in it',
init:()=>({cat:'',mode:'categories',depth:'1',hideroot:false}),
sum:b=>b.cat,
form:b=>`<div class="row r-3">${fText('Category','cat',b.cat,'Category name')}${fSelect('Show','mode',b.mode,[['categories','Subcategories only'],['pages','Subcategories and pages'],['all','Everything, including files'],['parents','Parent categories']])}${fSelect('Depth','depth',b.depth,[['0','0'],['1','1'],['2','2'],['3','3']])}</div>${fCheck('Hide the top category','hideroot',b.hideroot)}`,
wiki:b=>`<categorytree mode="${b.mode}"${b.depth!=='1'?` depth="${b.depth}"`:''}${b.hideroot?' hideroot="on"':''}>${b.cat.trim().replace(/^Category:/i,'')||'Category name'}</categorytree>`,
html:b=>`<div class="cattree"><span class="tw">▸</span> <a class="wl">${esc(b.cat.trim().replace(/^Category:/i,'')||'Category name')}</a> <span class="muted">(expands on your wiki)</span></div>`},
raw:{label:'Raw wikitext',tok:'</>',desc:'Any wikitext, kept exactly as written',
init:()=>({text:''}),
sum:b=>b.text.trim().split('\n')[0],
form:b=>fArea('Wikitext','text',b.text,'Anything the other blocks don’t cover. It goes into the page exactly as written.',5,true),
wiki:b=>b.text.replace(/^\n+/,'').replace(/\s+$/,''),
html:b=>b.text.trim()?`<div class="rawbox"><pre>${esc(b.text.replace(/^\n+/,'').replace(/\s+$/,''))}</pre></div>`:''},
rule:{label:'Horizontal rule',tok:'----',desc:'A dividing line',
init:()=>({}),
sum:()=>'',
form:()=>`<p class="note">Adds a horizontal line across the page.</p>`,
wiki:()=>'----',
html:()=>'<hr>'}
};
let state={title:'',blocks:[]};
const collapsed=new Set(),openTpl=new Set();
const mk=(type,props)=>Object.assign({id:uid(),type},DEFS[type].init(),props||{});
function example(){
return {title:'Sourdough bread',blocks:[
mk('infobox',{template:'Infobox',name:'Sourdough bread',image:'Sourdough loaf.jpg',caption:'A round country loaf',
rows:[{k:'Type',v:'Bread'},{k:'Main ingredient',v:'Flour, water, salt'},{k:'Leavening',v:'Wild yeast and [\u005Blactic acid bacteria]]'}]}),
mk('paragraph',{text:"'''Sourdough''' is a [\u005Bbread]] leavened by a naturally fermented starter rather than commercial [\u005Byeast]].<ref>Replace this with your source.</ref> The bacteria in the starter give the loaf its mildly sour flavour and help it keep longer."}),
mk('toc'),
mk('heading',{text:'History'}),
mk('paragraph',{text:'Naturally leavened bread is one of the oldest forms of raised bread, and was the usual way of baking until baker’s yeast became widely available.'}),
mk('heading',{text:'Method'}),
mk('image',{file:'Sourdough crumb.jpg',caption:'The open crumb of a well-proofed loaf'}),
mk('numbers',{items:'Feed the starter until it doubles\nMix starter with flour, water and salt\nLeave the dough to rise\n*Fold it every 30 minutes for the first two hours\nShape, proof overnight and bake in a hot oven'}),
mk('heading',{text:'Hydration',level:'3'}),
mk('table',{caption:'Typical hydration levels',sortable:true,cells:[['Style','Hydration','Crumb'],['Sandwich loaf','65%','Tight'],['Country loaf','75%','Open'],['Ciabatta','80%+','Very open']]}),
mk('heading',{text:'See also'}),
mk('bullets',{items:'[\u005BRye bread]]\n[\u005BSoda bread]]'}),
mk('refs'),
mk('categories',{items:'Breads\nFermented foods'})
]};
}
const BUILTIN_PRESETS=[
{name:'Person',description:'An infobox with a few details, About and Friends sections, and the People category.',
wikitext:"{\u007BInfobox\n| title = $TITLE\n| image = \n| caption = \n| label1 = Pronouns\n| data1 = \n| label2 = Location\n| data2 = \n| label3 = Interests\n| data3 = \n}}\n'''$TITLE''' is …\n\n== About ==\nWrite a few lines about them here.\n\n== Friends ==\n* [\u005BName of a friend]]\n\n[\u005BCategory:People]]"},
{name:'Article',description:'An introduction, Overview and History sections, See also, References and a category.',
wikitext:"'''$TITLE''' is …\n\n== Overview ==\nWhat it is and why it matters.\n\n== History ==\nHow it came about.\n\n== See also ==\n* [\u005BRelated page]]\n\n== References ==\n<references />\n\n[\u005BCategory:Category name]]"}
];
const EXAMPLE_PRESET={name:'Example: Sourdough bread',description:'A finished page that uses most kinds of blocks.',build:example};
function save(){}
function load(){return null}
function blockText(b){let w;try{w=DEFS[b.type].wiki(b)}catch(e){w=''}return b.src!=null&&w===b._g?b.src:w}
function gen(){
let out='',prev=null;
state.blocks.forEach(b=>{const w=blockText(b);if(!w||!w.trim())return;
if(out)out+=prev&&b.prevId===prev.id&&b.sep!=null?b.sep:'\n\n';
out+=w;prev=b});
return out;
}
function fmt(s,c){
return s
.replace(/'''''(.+?)'''''/g,'<b><i>$1</i></b>')
.replace(/'''(.+?)'''/g,'<b>$1</b>')
.replace(/''(.+?)''/g,'<i>$1</i>')
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g,'<a class="wl">$2</a>')
.replace(/\[\[([^\]]+)\]\]/g,'<a class="wl">$1</a>')
.replace(/\[(https?:\/\/[^\s\]]+) ([^\]]+)\]/g,'<a class="ext">$2</a>')
.replace(/\[(https?:\/\/[^\s\]]+)\]/g,()=>`<a class="ext">[${++c.ext}]</a>`)
.replace(/\{\{([^{}]+)\}\}/g,'<span class="tpl-inline">{\u007B$1}}</span>')
.replace(/<(\/?)(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();renderOutline();scheduleChecks();
if(hooks.onChange)hooks.onChange(w);
}
function renderPalette(){
$('#pb-palette').innerHTML=GROUPS.map(([id,name,types])=>`<div class="pal-group"><h2>${name}</h2><div class="pal-list">${types.map(t=>`<div class="pal-item" role="button" tabindex="0" draggable="true" data-type="${t}" title="${esc(DEFS[t].desc)}"><span class="tok">${esc(DEFS[t].tok)}</span><span>${DEFS[t].label}</span></div>`).join('')}</div></div>`).join('');
}
const blocksEl=$('#pb-blocks'),canvasEl=$('#pb-canvas');
function cardHtml(b){
const d=DEFS[b.type];const isC=collapsed.has(b.id);
return `<div class="block${isC?' collapsed':''}" data-id="${b.id}">
<div class="block-head">
<span class="grip" title="Drag to reorder">${ICON.grip}</span>
<button type="button" class="b-title" data-act="toggle" aria-expanded="${!isC}"><span class="name">${d.label}</span><span class="summary">${esc(String(d.sum(b)||'').slice(0,80))}</span><span class="foldnote"></span></button>
<span class="tools">${b.type==='heading'?`
<button type="button" class="icon" data-act="secup" aria-label="Move this section up" title="Move this section up">${ICON.up2}</button>
<button type="button" class="icon" data-act="secdown" aria-label="Move this section down" title="Move this section down">${ICON.down2}</button>
<button type="button" class="icon" data-act="fold" aria-pressed="${folded.has(b.id)}" aria-label="${folded.has(b.id)?'Show':'Hide'} the blocks in this section" title="${folded.has(b.id)?'Show':'Hide'} the blocks in this section">${folded.has(b.id)?ICON.unfold:ICON.fold}</button>`:''}
<button type="button" class="icon" data-act="up" aria-label="Move up" title="Move up">${ICON.up}</button>
<button type="button" class="icon" data-act="down" aria-label="Move down" title="Move down">${ICON.down}</button>
<button type="button" class="icon" data-act="dup" aria-label="Duplicate" title="Duplicate">${ICON.dup}</button>
<button type="button" class="icon del" data-act="del" aria-label="Delete" title="Delete">${ICON.x}</button>
</span>
</div>
<div class="block-body">${d.form(b)}</div>
</div>`;
}
function renderCanvas(){
blocksEl.innerHTML=state.blocks.length?state.blocks.map(cardHtml).join(''):
`<div class="empty"><strong>This page is empty</strong><p>Drag a block from the list into this space, or click one to add it at the end.</p></div>`;
blocksEl.querySelectorAll('[data-thumbs]').forEach(t=>fillThumbs(t.closest('.block')));
applyFold();
}
const folded=new Set();
const lvlOf=b=>b.type==='heading'?(+b.level||2):(b.type==='refs'&&b.heading?2:null);
function sectionEnd(i){const L=lvlOf(state.blocks[i])||2;for(let j=i+1;j<state.blocks.length;j++){const l=lvlOf(state.blocks[j]);if(l!=null&&l<=L)return j}return state.blocks.length}
function applyFold(){
let cur=null;const counts={};
state.blocks.forEach(b=>{const l=lvlOf(b);if(cur&&l!=null&&l<=cur.l)cur=null;
const card=cardOf(b.id);
if(cur){counts[cur.id]++;if(card)card.classList.add('fold-hid')}else if(card)card.classList.remove('fold-hid');
if(!cur&&b.type==='heading'&&folded.has(b.id)){cur={id:b.id,l};counts[b.id]=0}});
[...folded].forEach(id=>{if(!state.blocks.some(b=>b.id===id))folded.delete(id)});
blocksEl.querySelectorAll('.block').forEach(c=>{const s=c.querySelector('.foldnote');if(!s)return;const n=folded.has(c.dataset.id)?counts[c.dataset.id]||0:0;s.textContent=n?`${n} block${n===1?'':'s'} hidden`:''});
}
function revealBlock(id){const idx=state.blocks.findIndex(b=>b.id===id);let changed=false;
state.blocks.forEach((b,i)=>{if(b.type==='heading'&&folded.has(b.id)&&i<idx&§ionEnd(i)>idx){folded.delete(b.id);changed=true;const c=cardOf(b.id);if(c){const t=document.createElement('div');t.innerHTML=cardHtml(b);c.replaceWith(t.firstElementChild)}}});
if(changed)applyFold()}
function moveSection(i,dir){const L=lvlOf(state.blocks[i])||2,end=sectionEnd(i);
if(dir<0){let p=-1;for(let j=i-1;j>=0;j--){const l=lvlOf(state.blocks[j]);if(l!=null&&l<=L){if(l===L&&state.blocks[j].type==='heading')p=j;break}}
if(p<0){showToast('This is already the first section at its level.');return false}
const mine=state.blocks.splice(i,end-i);state.blocks.splice(p,0,...mine)}
else{const nb=state.blocks[end];if(!nb||nb.type!=='heading'||lvlOf(nb)!==L){showToast('This is already the last section at its level.');return false}
const theirs=state.blocks.splice(end,sectionEnd(end)-end);state.blocks.splice(i,0,...theirs)}
return true}
function commit(){renderCanvas();updateOutput();save();record()}
const hist={stack:[],i:-1,timer:null};
function record(){clearTimeout(hist.timer);hist.timer=null;const s=JSON.stringify(state);
if(hist.stack[hist.i]!==s){hist.stack=hist.stack.slice(0,hist.i+1);hist.stack.push(s);if(hist.stack.length>200)hist.stack.shift();hist.i=hist.stack.length-1}
histButtons()}
function recordSoon(){clearTimeout(hist.timer);hist.timer=setTimeout(record,500);histButtons()}
function resetHistory(){clearTimeout(hist.timer);hist.timer=null;hist.stack=[];hist.i=-1;record()}
function histButtons(){const u=$('#pb-undoBtn'),r=$('#pb-redoBtn');if(u)u.disabled=!(hist.i>0||hist.timer);if(r)r.disabled=hist.i>=hist.stack.length-1||!!hist.timer}
const cssq=x=>window.CSS&&CSS.escape?CSS.escape(x):String(x).replace(/["\\]/g,'\\$&');
function focusKey(){const a=document.activeElement;if(!a||!blocksEl.contains(a))return null;const c=a.closest('.block');if(!c)return null;
const sel=a.dataset.path?`[data-path="${cssq(a.dataset.path)}"]`:a.dataset.tdkey!=null?`[data-tdkey="${cssq(a.dataset.tdkey)}"]`:a.dataset.act?`[data-act="${cssq(a.dataset.act)}"]`:null;
return sel?{id:c.dataset.id,sel,pos:a.selectionStart,end:a.selectionEnd}:null}
function refocus(k){if(!k)return;const c=cardOf(k.id);const f=c&&c.querySelector(k.sel);if(!f)return;f.focus({preventScroll:true});
if(k.pos!=null&&f.setSelectionRange){try{f.setSelectionRange(Math.min(k.pos,f.value.length),Math.min(k.end,f.value.length))}catch(e){}}}
function restoreFrom(s){const k=focusKey();state=JSON.parse(s);titleEl.value=state.title;renderCanvas();updateOutput();save();refocus(k);histButtons()}
function undo(){acHide();if(hist.timer)record();if(hist.i>0){hist.i--;restoreFrom(hist.stack[hist.i])}}
function redo(){acHide();if(hist.timer)record();if(hist.i<hist.stack.length-1){hist.i++;restoreFrom(hist.stack[hist.i])}}
function rerenderCard(id){const c=cardOf(id),b=state.blocks.find(x=>x.id===id);if(!c||!b)return;const k=focusKey();
const t=document.createElement('div');t.innerHTML=cardHtml(b);const n=t.firstElementChild;c.replaceWith(n);if(k&&k.id===id)refocus(k);fillThumbs(n);applyFold()}
function fillThumbs(card){if(!card||!hooks.thumb)return;const box=card.querySelector('[data-thumbs]');if(!box)return;
const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
const files=(b.type==='image'?[b.file]:lines(b.items).map(l=>l.split('|')[0])).map(f=>stripFile(f).trim()).filter(Boolean).slice(0,12);
box.innerHTML=files.map(f=>`<figure class="tb" data-file="${esc(f)}"><span class="wait">Loading…</span><figcaption>${esc(f)}</figcaption></figure>`).join('');
box.querySelectorAll('.tb').forEach(fig=>{hooks.thumb(fig.dataset.file).then(r=>{const slot=fig.firstElementChild;if(!slot||slot.tagName==='FIGCAPTION')return;
if(r&&r.url){const img=document.createElement('img');img.src=r.url;img.alt='';img.loading='lazy';slot.replaceWith(img)}
else{slot.className='miss';slot.textContent='Not on this wiki'}},
()=>{const slot=fig.firstElementChild;if(slot&&slot.tagName!=='FIGCAPTION'){slot.className='miss';slot.textContent='Couldn’t load'}})})}
const smooth=()=>window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth';
function renderOutline(){const el=$('#pb-outline');if(!el)return;
const hs=state.blocks.filter(b=>b.type==='heading'||(b.type==='refs'&&b.heading));
el.innerHTML=hs.length?hs.map(b=>{const t=b.type==='refs'?'References':(b.text.trim()||'Untitled section');const l=b.type==='refs'?2:Math.min(5,Math.max(2,+b.level||2));
return `<li class="l${l}"><button type="button" data-goto="${b.id}" title="${esc(t)}">${esc(t)}</button></li>`}).join(''):'<li><p class="outline-empty">Headings you add show up here.</p></li>'}
function gotoBlock(id){revealBlock(id);const c=cardOf(id);if(!c)return;
if(collapsed.has(id)){collapsed.delete(id);c.classList.remove('collapsed');const t=c.querySelector('[data-act="toggle"]');if(t)t.setAttribute('aria-expanded','true')}
if(c.scrollIntoView)c.scrollIntoView({block:'start',behavior:smooth()});flash(id);
const f=c.querySelector('.block-body [data-path], .block-body [data-tdkey]');if(f)f.focus({preventScroll:true})}
const cardOf=id=>blocksEl.querySelector(`.block[data-id="${id}"]`);
function flash(id){const c=cardOf(id);if(!c)return;c.classList.remove('fresh');void c.offsetWidth;c.classList.add('fresh');setTimeout(()=>c.classList.remove('fresh'),1200)}
function addBlock(type,idx){
const b=mk(type);state.blocks.splice(idx,0,b);commit();revealBlock(b.id);
const c=cardOf(b.id);
if(c){c.scrollIntoView({block:'nearest',behavior:'smooth'});flash(b.id);
const f=c.querySelector('.block-body [data-path]');if(f)f.focus({preventScroll:true});}
}
function setPath(o,path,v){const p=path.split('.');let t=o;for(let i=0;i<p.length-1;i++){if(t[p[i]]==null)t[p[i]]=/^\d+$/.test(p[i+1])?[]:{};t=t[p[i]]}t[p[p.length-1]]=v}
function onField(e){
const el=e.target.closest('[data-path]');if(!el)return;
const card=el.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
setPath(b,el.dataset.path,el.type==='checkbox'?el.checked:el.value);
if(el.dataset.rerender){renderCanvas();const n=cardOf(b.id);const f=n&&n.querySelector(`[data-path="${el.dataset.path}"]`);if(f)f.focus()}
else{const s=card.querySelector('.summary');if(s)s.textContent=String(DEFS[b.type].sum(b)||'').slice(0,80);
const tc=card.querySelector('[data-tplcode]');if(tc)tc.textContent=infoboxTemplate(b);
const tn=card.querySelector('[data-tplname]');if(tn)tn.textContent='Template:'+(tdKey(b.template)||'Infobox');
const sb=card.querySelector('[data-act="savetpl"]');if(sb&&!sb.classList.contains('armed'))sb.textContent='Save to Template:'+(tdKey(b.template)||'Infobox')}
updateOutput();save();recordSoon();
}
blocksEl.addEventListener('input',onField);
blocksEl.addEventListener('input',e=>{const el=e.target;if(!el.dataset||el.dataset.tdkey==null)return;
const card=el.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
const k=el.dataset.tdkey,cur=b.params.find(p=>p.k.trim()===k);
if(cur){cur.v=el.value;cur.td=true}
else if(el.value.trim()){const e=tdFor(b.name),order=e&&e.data?e.data.order:[],oi=order.indexOf(k);let at=b.params.length;
if(oi>=0)for(let j=0;j<b.params.length;j++){if(order.indexOf(b.params[j].k.trim())>oi){at=j;break}}
b.params.splice(at,0,{k,v:el.value,td:true});if(at<b.params.length-1)setTimeout(()=>rerenderCard(b.id),0)}
const s=card.querySelector('.summary');if(s)s.textContent=String(DEFS[b.type].sum(b)||'').slice(0,80);
updateOutput();save();recordSoon()});
blocksEl.addEventListener('change',e=>{const el=e.target;if(!el.dataset||!el.dataset.path)return;
const card=el.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
if(hooks.templateData&&((b.type==='template'&&el.dataset.path==='name')||(b.type==='infobox'&&el.dataset.path==='template')))setTimeout(()=>rerenderCard(b.id),0);
else if((b.type==='image'&&el.dataset.path==='file')||(b.type==='gallery'&&el.dataset.path==='items'))fillThumbs(card);
else if(b.type==='infobox'&&b.mode==='dedicated'&&/^rows\.\d+\.k$/.test(el.dataset.path))setTimeout(()=>rerenderCard(b.id),0)});
blocksEl.addEventListener('change',e=>{if(e.target.matches('select,input[type=checkbox]'))onField(e)});
let lastField=null;
const FIELD_SEL='textarea[data-path],input[type=text][data-path]';
blocksEl.addEventListener('focusin',e=>{if(e.target.matches&&e.target.matches(FIELD_SEL))lastField=e.target});
function fieldFor(card){
if(lastField&&lastField.isConnected&&card.contains(lastField))return lastField;
return card.querySelector('.block-body textarea[data-path]')||card.querySelector('.block-body input[type=text][data-path]');
}
const WRAP={bold:["'''","'''",'bold text'],italic:["''","''",'italic text'],under:['<u>','</u>','underlined text'],strike:['<s>','</s>','struck-out text'],
sup:['<sup>','</sup>','2'],sub:['<sub>','</sub>','2'],small:['<small>','</small>','small text'],big:['<big>','</big>','big text'],code:['<code>','</code>','code'],
link:['[\u005B',']]','Page name'],ext:['[https://example.com ',']','link text'],ref:['<ref>','</ref>','Source details'],tpl:['{\u007B','}}','Template name'],nowiki:['<nowiki>','</nowiki>','text']};
function applyFmt(card,kind,arg){
if(kind==='ref'){openCite(card);return}
const f=fieldFor(card);if(!f)return;
const v=f.value,s=f.selectionStart==null?v.length:f.selectionStart,e=f.selectionEnd==null?v.length:f.selectionEnd,sel=v.slice(s,e);
let nv,ns,ne;
if(kind==='br'||kind==='sym'){const ins=kind==='br'?'<br />':arg;nv=v.slice(0,s)+ins+v.slice(e);ns=ne=s+ins.length}
else if(kind==='clear'){
if(!sel){f.focus();return}
const c=sel.replace(/'{2,}/g,'').replace(/<\/?(u|s|del|ins|sup|sub|small|big|code|span|nowiki|b|i|mark)\b[^>]*>/gi,'');
nv=v.slice(0,s)+c+v.slice(e);ns=s;ne=s+c.length}
else{
const [a,z,d]=kind==='color'?[`<span style="color:${arg}">`,'</span>','coloured text']:kind==='bg'?[`<span style="background-color:${arg}">`,'</span>','highlighted text']:WRAP[kind];
const q=a[0]==="'";
const around=v.slice(s-a.length,s)===a&&v.slice(e,e+z.length)===z&&!(q&&(v[s-a.length-1]==="'"||v[e+z.length]==="'"));
const within=sel.length>=a.length+z.length&&sel.startsWith(a)&&sel.endsWith(z)&&!(q&&(sel[a.length]==="'"||sel[sel.length-z.length-1]==="'"));
if(around){nv=v.slice(0,s-a.length)+sel+v.slice(e+z.length);ns=s-a.length;ne=ns+sel.length}
else if(within){const inner=sel.slice(a.length,sel.length-z.length);nv=v.slice(0,s)+inner+v.slice(e);ns=s;ne=s+inner.length}
else{const inner=sel||d;nv=v.slice(0,s)+a+inner+z+v.slice(e);ns=s+a.length;ne=ns+inner.length}
}
f.value=nv;f.focus();try{f.setSelectionRange(ns,ne)}catch(x){}
f.dispatchEvent(new Event('input',{bubbles:true}));
}
blocksEl.addEventListener('change',e=>{const t=e.target;const card=t.closest&&t.closest('.block');if(!card)return;
if(t.matches('input[data-fmtcolor]'))applyFmt(card,t.dataset.fmtcolor,t.value);
else if(t.matches('select[data-fmtsym]')){if(t.value)applyFmt(card,'sym',t.value);t.value=''}});
blocksEl.addEventListener('keydown',e=>{
if(!(e.ctrlKey||e.metaKey)||e.altKey||e.shiftKey)return;
const f=e.target;if(!f.matches||!f.matches(FIELD_SEL))return;
const card=f.closest('.block');if(!card||!card.querySelector('.fmtbar'))return;
const k={b:'bold',i:'italic',u:'under',k:'link'}[e.key.toLowerCase()];if(!k)return;
e.preventDefault();lastField=f;applyFmt(card,k);
});
const AC_KIND={image:{file:'file'},infobox:{template:'template',image:'file'},template:{name:'template'},hatnote:{target:'page'},cattree:{cat:'category'},gallery:{items:'fileline'},categories:{items:'catline'}};
const acEl=$('#pb-acList');let ac=null,acSeq=0,acTimer=null,acSkip=false;
function acContext(f){
const card=f.closest('.block');const b=card&&state.blocks.find(x=>x.id===card.dataset.id);if(!b)return null;
const v=f.value,c=f.selectionStart;if(c==null||c!==f.selectionEnd)return null;
const before=v.slice(0,c),rest=v.slice(c);
const kind=f.dataset.ac||(AC_KIND[b.type]||{})[f.dataset.path];
if(kind==='file'||kind==='template'||kind==='page'||kind==='category')
return {kind,q:before.replace(/^\s*(File|Image|Category|Template)\s*:/i,'').trim(),from:0,to:v.length,after:''};
if(kind==='fileline'||kind==='catline'){
const ls=before.lastIndexOf('\n')+1,lb=before.slice(ls);if(kind==='fileline'&&lb.includes('|'))return null;
const le=v.indexOf('\n',c),end=le<0?v.length:le;let to=end;
if(kind==='fileline'){const pi=v.slice(ls,end).indexOf('|');if(pi>=0)to=ls+pi}
return {kind:kind==='fileline'?'file':'category',q:lb.replace(/^\s*(File|Image|Category)\s*:/i,'').trim(),from:ls,to,after:''}}
let m=before.match(/\[\[([^\[\]|\n{}]*)$/);
if(m){let q=m[1],k='page',skip=0;const ns=q.match(/^\s*:?\s*(File|Image|Media|Category)\s*:\s*/i);
if(ns){k=/^c/i.test(ns[1])?'category':'file';skip=ns[0].length}
const tail=rest.match(/^[^\[\]\n|#]*/)[0];
return {kind:k,q:q.slice(skip).trim(),from:c-m[1].length+skip,to:c+tail.length,after:/^[^\[\]\n|#]*(\]\]|\||#)/.test(rest)?'':']]'}}
m=before.match(/\{\{([^{}|\n]*)$/);
if(m&&!/[:#]/.test(m[1])){const tail=rest.match(/^[^{}\n|]*/)[0];
return {kind:'template',q:m[1].trim(),from:c-m[1].length,to:c+tail.length,after:/^[^{}\n|]*(\}\}|\|)/.test(rest)?'':'}}'}}
return null;
}
function acHide(){if(acEl)acEl.hidden=true;if(ac&&ac.f){ac.f.removeAttribute('aria-activedescendant');ac.f.setAttribute('aria-expanded','false')}ac=null}
function acQuery(f){
if(!hooks.search||!acEl)return;
const ctx=acContext(f);if(!ctx||!ctx.q){acHide();return}
const my=++acSeq;clearTimeout(acTimer);
acTimer=setTimeout(()=>{hooks.search(ctx.kind,ctx.q).then(items=>{
if(my!==acSeq||document.activeElement!==f)return;
items=(items||[]).slice(0,8);if(!items.length){acHide();return}
acShow(f,ctx,items)},()=>{})},180);
}
function acShow(f,ctx,items){
ac={f,ctx,items,i:0};
acEl.innerHTML=items.map((t,i)=>`<div class="ac-opt${i?'':' on'}" role="option" id="${acEl.id}-o${i}" data-i="${i}" aria-selected="${i===0}">${esc(t)}</div>`).join('');
acEl.hidden=false;
const r=f.getBoundingClientRect(),w=Math.max(220,Math.min(420,r.width));
acEl.style.width=w+'px';acEl.style.left=Math.max(8,Math.min(r.left,window.innerWidth-w-8))+'px';
const h=acEl.offsetHeight;acEl.style.top=(r.bottom+2+h>window.innerHeight&&r.top-h-2>0?r.top-h-2:r.bottom+2)+'px';
f.setAttribute('aria-expanded','true');f.setAttribute('aria-controls',acEl.id);f.setAttribute('aria-autocomplete','list');f.setAttribute('aria-activedescendant',acEl.id+'-o0');
}
function acMove(d){if(!ac)return;ac.i=(ac.i+d+ac.items.length)%ac.items.length;
acEl.querySelectorAll('.ac-opt').forEach((o,i)=>{o.classList.toggle('on',i===ac.i);o.setAttribute('aria-selected',String(i===ac.i))});
const on=acEl.querySelector('.ac-opt.on');if(on){if(on.scrollIntoView)on.scrollIntoView({block:'nearest'});ac.f.setAttribute('aria-activedescendant',on.id)}}
function acAccept(i){if(!ac)return;const f=ac.f,ctx=ac.ctx,t=ac.items[i==null?ac.i:i];acHide();
const v=f.value,ins=t+ctx.after;f.value=v.slice(0,ctx.from)+ins+v.slice(ctx.to);const pos=ctx.from+ins.length;
f.focus();try{f.setSelectionRange(pos,pos)}catch(e){}
acSkip=true;f.dispatchEvent(new Event('input',{bubbles:true}));f.dispatchEvent(new Event('change',{bubbles:true}));acSkip=false}
blocksEl.addEventListener('input',e=>{if(acSkip)return;const f=e.target;if(f.matches&&(f.matches(FIELD_SEL)||f.matches('[data-tdkey]')))acQuery(f)});
blocksEl.addEventListener('keydown',e=>{if(!ac||e.target!==ac.f)return;
if(e.key==='ArrowDown'){e.preventDefault();acMove(1)}
else if(e.key==='ArrowUp'){e.preventDefault();acMove(-1)}
else if(e.key==='Enter'||e.key==='Tab'){e.preventDefault();acAccept()}
else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();acHide()}},true);
blocksEl.addEventListener('focusout',()=>setTimeout(()=>{if(ac&&document.activeElement!==ac.f)acHide()},120));
if(acEl){acEl.addEventListener('mousedown',e=>e.preventDefault());
acEl.addEventListener('click',e=>{const o=e.target.closest('.ac-opt');if(o)acAccept(+o.dataset.i)})}
document.addEventListener('scroll',e=>{if(ac&&e.target!==acEl)acHide()},true);
window.addEventListener('resize',()=>acHide());
let checkTimer=null,checkSeq=0;const existCache=new Map();
function scheduleChecks(){clearTimeout(checkTimer);checkTimer=setTimeout(runChecks,700)}
function extractTitles(text){
const t=text.replace(/<nowiki>[\s\S]*?<\/nowiki>/gi,'').replace(/<!--[\s\S]*?-->/g,'').replace(/\{\{\{[^{}]*\}\}\}/g,'');
const pages=new Set(),files=new Set(),cats=new Set(),tpls=new Set();
t.replace(/\[\[\s*:?\s*([^\[\]|#\n{}]+?)\s*(?=\||#|\]\])/g,(m,name)=>{const n=name.trim();if(!n)return m;
const ns=n.match(/^(File|Image|Media|Category)\s*:\s*(.+)$/i);
if(ns)(/^c/i.test(ns[1])?cats:files).add(ns[2].trim());else pages.add(n);return m});
t.replace(/\{\{\s*([^{}|\n]+?)\s*(?=\||\}\})/g,(m,name)=>{const n=name.trim();if(n&&!/[:#]/.test(n)&&!/^[A-Z0-9 _!]+$/.test(n))tpls.add(n);return m});
return {pages:[...pages],files:[...files].map(f=>'File:'+f),cats:[...cats].map(c=>'Category:'+c),tpls:[...tpls].map(x=>'Template:'+x)};
}
function localIssues(text){
const out=[];
const listed=/<references\b|\{\{\s*reflist/i.test(text);
const refs=/<ref\b/i.test(text.replace(/<references\b[^>]*>/gi,''));
if(refs&&!listed)out.push({warn:true,msg:'This page has references but nowhere to list them. Add a References block.'});
if(listed&&!refs){const r=state.blocks.find(b=>b.type==='refs');out.push({msg:'There’s a References block, but no references on the page yet.',id:r&&r.id})}
let last=1;
state.blocks.forEach(b=>{
if(b.type==='heading'){const l=+b.level||2;
if(!b.text.trim())out.push({warn:true,msg:'A heading has no text.',id:b.id});
else if(l>last+1)out.push({msg:`“${b.text.trim()}” skips a heading level. The contents box and screen readers work best when levels go one step at a time.`,id:b.id});
last=l}
if(b.type==='refs'&&b.heading)last=2;
if(b.type==='image'&&!stripFile(b.file).trim())out.push({warn:true,msg:'An image block has no file name.',id:b.id});
if(b.type==='template'&&!b.name.trim())out.push({warn:true,msg:'A template block has no template name.',id:b.id});
if(b.type==='paragraph'&&!b.text.trim())out.push({msg:'A paragraph block is empty, so it’s left out of the page.',id:b.id});
});
return out;
}
function renderChecks(issues,note,pending){
const el=$('#pb-checks');if(!el)return;
issues.sort((a,b)=>(b.warn?1:0)-(a.warn?1:0));
const n=issues.filter(x=>x.warn).length;const tab=$('#pb-tab-chk');if(tab)tab.textContent=n?`Checks (${n})`:'Checks';
const link=t=>hooks.url?`<a href="${esc(hooks.url(t))}" target="_blank" rel="noopener">${esc(t)}</a>`:esc(t);
el.innerHTML=(issues.length?`<ul class="issues">${issues.map(x=>`<li class="issue${x.warn?' warn':''}"><p>${esc(x.msg)}</p>${x.titles?`<p class="titles">${x.titles.map(link).join(', ')}</p>`:''}${x.id?`<button type="button" class="linkbtn" data-goto="${x.id}">Show the block</button>`:''}</li>`).join('')}</ul>`:(pending?'':'<p class="checks-ok">No problems found.</p>'))
+(pending?'<p class="checks-ok">Checking links against your wiki…</p>':'')+(note?`<p class="checks-ok">${esc(note)}</p>`:'');
}
function runChecks(){
const text=gen(),issues=localIssues(text),my=++checkSeq;
if(!hooks.exists){renderChecks(issues,'Red links, missing images and missing templates are checked when the builder runs on your wiki.');return}
const T=extractTitles(text);
const all=[].concat(T.pages,T.files,T.cats,T.tpls);
const need=all.filter(t=>!existCache.has(t)).slice(0,500);
const finish=()=>{if(my!==checkSeq)return;const miss=a=>a.filter(t=>existCache.get(t)===false);
const mf=miss(T.files),mt=miss(T.tpls),mp=miss(T.pages),mc=miss(T.cats);
if(mf.length)issues.push({warn:true,msg:mf.length===1?'An image that isn’t uploaded to this wiki:':'Images that aren’t uploaded to this wiki:',titles:mf});
if(mt.length)issues.push({warn:true,msg:mt.length===1?'A template that doesn’t exist on this wiki yet:':'Templates that don’t exist on this wiki yet:',titles:mt});
if(mp.length)issues.push({msg:'Links to pages that don’t exist yet (red links):',titles:mp});
if(mc.length)issues.push({msg:'Category pages that don’t exist yet. The page is still filed under them:',titles:mc});
renderChecks(issues)};
if(!need.length){finish();return}
renderChecks(issues.slice(),null,true);
hooks.exists(need).then(map=>{need.forEach(t=>existCache.set(t,!map||map[t]!==false));finish()},
()=>{if(my===checkSeq)renderChecks(issues,'Couldn’t check links against your wiki just now.')});
}
let toastTimer=null,undoFn=null;
function showToast(msg,undo){
$('#pb-toastMsg').textContent=msg;undoFn=undo||null;$('#pb-toastUndo').hidden=!undo;$('#pb-toast').hidden=false;
clearTimeout(toastTimer);toastTimer=setTimeout(()=>{$('#pb-toast').hidden=true;undoFn=null},6000);
}
$('#pb-toastUndo').addEventListener('click',()=>{const f=undoFn;undoFn=null;$('#pb-toast').hidden=true;if(f)f()});
blocksEl.addEventListener('mousedown',e=>{if(e.target.closest('.fmtbar button'))e.preventDefault()});
blocksEl.addEventListener('toggle',e=>{const d=e.target;if(!d.matches||!d.matches('details.tplcode'))return;const id=d.closest('.block').dataset.id;d.open?openTpl.add(id):openTpl.delete(id)},true);
blocksEl.addEventListener('click',e=>{
const btn=e.target.closest('[data-act]');if(!btn)return;
const card=btn.closest('.block');if(!card)return;
const id=card.dataset.id,i=state.blocks.findIndex(x=>x.id===id),b=state.blocks[i];if(!b)return;
const act=btn.dataset.act;
const refocus=(sel)=>{const c=cardOf(id);const f=c&&c.querySelector(sel);if(f)f.focus()};
switch(act){
case 'toggle':collapsed.has(id)?collapsed.delete(id):collapsed.add(id);card.classList.toggle('collapsed');btn.setAttribute('aria-expanded',String(!collapsed.has(id)));return;
case 'up':if(i>0){[state.blocks[i-1],state.blocks[i]]=[state.blocks[i],state.blocks[i-1]];commit();refocus('[data-act="up"]')}return;
case 'down':if(i<state.blocks.length-1){[state.blocks[i+1],state.blocks[i]]=[state.blocks[i],state.blocks[i+1]];commit();refocus('[data-act="down"]')}return;
case 'dup':{const c=JSON.parse(JSON.stringify(b));c.id=uid();state.blocks.splice(i+1,0,c);commit();flash(c.id);return}
case 'del':state.blocks.splice(i,1);collapsed.delete(id);commit();showToast(`${DEFS[b.type].label} deleted`,()=>{state.blocks.splice(Math.min(i,state.blocks.length),0,b);commit();flash(b.id)});return;
case 'fmt':applyFmt(card,btn.dataset.fmt);return;
case 'copytpl':copyText(infoboxTemplate(b)).then(ok=>{btn.textContent=ok?'Copied':'Select the code above to copy it';setTimeout(()=>btn.textContent='Copy template code',1800)});return;
case 'addrow':b.rows.push({k:'',v:''});commit();refocus(`[data-path="rows.${b.rows.length-1}.k"]`);return;
case 'secup':case 'secdown':if(moveSection(i,act==='secup'?-1:1)){commit();flash(id);refocus(`[data-act="${act}"]`)}return;
case 'fold':folded.has(id)?folded.delete(id):folded.add(id);rerenderCard(id);refocus('[data-act="fold"]');return;
case 'savetpl':saveTemplatePage(b,btn);return;
case 'trowup':{const r=+btn.dataset.i;if(r>0){[b.cells[r-1],b.cells[r]]=[b.cells[r],b.cells[r-1]];commit();refocus(`[data-act="trowup"][data-i="${r-1}"]`)}return}
case 'trowdown':{const r=+btn.dataset.i;if(r<b.cells.length-1){[b.cells[r+1],b.cells[r]]=[b.cells[r],b.cells[r+1]];commit();refocus(`[data-act="trowdown"][data-i="${r+1}"]`)}return}
case 'trowdel':if(b.cells.length>1){b.cells.splice(+btn.dataset.i,1);commit()}return;
case 'tdfill':{const m=tdMissing(b);if(m.length){b.rows=b.rows.filter(r=>r.k.trim()||r.v.trim());m.forEach(k=>b.rows.push({k,v:''}));commit()}return}
case 'delrow':b.rows.splice(+btn.dataset.i,1);if(!b.rows.length)b.rows.push({k:'',v:''});commit();return;
case 'addparam':b.params.push({k:'',v:''});commit();refocus(`[data-path="params.${b.params.length-1}.k"]`);return;
case 'delparam':b.params.splice(+btn.dataset.i,1);if(!b.params.length)b.params.push({k:'',v:''});commit();return;
case 'addtrow':b.cells.push(Array((b.cells[0]||['']).length).fill(''));commit();refocus(`[data-path="cells.${b.cells.length-1}.0"]`);return;
case 'addcol':b.cells.forEach(r=>r.push(''));commit();refocus(`[data-path="cells.0.${b.cells[0].length-1}"]`);return;
case 'deltrow':if(b.cells.length>1){b.cells.pop();commit()}return;
case 'delcol':if(b.cells[0].length>1){b.cells.forEach(r=>r.pop());if(b.align&&b.align.length>=b.cells[0].length+1)b.align.length=b.cells[0].length;commit()}return;
}
});
let drag=null,line=null;
const pal=$('#pb-palette');
pal.addEventListener('click',e=>{const it=e.target.closest('.pal-item');if(it)addBlock(it.dataset.type,state.blocks.length)});
pal.addEventListener('keydown',e=>{const it=e.target.closest('.pal-item');if(it&&(e.key==='Enter'||e.key===' ')){e.preventDefault();addBlock(it.dataset.type,state.blocks.length)}});
pal.addEventListener('dragstart',e=>{const it=e.target.closest('.pal-item');if(!it)return;drag={kind:'new',type:it.dataset.type};e.dataTransfer.effectAllowed='copy';e.dataTransfer.setData('text/plain',it.dataset.type)});
blocksEl.addEventListener('pointerdown',e=>{const g=e.target.closest('.grip');if(g)g.closest('.block').draggable=true});
document.addEventListener('pointerup',()=>blocksEl.querySelectorAll('.block[draggable="true"]:not(.dragging)').forEach(c=>c.draggable=false));
blocksEl.addEventListener('dragstart',e=>{
const card=e.target.closest&&e.target.closest('.block');if(!card||!card.draggable||e.target!==card)return;
drag={kind:'move',id:card.dataset.id};e.dataTransfer.effectAllowed='move';e.dataTransfer.setData('text/plain',card.dataset.id);
requestAnimationFrame(()=>card.classList.add('dragging'));
});
document.addEventListener('dragend',()=>{
blocksEl.querySelectorAll('.block').forEach(c=>{c.classList.remove('dragging');c.draggable=false});
hideLine();drag=null;
});
function targetFor(y){
for(const c of blocksEl.querySelectorAll('.block')){const r=c.getBoundingClientRect();if(y<r.top+r.height/2)return c.dataset.id}
return null;
}
function showLine(beforeId){
if(!line){line=document.createElement('div');line.className='drop-line'}
const ref=beforeId?cardOf(beforeId):null;
if(ref){if(line.nextSibling!==ref)blocksEl.insertBefore(line,ref)}
else if(blocksEl.lastChild!==line)blocksEl.appendChild(line);
canvasEl.classList.add('over');
}
function hideLine(){if(line&&line.parentNode)line.remove();canvasEl.classList.remove('over')}
canvasEl.addEventListener('dragover',e=>{if(!drag)return;e.preventDefault();e.dataTransfer.dropEffect=drag.kind==='new'?'copy':'move';showLine(targetFor(e.clientY))});
canvasEl.addEventListener('dragleave',e=>{if(!canvasEl.contains(e.relatedTarget))hideLine()});
canvasEl.addEventListener('drop',e=>{
if(!drag)return;e.preventDefault();
const before=targetFor(e.clientY);hideLine();const d=drag;drag=null;
if(d.kind==='new'){addBlock(d.type,before?state.blocks.findIndex(b=>b.id===before):state.blocks.length);return}
if(before===d.id)return;
const from=state.blocks.findIndex(b=>b.id===d.id);if(from<0)return;
const [m]=state.blocks.splice(from,1);
const to=before?state.blocks.findIndex(b=>b.id===before):state.blocks.length;
state.blocks.splice(to<0?state.blocks.length:to,0,m);commit();flash(m.id);
});
const LANGS=['text','bash','c','cpp','css','html','java','javascript','json','lua','php','python','ruby','sql','xml','yaml'];
function splitTop(s){const out=[];let cur='',d=0;
for(let i=0;i<s.length;){const two=s.substr(i,2);
if(two==='{\u007B'||two==='[\u005B'){d++;cur+=two;i+=2}
else if((two==='}}'||two===']]')&&d>0){d--;cur+=two;i+=2}
else if(s[i]==='|'&&d===0){out.push(cur);cur='';i++}
else{cur+=s[i];i++}}
out.push(cur);return out}
function topEq(s){let d=0;for(let i=0;i<s.length;i++){const two=s.substr(i,2);
if(two==='{\u007B'||two==='[\u005B'){d++;i++}else if((two==='}}'||two===']]')&&d>0){d--;i++}else if(s[i]==='='&&d===0)return i}return -1}
function matchPair(s,start,open,close){let d=0;for(let i=start;i<s.length;){
if(s.startsWith(open,i)){d++;i+=2}else if(s.startsWith(close,i)){d--;i+=2;if(d===0)return i}else i++}return -1}
function isBalanced(s){const c=re=>(s.match(re)||[]).length;
const refOpen=(s.match(/<ref\b[^>]*>/gi)||[]).filter(x=>!/\/\s*>$/.test(x)).length;
return c(/\{\{/g)<=c(/\}\}/g)&&refOpen<=c(/<\/ref\s*>/gi)&&c(/<!--/g)<=c(/-->/g)}
const attrsOf=s=>{const o={};String(s||'').replace(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g,(m,k,a,b,c)=>{o[k.toLowerCase()]=a!=null?a:b!=null?b:c!=null?c:true;return ''});return o};
const BLOCK_TAGS='div|center|table|noinclude|includeonly|onlyinclude|nowiki|math|poem|timeline|imagemap|inputbox|templatestyles|references|section|mapframe|score|hiero|chem|ce|graph|tabber|ul|ol|dl|p|h[1-6]|indicator|categorytree|gallery|syntaxhighlight|source|pre|blockquote';
function templateBlock(inner){
const parts=splitTop(inner);const name=parts[0].trim();
if(!name||/[:#{}<>\n|=\[\]]/.test(name))return null;
const params=parts.slice(1).map(p=>{const e=topEq(p);return e>=0?{k:p.slice(0,e).trim(),v:p.slice(e+1).trim()}:{k:'',v:p.trim()}});
if(params.some(p=>p.v.includes('\n')||p.k.includes('\n')))return null;
const lname=name.toLowerCase().replace(/_/g,' ');const pos=params.filter(p=>!p.k);
const get=k=>(params.find(p=>p.k===k)||{v:''}).v;
if(/^infobox\b/.test(lname)){
if(params.some(p=>/^(label|data)\d+$/.test(p.k))){
if(pos.length||params.some(p=>!/^(title|image|caption|(label|data)\d+)$/.test(p.k)))return ['template',{name,params}];
const nums=[...new Set(params.map(p=>(p.k.match(/^(?:label|data)(\d+)$/)||[])[1]).filter(Boolean).map(Number))].sort((a,b)=>a-b);
return ['infobox',{mode:'generic',template:name,name:get('title'),image:get('image'),caption:get('caption'),rows:nums.map(n=>({k:get('label'+n),v:get('data'+n)}))}];
}
if(pos.length)return ['template',{name,params}];
const rows=params.filter(p=>!['title','image','caption'].includes(p.k)).map(p=>({k:p.k,v:p.v}));
return ['infobox',{mode:'dedicated',template:name,name:get('title'),image:get('image'),caption:get('caption'),rows:rows.length?rows:[{k:'',v:''}]}];
}
const HAT={main:'Main','see also':'See also',further:'Further'};
if(own(HAT,lname)&¶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:[],a:[]};rows.push(cur)}
for(const c of cells){const parts=splitTop(c);if(parts.length>2)return null;
if(parts.length===2){const am=parts[0].trim().match(/^style\s*=\s*"\s*text-align\s*:\s*(left|center|right)\s*;?\s*"$/i);if(!am||h)return null;cur.c.push(parts[1].trim());cur.a.push(am[1].toLowerCase()==='left'?'':am[1].toLowerCase())}
else{cur.c.push(c.trim());cur.a.push('')}
cur.h.push(h)}continue}
return null}
if(!rows.length)return null;
const header=rows[0].h.every(Boolean);
if(!header&&rows[0].h.some(Boolean))return null;
if(rows.slice(1).some(r=>r.h.some(Boolean)))return null;
const w=Math.max(...rows.map(r=>r.c.length));const align=[];
for(let j=0;j<w;j++){const vals=new Set(rows.slice(header?1:0).map(r=>r.a[j]||''));if(vals.size>1)return null;align.push(vals.size?[...vals][0]:'')}
return {caption,header,sortable:cls.includes('sortable'),cells:rows.map(r=>r.c.concat(Array(w-r.c.length).fill(''))),align};
}
function tagBlock(tag,a,inner,attrText){
const keys=Object.keys(a);const trimNl=s=>s.replace(/^\n/,'').replace(/\n\s*$/,'');
switch(tag){
case 'gallery':{if(keys.some(k=>!['mode','heights','caption'].includes(k)))return null;const mode=a.mode&&a.mode!==true?String(a.mode).toLowerCase():'';
if(mode&&!GALLERY_MODES.some(x=>x[0]===mode))return null;
return ['gallery',{items:lines(inner).map(l=>l.trim()).join('\n'),mode,heights:a.heights&&a.heights!==true?String(a.heights):'',caption:a.caption&&a.caption!==true?String(a.caption).replace(/"/g,'"'):''}]}
case 'syntaxhighlight':case 'source':
if(!a.lang||a.lang===true||keys.some(k=>k!=='lang'&&k!=='line'))return null;
return ['code',{fmt:'syntaxhighlight',lang:String(a.lang),line:'line' in a,code:trimNl(inner)}];
case 'pre':return attrText?null:['code',{fmt:'pre',lang:'text',code:trimNl(inner)}];
case 'blockquote':{if(attrText)return null;const q=inner.match(/^([\s\S]*?)\s*<br\s*\/?>\s*(?:—|—)\s*([^\n<]+?)\s*$/i);
return ['quote',{fmt:'blockquote',text:(q?q[1]:inner).trim(),source:q?q[2]:''}]}
case 'categorytree':{if(keys.some(k=>!['mode','depth','hideroot'].includes(k))||inner.includes('\n'))return null;
const mode=String(a.mode||'categories').toLowerCase(),depth=String(a.depth==null?'1':a.depth);
if(!['categories','pages','all','parents'].includes(mode)||!/^[0-3]$/.test(depth))return null;
return ['cattree',{cat:inner.trim(),mode,depth,hideroot:!!a.hideroot&&a.hideroot!=='off'}]}
case 'references':return attrText||inner.trim()?null:['refs',{style:'references',heading:false}];
}
return null;
}
function parseWikitext(src){
const L=String(src).replace(/\r\n?/g,'\n').split('\n');
const out=[];let i=0;
const add=(type,props)=>out.push(mk(type,props));
const raw=t=>{if(t.trim())add('raw',{text:t.replace(/^\n+/,'').replace(/\s+$/,'')})};
const rest=()=>L.slice(i).join('\n');
const take=n=>{const s=L.slice(i,i+n).join('\n');i+=n;return s};
const span=(r,end)=>{if(end<0)return 0;if(r.slice(end).split('\n')[0].trim())return 0;return r.slice(0,end).split('\n').length};
const tagSpan=(r,name)=>{const re=new RegExp(`<(/?)(${name})\\b[^>]*?(/?)>`,'gi');let d=0,m;
while((m=re.exec(r))){if(m[1])d--;else if(!m[3])d++;if(d<=0)return re.lastIndex}return -1};
const standalone=k=>{const l=L[k];const r=L.slice(k).join('\n');
if(l.startsWith('{\u007B'))return span(r,matchPair(r,0,'{\u007B','}}'))>0;
if(/^\[\[\s*(File|Image)\s*:/i.test(l))return span(r,matchPair(r,0,'[\u005B',']]'))>0;
return false};
const blockTagRe=new RegExp(`^<(${BLOCK_TAGS})\\b`,'i');
const startsBlock=k=>/^(={2,6}[^=]|[*#:;\s]|\{\||-{4}|__(NO|FORCE)?TOC__\s*$|\[\[\s*Category\s*:|<!--)/i.test(L[k])||blockTagRe.test(L[k])||standalone(k);
const catLine=/^(\[\[\s*Category\s*:[^\]]*\]\]\s*)+$/i;
function step(){
const line=L[i],t=line.trim();let m,n,r,e;
if(t.startsWith('<!--')){r=rest();e=r.indexOf('-->');n=e<0?0:span(r,e+3);if(n){raw(take(n));return}}
if(/^[ \t]/.test(line)){let k=i;while(k<L.length&&/^[ \t]/.test(L[k])&&L[k].trim())k++;raw(take(k-i));return}
if(/^#redirect/i.test(t)){raw(take(1));return}
if((m=line.match(/^(={2,5})\s*([^=].*?)\s*\1\s*$/))){add('heading',{text:m[2],level:String(m[1].length)});i++;return}
if(/^__(NO|FORCE)?TOC__$/.test(t)){add('toc',{mode:t});i++;return}
if(/^-{4,}$/.test(t)){add('rule');i++;return}
if(catLine.test(t)){const names=[];while(i<L.length&&catLine.test(L[i].trim())){L[i].replace(/\[\[\s*Category\s*:\s*([^\]]*?)\s*\]\]/gi,(x,nm)=>{names.push(nm);return ''});i++}
add('categories',{items:names.join('\n')});return}
if((m=t.match(/^:\s*''\s*(Main article|See also|Further information)\s*:\s*\[\[([^\]|]+)\]\]\s*''$/))){
add('hatnote',{kind:{'Main article':'Main','See also':'See also','Further information':'Further'}[m[1]],target:m[2].trim(),markup:'plain'});i++;return}
if(/^[*#:;]/.test(line)){const c0=line[0],grp=c0==='*'||c0==='#'?x=>x[0]===c0:x=>x[0]===':'||x[0]===';';let k=i;while(k<L.length&&/^[*#:;]/.test(L[k])&&grp(L[k]))k++;const ls=L.slice(i,k);
const marks=ls.map(l=>l.match(/^[*#:;]+/)[0]);const ch=marks[0][0];
if((ch==='*'||ch==='#')&&marks.every(x=>[...x].every(c=>c===ch))){i=k;
add(ch==='*'?'bullets':'numbers',{items:ls.map(l=>{const mm=l.match(/^([*#]+)\s*(.*)$/);return '*'.repeat(mm[1].length-1)+mm[2]}).join('\n')})}
else raw(take(k-i));
return}
if(line.startsWith('{|')){let d=0,k=i;for(;k<L.length;k++){const l=L[k].trim();if(l.startsWith('{|'))d++;else if(l.startsWith('|}')){d--;if(d===0)break}}
if(k>=L.length){raw(take(L.length-i));return}
const ls=L.slice(i,k+1);const p=parseTable(ls);i=k+1;if(p)add('table',p);else raw(ls.join('\n'));return}
if((m=line.match(/^<(gallery|syntaxhighlight|source|pre|blockquote|categorytree|references)\b([^>]*)>/i))){
const tag=m[1].toLowerCase(),self=/\/\s*$/.test(m[2]),attr=m[2].replace(/\/\s*$/,'');r=rest();let innerEnd=m[0].length;
if(self)e=m[0].length;else{const cm=new RegExp(`</${tag}\\s*>`,'i').exec(r);e=cm?cm.index+cm[0].length:-1;if(cm)innerEnd=cm.index}
n=span(r,e);
if(n){const inner=r.slice(m[0].length,innerEnd);const whole=take(n);const blk=tagBlock(tag,attrsOf(attr),inner,attr.trim());if(blk)add(blk[0],blk[1]);else raw(whole);return}
}
if(/^<div class="(pb-note |mw-collapsible)/.test(line)){r=rest();e=tagSpan(r,'div');n=span(r,e);
if(n){const blk=divBlock(r.slice(0,e));if(blk){i+=n;add(blk[0],blk[1]);return}}}
if((m=line.match(blockTagRe))){r=rest();e=tagSpan(r,m[1]);n=span(r,e);if(n){raw(take(n));return}if(e<0){raw(take(1));return}}
if(line.startsWith('{\u007B')){r=rest();e=matchPair(r,0,'{\u007B','}}');n=span(r,e);
if(n){const whole=r.slice(0,e);i+=n;const blk=templateBlock(whole.slice(2,-2));if(blk)add(blk[0],blk[1]);else raw(whole);return}}
if(/^\[\[\s*(File|Image)\s*:/i.test(line)){r=rest();e=matchPair(r,0,'[\u005B',']]');n=span(r,e);
if(n){const whole=r.slice(0,e);i+=n;const p=imageBlock(whole.slice(2,-2));if(p)add('image',p);else raw(whole);return}}
const start=i;i++;
while(i<L.length){if(isBalanced(L.slice(start,i).join('\n'))&&(!L[i].trim()||startsBlock(i)))break;i++}
add('paragraph',{text:L.slice(start,i).join('\n').trim()});
}
while(i<L.length){
if(!L[i].trim()){i++;continue}
const s0=i,n0=out.length;step();
for(let k=n0;k<out.length;k++){out[k]._s=s0;out[k]._e=i}
}
const res=[];
for(const b of out){const prev=res[res.length-1];
if(prev&&b.type==='raw'&&prev.type==='raw'){prev._e=b._e;continue}
if(prev&&b.type==='categories'&&prev.type==='categories'){prev.items+='\n'+b.items;prev._e=b._e;continue}
if(prev&&b.type==='refs'&&!b.heading&&prev.type==='heading'&&prev.level==='2'&&prev.text.trim()==='References'){res.pop();b.heading=true;b._s=prev._s}
res.push(b)}
let before=null;
res.forEach(b=>{
b.src=L.slice(b._s,b._e).join('\n');
if(b.type==='raw')b.text=b.src;
try{b._g=DEFS[b.type].wiki(b)}catch(e){b._g=null}
if(before){b.sep='\n'+L.slice(before._e,b._s).map(x=>x+'\n').join('');b.prevId=before.id}
before=b;
});
res.forEach(b=>{delete b._s;delete b._e});
return res;
}
const titleEl=$('#pb-pageTitle');
titleEl.addEventListener('input',()=>{state.title=titleEl.value;renderPreview();save();recordSoon()});
$('#pb-undoBtn').addEventListener('click',undo);
$('#pb-redoBtn').addEventListener('click',redo);
$('.app').addEventListener('keydown',e=>{if(!(e.ctrlKey||e.metaKey)||e.altKey)return;const k=e.key.toLowerCase();
if(k==='z'&&!e.shiftKey){e.preventDefault();undo()}
else if((k==='z'&&e.shiftKey)||k==='y'){e.preventDefault();redo()}});
$('#pb-outline').addEventListener('click',e=>{const b=e.target.closest('[data-goto]');if(b)gotoBlock(b.dataset.goto)});
$('#pb-checks').addEventListener('click',e=>{const b=e.target.closest('[data-goto]');if(b)gotoBlock(b.dataset.goto)});
$('#pb-collapseAll').addEventListener('click',()=>{state.blocks.forEach(b=>collapsed.add(b.id));renderCanvas()});
$('#pb-expandAll').addEventListener('click',()=>{collapsed.clear();renderCanvas()});
function showUndoReplace(prev,msg){
showToast(msg,()=>{state=prev;titleEl.value=state.title;commit()});
}
let armTimer=null;
const clearBtn=$('#pb-clearPage');
clearBtn.addEventListener('click',()=>{
if(!clearBtn.classList.contains('armed')){
clearBtn.classList.add('armed');clearBtn.textContent='Click again to clear';
armTimer=setTimeout(()=>{clearBtn.classList.remove('armed');clearBtn.textContent='Clear page'},3000);return;
}
clearTimeout(armTimer);clearBtn.classList.remove('armed');clearBtn.textContent='Clear page';
const prev=JSON.parse(JSON.stringify(state));
state={title:'',blocks:[]};collapsed.clear();titleEl.value='';commit();
showUndoReplace(prev,'Page cleared');
});
const tabs=[[$('#pb-tab-code'),$('#pb-panel-code')],[$('#pb-tab-prev'),$('#pb-panel-prev')],[$('#pb-tab-chk'),$('#pb-panel-chk')]];
tabs.forEach(([t])=>t.addEventListener('click',()=>tabs.forEach(([x,p])=>{const on=x===t;x.setAttribute('aria-selected',String(on));p.hidden=!on})));
$('.tabs').addEventListener('keydown',e=>{
if(e.key!=='ArrowRight'&&e.key!=='ArrowLeft')return;
const i=tabs.findIndex(([t])=>t.getAttribute('aria-selected')==='true');const n=tabs[(i+(e.key==='ArrowRight'?1:tabs.length-1))%tabs.length][0];n.click();n.focus();
});
function copyText(t){
const fallback=()=>{const ta=document.createElement('textarea');ta.value=t;ta.setAttribute('readonly','');ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();
let ok=false;try{ok=document.execCommand('copy')}catch(e){}ta.remove();return ok};
try{if(navigator.clipboard&&navigator.clipboard.writeText)return navigator.clipboard.writeText(t).then(()=>true,fallback)}catch(e){}
return Promise.resolve(fallback());
}
$('#pb-tab-chk').addEventListener('click',runChecks);
const copyBtn=$('#pb-copyBtn');
function copied(label){copyBtn.textContent=label;setTimeout(()=>copyBtn.textContent='Copy wikitext',1800)}
copyBtn.addEventListener('click',()=>{
const t=gen();if(!t){copied('Nothing to copy');return}
copyText(t).then(ok=>{if(ok){copied('Copied');return}
tabs[0][0].click();const r=document.createRange();r.selectNodeContents($('#pb-code'));const s=getSelection();s.removeAllRanges();s.addRange(r);
copied('Selected — press Ctrl+C')});
});
const dlg=$('#pb-importDlg'),impText=$('#pb-importText'),impFile=$('#pb-importFile'),impTitle=$('#pb-importTitleInput'),impErr=$('#pb-importErr');
const closeDlg=()=>{try{dlg.close()}catch(e){dlg.removeAttribute('open')}};
$('#pb-importBtn').addEventListener('click',()=>{impErr.textContent='';impTitle.value='';
try{dlg.showModal()}catch(e){dlg.setAttribute('open','')}impText.focus()});
$('#pb-importCancel').addEventListener('click',closeDlg);
impFile.addEventListener('change',()=>{const f=impFile.files&&impFile.files[0];if(!f)return;
f.text().then(txt=>{impText.value=txt;$('#pb-importFileName').textContent=f.name;impErr.textContent='';
if(!impTitle.value.trim())impTitle.value=f.name.replace(/\.[^.]+$/,'').replace(/_/g,' ')},
()=>{impErr.textContent='That file couldn’t be read. Open it in a text editor and paste its contents instead.'});
impFile.value=''});
$('#pb-importGo').addEventListener('click',()=>{
const src=impText.value;if(!src.trim()){impErr.textContent='Paste some wikitext or open a file first.';impText.focus();return}
let blocks;try{blocks=parseWikitext(src)}catch(e){impErr.textContent='Part of that text couldn’t be read. Try importing a smaller section of it.';return}
if(!blocks.length){impErr.textContent='No page content was found in that text.';return}
const prev=JSON.parse(JSON.stringify(state));
const mode=(dlg.querySelector('input[name=importMode]:checked')||{}).value;
if(mode==='append')state.blocks=state.blocks.concat(blocks);else{state.blocks=blocks;collapsed.clear()}
if(impTitle.value.trim()){state.title=impTitle.value.trim();titleEl.value=state.title}
commit();closeDlg();impText.value='';$('#pb-importFileName').textContent='';
const nr=blocks.filter(b=>b.type==='raw').length;
showToast(`Imported ${blocks.length} block${blocks.length===1?'':'s'}${nr?`, ${nr} kept as raw wikitext`:''}`,()=>{state=prev;titleEl.value=state.title;commit()});
const first=cardOf(blocks[0].id);if(first&&first.scrollIntoView)first.scrollIntoView({block:'nearest'});
});
const showDlg=d=>{try{d.showModal()}catch(e){d.setAttribute('open','')}};
const hideDlg=d=>{try{d.close()}catch(e){d.removeAttribute('open')}};
let citeTarget=null;
function citeKindChanged(){const k=$('#pb-citeKind').value;$('#pb-citeDlg').querySelectorAll('[data-for]').forEach(el=>{el.hidden=!el.dataset.for.split(' ').includes(k)});
$('#pb-citeWorkLabel').textContent=k==='book'?'Publisher':'Website';$('#pb-citeFmtWrap').hidden=k==='other'}
function openCite(card){const f=fieldFor(card);if(!f)return;
citeTarget={f,pos:f.selectionEnd==null?f.value.length:f.selectionEnd};
[$('#pb-citeTitle'),$('#pb-citeUrl'),$('#pb-citeAuthor'),$('#pb-citeWork'),$('#pb-citeDate'),$('#pb-citeText'),$('#pb-citeName')].forEach(x=>{x.value=''});
$('#pb-citeAccess').value=new Date().toISOString().slice(0,10);$('#pb-citeErr').textContent='';
const names=[];gen().replace(/<ref\s+name\s*=\s*"?([^"\/>]+?)"?\s*>/gi,(m,n)=>{n=n.trim();if(n&&!names.includes(n))names.push(n);return m});
$('#pb-citeReuse').innerHTML=names.length?`<p class="sub">Use a reference that’s already on the page</p><div class="cite-reuse">${names.map(n=>`<button type="button" class="btn" data-reuse="${esc(n)}">${esc(n)}</button>`).join('')}</div><p class="sub">Or add a new one</p>`:'';
citeKindChanged();showDlg($('#pb-citeDlg'));$('#pb-citeTitle').focus()}
function citeInsert(t){if(!citeTarget)return;const {f,pos}=citeTarget;citeTarget=null;hideDlg($('#pb-citeDlg'));
const v=f.value;f.value=v.slice(0,pos)+t+v.slice(pos);f.focus();try{f.setSelectionRange(pos+t.length,pos+t.length)}catch(e){}
f.dispatchEvent(new Event('input',{bubbles:true}))}
function citeWikitext(){
const k=$('#pb-citeKind').value,tpl=$('#pb-citeFmt').value==='tpl',g=x=>$(x).value.trim();
const title=g('#pb-citeTitle'),url=g('#pb-citeUrl'),author=g('#pb-citeAuthor'),work=g('#pb-citeWork'),date=g('#pb-citeDate'),access=g('#pb-citeAccess'),text=g('#pb-citeText');
let body;
if(k==='other'){if(!text)return {err:'Write the reference text first.'};body=text}
else{
if(k==='web'&&!title&&!url)return {err:'Add a title or a link.'};
if(k==='book'&&!title)return {err:'Add the book’s title.'};
if(tpl){const ps=[['title',title],['url',k==='web'?url:''],['author',author],[k==='web'?'website':'publisher',work],['date',date],['access-date',k==='web'&&url?access:'']].filter(x=>x[1]);
body=`{\u007B${k==='web'?'cite web':'cite book'}${ps.map(([a,v])=>`|${a}=${v}`).join('')}}}`}
else{const parts=[];if(author)parts.push(author.replace(/\.$/,'')+'.');
if(k==='web')parts.push(url?(title?`"[${url} ${title}]".`:`[${url}].`):`"${title}".`);else parts.push(`''${title}''.`);
if(work)parts.push(k==='web'?`''${work}''.`:`${work}.`);if(date)parts.push(date+'.');if(k==='web'&&url&&access)parts.push(`Retrieved ${access}.`);
body=parts.join(' ')}}
const nm=g('#pb-citeName').replace(/["<>\/]/g,'');
return {text:nm?`<ref name="${nm}">${body}</ref>`:`<ref>${body}</ref>`};
}
$('#pb-citeKind').addEventListener('change',citeKindChanged);
$('#pb-citeCancel').addEventListener('click',()=>{citeTarget=null;hideDlg($('#pb-citeDlg'))});
$('#pb-citeGo').addEventListener('click',()=>{const r=citeWikitext();if(r.err){$('#pb-citeErr').textContent=r.err;return}citeInsert(r.text)});
$('#pb-citeReuse').addEventListener('click',e=>{const b=e.target.closest('[data-reuse]');if(b)citeInsert(`<ref name="${b.dataset.reuse}" />`)});
let upTarget=null,upFiles=[];
const objUrl=f=>window.URL&&URL.createObjectURL?URL.createObjectURL(f):'';
const cleanName=n=>String(n).replace(/[#<>\[\]|{}:\/\\]+/g,'-').replace(/\s+/g,' ').trim();
function openUpload(id,files){if(!hooks.upload||!files.length)return;
upTarget=id;upFiles=files.map(f=>({file:f,url:objUrl(f),name:cleanName(f.name),msg:'',bad:false}));
$('#pb-upErr').textContent='';$('#pb-upGo').disabled=false;$('#pb-upGo').textContent=upFiles.length>1?`Upload ${upFiles.length} pictures`:'Upload';
renderUpList();showDlg($('#pb-upDlg'));const i=$('#pb-upList input');if(i)i.focus()}
function renderUpList(){$('#pb-upList').innerHTML=upFiles.map((u,i)=>`<div class="upitem">${u.url?`<img src="${u.url}" alt="">`:''}<div class="upfields"><label class="field"><span>File name</span><input type="text" data-upi="${i}" value="${esc(u.name)}"${u.done?' disabled':''}></label><p class="upstate${u.bad?' bad':''}">${esc(u.msg)}</p>${u.dup&&!u.done?`<button type="button" class="linkbtn" data-usedup="${i}">Use File:${esc(u.dup)} instead</button>`:''}${u.canForce&&!u.done?`<button type="button" class="linkbtn" data-force="${i}">Upload anyway</button>`:''}</div></div>`).join('')}
function uploadProblem(u,err){const w=(err&&err.warnings)||{};u.bad=true;u.dup=null;u.canForce=false;
if(w.exists||w['exists-normalized']||w['page-exists'])u.msg=`A file called ${u.name} is already on the wiki. Change the name to upload yours.`;
else if(w.duplicate){u.dup=String([].concat(w.duplicate)[0]).replace(/_/g,' ');u.msg='This picture is already on the wiki.'}
else if(w.badfilename){u.name=String(w.badfilename).replace(/_/g,' ');u.msg=`Your wiki needs a different name, so it suggests ${u.name}.`;u.canForce=true}
else if(w['was-deleted']||w['duplicate-archive']){u.msg='A file with this name or content was deleted from the wiki before.';u.canForce=true}
else if(Object.keys(w).length){u.msg='Your wiki warned about this file: '+Object.keys(w).join(', ')+'.';u.canForce=true}
else u.msg=(err&&err.info)||'The upload didn’t work.'}
function runUploads(){const go=$('#pb-upGo');go.disabled=true;go.textContent='Uploading…';const desc=$('#pb-upDesc').value.trim();
let chain=Promise.resolve();
upFiles.filter(u=>!u.done).forEach(u=>{chain=chain.then(()=>{u.msg='Uploading…';u.bad=false;renderUpList();
return Promise.resolve(hooks.upload(u.file,u.name,desc,!!u.force)).then(r=>{u.done=true;u.final=r.filename;u.msg='Uploaded'},err=>uploadProblem(u,err)).then(renderUpList)})});
chain.then(()=>{go.disabled=false;go.textContent='Try again';if(upFiles.every(u=>u.done))finishUploads()})}
function finishUploads(){const b=state.blocks.find(x=>x.id===upTarget);const names=upFiles.filter(u=>u.done).map(u=>u.final);
hideDlg($('#pb-upDlg'));upFiles.forEach(u=>{if(u.url&&window.URL&&URL.revokeObjectURL)URL.revokeObjectURL(u.url)});upFiles=[];
if(!b||!names.length)return;
if(b.type==='image')b.file=names[0];else b.items=lines(b.items).concat(names).join('\n');
commit();revealBlock(b.id);flash(b.id);showToast(names.length===1?`Added ${names[0]}`:`Added ${names.length} pictures`)}
$('#pb-upGo').addEventListener('click',runUploads);
$('#pb-upCancel').addEventListener('click',()=>{if(upFiles.some(u=>u.done))finishUploads();else{hideDlg($('#pb-upDlg'));upFiles=[]}});
$('#pb-upList').addEventListener('input',e=>{const i=e.target.dataset.upi;if(i!=null&&upFiles[+i])upFiles[+i].name=e.target.value});
$('#pb-upList').addEventListener('click',e=>{const d=e.target.closest('[data-usedup]'),f=e.target.closest('[data-force]');
if(d){const u=upFiles[+d.dataset.usedup];u.done=true;u.final=u.dup;u.bad=false;u.msg='Using the file that’s already on the wiki';renderUpList();if(upFiles.every(x=>x.done))finishUploads()}
if(f){upFiles[+f.dataset.force].force=true;runUploads()}});
blocksEl.addEventListener('change',e=>{const inp=e.target;if(!inp.matches||!inp.matches('input[data-upload]'))return;const card=inp.closest('.block');
openUpload(card.dataset.id,[...inp.files].filter(f=>/^image\//.test(f.type)).slice(0,20));inp.value=''});
const hasFiles=e=>!!(e.dataTransfer&&[...(e.dataTransfer.types||[])].includes('Files'));
const clearDropMarks=()=>blocksEl.querySelectorAll('.block.dropfile').forEach(c=>c.classList.remove('dropfile'));
canvasEl.addEventListener('dragover',e=>{if(drag||!hooks.upload||!hasFiles(e))return;e.preventDefault();e.dataTransfer.dropEffect='copy';
const card=e.target.closest&&e.target.closest('.block');const t=card&&state.blocks.find(b=>b.id===card.dataset.id);clearDropMarks();
if(t&&(t.type==='image'||t.type==='gallery')){card.classList.add('dropfile');hideLine()}else showLine(targetFor(e.clientY))});
canvasEl.addEventListener('dragleave',e=>{if(!canvasEl.contains(e.relatedTarget))clearDropMarks()});
canvasEl.addEventListener('drop',e=>{if(drag||!hooks.upload||!hasFiles(e))return;e.preventDefault();hideLine();clearDropMarks();
const files=[...e.dataTransfer.files].filter(f=>/^image\//.test(f.type)).slice(0,20);if(!files.length){showToast('Only pictures can be uploaded here.');return}
const card=e.target.closest&&e.target.closest('.block');const t=card&&state.blocks.find(b=>b.id===card.dataset.id);
if(t&&(t.type==='image'||t.type==='gallery')){openUpload(t.id,t.type==='image'?files.slice(0,1):files);return}
const before=targetFor(e.clientY);const idx=before?state.blocks.findIndex(b=>b.id===before):state.blocks.length;
const nb=mk(files.length>1?'gallery':'image',files.length>1?{items:''}:{});state.blocks.splice(idx,0,nb);commit();openUpload(nb.id,files)});
function saveTemplatePage(b,btn){const name=tdKey(b.template)||'Infobox',label=`Save to Template:${name}`;
const reset=()=>{btn.disabled=false;btn.classList.remove('armed');btn.textContent=label};
const go=()=>{btn.disabled=true;btn.textContent='Saving…';
Promise.resolve(hooks.saveTemplate(name,infoboxTemplate(b))).then(()=>{reset();tdCache.delete(name);existCache.set('Template:'+name,true);scheduleChecks();showToast(`Saved Template:${name}`)},
err=>{reset();showToast(`Couldn’t save the template: ${(err&&err.info)||err||'unknown error'}`)})};
if(btn.classList.contains('armed')){go();return}
Promise.resolve(hooks.exists(['Template:'+name])).then(m=>{if(m&&m['Template:'+name]===false)go();
else{btn.classList.add('armed');btn.textContent=`Replace the existing Template:${name}?`;setTimeout(()=>{if(btn.isConnected&&!btn.disabled&&btn.classList.contains('armed'))reset()},5000)}},go)}
blocksEl.addEventListener('paste',e=>{const f=e.target;const m=f.dataset&&f.dataset.path&&f.dataset.path.match(/^cells\.(\d+)\.(\d+)$/);if(!m)return;
const txt=((e.clipboardData||window.clipboardData)||{getData:()=>''}).getData('text');if(!txt)return;
const body=txt.replace(/\r/g,'').replace(/\n+$/,'');if(!/[\t\n]/.test(body))return;e.preventDefault();
const card=f.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b)return;
const rows=body.split('\n').map(l=>l.split('\t').map(c=>c.replace(/^"([\s\S]*)"$/,'$1').replace(/""/g,'"').replace(/\|/g,'|').trim()));
const r0=+m[1],c0=+m[2],width=Math.max(b.cells[0].length,c0+Math.max(...rows.map(r=>r.length)));
b.cells.forEach(r=>{while(r.length<width)r.push('')});
rows.forEach((cells,ri)=>{const R=r0+ri;while(b.cells.length<=R)b.cells.push(Array(width).fill(''));cells.forEach((v,ci)=>{b.cells[R][c0+ci]=v})});
commit();showToast(`Pasted ${rows.length} row${rows.length===1?'':'s'}`)});
let qi=null;
const qiBox=$('#pb-qiBox'),qiInput=$('#pb-qiInput'),qiList=$('#pb-qiList');
function qiOpen(at,replace,anchor){qi={at,replace,anchor,i:0,items:[]};qiBox.hidden=false;qiInput.value='';qiFilter();
const r=(anchor||canvasEl).getBoundingClientRect(),W=Math.min(320,window.innerWidth-16);
qiBox.style.left=Math.max(8,Math.min(r.left+(anchor?0:24),window.innerWidth-W-8))+'px';
qiBox.style.top=Math.max(8,Math.min(anchor?r.bottom+4:r.top+24,window.innerHeight-320))+'px';qiInput.focus()}
function qiFilter(){if(!qi)return;const q=qiInput.value.trim().toLowerCase();
qi.items=GROUPS.reduce((a,g)=>a.concat(g[2]),[]).filter(t=>!q||(DEFS[t].label+' '+DEFS[t].desc+' '+t).toLowerCase().includes(q));qi.i=0;
qiList.innerHTML=qi.items.length?qi.items.map((t,i)=>`<div class="ac-opt qi-opt${i?'':' on'}" role="option" id="${qiList.id}-o${i}" data-t="${t}" aria-selected="${i===0}"><span class="tok">${esc(DEFS[t].tok)}</span>${esc(DEFS[t].label)}</div>`).join(''):'<p class="note">No block matches that.</p>';
if(qi.items.length)qiInput.setAttribute('aria-activedescendant',qiList.id+'-o0');else qiInput.removeAttribute('aria-activedescendant')}
function qiMove(d){if(!qi||!qi.items.length)return;qi.i=(qi.i+d+qi.items.length)%qi.items.length;
qiList.querySelectorAll('.qi-opt').forEach((o,i)=>{o.classList.toggle('on',i===qi.i);o.setAttribute('aria-selected',String(i===qi.i))});
const on=qiList.querySelector('.qi-opt.on');if(on){if(on.scrollIntoView)on.scrollIntoView({block:'nearest'});qiInput.setAttribute('aria-activedescendant',on.id)}}
function qiClose(back){if(!qi)return;const a=qi.anchor;qi=null;qiBox.hidden=true;if(back&&a&&a.isConnected)a.focus()}
function qiPick(t){if(!qi)return;const at=qi.at,rp=qi.replace;qiClose(false);
if(rp){const i=state.blocks.findIndex(b=>b.id===rp);if(i>=0){state.blocks.splice(i,1);addBlock(t,i);return}}
addBlock(t,Math.min(at,state.blocks.length))}
qiInput.addEventListener('input',qiFilter);
qiInput.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();qiMove(1)}else if(e.key==='ArrowUp'){e.preventDefault();qiMove(-1)}
else if(e.key==='Enter'){e.preventDefault();if(qi&&qi.items[qi.i])qiPick(qi.items[qi.i])}else if(e.key==='Escape'){e.preventDefault();e.stopPropagation();qiClose(true)}});
qiInput.addEventListener('blur',()=>setTimeout(()=>{if(qi&&document.activeElement!==qiInput)qiClose(false)},150));
qiList.addEventListener('mousedown',e=>e.preventDefault());
qiList.addEventListener('click',e=>{const o=e.target.closest('.qi-opt');if(o)qiPick(o.dataset.t)});
$('.app').addEventListener('keydown',e=>{if(e.key!=='/'||e.ctrlKey||e.metaKey||e.altKey||qi)return;
const t=e.target;if(t.closest&&t.closest('input,textarea,select,[contenteditable="true"],dialog'))return;
e.preventDefault();const card=document.activeElement&&document.activeElement.closest&&document.activeElement.closest('.block');
const idx=card?state.blocks.findIndex(b=>b.id===card.dataset.id)+1:state.blocks.length;qiOpen(idx,null,null)});
blocksEl.addEventListener('input',e=>{const f=e.target;if(qi||!f.matches||!f.matches('textarea[data-path="text"]')||f.value!=='/')return;
const card=f.closest('.block');const b=state.blocks.find(x=>x.id===card.dataset.id);if(!b||b.type!=='paragraph')return;
qiOpen(state.blocks.indexOf(b),b.id,f)});
let presetCache=null;
function loadPresets(){
if(!hooks.presets)return Promise.resolve(BUILTIN_PRESETS.concat([EXAMPLE_PRESET]));
return hooks.presets().then(l=>l&&l.length?l:BUILTIN_PRESETS,()=>BUILTIN_PRESETS);
}
const presetDlg=$('#pb-presetDlg');
const closePresets=()=>{try{presetDlg.close()}catch(e){presetDlg.removeAttribute('open')}};
function openPresets(fresh){
$('#pb-presetNote').textContent=fresh?'This page is empty. Pick a layout to start from, or start with a blank page.':'Pick a layout to start from. It replaces what’s on the page now, and you can undo it.';
$('#pb-presetCancel').textContent=fresh?'Start blank':'Cancel';
$('#pb-presetList').innerHTML='<p class="note">Loading presets…</p>';
try{presetDlg.showModal()}catch(e){presetDlg.setAttribute('open','')}
(presetCache?Promise.resolve(presetCache):loadPresets()).then(list=>{presetCache=list;
$('#pb-presetList').innerHTML=list.map((p,i)=>`<button type="button" class="preset" data-i="${i}"><strong>${esc(p.name)}</strong>${p.description?`<span>${esc(p.description)}</span>`:''}</button>`).join('');
const f=$('#pb-presetList .preset');if(f)f.focus()});
}
function applyPreset(p){
const prev=JSON.parse(JSON.stringify(state));let blocks;
if(p.build){const ex=p.build();blocks=ex.blocks;if(!state.title.trim()){state.title=ex.title;titleEl.value=state.title}}
else blocks=parseWikitext(String(p.wikitext).replace(/\$TITLE/g,state.title.trim()||'Page name'));
closePresets();state.blocks=blocks;collapsed.clear();commit();
if(prev.blocks.length)showToast(`${p.name} loaded`,()=>{state=prev;titleEl.value=state.title;commit()});
}
$('#pb-presetsBtn').addEventListener('click',()=>openPresets(false));
$('#pb-presetCancel').addEventListener('click',closePresets);
$('#pb-presetList').addEventListener('click',e=>{const b=e.target.closest('.preset');if(b&&presetCache)applyPreset(presetCache[+b.dataset.i])});
renderPalette();renderCanvas();
return {
load(text,title){
let blocks;
try{blocks=parseWikitext(text)}catch(e){blocks=text.trim()?[mk('raw',{text})]:[]}
state={title:title||'',blocks};collapsed.clear();titleEl.value=state.title;commit();resetHistory();
},
gen,
showToast,
offerPresets(){openPresets(true)},
replace(text){let blocks;try{blocks=parseWikitext(text)}catch(e){blocks=[mk('raw',{text})]}state.blocks=blocks;collapsed.clear();commit()},
hideMenus(){acHide()}
};
}
function createBuilder( box ) {
const $box = $( box );
const root = document.createElement( 'div' );
root.className = 'pb-root';
root.hidden = true;
root.setAttribute( 'role', 'dialog' );
root.setAttribute( 'aria-modal', 'true' );
root.setAttribute( 'aria-label', 'Page builder' );
root.innerHTML = MARKUP;
document.body.appendChild( root );
const q = ( sel ) => root.querySelector( sel );
const live = q( '#pb-live' );
const rough = q( '#pb-preview' );
const status = q( '#pb-liveStatus' );
const panelPrev = q( '#pb-panel-prev' );
const doneBtn = q( '#pb-doneBuilder' );
const cancelBtn = q( '#pb-cancelBuilder' );
const pageTitle = mw.config.get( 'wgPageName' );
const lang = mw.config.get( 'wgPageContentLanguage' );
live.setAttribute( 'lang', lang );
live.setAttribute( 'dir', $( 'html' ).attr( 'dir' ) || 'ltr' );
let baseline = '';
let opener = null;
let seq = 0;
let timer = null;
let lastRendered = null;
let cancelArmed = null;
const api = new mw.Api();
const NS = { page: 0, file: 6, category: 14, template: 10 };
const userLang = mw.config.get( 'wgUserLanguage' );
const pick = ( v ) => v === null || v === undefined ? '' : typeof v === 'string' ? v : ( v[ userLang ] || v.en || Object.keys( v ).map( ( k ) => v[ k ] )[ 0 ] || '' );
const thumbCache = new Map();
const hooks = {
onChange: () => {
scheduleLive();
saveDraftSoon();
},
upload( file, name, desc, force ) {
return new Promise( ( resolve, reject ) => {
const params = { action: 'upload', filename: name, file: file, comment: desc || 'Uploaded with the page builder', formatversion: 2 };
if ( desc ) {
params.text = desc;
}
if ( force ) {
params.ignorewarnings = 1;
}
api.postWithEditToken( params, { contentType: 'multipart/form-data' } ).then( ( d ) => {
const u = d.upload || {};
if ( u.result === 'Success' ) {
resolve( { filename: String( u.filename || name ).replace( /_/g, ' ' ) } );
} else {
reject( { warnings: u.warnings || {}, info: u.result || 'The upload didn’t work.' } );
}
}, ( code, d ) => reject( { code: code, info: ( d && d.error && d.error.info ) || code } ) );
} );
},
saveTemplate( name, code ) {
return new Promise( ( resolve, reject ) => {
api.postWithEditToken( { action: 'edit', title: 'Template:' + name, text: code, summary: 'Infobox template made with the page builder', formatversion: 2 } ).then( ( d ) => {
if ( d.edit && d.edit.result === 'Success' ) {
resolve( true );
} else {
reject( { info: ( d.edit && d.edit.info ) || 'Your wiki didn’t accept the edit.' } );
}
}, ( code, d ) => reject( { info: ( d && d.error && d.error.info ) || code } ) );
} );
},
url: ( t ) => mw.util.getUrl( t ),
search( kind, query ) {
return api.get( { action: 'query', list: 'prefixsearch', pssearch: query, psnamespace: NS[ kind ], pslimit: 8, formatversion: 2 } )
.then( ( d ) => ( ( d.query && d.query.prefixsearch ) || [] ).map( ( x ) => kind === 'page' ? x.title : x.title.replace( /^[^:]+:/, '' ) ) );
},
exists( titles ) {
const out = {};
const batches = [];
for ( let i = 0; i < titles.length; i += 50 ) {
batches.push( titles.slice( i, i + 50 ) );
}
return Promise.all( batches.map( ( batch ) => api.post( { action: 'query', titles: batch.join( '|' ), formatversion: 2 } ).then( ( d ) => {
const res = d.query || {};
const norm = {};
( res.normalized || [] ).forEach( ( n ) => {
norm[ n.from ] = n.to;
} );
const pages = {};
( res.pages || [] ).forEach( ( pg ) => {
pages[ pg.title ] = pg;
} );
batch.forEach( ( t ) => {
const pg = pages[ norm[ t ] || t ];
out[ t ] = !pg || !( pg.invalid || ( pg.missing && !pg.known ) );
} );
} ) ) ).then( () => out );
},
templateData( name ) {
return api.get( { action: 'templatedata', titles: 'Template:' + name, redirects: 1, lang: userLang, formatversion: 2 } ).then( ( d ) => {
const pages = d.pages || {};
const t = Object.keys( pages ).map( ( k ) => pages[ k ] )[ 0 ];
if ( !t || t.missing || t.notemplatedata || !t.params ) {
return null;
}
const params = {};
Object.keys( t.params ).forEach( ( k ) => {
const pr = t.params[ k ];
params[ k ] = { label: pick( pr.label ), description: pick( pr.description ), required: !!pr.required, suggested: !!pr.suggested, deprecated: !!pr.deprecated, type: pr.type || 'unknown' };
} );
const order = ( t.paramOrder || Object.keys( params ) ).filter( ( k ) => params[ k ] );
return { description: pick( t.description ), params: params, order: order };
} );
},
thumb( file ) {
if ( !thumbCache.has( file ) ) {
thumbCache.set( file, api.get( { action: 'query', titles: 'File:' + file, prop: 'imageinfo', iiprop: 'url', iiurlwidth: 200, formatversion: 2 } ).then( ( d ) => {
const pg = ( ( d.query && d.query.pages ) || [] )[ 0 ];
const ii = pg && pg.imageinfo && pg.imageinfo[ 0 ];
return ii ? { url: ii.thumburl || ii.url } : { missing: true };
} ) );
}
return thumbCache.get( file );
},
presets() {
return api.get( { action: 'query', prop: 'revisions', titles: 'MediaWiki:Gadget-PageBuilder-presets.json', rvprop: 'content', rvslots: 'main', formatversion: 2 } ).then( ( d ) => {
const pg = ( ( d.query && d.query.pages ) || [] )[ 0 ];
if ( !pg || pg.missing || !pg.revisions ) {
return null;
}
const list = JSON.parse( pg.revisions[ 0 ].slots.main.content );
return Array.isArray( list ) ? list.filter( ( x ) => x && typeof x.name === 'string' && typeof x.wikitext === 'string' ) : null;
} );
}
};
const app = createApp( root, hooks );
function scheduleLive() {
if ( root.hidden || panelPrev.hidden ) {
return;
}
clearTimeout( timer );
timer = setTimeout( renderLive, 600 );
}
function renderLive() {
const text = app.gen();
if ( text === lastRendered ) {
return;
}
const my = ++seq;
status.textContent = 'Updating preview…';
new mw.Api().post( {
action: 'parse',
title: pageTitle,
text: text,
contentmodel: 'wikitext',
prop: 'text|modules|jsconfigvars|categorieshtml|displaytitle',
pst: true,
preview: true,
disableeditsection: true,
disablelimitreport: true,
formatversion: 2
} ).then( ( data ) => {
if ( my !== seq ) {
return;
}
const p = data.parse || {};
lastRendered = text;
if ( p.jsconfigvars ) {
mw.config.set( p.jsconfigvars );
}
mw.loader.load( ( p.modulestyles || [] ).concat( p.modules || [] ) );
live.innerHTML = '<div class="pb-ptitle">' + ( p.displaytitle || mw.html.escape( mw.config.get( 'wgTitle' ) ) ) + '</div>' +
( p.text || '' ) + ( p.categorieshtml || '' );
live.hidden = false;
rough.hidden = true;
status.textContent = '';
mw.hook( 'wikipage.content' ).fire( $( live ) );
}, ( code ) => {
if ( my !== seq ) {
return;
}
live.hidden = true;
rough.hidden = false;
status.textContent = 'Your wiki couldn’t render the preview (' + code + '), so this is a rough version. It will try again when you make a change.';
} );
}
q( '#pb-tab-prev' ).addEventListener( 'click', scheduleLive );
function disarmCancel() {
clearTimeout( cancelArmed );
cancelArmed = null;
cancelBtn.classList.remove( 'armed' );
cancelBtn.textContent = 'Cancel';
}
function close() {
disarmCancel();
clearTimeout( timer );
root.hidden = true;
document.documentElement.classList.remove( 'pb-open' );
if ( opener ) {
opener.focus();
}
}
cancelBtn.addEventListener( 'click', () => {
if ( app.gen() === baseline || cancelArmed ) {
clearTimeout( draftTimer );
draftTimer = null;
drafts.clear();
close();
return;
}
cancelBtn.classList.add( 'armed' );
cancelBtn.textContent = 'Discard changes?';
cancelArmed = setTimeout( disarmCancel, 3000 );
} );
doneBtn.addEventListener( 'click', () => {
const out = app.gen();
const changed = out !== baseline;
if ( changed ) {
$box.textSelection( 'setContents', out );
box.dispatchEvent( new Event( 'input', { bubbles: true } ) );
box.dispatchEvent( new Event( 'change', { bubbles: true } ) );
}
close();
if ( changed ) {
mw.notify( 'Your changes are in the edit box. Preview and save the page as usual.', { title: 'Page builder' } );
}
} );
const saveBtn = q( '#pb-saveBuilder' );
const saveDlg = q( '#pb-saveDlg' );
const saveSummary = q( '#pb-saveSummary' );
const saveMinor = q( '#pb-saveMinor' );
const saveWatch = q( '#pb-saveWatch' );
const saveReview = q( '#pb-saveReview' );
const saveDiff = q( '#pb-saveDiff' );
const saveErr = q( '#pb-saveErr' );
const saveGo = q( '#pb-saveGo' );
const form = box.form || document.getElementById( 'editform' );
const field = ( name ) => form ? form.querySelector( '[name="' + name + '"]' ) : null;
const section = ( field( 'wpSection' ) || { value: '' } ).value || '';
let diffFor = null;
if ( section === 'new' ) {
saveBtn.hidden = true;
}
function closeSave() {
try {
saveDlg.close();
} catch ( e ) {
saveDlg.removeAttribute( 'open' );
}
}
saveBtn.addEventListener( 'click', () => {
app.hideMenus();
saveErr.textContent = '';
const sum = field( 'wpSummary' );
if ( !saveSummary.value && sum ) {
saveSummary.value = sum.value;
}
const minor = field( 'wpMinoredit' );
q( '#pb-saveMinorWrap' ).hidden = !minor;
saveMinor.checked = !!( minor && minor.checked );
const watch = field( 'wpWatchthis' );
saveWatch.checked = !!( watch && watch.checked );
saveReview.open = false;
diffFor = null;
saveGo.disabled = false;
saveGo.textContent = 'Save page';
try {
saveDlg.showModal();
} catch ( e ) {
saveDlg.setAttribute( 'open', '' );
}
saveSummary.focus();
} );
q( '#pb-saveCancel' ).addEventListener( 'click', closeSave );
saveReview.addEventListener( 'toggle', () => {
if ( !saveReview.open ) {
return;
}
const text = app.gen();
if ( diffFor === text ) {
return;
}
diffFor = text;
const from = box.defaultValue;
if ( from.replace( /\s+$/, '' ) === text ) {
saveDiff.innerHTML = '<p>There are no changes.</p>';
return;
}
saveDiff.textContent = 'Loading your changes…';
mw.loader.load( 'mediawiki.diff.styles' );
api.post( {
action: 'compare',
fromtitle: pageTitle,
totitle: pageTitle,
fromslots: 'main',
'fromtext-main': from,
'fromcontentmodel-main': 'wikitext',
toslots: 'main',
'totext-main': text,
'tocontentmodel-main': 'wikitext',
prop: 'diff',
formatversion: 2
} ).then( ( d ) => {
if ( diffFor !== text ) {
return;
}
const body = d.compare && d.compare.body;
saveDiff.innerHTML = body ?
'<table class="diff diff-contentalign-left diff-editfont-monospace"><colgroup><col class="diff-marker"><col class="diff-content"><col class="diff-marker"><col class="diff-content"></colgroup>' + body + '</table>' :
'<p>There are no changes.</p>';
}, () => {
if ( diffFor === text ) {
diffFor = null;
saveDiff.textContent = 'Couldn’t load your changes. You can still save, or click Done and use Show changes in the editor.';
}
} );
} );
saveGo.addEventListener( 'click', () => {
const text = app.gen();
const fail = ( msg ) => {
saveErr.textContent = msg;
saveGo.disabled = false;
saveGo.textContent = 'Save page';
};
saveErr.textContent = '';
saveGo.disabled = true;
saveGo.textContent = 'Saving…';
const params = {
action: 'edit',
title: pageTitle,
text: text,
summary: saveSummary.value,
watchlist: saveWatch.checked ? 'watch' : 'nochange',
formatversion: 2
};
if ( saveMinor.checked && !q( '#pb-saveMinorWrap' ).hidden ) {
params.minor = true;
}
if ( section ) {
params.section = section;
}
const base = field( 'wpEdittime' );
const started = field( 'wpStarttime' );
if ( base && base.value ) {
params.basetimestamp = base.value;
}
if ( started && started.value ) {
params.starttimestamp = started.value;
}
if ( !mw.config.get( 'wgArticleId' ) ) {
params.createonly = true;
}
api.postWithEditToken( params ).then( ( d ) => {
const r = d.edit || {};
if ( r.result !== 'Success' ) {
fail( r.info || 'Your wiki didn’t accept the edit.' );
return;
}
drafts.clear();
box.value = box.defaultValue;
$( window ).off( 'beforeunload' );
window.onbeforeunload = null;
saveGo.textContent = 'Saved';
location.href = mw.util.getUrl( pageTitle );
}, ( code, d ) => {
if ( code === 'editconflict' ) {
fail( 'Someone else saved this page after you started editing. Click Done, then save from the editor so you can see and merge their changes.' );
} else {
fail( ( d && d.error && d.error.info ) || ( 'Your wiki returned an error: ' + code ) );
}
} );
} );
const banner = q( '#pb-draftBanner' );
const draftMsg = q( '#pb-draftMsg' );
const draftKey = 'pb-draft:' + ( mw.config.get( 'wgWikiID' ) || '' ) + ':' + pageTitle + ':' + section;
const hashText = ( t ) => {
let h = 2166136261;
for ( let i = 0; i < t.length; i++ ) {
h ^= t.charCodeAt( i );
h = Math.imul( h, 16777619 );
}
return ( h >>> 0 ).toString( 16 ) + ':' + t.length;
};
const drafts = {
get() {
try {
return JSON.parse( localStorage.getItem( draftKey ) || 'null' );
} catch ( e ) {
return null;
}
},
set( v ) {
try {
localStorage.setItem( draftKey, JSON.stringify( v ) );
} catch ( e ) {}
},
clear() {
try {
localStorage.removeItem( draftKey );
} catch ( e ) {}
}
};
let draftTimer = null;
let pendingDraft = null;
function saveDraftNow() {
clearTimeout( draftTimer );
draftTimer = null;
if ( root.hidden ) {
return;
}
const text = app.gen();
if ( text === baseline ) {
drafts.clear();
return;
}
drafts.set( { text: text, base: hashText( box.defaultValue ), time: Date.now() } );
}
function saveDraftSoon() {
clearTimeout( draftTimer );
draftTimer = setTimeout( saveDraftNow, 1000 );
}
window.addEventListener( 'pagehide', () => {
if ( draftTimer ) {
saveDraftNow();
}
} );
const whenText = ( t ) => {
const m = Math.round( ( Date.now() - t ) / 60000 );
if ( m < 1 ) {
return 'a moment ago';
}
if ( m < 60 ) {
return m + ( m === 1 ? ' minute ago' : ' minutes ago' );
}
const hrs = Math.round( m / 60 );
if ( hrs < 24 ) {
return hrs + ( hrs === 1 ? ' hour ago' : ' hours ago' );
}
return 'on ' + new Date( t ).toLocaleString();
};
q( '#pb-draftRestore' ).addEventListener( 'click', () => {
if ( pendingDraft ) {
app.replace( pendingDraft.text );
app.showToast( 'Draft restored. Undo puts the page back the way it was.' );
}
pendingDraft = null;
banner.hidden = true;
} );
q( '#pb-draftDiscard' ).addEventListener( 'click', () => {
pendingDraft = null;
banner.hidden = true;
drafts.clear();
} );
return {
open( from ) {
opener = from || null;
const text = $box.textSelection( 'getContents' );
app.load( text, mw.config.get( 'wgTitle' ) );
baseline = app.gen();
lastRendered = null;
live.innerHTML = '';
root.hidden = false;
document.documentElement.classList.add( 'pb-open' );
const first = root.querySelector( '.pal-item' );
if ( first ) {
first.focus();
}
scheduleLive();
pendingDraft = null;
banner.hidden = true;
const draft = drafts.get();
if ( draft && typeof draft.text === 'string' ) {
if ( draft.base !== hashText( box.defaultValue ) ) {
drafts.clear();
} else if ( draft.text !== baseline ) {
pendingDraft = draft;
draftMsg.textContent = 'You have unsaved changes to this page from ' + whenText( draft.time ) + '. Do you want to carry on where you left off?';
banner.hidden = false;
}
}
if ( !text.trim() && !pendingDraft ) {
app.offerPresets();
}
}
};
}
$( () => {
const box = document.getElementById( 'wpTextbox1' );
if ( !box || box.readOnly ) {
return;
}
let builder = null;
const launch = document.createElement( 'button' );
launch.type = 'button';
launch.className = 'pb-launch';
launch.textContent = 'Open page builder';
launch.addEventListener( 'click', () => {
if ( !builder ) {
builder = createBuilder( box );
}
builder.open( launch );
} );
const anchor = box.closest( '.wikiEditor-ui' ) || box;
anchor.parentNode.insertBefore( launch, anchor );
mw.hook( 'pagebuilder.open' ).add( () => launch.click() );
if ( mw.util.getParamValue( 'pagebuilder' ) ) {
launch.click();
}
} );
}() );