// ==UserScript==
// @name Bwiki Quick Editor
// @version 0.1
// @description Wiki 快速编辑器,无需打开编辑页面。
// @author lu
// @match *://wiki.biligame.com/*
// @namespace https://greasyfork.org/users/416853
// @require https://code.jquery.com/jquery-3.6.0.min.js
// @license CC BY-SA
// @license MIT license for highlight-within-textarea
// @license CC BY-SA for Bwiki Quick Editor
// ==/UserScript==
/*
* highlight-within-textarea
*
* @author Will Boyd
* @github https://github.com/lonekorean/highlight-within-textarea
* @license MIT license
*
* this copy alreay a little modified by Lu.
*/
(function($) {
let ID = 'hwt';
let HighlightWithinTextarea = function($el, config) {
this.init($el, config);
};
HighlightWithinTextarea.prototype = {
init: function($el, config) {
this.$el = $el;
// backwards compatibility with v1 (deprecated)
if (this.getType(config) === 'function') {
config = { highlight: config };
}
if (this.getType(config) === 'custom') {
this.highlight = config;
this.generate();
} else {
console.error('valid config object not provided');
}
},
// returns identifier strings that aren't necessarily "real" JavaScript types
getType: function(instance) {
let type = typeof instance;
if (!instance) {
return 'falsey';
} else if (Array.isArray(instance)) {
if (instance.length === 2 && typeof instance[0] === 'number' && typeof instance[1] === 'number') {
return 'range';
} else {
return 'array';
}
} else if (type === 'object') {
if (instance instanceof RegExp) {
return 'regexp';
} else if (instance.hasOwnProperty('highlight')) {
return 'custom';
}
} else if (type === 'function' || type === 'string') {
return type;
}
return 'other';
},
generate: function() {
this.$el
.addClass(ID + '-input ' + ID + '-content')
.on('input.' + ID, this.handleInput.bind(this))
.on('scroll.' + ID, this.handleScroll.bind(this));
this.$highlights = $('<div>', { class: ID + '-highlights ' + ID + '-content' });
this.$backdrop = $('<div>', { class: ID + '-backdrop' })
.append(this.$highlights);
this.$container = $('<div>', { class: ID + '-container' })
.insertAfter(this.$el)
.append(this.$backdrop, this.$el) // moves $el into $container
.on('scroll', this.blockContainerScroll.bind(this));
this.browser = this.detectBrowser();
switch (this.browser) {
case 'firefox':
this.fixFirefox();
break;
case 'ios':
this.fixIOS();
break;
}
// plugin function checks this for success
this.isGenerated = true;
// trigger input event to highlight any existing input
this.handleInput();
},
// browser sniffing sucks, but there are browser-specific quirks to handle
// that are not a matter of feature detection
detectBrowser: function() {
let ua = window.navigator.userAgent.toLowerCase();
if (ua.indexOf('firefox') !== -1) {
return 'firefox';
} else if (!!ua.match(/msie|trident\/7|edge/)) {
return 'ie';
} else if (!!ua.match(/ipad|iphone|ipod/) && ua.indexOf('windows phone') === -1) {
// Windows Phone flags itself as "like iPhone", thus the extra check
return 'ios';
} else {
return 'other';
}
},
// Firefox doesn't show text that scrolls into the padding of a textarea, so
// rearrange a couple box models to make highlights behave the same way
fixFirefox: function() {
// take padding and border pixels from highlights div
let padding = this.$highlights.css([
'padding-top', 'padding-right', 'padding-bottom', 'padding-left'
]);
let border = this.$highlights.css([
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'
]);
this.$highlights.css({
'padding': '0',
'border-width': '0'
});
this.$backdrop
.css({
// give padding pixels to backdrop div
'margin-top': '+=' + padding['padding-top'],
'margin-right': '+=' + padding['padding-right'],
'margin-bottom': '+=' + padding['padding-bottom'],
'margin-left': '+=' + padding['padding-left'],
})
.css({
// give border pixels to backdrop div
'margin-top': '+=' + border['border-top-width'],
'margin-right': '+=' + border['border-right-width'],
'margin-bottom': '+=' + border['border-bottom-width'],
'margin-left': '+=' + border['border-left-width'],
});
},
// iOS adds 3px of (unremovable) padding to the left and right of a textarea,
// so adjust highlights div to match
fixIOS: function() {
this.$highlights.css({
'padding-left': '+=3px',
'padding-right': '+=3px'
});
},
handleInput: function() {
let input = this.$el.val();
let ranges = this.getRanges(input, this.highlight);
let unstaggeredRanges = this.removeStaggeredRanges(ranges);
let boundaries = this.getBoundaries(unstaggeredRanges);
this.renderMarks(boundaries);
},
getRanges: function(input, highlight) {
let type = this.getType(highlight);
switch (type) {
case 'array':
return this.getArrayRanges(input, highlight);
case 'function':
return this.getFunctionRanges(input, highlight);
case 'regexp':
return this.getRegExpRanges(input, highlight);
case 'string':
return this.getStringRanges(input, highlight);
case 'range':
return this.getRangeRanges(input, highlight);
case 'custom':
return this.getCustomRanges(input, highlight);
default:
if (!highlight) {
// do nothing for falsey values
return [];
} else {
console.error('unrecognized highlight type');
}
}
},
getArrayRanges: function(input, arr) {
let ranges = arr.map(this.getRanges.bind(this, input));
return Array.prototype.concat.apply([], ranges);
},
getFunctionRanges: function(input, func) {
return this.getRanges(input, func(input));
},
getRegExpRanges: function(input, regex) {
let ranges = [];
let match;
while (match = regex.exec(input), match !== null) {
ranges.push([match.index, match.index + match[0].length]);
if (!regex.global) {
// non-global regexes do not increase lastIndex, causing an infinite loop,
// but we can just break manually after the first match
break;
}
}
return ranges;
},
getStringRanges: function(input, str) {
let ranges = [];
let inputLower = input.toLowerCase();
let strLower = str.toLowerCase();
let index = 0;
while (index = inputLower.indexOf(strLower, index), index !== -1) {
ranges.push([index, index + strLower.length]);
index += strLower.length;
}
return ranges;
},
getRangeRanges: function(input, range) {
return [range];
},
getCustomRanges: function(input, custom) {
let ranges = this.getRanges(input, custom.highlight);
if (custom.className) {
ranges.forEach(function(range) {
// persist class name as a property of the array
if (range.className) {
range.className = custom.className + ' ' + range.className;
} else {
range.className = custom.className;
}
});
}
return ranges;
},
// prevent staggered overlaps (clean nesting is fine)
removeStaggeredRanges: function(ranges) {
let unstaggeredRanges = [];
ranges.forEach(function(range) {
let isStaggered = unstaggeredRanges.some(function(unstaggeredRange) {
let isStartInside = range[0] > unstaggeredRange[0] && range[0] < unstaggeredRange[1];
let isStopInside = range[1] > unstaggeredRange[0] && range[1] < unstaggeredRange[1];
return isStartInside !== isStopInside; // xor
});
if (!isStaggered) {
unstaggeredRanges.push(range);
}
});
return unstaggeredRanges;
},
getBoundaries: function(ranges) {
let boundaries = [];
ranges.forEach(function(range) {
boundaries.push({
type: 'start',
index: range[0],
className: range.className
});
boundaries.push({
type: 'stop',
index: range[1]
});
});
this.sortBoundaries(boundaries);
return boundaries;
},
sortBoundaries: function(boundaries) {
// backwards sort (since marks are inserted right to left)
boundaries.sort(function(a, b) {
if (a.index !== b.index) {
return b.index - a.index;
} else if (a.type === 'stop' && b.type === 'start') {
return 1;
} else if (a.type === 'start' && b.type === 'stop') {
return -1;
} else {
return 0;
}
});
},
renderMarks: function(boundaries) {
let input = this.$el.val();
boundaries.forEach(function(boundary, index) {
let markup;
if (boundary.type === 'start') {
markup = '{{hwt-mark-start|' + index + '}}';
} else {
markup = '{{hwt-mark-stop}}';
}
input = input.slice(0, boundary.index) + markup + input.slice(boundary.index);
});
// this keeps scrolling aligned when input ends with a newline
input = input.replace(/\n(\{\{hwt-mark-stop\}\})?$/, '\n\n$1');
// encode HTML entities
input = input.replace(/</g, '<').replace(/>/g, '>');
if (this.browser === 'ie') {
// IE/Edge wraps whitespace differently in a div vs textarea, this fixes it
input = input.replace(/ /g, ' <wbr>');
}
// replace start tokens with opening <mark> tags with class name
input = input.replace(/\{\{hwt-mark-start\|(\d+)\}\}/g, function(match, submatch) {
var className = boundaries[+submatch].className;
if (className) {
return '<span class="' + className + '">';
} else {
return '<span>';
}
});
// replace stop tokens with closing </mark> tags
input = input.replace(/\{\{hwt-mark-stop\}\}/g, '</span>');
this.$highlights.html(input);
},
handleScroll: function() {
let scrollTop = this.$el.scrollTop();
this.$backdrop.scrollTop(scrollTop);
// Chrome and Safari won't break long strings of spaces, which can cause
// horizontal scrolling, this compensates by shifting highlights by the
// horizontally scrolled amount to keep things aligned
let scrollLeft = this.$el.scrollLeft();
this.$backdrop.css('transform', (scrollLeft > 0) ? 'translateX(' + -scrollLeft + 'px)' : '');
},
// in Chrome, page up/down in the textarea will shift stuff within the
// container (despite the CSS), this immediately reverts the shift
blockContainerScroll: function() {
this.$container.scrollLeft(0);
},
destroy: function() {
this.$backdrop.remove();
this.$el
.unwrap()
.removeClass(ID + '-text ' + ID + '-input')
.off(ID)
.removeData(ID);
},
};
// register the jQuery plugin
$.fn.highlightWithinTextarea = function(options) {
return this.each(function() {
let $this = $(this);
let plugin = $this.data(ID);
if (typeof options === 'string') {
if (plugin) {
switch (options) {
case 'update':
plugin.handleInput();
break;
case 'destroy':
plugin.destroy();
break;
default:
console.error('unrecognized method string');
}
} else {
console.error('plugin must be instantiated first');
}
} else {
if (plugin) {
plugin.destroy();
}
plugin = new HighlightWithinTextarea($this, options);
if (plugin.isGenerated) {
$this.data(ID, plugin);
}
}
});
};
})(jQuery);
function hwt_add_css(){
let s = document.createElement("style");
s.innerHTML = `
.hwt-container {
display: inline-block;
position: relative;
overflow: hidden !important;
-webkit-text-size-adjust: none !important;
}
/* z-index is support for bwiki */
.hwt-backdrop {
position: absolute !important;
top: 0 !important;
right: -99px !important;
bottom: 0 !important;
left: 0 !important;
padding-right: 99px !important;
overflow-x: hidden !important;
overflow-y: auto !important;
z-index: 1501;
pointer-events: none;
font-family: Consolas;
color: black;
}
.hwt-highlights {
width: auto !important;
height: auto !important;
border-color: transparent !important;
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow: hidden !important;
}
.hwt-input {
display: block !important;
position: relative !important;
margin: 0;
padding: 0;
border-radius: 0;
font: inherit;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.hwt-content {
border: 1px solid;
background: none transparent !important;
}
.hwt-content span {
padding: 0 !important;
}
`;
document.head.appendChild(s);
}
hwt_add_css()
/*
* END OF
* highlight-within-textarea
*
* @author Will Boyd
* @github https://github.com/lonekorean/highlight-within-textarea
* @license MIT license
*
* this copy alreay a little modified by Lu.
*/
/*
* Bwiki Quick Editor
* @author Lu
* @bid 39886146
* @license CC BY-SA
*
* TODO: 检查mediawiki命名空间中的文件后缀,增加CSS/JS高亮规则
*
*/
const edit_btn_id = "lu_edit_btn";
const pannel_id = "lu_editor";
const pannel_textarea_id = "lu_editor_textarea";
function main(){
// 检查是否适合编辑
if (!editable()) {
console.log("Quick Editor: 本页面暂不支持使用。因为mw属性中 不是article 或者 action 不为 view");
return;
}
add_editor_hightlight_css(); // 添加 css
init_editor_wiki_code(); // 初始化编辑器文本
addEditBtn(); // 添加右下角按钮
addEditorPannel(); // 添加编辑面板
addClickByID(edit_btn_id, edit_btn_onclick); // 按钮事件
addClickByID("lu_edit_close", edit_btn_onclick); // 关闭按钮事件
addClickByID("lu_edit_save", save_btn_onclick); // 保存按钮事件
}
function add_editor_hightlight_css(){
let diy_style = document.createElement("style");
diy_style.innerHTML = `
.hwt-content .code_brackets {color:#6B00A8;}
.hwt-content .code_tag {color:#0071B6;}
.hwt-content .code_link {color:#0071B6;}
.hwt-content .code_tag_attr {color:#00B2CB;}
.hwt-content .code_template {color:#224CB0;}
.hwt-content .code_magic_words {color: #00B2CB;}
.hwt-content .code_template_name {color:#8F27A4;}
.hwt-content .code_var {color:#0033bb;}
.hwt-content .code_var .code_template_name {color:#0033bb; font-weight: bold;}
.hwt-content .code_head {color: #0094C2; font-weight: bold;}
.hwt-content .code_head_text {color: #0064B2; font-weight: bold;}
.hwt-content .code_comment {color:green; opacity: 0.55; font-style:italic;}
.hwt-content .code_italic {font-style:italic;}
.hwt-content .code_bold {font-weight: bold;}
.hwt-content .code_strike {text-decoration: line-through;}
`;
document.head.appendChild(diy_style);
}
function init_editor_wiki_code() {
// 拼接API(获取当前页面 wiki 代码)
let api = get_api_url();
const page_id = get_page_id();
let page_code_url = api + "?action=query&format=json&prop=revisions&rvlimit=1" +
"&rvprop=ids|timestamp|flags|comment|user|userid|content&pageids=" + page_id.toString();
// 获取本页内容
fetch(page_code_url).then(response => response.json()).then(data => {
document.getElementById(pannel_textarea_id).value = data.query["pages"][page_id]["revisions"][0]["*"];
// 代码高亮 初始化
highlight_wiki_text();
});
}
function editable(){
/* 检查此页面是否可以编辑
* 条件: wgIsArticle == true 和 wgAction == view
* */
let is_article = mw.config.get("wgIsArticle")
let page_action = mw.config.get("wgAction")
if (!(is_article && page_action === "view")) {
console.log("Quick Editor: not article or not view action, editor will not load");
return false;
}
return true;
}
function get_api_url(){ /* 拼接 bwiki api url */
return "https://wiki.biligame.com" + mw.config.get("wgScriptPath") + "/api.php";
}
function get_page_id(){ /* 获取 page id */
return mw.config.get("wgArticleId").toString();
}
function save_btn_onclick(){
/* 保存 */
document.getElementById("lu_edit_save").disabled=true;
setTimeout(function (){
document.getElementById("lu_edit_save").disabled=false;
}, 5000);
// 获取需要的信息
let wikitext = document.getElementById(pannel_textarea_id).value;
let summary = document.getElementById("lu_edit_comment").value;
let page_id = get_page_id();
let api = get_api_url();
/* 先获取token,然后保存 */
fetch(api+"?meta=tokens&action=query&format=json")
.then(function(res){return res.json()})
.then(function(res_data){
let token = res_data.query.tokens["csrftoken"];
// 保存需要的参数
let param = new FormData();
param.append("action","edit");
param.append("format","json");
param.append("token", token);
param.append("pageid", page_id);
param.append("text", wikitext);
param.append("summary", summary);
param.append("minor",false);
param.append("nocreate", true);
// 保存
fetch(api, {
method: "POST",
body: param
}).then(function(res){console.log(res);return res.json()}).then(function(data){
// 返回的json有至少三种可能:
// {"edit":{"result":"Success","pageid":123,"title":"...","oldrevid":2310,"newrevid":2313, ...}}
// {"edit":{"result":"Success","pageid":123,"title":"...","nochange":"",...}}
// {"error":{"code":"articleexists","info":"The article you tried to create has been created already."...}}
console.log(JSON.stringify(data));
if(data.error){
alert(error.code + " " + error.info);
return;
}
if(data.edit.result){
if(data.edit["nochange"] === ""){
alert("成功提交,但你没有修改任何内容");
}else{
alert("成功保存更改");
}
location.reload();
}
});
});
}
function highlight_wiki_text_update(){ /* 手动更新高亮 */
$('#'+pannel_textarea_id).highlightWithinTextarea('update');
}
/* 高亮 mediawiki code
* 基于正则匹配关键词
* 参考了
* - https://github.com/ajaxorg/ace/blob/23208f2f19/src/mode/mediawiki_highlight_rules.js
* - https://github.com/Frederisk/Wikitext-VSCode-Extension/blob/master/language-configuration.yaml
*/
function highlight_wiki_text(){
return $('#' + pannel_textarea_id).highlightWithinTextarea({
highlight: [
{ // 注释
highlight: /<!--[^]*?-->/gi,
className: 'code_comment'
},
{ // 括号 和 特定符号
highlight: ["{{{", "}}}", "{{", "}}", "|", "[[", "]]", "[", "]", "----"],
className: 'code_brackets'
},
{ // 括号 和 特定符号,如表格。最后一项是匹配行首的“|”
highlight: ["||", "!!", "-|", "|-", "-{", "}-", "{|", "|}", /(?<=\n|^)\|/gi],
className: 'code_brackets code_bold'
},
{ // 支持:行首的叹号,允许前有空格; 行首的*#:
highlight: [/(?<=\n[\s]*)!/gi, /(?<=\n|^)[*#:;]+/gi],
className: 'code_brackets code_bold'
},
{ // 匹配链接, [balabala],[[balabala]] [url balabala]
highlight: [/(?<=\[)[^\]\|\n\s]+(?=[(\]\])|\||\s])/gi, /(?<=\[\[)[^\]\|\n]+(?=[(\]\])|\|])/gi, /[a-zA-z]+:\/\/[^\s]*/gi],
className: 'code_link'
},
{ // 模板名字,以 {{ 开始,遇到{、}、|、换行停止
highlight: /(?<={{)[^{}\|\n]+/gi,
className: 'code_template_name'
},
{ // 变量。 {{{xxx}}}
highlight: /{{{[^}]+}}}/gi,
className: 'code_var'
},
{ // 斜体 '''it'''
highlight: [/'''[^'\n]+'''/gi],
className: 'code_italic'
},
{ // 粗体 ''it'' 第二个正则匹配前后可能有斜体的粗体
highlight: [/(?<=[^']+)''[^'\n]+''/gi, /(?<=('''[^'\n]+'''))''[^'\n]+''(?=([^']|('''[^'\n]+''')))/gi],
className: 'code_bold'
},
{ // 五个点代表粗体+斜体
highlight: [/'''''[^'\n]+'''''/gi],
className: 'code_bold code_italic'
},
{ // 标题内容 === 只匹配中间部分 ===
highlight: /(?<=(\n|^)[=]+)[^=\n]+(?==)/gi,
className: 'code_head_text'
},
{ // 标题 == head ==
highlight: /(?<=\n|^)(={1,6})([^=\n]+)(\1)(?!=)/gi,
className: 'code_head'
},
{ // 重定向
highlight: /(#REDIRECT|#redirect|#Redirect|#重定向)(\s+)/gi,
className: 'code_magic_words'
},
{ // magic words
highlight: /(__NOTOC__|__FORCETOC__|__TOC__|__NOEDITSECTION__|__NEWSECTIONLINK__|__NONEWSECTIONLINK__|__NOWYSIWYG__|__NOGALLERY__|__HIDDENCAT__|__EXPECTUNUSEDCATEGORY__|__NOCONTENTCONVERT__|__NOCC__|__NOTITLECONVERT__|__NOTC__|__START__|__END__|__INDEX__|__NOINDEX__|__STATICREDIRECT__|__NOGLOBAL__|__DISAMBIG__)/gi,
className: 'code_magic_words'
},
{ // tag,类HTML标签
highlight: /<[/]?[^>\n!]+>/gi,
className: 'code_tag'
},
{ // 标签属性 tag attr , 类HTML标签中,第一个空格后的部分
highlight: /(?<=<[/]?[^>\n!]+)\s[^>]+/gi,
className: 'code_tag_attr'
},
{ // for <s>
highlight: /(?<=<s>)[^<]*(?=<\/s>)/gi,
className: 'code_strike'
}
]
});
}
function edit_btn_onclick(){
/* 点击编辑按钮后显示/隐藏编辑框。但要判定窗口够不够大 */
let btn = document.getElementById(pannel_id);
if(btn.style.display === 'block'){
btn.style.display = 'none';
}else{
if(ScreenBigEnough()){
alert("Editor 需要屏幕大于800x500像素才能正常显示");
return;
}
btn.style.display = 'block';
}
}
function ScreenBigEnough(){
return (window.innerHeight < 500)|| (window.innerWidth < 800);
}
function addClickByID(elm_id, func) { /* 为ID元素增加点击事件 */
document.getElementById(elm_id).addEventListener("click", func);
}
function addEditorPannel(){
/* 添加编辑栏 */
// 面板框架
let pannel = document.createElement("div");
pannel.id = pannel_id;
pannel.innerHTML = `<div style="font-weight: bold;">快速编辑器(测试版)</div><br>`;
/* fix定位的编辑框 其中z-index针对 */
pannel.style = `
display:none;
position: fixed;
width: 800px;
height: 600px;
top: 10%;
right: 10%;
padding:10px;
background: white;
border:1px solid rgba(172,172,172,0.6);
font-size:1.2em;
z-index: 1501;
}`;
// 编辑框
let textarea = document.createElement("textarea");
textarea.id = pannel_textarea_id;
textarea.style = `
width: 780px;
height: 400px;
font-family: Consolas;
outline:none;
border:1px solid rgba(172,172,172,0.4);
color: white;
color: rgba(0,0,0,0);
caret-color: black;
}`;
textarea.innerHTML = "尝试加载本页代码中…… (如果读完这句话还没加载完,大概率是脚本出Bug了)"
pannel.appendChild(textarea);
let the_div = document.createElement("div");
the_div.innerHTML = `
<div style="margin:10px 0;">编辑摘要: <!--<small>快速选择:</small>-->
<input id="lu_edit_comment" name="lu_edit_comment" style="width:100%;" type="text" value="" placeholder="请简要描述您所作出的修改"></input>
</div>
<button id="lu_edit_save" class="btn btn-success">保存</button>
<div id="lu_edit_close" style="float:right;" class="btn btn-warning">关闭</div>
`;
pannel.appendChild(the_div);
document.body.append(pannel);
}
function addEditBtn() {
/* 右下角增加编辑按钮, 基于bootstrap btn 样式 */
let edit_btn = document.createElement("div");
edit_btn.innerHTML = "快速编辑";
edit_btn.style = "position: fixed; bottom: 5px; right: 5px; z-index: 1500;";
edit_btn.id = edit_btn_id;
edit_btn.classList.add("btn");
edit_btn.classList.add("btn-info");
document.body.append(edit_btn);
return edit_btn;
}
// 入口
setTimeout(function (){
main();
}, 1000); // timeout 是为了等待 mw 全部加载。