ഉപയോക്താവ്:Ranjithsiji/WikiEditMobile.js
ശ്രദ്ധിക്കുക: സേവ് ചെയ്തശേഷം മാറ്റങ്ങൾ കാണാനായി താങ്കൾക്ക് ബ്രൗസറിന്റെ കാഷെ ഒഴിവാക്കേണ്ടി വന്നേക്കാം.
- ഫയർഫോക്സ് / സഫാരി: Reload ബട്ടൺ അമർത്തുമ്പോൾ Shift കീ അമർത്തി പിടിക്കുകയോ, Ctrl-F5 അല്ലെങ്കിൽ Ctrl-R (മാക്കിന്റോഷിൽ ⌘-R ) എന്ന് ഒരുമിച്ച് അമർത്തുകയോ ചെയ്യുക
- ഗൂഗിൾ ക്രോം: Ctrl-Shift-R (മാക്കിന്റോഷിൽ ⌘-Shift-R ) അമർത്തുക
- ഇന്റർനെറ്റ് എക്സ്പ്ലോറർ: Refresh ബട്ടൺ അമർത്തുമ്പോൾ Ctrl കീ അമർത്തിപിടിക്കുക. അല്ലെങ്കിൽ Ctrl-F5 അമർത്തുക
- ഓപ്പറ: Menu → Settings എടുക്കുക (മാക്കിൽ Opera → Preferences) എന്നിട്ട് Privacy & security → Clear browsing data → Cached images and files ചെയ്യുക.
/**
* WikiEditMobile — Mobile visual section editor for Wikipedia
*
* A MediaWiki user script providing a touch-friendly WYSIWYG editor for
* article sections on mobile.
* built for the Minerva skin with Parsoid HTML round-trip conversion.
*
* Edit flow per section:
* mw.Api (wikitext) → Parsoid (HTML) → contentEditable editor
* → on save: Parsoid (wikitext) → mw.Api (edit)
*
* Installation — add ONE of these to your user JS page:
*
* [[User:Ranjithsiji/minerva.js]] ← mobile only (recommended)
* [[User:Ranjithsiji/common.js]] ← all skins
*
* importScript('User:YOU/WikiEditMobile.js');
*
* Requirements: logged-in Wikipedia account, Parsoid REST API available
* (true for all Wikimedia wikis).
*/
/* global mw */
(function () {
'use strict';
// ── Guards ──────────────────────────────────────────────────────────────────
if (mw.config.get('wgAction') !== 'view') return;
if (!mw.config.get('wgIsArticle')) return;
if (mw.config.get('wgNamespaceNumber') !== 0) return; // main namespace only
if (!mw.config.get('wgUserId')) return; // must be logged in
// ── Config ───────────────────────────────────────────────────────────────────
var PAGE = mw.config.get('wgPageName'); // e.g. "Albert_Einstein"
var USER = mw.config.get('wgUserName');
var ORIGIN = location.origin; // e.g. "https://en.wikipedia.org"
var REST = ORIGIN + '/api/rest_v1/transform';
// ── Toolbar button definitions (nicEdit-inspired) ───────────────────────────
// Each entry is either a separator { sep:true } or a button descriptor.
// special:true buttons open a dialog pane (like nicEditorAdvancedButton)
// instead of calling execCommand directly.
// Unicode labels — all glyphs are standard Unicode code points, no image
// files needed (contrast with nicEdit which uses a sprite GIF).
//
// 𝐁 U+1D401 Mathematical Bold Capital B
// 𝐼 U+1D408 Mathematical Italic Capital I
// H¹ / H² / H³ plain letter + superscript digit (U+00B9, U+00B2, U+00B3)
// ① U+2460 Circled Digit One (ordered list)
// ⁍ U+204D Black Right Pointing Bullet (unordered list)
// 🖼 U+1F5BC Frame With Picture (insert image)
// ⊞ U+229E Squared Plus / 4-cell grid (insert table)
// 🔗 U+1F517 Link Symbol (insert link)
var BUTTONS = [
{ cmd: 'bold', label: '𝐁', title: 'Bold (Ctrl+B)' },
{ cmd: 'italic', label: '𝐼', title: 'Italic (Ctrl+I)' },
{ sep: true },
{ cmd: 'formatBlock', val: 'H1', label: 'H¹', title: 'Heading 1' },
{ cmd: 'formatBlock', val: 'H2', label: 'H²', title: 'Heading 2' },
{ cmd: 'formatBlock', val: 'H3', label: 'H³', title: 'Heading 3' },
{ sep: true },
{ cmd: 'insertOrderedList', label: '①', title: 'Numbered list' },
{ cmd: 'insertUnorderedList', label: '⁍', title: 'Bullet list' },
{ sep: true },
{ cmd: 'insertMedia', label: '🖼', title: 'Insert image', special: true },
{ cmd: 'insertTable', label: '⊞', title: 'Insert table', special: true },
{ cmd: 'createLink', label: '🔗', title: 'Insert link', special: true },
];
// ── Keyboard shortcuts (nicEdit also maps Ctrl+key to commands) ─────────────
var KEY_MAP = { b: 'bold', i: 'italic', z: 'undo', y: 'redo' };
// ── Embedded CSS ─────────────────────────────────────────────────────────────
var CSS = [
/* Floating action button */
'#wem-fab{',
'position:fixed;bottom:72px;right:16px;z-index:9990;',
'width:52px;height:52px;border-radius:50%;',
'background:#3366cc;color:#fff;font-size:22px;line-height:1;',
'border:none;box-shadow:0 3px 10px rgba(0,0,0,.35);',
'cursor:pointer;touch-action:manipulation;',
'display:flex;align-items:center;justify-content:center;',
'transition:transform .12s;',
'}',
'#wem-fab:active{transform:scale(.9)}',
/* Full-screen overlay */
'#wem-overlay{',
'position:fixed;inset:0;z-index:9995;',
'display:flex;flex-direction:column;',
'background:#fff;font-family:sans-serif;',
'overscroll-behavior:contain;',
'}',
'#wem-overlay[hidden]{display:none}',
/* Header bar */
'#wem-hdr{',
'background:#3366cc;color:#fff;',
'padding:10px 12px;display:flex;align-items:center;gap:10px;',
'min-height:52px;flex-shrink:0;',
'}',
'#wem-back{',
'background:none;border:none;color:#fff;font-size:24px;',
'cursor:pointer;padding:4px 8px;line-height:1;',
'touch-action:manipulation;flex-shrink:0;',
'}',
'#wem-hdr-title{',
'flex:1;font-size:17px;font-weight:600;',
'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;',
'}',
'#wem-save{',
'background:#27ae60;border:none;color:#fff;',
'padding:7px 14px;border-radius:4px;font-size:14px;font-weight:600;',
'cursor:pointer;touch-action:manipulation;flex-shrink:0;',
'transition:opacity .15s;',
'}',
'#wem-save:disabled{opacity:.45;cursor:default}',
/* Section list */
'#wem-sections{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch}',
'.wem-sec{',
'display:flex;align-items:center;',
'padding:15px 16px;border-bottom:1px solid #eaecf0;',
'cursor:pointer;-webkit-tap-highlight-color:transparent;',
'}',
'.wem-sec:active{background:#f0f4ff}',
'.wem-sec-lv{color:#72777d;font-size:12px;min-width:30px;font-family:monospace}',
'.wem-sec-nm{flex:1;font-size:16px}',
'.wem-sec-arr{color:#a2a9b1;font-size:20px;padding-left:8px}',
/* Editor pane */
'#wem-editor{flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:0}',
/* Toolbar — sits at the very bottom of the overlay, just above the
soft keyboard. Horizontally scrollable so all 9 buttons fit on
narrow screens without wrapping. border-top separates from content. */
'#wem-toolbar{',
'display:flex;align-items:center;flex-wrap:nowrap;',
'overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;',
'background:#f8f9fa;',
'border-top:2px solid #c8ccd1;',
/* Push content above the iPhone home-indicator notch */
'padding:6px 6px calc(6px + env(safe-area-inset-bottom, 0px)) 6px;',
'gap:4px;flex-shrink:0;',
'}',
'#wem-toolbar[hidden]{display:none}',
'#wem-toolbar::-webkit-scrollbar{display:none}',
/* Buttons — 44 px tall (Apple HIG minimum touch target) for bottom-of-
screen thumb access. Icon font-size bumped so Unicode glyphs are
clearly readable at arm's length. */
'.wem-btn{',
'flex-shrink:0;min-width:44px;height:44px;padding:0 8px;',
'background:#fff;border:1px solid #c8ccd1;border-radius:6px;',
'font-size:16px;cursor:pointer;touch-action:manipulation;',
'display:flex;align-items:center;justify-content:center;',
'transition:background .1s;white-space:nowrap;',
'}',
'.wem-btn:active,.wem-btn.wem-active{background:#d0d7f0;border-color:#3366cc}',
'.wem-sep{width:1px;height:32px;background:#c8ccd1;flex-shrink:0;margin:0 2px}',
/* The contentEditable editing surface */
'#wem-editable-wrap{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:12px;min-height:0}',
'#wem-editable{',
'min-height:200px;outline:none;word-wrap:break-word;',
'line-height:1.6;font-size:15px;',
'}',
'#wem-editable h2{font-size:1.3em;border-bottom:1px solid #eaecf0;padding-bottom:4px;margin:12px 0 6px}',
'#wem-editable h3{font-size:1.15em;margin:10px 0 4px}',
'#wem-editable a{color:#3366cc}',
'#wem-editable ul,#wem-editable ol{padding-left:1.6em;margin:4px 0}',
'#wem-editable p{margin:4px 0}',
'#wem-editable table{border-collapse:collapse;font-size:13px}',
'#wem-editable td,#wem-editable th{border:1px solid #a2a9b1;padding:4px 6px}',
/* Status bar */
'#wem-status{',
'padding:5px 12px;font-size:12px;color:#54595d;',
'background:#f8f9fa;border-top:1px solid #eaecf0;',
'min-height:26px;flex-shrink:0;line-height:16px;',
'}',
'#wem-status.wem-err{color:#d33}',
'#wem-status.wem-ok{color:#27ae60}',
/* ── Dialog (like nicEditorPane, but full-width bottom sheet on mobile) ── */
/* Backdrop dims the editor while the dialog is open */
'#wem-dialog-backdrop{',
'position:absolute;inset:0;z-index:100;',
'background:rgba(0,0,0,.45);',
'display:flex;align-items:flex-end;', /* dialog slides up from bottom */
'}',
'#wem-dialog-backdrop[hidden]{display:none}',
'#wem-dialog{',
'width:100%;background:#fff;',
'border-radius:16px 16px 0 0;',
'padding:20px 16px 32px;',
'box-sizing:border-box;',
'box-shadow:0 -4px 24px rgba(0,0,0,.18);',
'}',
'#wem-dialog-title{',
'font-size:17px;font-weight:700;margin:0 0 16px;color:#202122;',
'}',
'.wem-field{margin-bottom:14px}',
'.wem-field label{',
'display:block;font-size:13px;font-weight:600;',
'color:#54595d;margin-bottom:4px;',
'}',
'.wem-field input,.wem-field select{',
'width:100%;box-sizing:border-box;',
'padding:10px 12px;font-size:15px;',
'border:1px solid #c8ccd1;border-radius:6px;',
'background:#fff;appearance:none;-webkit-appearance:none;',
'}',
'.wem-field input:focus,.wem-field select:focus{',
'outline:none;border-color:#3366cc;',
'box-shadow:0 0 0 2px rgba(51,102,204,.25);',
'}',
'.wem-field-row{display:flex;gap:10px}',
'.wem-field-row .wem-field{flex:1;margin-bottom:0}',
'.wem-field-row .wem-field.narrow{flex:0 0 80px}',
'#wem-dialog-footer{',
'display:flex;gap:10px;margin-top:20px;',
'}',
'#wem-dialog-footer button{',
'flex:1;padding:12px;border-radius:8px;',
'font-size:15px;font-weight:600;cursor:pointer;border:none;',
'touch-action:manipulation;',
'}',
'#wem-dlg-cancel{background:#f8f9fa;color:#202122;border:1px solid #c8ccd1!important}',
'#wem-dlg-insert{background:#3366cc;color:#fff}',
'#wem-dlg-insert:disabled{opacity:.5}',
].join('');
// ──────────────────────────────────────────────────────────────────────────────
// API layer
// ──────────────────────────────────────────────────────────────────────────────
var mwApi = new mw.Api();
/**
* Returns the list of sections for the current page.
* @returns {Promise<Array>} Array of section objects with {index, line, toclevel}
*/
function getSections() {
return mwApi.get({
action: 'parse',
page: PAGE,
prop: 'sections',
format: 'json',
formatversion: 2,
}).then(function (d) { return d.parse.sections; });
}
/**
* Fetches the raw wikitext for a single section.
* @param {number|string} sectionIndex 0 = lead/intro, 1+ = numbered sections
* @returns {Promise<string>}
*/
function getSectionWikitext(sectionIndex) {
return mwApi.get({
action: 'parse',
page: PAGE,
section: sectionIndex,
prop: 'wikitext',
format: 'json',
formatversion: 2,
}).then(function (d) { return d.parse.wikitext; });
}
/**
* Converts wikitext → HTML via the Parsoid REST API.
* body_only=true strips the outer <html>/<head> wrapper so we get
* clean HTML suitable for contentEditable (same approach nicEdit uses
* for its iframe mode, but without needing an iframe).
* @param {string} wikitext
* @returns {Promise<string>} HTML string
*/
function wikitextToHtml(wikitext) {
return fetch(REST + '/wikitext/to/html/' + encodeURIComponent(PAGE), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ wikitext: wikitext, body_only: 'true' }),
}).then(function (r) {
if (!r.ok) throw new Error('Parsoid wikitext→html: HTTP ' + r.status);
return r.text();
});
}
/**
* Converts HTML → wikitext via the Parsoid REST API.
* scrub_wikitext=1 cleans up Parsoid-specific HTML attributes before conversion,
* reducing noise from browser-edited markup.
* @param {string} html
* @returns {Promise<string>} wikitext string
*/
function htmlToWikitext(html) {
return fetch(REST + '/html/to/wikitext/' + encodeURIComponent(PAGE), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ html: html, scrub_wikitext: '1' }),
}).then(function (r) {
if (!r.ok) throw new Error('Parsoid html→wikitext: HTTP ' + r.status);
return r.text();
});
}
/**
* Saves a section's wikitext via the MediaWiki edit API.
* postWithToken automatically fetches and attaches the CSRF token.
* @param {number|string} sectionIndex
* @param {string} wikitext
* @returns {Promise<Object>} API response
*/
function saveSection(sectionIndex, wikitext) {
return mwApi.postWithToken('csrf', {
action: 'edit',
title: PAGE,
section: sectionIndex,
text: wikitext,
summary: 'Visual mobile edit ([[User:' + USER + '/WikiEditMobile.js|WikiEditMobile]])',
format: 'json',
});
}
// ──────────────────────────────────────────────────────────────────────────────
// MobileEditor class — nicEdit-inspired contentEditable wrapper
//
// nicEdit wraps a div or textarea in a contentEditable surface and dispatches
// execCommand calls from toolbar buttons. We follow the same pattern but
// target mobile: a horizontally-scrolling toolbar with 36×36px touch targets,
// no image sprites (Unicode labels instead), and touch event handling.
// ──────────────────────────────────────────────────────────────────────────────
function MobileEditor(toolbarEl, editableEl) {
this.toolbar = toolbarEl;
this.editable = editableEl;
this._activeStateTimer = null;
this._buildToolbar();
this.editable.contentEditable = 'true';
this.editable.addEventListener('keydown', this._onKeyDown.bind(this));
// Update button active states when selection changes (like nicEditorButton.checkNodes)
this.editable.addEventListener('keyup', this._updateActiveStates.bind(this));
this.editable.addEventListener('mouseup', this._updateActiveStates.bind(this));
// selectionchange fires on mobile tap-selection
document.addEventListener('selectionchange', this._updateActiveStates.bind(this));
}
MobileEditor.prototype._buildToolbar = function () {
var self = this;
this.toolbar.innerHTML = '';
this._btnEls = {}; // cmd → button element, for active-state tracking
BUTTONS.forEach(function (def) {
if (def.sep) {
var sep = document.createElement('div');
sep.className = 'wem-sep';
self.toolbar.appendChild(sep);
return;
}
var btn = document.createElement('button');
btn.className = 'wem-btn';
btn.type = 'button';
btn.title = def.title || '';
btn.innerHTML = def.label;
if (def.style) { btn.style.cssText = def.style; }
// Prevent blur on the editable when tapping toolbar buttons.
// nicEditorButton.construct does the same with bkLib.cancelEvent on mousedown.
btn.addEventListener('mousedown', function (e) {
e.preventDefault();
self._exec(def);
});
// Touch devices fire touchstart; we preventDefault to avoid the 300ms delay
// and the subsequent mousedown that would double-fire.
btn.addEventListener('touchstart', function (e) {
e.preventDefault();
self._exec(def);
}, { passive: false });
self.toolbar.appendChild(btn);
if (def.cmd) { self._btnEls[def.cmd] = btn; }
});
};
/**
* Save the current selection so it can be restored after a dialog closes.
* Mirrors nicEditorInstance.saveRng / restoreRng.
*/
MobileEditor.prototype._saveSelection = function () {
var sel = window.getSelection();
this._savedRange = (sel && sel.rangeCount > 0) ? sel.getRangeAt(0).cloneRange() : null;
};
MobileEditor.prototype._restoreSelection = function () {
if (!this._savedRange) return;
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(this._savedRange);
};
/**
* Execute a formatting command.
* Mirrors nicEditorButton.mouseClick → ne.nicCommand → execCommand.
* For special buttons (link, media, table) save the selection first,
* then open a dialog pane — mirrors nicEditorAdvancedButton.mouseClick.
*/
MobileEditor.prototype._exec = function (def) {
if (def.special) {
// Save selection before the dialog steals focus (nicEdit: saveRng)
this._saveSelection();
if (def.cmd === 'createLink') { showLinkDialog(this); return; }
if (def.cmd === 'insertMedia') { showMediaDialog(this); return; }
if (def.cmd === 'insertTable') { showTableDialog(this); return; }
return;
}
this.editable.focus();
if (def.val) {
document.execCommand(def.cmd, false, def.val);
} else {
document.execCommand(def.cmd, false, null);
}
this._updateActiveStates();
};
/**
* Keyboard shortcuts — mirrors nicEditorButton.key which listens for Ctrl+key.
*/
MobileEditor.prototype._onKeyDown = function (e) {
if (e.ctrlKey || e.metaKey) {
var cmd = KEY_MAP[e.key.toLowerCase()];
if (cmd) {
e.preventDefault();
document.execCommand(cmd, false, null);
this._updateActiveStates();
}
}
};
/**
* Reflect the active formatting state on toolbar buttons.
* nicEditorButton.checkNodes does the same by walking up the DOM tree
* to see if the cursor is inside a relevant tag.
*/
MobileEditor.prototype._updateActiveStates = function () {
var self = this;
// Debounce to avoid firing on every selectionchange pixel
clearTimeout(this._activeStateTimer);
this._activeStateTimer = setTimeout(function () {
['bold', 'italic', 'insertOrderedList', 'insertUnorderedList'].forEach(function (cmd) {
var el = self._btnEls[cmd];
if (!el) return;
try {
if (document.queryCommandState(cmd)) {
el.classList.add('wem-active');
} else {
el.classList.remove('wem-active');
}
} catch (ex) { /* queryCommandState not supported for this cmd */ }
});
}, 50);
};
MobileEditor.prototype.getContent = function () {
return this.editable.innerHTML;
};
MobileEditor.prototype.setContent = function (html) {
this.editable.innerHTML = html;
};
MobileEditor.prototype.focus = function () {
// Small delay so the overlay animation finishes first on mobile
var el = this.editable;
setTimeout(function () { el.focus(); }, 80);
};
// ──────────────────────────────────────────────────────────────────────────────
// Dialog system
// Mirrors nicEdit's nicEditorPane + nicEditorAdvancedButton.addForm:
// a floating panel that collects input, restores the saved selection, then
// calls execCommand or insertHTML to modify the editable content.
// ──────────────────────────────────────────────────────────────────────────────
var activeEditorRef = null; // editor instance that opened the current dialog
/**
* Build and show a bottom-sheet dialog.
* @param {string} title - Dialog heading
* @param {string} insertLabel - Text for the insert button
* @param {Function} buildBody - Called with the dialog body element; should
* append .wem-field rows and return a function
* that returns the collected values (or null to cancel).
* @param {Function} onInsert - Called with the collected values when user confirms.
*/
function openDialog(title, insertLabel, buildBody, onInsert) {
var backdrop = document.getElementById('wem-dialog-backdrop');
backdrop.innerHTML = '';
backdrop.hidden = false;
var dlg = ce('div', { id: 'wem-dialog', role: 'dialog', 'aria-modal': 'true' });
var ttl = ce('p', { id: 'wem-dialog-title' });
ttl.textContent = title;
var body = ce('div', { id: 'wem-dialog-body' });
var footer = ce('div', { id: 'wem-dialog-footer' });
var cancelBtn = ce('button', { id: 'wem-dlg-cancel', type: 'button' });
cancelBtn.textContent = 'Cancel';
var insertBtn = ce('button', { id: 'wem-dlg-insert', type: 'button' });
insertBtn.textContent = insertLabel;
footer.appendChild(cancelBtn);
footer.appendChild(insertBtn);
dlg.appendChild(ttl);
dlg.appendChild(body);
dlg.appendChild(footer);
backdrop.appendChild(dlg);
// buildBody populates the form and returns a collect() function
var collect = buildBody(body);
function close() {
backdrop.hidden = true;
backdrop.innerHTML = '';
}
cancelBtn.addEventListener('click', function () {
close();
if (activeEditorRef) { activeEditorRef._restoreSelection(); }
});
insertBtn.addEventListener('click', function () {
var values = collect();
if (!values) return; // validation failed inside collect()
close();
if (activeEditorRef) {
activeEditorRef._restoreSelection();
activeEditorRef.editable.focus();
}
onInsert(values);
});
// Tap outside dialog closes it
backdrop.addEventListener('click', function (e) {
if (e.target === backdrop) {
close();
if (activeEditorRef) { activeEditorRef._restoreSelection(); }
}
});
// Focus first input
setTimeout(function () {
var first = dlg.querySelector('input, select');
if (first) { first.focus(); }
}, 50);
}
function closeDialog() {
var backdrop = document.getElementById('wem-dialog-backdrop');
if (backdrop) { backdrop.hidden = true; backdrop.innerHTML = ''; }
}
// ── Field helpers (mirrors nicEditorAdvancedButton.addForm) ──────────────────
function makeField(id, labelTxt, inputEl) {
var wrap = ce('div', { 'class': 'wem-field' });
var label = ce('label');
label.setAttribute('for', id);
label.textContent = labelTxt;
inputEl.id = id;
wrap.appendChild(label);
wrap.appendChild(inputEl);
return wrap;
}
function textInput(placeholder, value) {
var el = ce('input', { type: 'text' });
el.placeholder = placeholder || '';
el.value = value || '';
return el;
}
function selectInput(options, selectedVal) {
var el = ce('select');
options.forEach(function (opt) {
var o = ce('option', { value: opt[0] });
o.textContent = opt[1];
if (opt[0] === selectedVal) { o.selected = true; }
el.appendChild(o);
});
return el;
}
function numberInput(min, max, val) {
var el = ce('input', { type: 'number' });
el.min = String(min);
el.max = String(max);
el.value = String(val);
el.style.cssText = 'width:64px';
return el;
}
// ── Link dialog ──────────────────────────────────────────────────────────────
function showLinkDialog(edRef) {
activeEditorRef = edRef;
// Pre-fill href if cursor is already inside an <a> — mirrors nicLinkButton
var sel = window.getSelection();
var node = sel && sel.anchorNode;
var existingA = null;
while (node && node !== edRef.editable) {
if (node.nodeName === 'A') { existingA = node; break; }
node = node.parentNode;
}
openDialog('Insert / Edit Link', 'Insert link',
function (body) {
var hrefEl = textInput('https://…', (existingA && existingA.href) || 'https://');
var textEl = textInput('Display text (optional)',
existingA ? (existingA.textContent || '') : '');
var targetEl = selectInput(
[['', 'Same window'], ['_blank', 'New tab']],
(existingA && existingA.target) || ''
);
body.appendChild(makeField('dlg-href', 'URL', hrefEl));
body.appendChild(makeField('dlg-ltext', 'Link text', textEl));
body.appendChild(makeField('dlg-target', 'Opens in', targetEl));
return function collect() {
var href = hrefEl.value.trim();
if (!href || href === 'https://') {
hrefEl.focus();
hrefEl.style.borderColor = '#d33';
return null;
}
return { href: href, text: textEl.value.trim(), target: targetEl.value };
};
},
function onInsert(v) {
if (existingA) {
// Update existing link in-place
existingA.href = v.href;
existingA.target = v.target;
if (v.text) { existingA.textContent = v.text; }
} else {
// If the user typed display text, select it first so execCommand wraps it
if (v.text) {
document.execCommand('insertText', false, v.text);
// Re-select the just-inserted text
var s = window.getSelection();
var r = s.getRangeAt(0);
r.setStart(r.startContainer, r.startOffset - v.text.length);
s.removeAllRanges();
s.addRange(r);
}
document.execCommand('createLink', false, v.href);
// Set target attribute on the newly created <a>
if (v.target) {
var links = edRef.editable.querySelectorAll('a[href="' + v.href + '"]');
links.forEach(function (a) { a.target = v.target; });
}
}
}
);
}
// ── Media (image) dialog ─────────────────────────────────────────────────────
// Inserts a Parsoid-compatible <figure> element.
// Parsoid's html→wikitext converter recognises this pattern and produces
// [[File:Name.jpg|type|align|widthpx|Caption]] syntax.
function showMediaDialog(edRef) {
activeEditorRef = edRef;
openDialog('Insert Image', 'Insert image',
function (body) {
var fileEl = textInput('Example.jpg', '');
var captionEl = textInput('Image caption', '');
var typeEl = selectInput(
[['Thumb', 'Thumbnail'], ['Frame', 'Framed'], ['Frameless', 'Frameless'], ['', 'Plain']],
'Thumb'
);
var alignEl = selectInput(
[['', 'Default'], ['right', 'Right'], ['left', 'Left'], ['center', 'Center'], ['none', 'None']],
'right'
);
var widthEl = numberInput(50, 1200, 220);
body.appendChild(makeField('dlg-file', 'File name (no "File:" prefix)', fileEl));
body.appendChild(makeField('dlg-caption', 'Caption', captionEl));
// Type + Align on one row
var row1 = ce('div', { 'class': 'wem-field-row' });
row1.appendChild(makeField('dlg-type', 'Type', typeEl));
row1.appendChild(makeField('dlg-align', 'Alignment', alignEl));
body.appendChild(row1);
// Width on its own row
var row2 = ce('div', { 'class': 'wem-field-row' });
var wfWrap = ce('div', { 'class': 'wem-field narrow' });
var wLabel = ce('label'); wLabel.setAttribute('for', 'dlg-width');
wLabel.textContent = 'Width (px)';
widthEl.id = 'dlg-width';
wfWrap.appendChild(wLabel); wfWrap.appendChild(widthEl);
row2.appendChild(wfWrap);
body.appendChild(row2);
return function collect() {
var fname = fileEl.value.trim().replace(/^File:/i, '');
if (!fname) {
fileEl.focus(); fileEl.style.borderColor = '#d33';
return null;
}
return {
file: fname,
caption: captionEl.value.trim(),
type: typeEl.value,
align: alignEl.value,
width: parseInt(widthEl.value, 10) || 220,
};
};
},
function onInsert(v) {
// Build a Parsoid-style <figure> that round-trips cleanly via html→wikitext
var typeofAttr = 'mw:File' + (v.type ? '/' + v.type : '');
var alignClass = v.align ? ' mw-halign-' + v.align : '';
var href = './File:' + encodeURIComponent(v.file);
var html = '<figure typeof="' + typeofAttr + '" class="mw-default-size' + alignClass + '">' +
'<a href="' + href + '">' +
'<img resource="' + href + '" src="" alt="' + escHtml(v.file) +
'" width="' + v.width + '"></a>' +
(v.caption ? '<figcaption>' + escHtml(v.caption) + '</figcaption>' : '') +
'</figure><p></p>';
document.execCommand('insertHTML', false, html);
}
);
}
// ── Table dialog ─────────────────────────────────────────────────────────────
function showTableDialog(edRef) {
activeEditorRef = edRef;
openDialog('Insert Table', 'Insert table',
function (body) {
var rowsEl = numberInput(1, 50, 3);
var colsEl = numberInput(1, 20, 3);
var hdrEl = selectInput(
[['yes', 'Yes — first row as header'],
['no', 'No header row']],
'yes'
);
var sizeRow = ce('div', { 'class': 'wem-field-row' });
var rWrap = ce('div', { 'class': 'wem-field' });
var rLbl = ce('label'); rLbl.setAttribute('for', 'dlg-rows');
rLbl.textContent = 'Rows'; rowsEl.id = 'dlg-rows';
rWrap.appendChild(rLbl); rWrap.appendChild(rowsEl);
var cWrap = ce('div', { 'class': 'wem-field' });
var cLbl = ce('label'); cLbl.setAttribute('for', 'dlg-cols');
cLbl.textContent = 'Columns'; colsEl.id = 'dlg-cols';
cWrap.appendChild(cLbl); cWrap.appendChild(colsEl);
sizeRow.appendChild(rWrap);
sizeRow.appendChild(cWrap);
body.appendChild(sizeRow);
body.appendChild(makeField('dlg-hdr', 'Header row', hdrEl));
return function collect() {
return {
rows: Math.max(1, parseInt(rowsEl.value, 10) || 3),
cols: Math.max(1, parseInt(colsEl.value, 10) || 3),
header: hdrEl.value === 'yes',
};
};
},
function onInsert(v) {
var html = '<table style="border-collapse:collapse;width:100%"><tbody>';
for (var r = 0; r < v.rows; r++) {
html += '<tr>';
for (var c = 0; c < v.cols; c++) {
if (r === 0 && v.header) {
html += '<th style="border:1px solid #a2a9b1;padding:6px 8px;background:#eaecf0">Header ' + (c + 1) + '</th>';
} else {
html += '<td style="border:1px solid #a2a9b1;padding:6px 8px"> </td>';
}
}
html += '</tr>';
}
html += '</tbody></table><p></p>';
document.execCommand('insertHTML', false, html);
}
);
}
// ── HTML escape helper ───────────────────────────────────────────────────────
function escHtml(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
// ──────────────────────────────────────────────────────────────────────────────
// UI
// ──────────────────────────────────────────────────────────────────────────────
var editor = null; // MobileEditor instance
var curSection = null; // currently open section descriptor
var curView = 'sections'; // 'sections' | 'editor'
function setStatus(msg, type) {
var el = document.getElementById('wem-status');
el.textContent = msg;
el.className = type === 'err' ? 'wem-err' : type === 'ok' ? 'wem-ok' : '';
}
function showSectionsView() {
curView = 'sections';
curSection = null;
document.getElementById('wem-sections').hidden = false;
document.getElementById('wem-editor').hidden = true;
document.getElementById('wem-toolbar').hidden = true; // hide when browsing sections
document.getElementById('wem-hdr-title').textContent = 'Edit article';
document.getElementById('wem-save').disabled = true;
document.getElementById('wem-back').style.visibility = 'hidden';
setStatus('');
if (editor) { editor.setContent(''); }
}
function showEditorView(section) {
curView = 'editor';
curSection = section;
document.getElementById('wem-sections').hidden = true;
document.getElementById('wem-editor').hidden = false;
document.getElementById('wem-toolbar').hidden = false; // show at bottom above keyboard
document.getElementById('wem-hdr-title').textContent = section.line || 'Introduction';
document.getElementById('wem-back').style.visibility = 'visible';
document.getElementById('wem-save').disabled = true;
setStatus('Fetching section wikitext…');
getSectionWikitext(section.index)
.then(function (wt) {
setStatus('Converting to HTML via Parsoid…');
return wikitextToHtml(wt);
})
.then(function (html) {
editor.setContent(html);
editor.focus();
document.getElementById('wem-save').disabled = false;
setStatus('Ready — edit freely, then tap Save');
})
['catch'](function (err) {
setStatus('⚠ ' + err.message, 'err');
});
}
function handleSave() {
if (!curSection || !editor) return;
var saveBtn = document.getElementById('wem-save');
saveBtn.disabled = true;
setStatus('Converting back to wikitext…');
var html = editor.getContent();
htmlToWikitext(html)
.then(function (wt) {
setStatus('Saving…');
return saveSection(curSection.index, wt);
})
.then(function (res) {
if (res.edit && res.edit.result === 'Success') {
setStatus('✓ Saved! Reloading…', 'ok');
setTimeout(function () { location.reload(); }, 1400);
} else {
// API returned an error object
var errMsg = (res.error && res.error.info) ? res.error.info : JSON.stringify(res);
throw new Error(errMsg);
}
})
['catch'](function (err) {
setStatus('⚠ Save failed: ' + err.message, 'err');
saveBtn.disabled = false;
});
}
// ── DOM builders ─────────────────────────────────────────────────────────────
function buildOverlay() {
// ── Header ──
var hdr = ce('div', { id: 'wem-hdr' });
var back = ce('button', { id: 'wem-back', 'aria-label': 'Back' });
back.textContent = '←';
var titleEl = ce('span', { id: 'wem-hdr-title' });
titleEl.textContent = 'Edit article';
var saveBtn = ce('button', { id: 'wem-save' });
saveBtn.textContent = 'Save';
saveBtn.disabled = true;
hdr.appendChild(back);
hdr.appendChild(titleEl);
hdr.appendChild(saveBtn);
// ── Section list view ──
var sectionsDiv = ce('div', { id: 'wem-sections' });
// ── Editor view (content only — toolbar lives outside this pane) ──
var editorPane = ce('div', { id: 'wem-editor' });
editorPane.hidden = true;
var editWrap = ce('div', { id: 'wem-editable-wrap' });
var editableDiv = ce('div', { id: 'wem-editable' });
editableDiv.setAttribute('role', 'textbox');
editableDiv.setAttribute('aria-multiline', 'true');
editableDiv.setAttribute('aria-label', 'Section content');
editWrap.appendChild(editableDiv);
editorPane.appendChild(editWrap);
// ── Status bar (between content area and toolbar) ──
var status = ce('div', { id: 'wem-status' });
// ── Toolbar — bottom of overlay, appears just above the soft keyboard.
// Starts hidden; shown only when the editor view is active. ──
var toolbar = ce('div', { id: 'wem-toolbar' });
toolbar.hidden = true;
// ── Dialog backdrop (position:absolute inside overlay covers everything
// including the toolbar when a dialog is open) ──
var backdrop = ce('div', { id: 'wem-dialog-backdrop' });
backdrop.hidden = true;
// ── Overlay — stacking order (top → bottom on screen):
// header · sections/editor · status · toolbar · dialog backdrop ──
var overlay = ce('div', { id: 'wem-overlay' });
overlay.hidden = true;
overlay.appendChild(hdr);
overlay.appendChild(sectionsDiv);
overlay.appendChild(editorPane);
overlay.appendChild(status);
overlay.appendChild(toolbar); // ← bottom of screen, above keyboard
overlay.appendChild(backdrop);
document.body.appendChild(overlay);
// ── Wire up editor ──
editor = new MobileEditor(toolbar, editableDiv);
// ── Events ──
back.addEventListener('click', function () {
// If a dialog is open, close it first (like nicEditorAdvancedButton.removePane)
if (!backdrop.hidden) { closeDialog(); return; }
if (curView === 'editor') {
showSectionsView();
} else {
overlay.hidden = true;
}
});
saveBtn.addEventListener('click', handleSave);
// Hardware back / Escape key
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !overlay.hidden) {
if (curView === 'editor') { showSectionsView(); }
else { overlay.hidden = true; }
}
});
return { overlay: overlay, sectionsEl: sectionsDiv };
}
function makeSectionItem(section) {
var item = ce('div', { 'class': 'wem-sec' });
// Indent based on heading level (like nicEdit's reorder)
item.style.paddingLeft = (16 + Math.max(0, (section.toclevel - 1) * 12)) + 'px';
var lv = ce('span', { 'class': 'wem-sec-lv' });
lv.textContent = 'H' + (section.toclevel + 1);
var nm = ce('span', { 'class': 'wem-sec-nm' });
nm.textContent = section.line;
var arr = ce('span', { 'class': 'wem-sec-arr' });
arr.textContent = '›';
item.appendChild(lv);
item.appendChild(nm);
item.appendChild(arr);
item.addEventListener('click', function () { showEditorView(section); });
return item;
}
// Tiny element factory (mirrors nicEdit's bkElement pattern)
function ce(tag, attrs) {
var el = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function (k) {
if (k === 'class') { el.className = attrs[k]; }
else { el.setAttribute(k, attrs[k]); }
});
}
return el;
}
// ── Bootstrap ─────────────────────────────────────────────────────────────────
function init() {
if (document.getElementById('wem-fab')) return; // guard against double-init
// Inject styles
var style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
// Build the overlay (hidden until FAB is tapped)
var ui = buildOverlay();
showSectionsView();
// Floating action button — pencil icon, fixed bottom-right
var fab = ce('button', { id: 'wem-fab', title: 'Visual section editor', 'aria-label': 'Visual section editor' });
fab.innerHTML = '✎'; // ✎
document.body.appendChild(fab);
fab.addEventListener('click', function () {
ui.overlay.hidden = false;
showSectionsView();
setStatus('Loading sections…');
getSections()
.then(function (sections) {
var container = ui.sectionsEl;
container.innerHTML = '';
// Section 0 = lead / introduction (before the first heading)
container.appendChild(
makeSectionItem({ index: 0, line: 'Introduction', toclevel: 1 })
);
sections.forEach(function (s) {
if (s.line) {
container.appendChild(makeSectionItem(s));
}
});
setStatus('Tap a section to begin editing');
})
['catch'](function (err) {
setStatus('⚠ Could not load sections: ' + err.message, 'err');
});
});
}
// mw.hook fires after the page content is ready, including after
// Minerva's ajax-navigation loads a new page — same pattern nicEdit
// recommends via bkLib.onDomLoaded.
mw.hook('wikipage.content').add(init);
}());