1
0
mirror of https://github.com/gnosygnu/xowa.git synced 2026-03-02 03:49:30 +00:00

Res: Add resources from xowa_app_windows_64_v4.5.26.1810

This commit is contained in:
gnosygnu
2018-11-02 09:58:55 -04:00
parent a672fd8340
commit 5721913241
6057 changed files with 1156950 additions and 0 deletions

View File

@@ -0,0 +1,155 @@
xo.xtns = xo.xtns || {};
xo.xtns.graph = new function() {
var xolog = new xo.logs.logger('xo.xtns.graph');
var vega_cbks = {} // map of callbacks for tabular
// var page_cache = {}; // TOMBSTONE: do not try to cache requests; multiple requests for same page may start at same time
this.load_xowa = function(url, opt, callback) {
this.init_xowa_props(opt);
// cache callback
var vega_cbk_guid = Uuid_.make();
vega_cbks[vega_cbk_guid] = callback;
// call xo_server
var page_guid = xowa.page.guid;
xolog.info("get_page.bgn", "page", opt.xowa_page);
xo.server.send_by_bridge('xowa.app.util.misc', 'page_get',
{ 'page_get_cbk':'xo.xtns.graph.load_xowa_recv'
, 'protocol':opt.xowa_protocol
, 'wiki':opt.xowa_wiki
, 'page':opt.xowa_page
, 'page_guid':page_guid
, 'vega_cbk_guid':vega_cbk_guid
});
}
this.load_xowa_recv = function(msg_str) {
// parse msg
var msg = JSON.parse(msg_str);
xolog.info("get_page.end", "page", msg.page);
// get page_text
var page_text = msg.page_text;
var protocol = msg.protocol;
switch (protocol) {
case 'map:':
case 'tabular:':
page_text = "{\"jsondata\":" + page_text + "}"; // format as "{jsondata:page_text}"; note that this is done by JsonConfig api
break;
}
// get callback and process page_text
var callback = vega_cbks[msg.vega_cbk_guid];
callback(null, page_text);
return "pass:load_xowa_recv"; // return something for xoajax
}
this.init_xowa_props = function(opt) {
// get protocol
var url = opt.url;
var protocol_end = url.indexOf(':/');
var protocol = 'unknown';
if (protocol_end != -1) {
protocol = url.substring(0, protocol_end + 1).toLowerCase(); // +1 to capture ":"; note that graph2.compiled.js compares to "wikiraw:", "map:", etc.
}
// get page
var page = '';
var wiki = 'commons.wikimedia.org';
switch (protocol) {
case 'map:':
page = replace_at_bgn_or_null(url, "map:///", "Data:");
break;
case 'tabular:':
page = replace_at_bgn_or_null(url, "tabular:///", "Data:");
break;
case 'wikiraw:':
page = replace_at_bgn_or_null(url, "wikiraw:///", "");
wiki = xowa.page.wiki;
break;
}
if (page == null) {
xolog.warn("unknown page protocol", "protocol", protocol, "url", url);
}
// set xowa_props
opt.xowa_protocol = protocol;
opt.xowa_wiki = wiki;
opt.xowa_page = page;
}
// XOWA: from /modules/graph1.js|graph2.js; modified b/c MW use mw.hook('wikipage.content'); DATE:2018-02-25
this.drawVegaGraph = function(version, elem, data, callback)
{
if (version == 1)
{
vg.parse.spec
( data
, function (chart)
{
if (chart)
{
chart({el: elem}).update();
}
}
);
}
else
{
vg.parse.spec
( data
, function (error, chart)
{
if (!error)
{
chart({el:elem}).update();
}
if (callback)
{
callback(error);
}
}
);
}
};
this.exec = function()
{
xolog.info("running graphs");
var $ = jQuery;
var $content = $('#content');
// from graph2.compiled.js; modified b/c MW saves data in mw.config.get('wgGraphSpecs')
$content.find( '.mw-graph' ).each
( function ()
{
var elem = this;
var version = $(this).attr('xo-graph-version');
var html = $(this).html();
var spec = JSON.parse(Html_.decode(html));
xolog.info("running graph", "version", version); //, "data", html);
// blank out html; run graph
$(this).html('');
xo.xtns.graph.drawVegaGraph
( version
, this
, spec
, function (error)
{
if (error)
console.log(error);
}
);
}
);
}
var replace_at_bgn_or_null = function(str, find, repl) {
if (str.startsWith(find))
return repl + str.substring(find.length, str.length);
else
return null;
}
}

View File

@@ -0,0 +1,73 @@
// NOTE: edited from original to use local json libraries; DATE:2015-09-05
(function($){
if (window.xtn__graph__exec == null) {
window.xtn__graph__exec = graph_exec;
}
/*
function graph_exec() {
var $content = $('#content');
$content.find( '.mw-graph.mw-graph-always' ).each( function () {
console.log('found .mw-graph.mw-graph-always');
});
$content.find( '.mw-graph' ).each( function () {
console.log('found .mw-graph');
});
$content.find( '.mw-graph' ).each( function () {
// $content.find( '.mw-wiki-graph' ).each( function () {
// $content.find( '.mw-graph' ).each( function () {
var graphId = $( this ).data( 'graph-id' );
var elem = this;
var spec = JSON.parse(htmlDecode($(this).html()));
$(this).html('');
vg.parse.spec
( spec, function(chart)
{
if (chart) {
chart({ el: elem}).update();
}
}
);
});
}
*/
window.drawVegaGraph = function ( elem, data, callback ) {
vg.parse.spec( data, function ( error, chart ) {
if ( !error ) {
chart( { el: elem } ).update();
}
if ( callback ) {
callback( error );
}
} );
};
function graph_exec() {
var $content = $('#content');
var specs = {}; // mw.config.get( 'wgGraphSpecs' );
if ( !specs ) {
return;
}
$content.find( '.mw-graph,.mw-graph-always' ).each( function () {
// var graphId = $( this ).data( 'graph-id' );
// if ( !specs.hasOwnProperty( graphId ) ) {
// mw.log.warn( graphId );
// } else {
var elem = this;
var spec = JSON.parse(htmlDecode($(this).html()));
$(this).html('');
window.drawVegaGraph( this, spec, function ( error ) {
// window.drawVegaGraph( this, specs[ graphId ], function ( error ) {
if ( error ) {
// mw.log.warn( error );
}
} );
// }
} );
}
})(jQuery);
function htmlDecode(input)
{
var doc = new DOMParser().parseFromString(input, "text/html");
return doc.documentElement.textContent;
}

View File

@@ -0,0 +1,3 @@
// Back-compat: Export module as global
// XOWA: unused b/c module is not available
window.d3 = module.exports;

9554
res/bin/any/xowa/xtns/Graph/lib/d3.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,505 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.d3||(g.d3 = {}));g=(g.layout||(g.layout = {}));g.cloud = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
// Word cloud layout by Jason Davies, https://www.jasondavies.com/wordcloud/
// Algorithm due to Jonathan Feinberg, http://static.mrfeinberg.com/bv_ch03.pdf
var dispatch = require("d3-dispatch").dispatch;
var cloudRadians = Math.PI / 180,
cw = 1 << 11 >> 5,
ch = 1 << 11;
module.exports = function() {
var size = [256, 256],
text = cloudText,
font = cloudFont,
fontSize = cloudFontSize,
fontStyle = cloudFontNormal,
fontWeight = cloudFontNormal,
rotate = cloudRotate,
padding = cloudPadding,
spiral = archimedeanSpiral,
words = [],
timeInterval = Infinity,
event = dispatch("word", "end"),
timer = null,
random = Math.random,
cloud = {},
canvas = cloudCanvas;
cloud.canvas = function(_) {
return arguments.length ? (canvas = functor(_), cloud) : canvas;
};
cloud.start = function() {
var contextAndRatio = getContext(canvas()),
board = zeroArray((size[0] >> 5) * size[1]),
bounds = null,
n = words.length,
i = -1,
tags = [],
data = words.map(function(d, i) {
d.text = text.call(this, d, i);
d.font = font.call(this, d, i);
d.style = fontStyle.call(this, d, i);
d.weight = fontWeight.call(this, d, i);
d.rotate = rotate.call(this, d, i);
d.size = ~~fontSize.call(this, d, i);
d.padding = padding.call(this, d, i);
return d;
}).sort(function(a, b) { return b.size - a.size; });
if (timer) clearInterval(timer);
timer = setInterval(step, 0);
step();
return cloud;
function step() {
var start = Date.now();
while (Date.now() - start < timeInterval && ++i < n && timer) {
var d = data[i];
d.x = (size[0] * (random() + .5)) >> 1;
d.y = (size[1] * (random() + .5)) >> 1;
cloudSprite(contextAndRatio, d, data, i);
if (d.hasText && place(board, d, bounds)) {
tags.push(d);
event.word(d);
if (bounds) cloudBounds(bounds, d);
else bounds = [{x: d.x + d.x0, y: d.y + d.y0}, {x: d.x + d.x1, y: d.y + d.y1}];
// Temporary hack
d.x -= size[0] >> 1;
d.y -= size[1] >> 1;
}
}
if (i >= n) {
cloud.stop();
event.end(tags, bounds);
}
}
}
cloud.stop = function() {
if (timer) {
clearInterval(timer);
timer = null;
}
return cloud;
};
function getContext(canvas) {
canvas.width = canvas.height = 1;
var ratio = Math.sqrt(canvas.getContext("2d").getImageData(0, 0, 1, 1).data.length >> 2);
canvas.width = (cw << 5) / ratio;
canvas.height = ch / ratio;
var context = canvas.getContext("2d");
context.fillStyle = context.strokeStyle = "red";
context.textAlign = "center";
return {context: context, ratio: ratio};
}
function place(board, tag, bounds) {
var perimeter = [{x: 0, y: 0}, {x: size[0], y: size[1]}],
startX = tag.x,
startY = tag.y,
maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]),
s = spiral(size),
dt = random() < .5 ? 1 : -1,
t = -dt,
dxdy,
dx,
dy;
while (dxdy = s(t += dt)) {
dx = ~~dxdy[0];
dy = ~~dxdy[1];
if (Math.min(Math.abs(dx), Math.abs(dy)) >= maxDelta) break;
tag.x = startX + dx;
tag.y = startY + dy;
if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 ||
tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]) continue;
// TODO only check for collisions within current bounds.
if (!bounds || !cloudCollide(tag, board, size[0])) {
if (!bounds || collideRects(tag, bounds)) {
var sprite = tag.sprite,
w = tag.width >> 5,
sw = size[0] >> 5,
lx = tag.x - (w << 4),
sx = lx & 0x7f,
msx = 32 - sx,
h = tag.y1 - tag.y0,
x = (tag.y + tag.y0) * sw + (lx >> 5),
last;
for (var j = 0; j < h; j++) {
last = 0;
for (var i = 0; i <= w; i++) {
board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0);
}
x += sw;
}
delete tag.sprite;
return true;
}
}
}
return false;
}
cloud.timeInterval = function(_) {
return arguments.length ? (timeInterval = _ == null ? Infinity : _, cloud) : timeInterval;
};
cloud.words = function(_) {
return arguments.length ? (words = _, cloud) : words;
};
cloud.size = function(_) {
return arguments.length ? (size = [+_[0], +_[1]], cloud) : size;
};
cloud.font = function(_) {
return arguments.length ? (font = functor(_), cloud) : font;
};
cloud.fontStyle = function(_) {
return arguments.length ? (fontStyle = functor(_), cloud) : fontStyle;
};
cloud.fontWeight = function(_) {
return arguments.length ? (fontWeight = functor(_), cloud) : fontWeight;
};
cloud.rotate = function(_) {
return arguments.length ? (rotate = functor(_), cloud) : rotate;
};
cloud.text = function(_) {
return arguments.length ? (text = functor(_), cloud) : text;
};
cloud.spiral = function(_) {
return arguments.length ? (spiral = spirals[_] || _, cloud) : spiral;
};
cloud.fontSize = function(_) {
return arguments.length ? (fontSize = functor(_), cloud) : fontSize;
};
cloud.padding = function(_) {
return arguments.length ? (padding = functor(_), cloud) : padding;
};
cloud.random = function(_) {
return arguments.length ? (random = _, cloud) : random;
};
cloud.on = function() {
var value = event.on.apply(event, arguments);
return value === event ? cloud : value;
};
return cloud;
};
function cloudText(d) {
return d.text;
}
function cloudFont() {
return "serif";
}
function cloudFontNormal() {
return "normal";
}
function cloudFontSize(d) {
return Math.sqrt(d.value);
}
function cloudRotate() {
return (~~(Math.random() * 6) - 3) * 30;
}
function cloudPadding() {
return 1;
}
// Fetches a monochrome sprite bitmap for the specified text.
// Load in batches for speed.
function cloudSprite(contextAndRatio, d, data, di) {
if (d.sprite) return;
var c = contextAndRatio.context,
ratio = contextAndRatio.ratio;
c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio);
var x = 0,
y = 0,
maxh = 0,
n = data.length;
--di;
while (++di < n) {
d = data[di];
c.save();
c.font = d.style + " " + d.weight + " " + ~~((d.size + 1) / ratio) + "px " + d.font;
var w = c.measureText(d.text + "m").width * ratio,
h = d.size << 1;
if (d.rotate) {
var sr = Math.sin(d.rotate * cloudRadians),
cr = Math.cos(d.rotate * cloudRadians),
wcr = w * cr,
wsr = w * sr,
hcr = h * cr,
hsr = h * sr;
w = (Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5 << 5;
h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr));
} else {
w = (w + 0x1f) >> 5 << 5;
}
if (h > maxh) maxh = h;
if (x + w >= (cw << 5)) {
x = 0;
y += maxh;
maxh = 0;
}
if (y + h >= ch) break;
c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio);
if (d.rotate) c.rotate(d.rotate * cloudRadians);
c.fillText(d.text, 0, 0);
if (d.padding) c.lineWidth = 2 * d.padding, c.strokeText(d.text, 0, 0);
c.restore();
d.width = w;
d.height = h;
d.xoff = x;
d.yoff = y;
d.x1 = w >> 1;
d.y1 = h >> 1;
d.x0 = -d.x1;
d.y0 = -d.y1;
d.hasText = true;
x += w;
}
var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data,
sprite = [];
while (--di >= 0) {
d = data[di];
if (!d.hasText) continue;
var w = d.width,
w32 = w >> 5,
h = d.y1 - d.y0;
// Zero the buffer
for (var i = 0; i < h * w32; i++) sprite[i] = 0;
x = d.xoff;
if (x == null) return;
y = d.yoff;
var seen = 0,
seenRow = -1;
for (var j = 0; j < h; j++) {
for (var i = 0; i < w; i++) {
var k = w32 * j + (i >> 5),
m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0;
sprite[k] |= m;
seen |= m;
}
if (seen) seenRow = j;
else {
d.y0++;
h--;
j--;
y++;
}
}
d.y1 = d.y0 + seenRow;
d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32);
}
}
// Use mask-based collision detection.
function cloudCollide(tag, board, sw) {
sw >>= 5;
var sprite = tag.sprite,
w = tag.width >> 5,
lx = tag.x - (w << 4),
sx = lx & 0x7f,
msx = 32 - sx,
h = tag.y1 - tag.y0,
x = (tag.y + tag.y0) * sw + (lx >> 5),
last;
for (var j = 0; j < h; j++) {
last = 0;
for (var i = 0; i <= w; i++) {
if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0))
& board[x + i]) return true;
}
x += sw;
}
return false;
}
function cloudBounds(bounds, d) {
var b0 = bounds[0],
b1 = bounds[1];
if (d.x + d.x0 < b0.x) b0.x = d.x + d.x0;
if (d.y + d.y0 < b0.y) b0.y = d.y + d.y0;
if (d.x + d.x1 > b1.x) b1.x = d.x + d.x1;
if (d.y + d.y1 > b1.y) b1.y = d.y + d.y1;
}
function collideRects(a, b) {
return a.x + a.x1 > b[0].x && a.x + a.x0 < b[1].x && a.y + a.y1 > b[0].y && a.y + a.y0 < b[1].y;
}
function archimedeanSpiral(size) {
var e = size[0] / size[1];
return function(t) {
return [e * (t *= .1) * Math.cos(t), t * Math.sin(t)];
};
}
function rectangularSpiral(size) {
var dy = 4,
dx = dy * size[0] / size[1],
x = 0,
y = 0;
return function(t) {
var sign = t < 0 ? -1 : 1;
// See triangular numbers: T_n = n * (n + 1) / 2.
switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) {
case 0: x += dx; break;
case 1: y += dy; break;
case 2: x -= dx; break;
default: y -= dy; break;
}
return [x, y];
};
}
// TODO reuse arrays?
function zeroArray(n) {
var a = [],
i = -1;
while (++i < n) a[i] = 0;
return a;
}
function cloudCanvas() {
return document.createElement("canvas");
}
function functor(d) {
return typeof d === "function" ? d : function() { return d; };
}
var spirals = {
archimedean: archimedeanSpiral,
rectangular: rectangularSpiral
};
},{"d3-dispatch":2}],2:[function(require,module,exports){
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
factory((global.dispatch = {}));
}(this, function (exports) { 'use strict';
function Dispatch(types) {
var i = -1,
n = types.length,
callbacksByType = {},
callbackByName = {},
type,
that = this;
that.on = function(type, callback) {
type = parseType(type);
// Return the current callback, if any.
if (arguments.length < 2) {
return (callback = callbackByName[type.name]) && callback.value;
}
// If a type was specified…
if (type.type) {
var callbacks = callbacksByType[type.type],
callback0 = callbackByName[type.name],
i;
// Remove the current callback, if any, using copy-on-remove.
if (callback0) {
callback0.value = null;
i = callbacks.indexOf(callback0);
callbacksByType[type.type] = callbacks = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
delete callbackByName[type.name];
}
// Add the new callback, if any.
if (callback) {
callback = {value: callback};
callbackByName[type.name] = callback;
callbacks.push(callback);
}
}
// Otherwise, if a null callback was specified, remove all callbacks with the given name.
else if (callback == null) {
for (var otherType in callbacksByType) {
if (callback = callbackByName[otherType + type.name]) {
callback.value = null;
var callbacks = callbacksByType[otherType], i = callbacks.indexOf(callback);
callbacksByType[otherType] = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
delete callbackByName[callback.name];
}
}
}
return that;
};
while (++i < n) {
type = types[i] + "";
if (!type || (type in that)) throw new Error("illegal or duplicate type: " + type);
callbacksByType[type] = [];
that[type] = applier(type);
}
function parseType(type) {
var i = (type += "").indexOf("."), name = type;
if (i >= 0) type = type.slice(0, i); else name += ".";
if (type && !callbacksByType.hasOwnProperty(type)) throw new Error("unknown type: " + type);
return {type: type, name: name};
}
function applier(type) {
return function() {
var callbacks = callbacksByType[type], // Defensive reference; copy-on-remove.
callback,
callbackValue,
i = -1,
n = callbacks.length;
while (++i < n) {
if (callbackValue = (callback = callbacks[i]).value) {
callbackValue.apply(this, arguments);
}
}
return that;
};
}
}
function dispatch() {
return new Dispatch(arguments);
}
dispatch.prototype = Dispatch.prototype; // allow instanceof
exports.dispatch = dispatch;
}));
},{}]},{},[1])(1)
});

View File

@@ -0,0 +1,697 @@
(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
( function ( $, mw, vg ) {
'use strict';
/* global require */
var VegaWrapper = require( 'mw-graph-shared' );
// eslint-disable-next-line no-new
new VegaWrapper( {
datalib: vg.util,
useXhr: true,
isTrusted: true, // mw.config.get( 'wgGraphIsTrusted' ),
domains: '', // mw.config.get( 'wgGraphAllowedDomains' ),
domainMap: false,
logger: function ( warning ) {
mw.log.warn( warning );
},
parseUrl: function ( opt ) {
// Parse URL
var uri = {}; // new mw.Uri( opt.url );
// reduce confusion, only keep expected values
if ( uri.port ) {
uri.host += ':' + uri.port;
delete uri.port;
}
// If url begins with protocol:///... mark it as having relative host
if ( /^[a-z]+:\/\/\//.test( opt.url ) ) {
uri.isRelativeHost = true;
}
if ( uri.protocol ) {
// All other libs use trailing colon in the protocol field
uri.protocol += ':';
}
// Node's path includes the query, whereas pathname is without the query
// Standardizing on pathname
uri.pathname = uri.path;
delete uri.path;
return uri;
},
formatUrl: function ( uri, opt ) {
// Format URL back into a string
// Revert path into pathname
uri.path = uri.pathname;
delete uri.pathname;
if ( location.host.toLowerCase() === uri.host.toLowerCase() ) {
if ( !mw.config.get( 'wgGraphIsTrusted' ) ) {
// Only send this header when hostname is the same.
// This is broader than the same-origin policy,
// but playing on the safer side.
opt.headers = { 'Treat-as-Untrusted': 1 };
}
} else if ( opt.addCorsOrigin ) {
// All CORS api calls require origin parameter.
// It would be better to use location.origin,
// but apparently it's not universal yet.
uri.query.origin = location.protocol + '//' + location.host;
}
uri.protocol = VegaWrapper.removeColon( uri.protocol );
return uri.toString();
},
languageCode: 'en' // mw.config.get( 'wgUserLanguage' )
} );
/**
* Set up drawing canvas inside the given element and draw graph data
*
* @param {HTMLElement} element
* @param {Object|string} data graph spec
* @param {Function} [callback] function(error) called when drawing is done
*/
window.drawVegaGraph = function ( element, data, callback ) {
vg.parse.spec( data, function ( error, chart ) {
if ( !error ) {
chart( { el: element } ).update();
}
if ( callback ) {
callback( error );
}
} );
};
// mw.hook( 'wikipage.content' ).add( function ( $content ) {
var $content = $('#content');
var specs = {}; // mw.config.get( 'wgGraphSpecs' );
if ( !specs ) {
return;
}
$content.find( '.mw-graph.mw-graph-always' ).each( function () {
var graphId = $( this ).data( 'graph-id' );
if ( !specs.hasOwnProperty( graphId ) ) {
// mw.log.warn( graphId );
} else {
window.drawVegaGraph( this, specs[ graphId ], function ( error ) {
if ( error ) {
// mw.log.warn( error );
}
} );
}
} );
// } );
}( jQuery, null, vg ) );
},{"mw-graph-shared":3}],2:[function(require,module,exports){
'use strict';
/**
* Convert a list of domains into an object with a test method.
* equivalent regex: (any-subdomain)\.(wikipedia\.org|wikivoyage\.org|...)
*
* @param domains array of string domains
* @param allowSubdomains if true, allows any sub and sub-sub-* domains
* @returns {*}
*/
module.exports = function makeValidator(domains, allowSubdomains) {
if (!domains || domains.length === 0) return {
// Optimization - always return false
test: function () {
return false;
}
};
return new RegExp(
(allowSubdomains ? '^([^@/:]*\\.)?(' : '^(') +
domains
.map(function (s) {
return s.replace('.', '\\.');
})
.join('|') + ')$', 'i');
};
},{}],3:[function(require,module,exports){
'use strict';
/* global module */
var makeValidator = require('domain-validator'),
parseWikidataValue = require('wd-type-parser');
module.exports = VegaWrapper;
module.exports.removeColon = removeColon;
/**
* Utility function to remove trailing colon from a protocol
* @param {string} protocol
* @return {string}
*/
function removeColon(protocol) {
return protocol && protocol.length && protocol[protocol.length - 1] === ':'
? protocol.substring(0, protocol.length - 1) : protocol;
}
/**
* Shared library to wrap around vega code
* @param {Object} wrapperOpts Configuration options
* @param {Object} wrapperOpts.datalib Vega's datalib object
* @param {Object} wrapperOpts.datalib.load Vega's data loader
* @param {Function} wrapperOpts.datalib.load.loader Vega's data loader function
* @param {Function} wrapperOpts.datalib.extend similar to jquery's extend()
* @param {boolean} wrapperOpts.useXhr true if we should use XHR, false for node.js http loading
* @param {boolean} wrapperOpts.isTrusted true if the graph spec can be trusted
* @param {Object} wrapperOpts.domains allowed protocols and a list of their domains
* @param {Object} wrapperOpts.domainMap domain remapping
* @param {Function} wrapperOpts.logger
* @param {Function} wrapperOpts.parseUrl
* @param {Function} wrapperOpts.formatUrl
* @param {string} [wrapperOpts.languageCode]
* @constructor
*/
function VegaWrapper(wrapperOpts) {
var self = this;
// Copy all options into this object
self.objExtender = wrapperOpts.datalib.extend;
self.objExtender(self, wrapperOpts);
self.validators = {};
self.datalib.load.loader = function (opt, callback) {
var error = callback || function (e) { throw e; }, url;
try {
url = self.sanitizeUrl(opt); // enable override
} catch (err) {
error(err);
return;
}
// Process data response
var cb = function (error, data) {
return self.dataParser(error, data, opt, callback);
};
/*
if (self.useXhr) {
return self.datalib.load.xhr(url, opt, cb);
} else {
return self.datalib.load.http(url, opt, cb);
}
*/
return xo.xtns.graph.load_xowa(url, opt, cb);
};
self.datalib.load.sanitizeUrl = self.sanitizeUrl.bind(self);
// Prevent accidental use
self.datalib.load.file = alwaysFail;
if (self.useXhr) {
self.datalib.load.http = alwaysFail;
} else {
self.datalib.load.xhr = alwaysFail;
}
}
/**
* Check if host was listed in the allowed domains, normalize it, and get correct protocol
* @param {string} host
* @returns {Object}
*/
VegaWrapper.prototype.sanitizeHost = function sanitizeHost(host) {
// First, map the host
host = (this.domainMap && this.domainMap[host]) || host;
if (this.testHost('https:', host)) {
return {host: host, protocol: 'https:'};
} else if (this.testHost('http:', host)) {
return {host: host, protocol: 'http:'};
}
return undefined;
};
/**
* Test host against the list of allowed domains based on the protocol
* @param {string} protocol
* @param {string} host
* @returns {boolean}
*/
VegaWrapper.prototype.testHost = function testHost(protocol, host) {
if (!this.validators[protocol]) {
var domains = this._getProtocolDomains(protocol);
if (domains) {
this.validators[protocol] = makeValidator(domains, protocol === 'https:' || protocol === 'http:');
} else {
return false;
}
}
return this.validators[protocol].test(host);
};
/**
* Gets allowed domains for a given protocol. Assumes protocol ends with a ':'.
* Handles if this.domains's keys do not end in the ':'.
* @param {string} protocol
* @return {[]|false}
* @private
*/
VegaWrapper.prototype._getProtocolDomains = function _getProtocolDomains(protocol) {
return this.domains[protocol] || this.domains[removeColon(protocol)];
};
/**
* Validate and update urlObj to be safe for client-side and server-side usage
* @param {Object} opt passed by the vega loader, and will add 'graphProtocol' param
* @returns {boolean} true on success
*/
VegaWrapper.prototype.sanitizeUrl = function sanitizeUrl(opt) {
// In some cases we may receive a badly formed URL in a form customprotocol:https://...
opt.url = opt.url.replace(/^([a-z]+:)https?:\/\//, '$1//');
// XOWA: comment out URL parsing code as it relies on various unavailable MediaWiki functions; DATE:2018-03-13
// note that URL parsing will be done directly in Xograph.js
/*
var decodedPathname,
isRelativeProtocol = /^\/\//.test(opt.url),
urlParts = this.parseUrl(opt),
sanitizedHost = this.sanitizeHost(urlParts.host);
// if (!sanitizedHost) {
// throw new Error('URL hostname is not whitelisted: ' + opt.url);
// }
urlParts.host = sanitizedHost.host;
if (!urlParts.protocol) {
// node.js mode only - browser's url parser will always set protocol to current one
// Update protocol-relative URLs
urlParts.protocol = sanitizedHost.protocol;
isRelativeProtocol = true;
}
// Save original protocol to post-process the data
opt.graphProtocol = urlParts.protocol;
if (opt.type === 'open') {
// Trim the value here because mediawiki will do it anyway, so we might as well save on redirect
decodedPathname = decodeURIComponent(urlParts.pathname).trim();
switch (urlParts.protocol) {
case 'http:':
case 'https:':
// The default protocol for the open action is wikititle, so if isRelativeProtocol is set,
// we treat the whole pathname as title (without the '/' prefix).
if (!isRelativeProtocol) {
// If we get http:// and https:// protocol hardcoded, remove the '/wiki/' prefix instead
if (!/^\/wiki\/.+$/.test(decodedPathname)) {
throw new Error('wikititle: http(s) links must begin with /wiki/ prefix');
}
decodedPathname = decodedPathname.substring('/wiki'.length);
}
opt.graphProtocol = 'wikititle';
// fall-through
case 'wikititle:':
// wikititle:///My_page or wikititle://en.wikipedia.org/My_page
// open() at this point may only be used to link to a Wiki page, as it may be invoked
// without a click, thus potentially causing a privacy issue.
if (Object.keys(urlParts.query).length !== 0) {
throw new Error('wikititle: query parameters are not allowed');
}
if (!/^\/[^|]+$/.test(decodedPathname)) {
throw new Error('wikititle: invalid title');
}
urlParts.pathname = '/wiki/' + encodeURIComponent(decodedPathname.substring(1).replace(' ', '_'));
urlParts.protocol = sanitizedHost.protocol;
break;
default:
throw new Error('"open()" action only allows links with wikititle protocol, e.g. wikititle:///My_page');
}
} else {
switch (urlParts.protocol) {
case 'http:':
case 'https:':
if (!this.isTrusted) {
throw new Error('HTTP and HTTPS protocols are not supported for untrusted graphs.\n' +
'Use wikiraw:, wikiapi:, wikirest:, wikirawupload:, and other protocols.\n' +
'See https://www.mediawiki.org/wiki/Extension:Graph#External_data');
}
// keep the original URL
break;
case 'wikiapi:':
// wikiapi:///?action=query&list=allpages
// Call to api.php - ignores the path parameter, and only uses the query
urlParts.query = this.objExtender(urlParts.query, {format: 'json', formatversion: '2'});
urlParts.pathname = '/w/api.php';
urlParts.protocol = sanitizedHost.protocol;
opt.addCorsOrigin = true;
break;
case 'wikirest:':
// wikirest:///api/rest_v1/page/...
// Call to RESTbase api - requires the path to start with "/api/"
// The /api/... path is safe for GET requests
if (!/^\/api\//.test(urlParts.pathname)) {
throw new Error('wikirest: protocol must begin with the /api/ prefix');
}
// keep urlParts.query
// keep urlParts.pathname
urlParts.protocol = sanitizedHost.protocol;
break;
case 'wikiraw:':
case 'tabular:':
case 'map:':
// wikiraw:///MyPage/data
// Get content of a wiki page, where the path is the title
// of the page with an additional leading '/' which gets removed.
// Uses mediawiki api, and extract the content after the request
// Query value must be a valid MediaWiki title string, but we only ensure
// there is no pipe symbol, the rest is handled by the api.
decodedPathname = decodeURIComponent(urlParts.pathname);
if (!/^\/[^|]+$/.test(decodedPathname)) {
throw new Error(urlParts.protocol + ' invalid title');
}
if (urlParts.protocol === 'wikiraw:') {
urlParts.query = {
format: 'json',
formatversion: '2',
action: 'query',
prop: 'revisions',
rvprop: 'content',
titles: decodedPathname.substring(1)
};
} else {
urlParts.query = {
format: 'json',
formatversion: '2',
action: 'jsondata',
title: decodedPathname.substring(1)
};
if (urlParts.siteLanguage || this.languageCode) {
urlParts.query.uselang = urlParts.siteLanguage || this.languageCode;
}
}
urlParts.pathname = '/w/api.php';
urlParts.protocol = sanitizedHost.protocol;
opt.addCorsOrigin = true;
break;
case 'wikifile:':
// wikifile:///Einstein_1921.jpg
// Get an image for the graph, e.g. from commons, by using Special:Redirect
urlParts.pathname = '/wiki/Special:Redirect/file' + urlParts.pathname;
urlParts.protocol = sanitizedHost.protocol;
// keep urlParts.query
break;
case 'wikirawupload:':
// wikirawupload://upload.wikimedia.org/wikipedia/commons/3/3e/Einstein_1921.jpg
// Get an image for the graph, e.g. from commons
// This tag specifies any content from the uploads.* domain, without query params
this._validateExternalService(urlParts, sanitizedHost, opt.url);
urlParts.query = {};
// keep urlParts.pathname
break;
case 'wikidatasparql:':
// wikidatasparql:///?query=<QUERY>
// Runs a SPARQL query, converting it to
// https://query.wikidata.org/bigdata/namespace/wdq/sparql?format=json&query=...
this._validateExternalService(urlParts, sanitizedHost, opt.url);
if (!urlParts.query || !urlParts.query.query) {
throw new Error('wikidatasparql: missing query parameter in: ' + opt.url);
}
// Only keep the "query" parameter
urlParts.query = {query: urlParts.query.query};
urlParts.pathname = '/bigdata/namespace/wdq/sparql';
opt.headers = this.objExtender(opt.headers || {}, {'Accept': 'application/sparql-results+json'});
break;
case 'geoshape:':
case 'geoline:':
// geoshape:///?ids=Q16,Q30 or geoshape:///?query=...
// Get geoshapes data from OSM database by supplying Wikidata IDs
// https://maps.wikimedia.org/shape?ids=Q16,Q30
// 'geoline:' is an identical service, except that it returns lines instead of polygons
this._validateExternalService(urlParts, sanitizedHost, opt.url, 'geoshape:');
if (!urlParts.query || (!urlParts.query.ids && !urlParts.query.query)) {
throw new Error(opt.graphProtocol + ' missing ids or query parameter in: ' + opt.url);
}
// the query object is not modified
urlParts.pathname = '/' + removeColon(opt.graphProtocol);
break;
case 'mapsnapshot:':
// mapsnapshot:///?width=__&height=__&zoom=__&lat=__&lon=__ [&style=__]
// Converts it into a snapshot image request for Kartotherian:
// https://maps.wikimedia.org/img/{style},{zoom},{lat},{lon},{width}x{height}[@{scale}x].{format}
// (scale will be set to 2, and format to png)
if (!urlParts.query) {
throw new Error('mapsnapshot: missing required parameters');
}
validate(urlParts, 'width', 1, 4096);
validate(urlParts, 'height', 1, 4096);
validate(urlParts, 'zoom', 0, 22);
validate(urlParts, 'lat', -90, 90, true);
validate(urlParts, 'lon', -180, 180, true);
var query = urlParts.query;
if (query.style && !/^[-_0-9a-z]+$/.test(query.style)) {
throw new Error('mapsnapshot: if style is given, it must be letters/numbers/dash/underscores only');
}
// Uses the same configuration as geoshape service, so reuse settings
this._validateExternalService(urlParts, sanitizedHost, opt.url, 'geoshape:');
urlParts.pathname = '/img/' + (query.style || 'osm-intl') + ',' + query.zoom + ',' +
query.lat + ',' + query.lon + ',' + query.width + 'x' + query.height + '@2x.png';
urlParts.query = {}; // deleting it would cause errors in mw.Uri()
break;
default:
throw new Error('Unknown protocol ' + opt.url);
}
}
return this.formatUrl(urlParts, opt);
*/
return opt.url;
};
function validate(urlParts, name, min, max, isFloat) {
var value = urlParts.query[name];
if (value === undefined) {
throw new Error(urlParts.protocol + ' parameter ' + name + ' is not set');
}
if (!(isFloat ? /^-?[0-9]+\.?[0-9]*$/ : /^-?[0-9]+$/).test(value)) {
throw new Error(urlParts.protocol + ' parameter ' + name + ' is not a number');
}
value = isFloat ? parseFloat(value) : parseInt(value);
if (value < min || value > max) {
throw new Error(urlParts.protocol + ' parameter ' + name + ' is not valid');
}
}
VegaWrapper.prototype._validateExternalService = function _validateExternalService(urlParts, sanitizedHost, url, protocolOverride) {
var protocol = protocolOverride || urlParts.protocol,
domains = this._getProtocolDomains(protocol);
if (!domains) {
throw new Error(protocol + ': protocol is disabled: ' + url);
}
if (urlParts.isRelativeHost) {
urlParts.host = domains[0];
urlParts.protocol = this.sanitizeHost(urlParts.host).protocol;
} else {
urlParts.protocol = sanitizedHost.protocol;
}
if (!this.testHost(protocol, urlParts.host)) {
throw new Error(protocol + ': URL must either be relative (' + protocol + '///...), or use one of the allowed hosts: ' + url);
}
};
/**
* Performs post-processing of the data requested by the graph's spec
*/
VegaWrapper.prototype.dataParser = function dataParser(error, data, opt, callback) {
if (!error) {
try {
data = this.parseDataOrThrow(data, opt);
} catch (e) {
error = e;
}
}
if (error) data = undefined;
callback(error, data);
};
/**
* Parses the response from MW Api, throwing an error or logging warnings
*/
VegaWrapper.prototype.parseMWApiResponse = function parseMWApiResponse(data) {
data = JSON.parse(data);
if (data.error) {
throw new Error('API error: ' + JSON.stringify(data.error));
}
if (data.warnings) {
this.logger('API warnings: ' + JSON.stringify(data.warnings));
}
return data;
};
/**
* Performs post-processing of the data requested by the graph's spec, and throw on error
*/
VegaWrapper.prototype.parseDataOrThrow = function parseDataOrThrow(data, opt) {
switch (opt.xowa_protocol) { // XOWA: use opt.xowa_protocol
case 'wikiapi:':
data = this.parseMWApiResponse(data);
break;
case 'wikiraw:':
// XOWA: comment out call to MW API; Xograph will make call to xo_server; DATE:2018-03-13
/*
data = this.parseMWApiResponse(data);
try {
data = data.query.pages[0].revisions[0].content;
} catch (e) {
throw new Error('Page content not available ' + opt.url);
}
*/
break;
case null:
case 'tabular:':
case 'map:':
data = this.parseMWApiResponse(data).jsondata;
var metadata = [{
description: data.description,
license_code: data.license.code,
license_text: data.license.text,
license_url: data.license.url,
sources: data.sources
}];
if (opt.xowa_protocol === 'tabular:') {
var fields = data.schema.fields.map(function (v) {
return v.name;
});
data = {
meta: metadata,
fields: data.schema.fields,
data: data.data.map(function (v) {
var row = {}, i;
for (i = 0; i < fields.length; i++) {
// Need to copy nulls too -- Vega has no easy way to test for undefined
row[fields[i]] = v[i];
}
return row;
})
}
} else {
metadata[0].zoom = data.zoom;
metadata[0].latitude = data.latitude;
metadata[0].longitude = data.longitude;
data = {
meta: metadata,
data: data.data
};
}
break;
case 'wikidatasparql:':
data = JSON.parse(data);
if (!data.results || !Array.isArray(data.results.bindings)) {
throw new Error('SPARQL query result does not have "results.bindings"');
}
data = data.results.bindings.map(function (row) {
var key, result = {};
for (key in row) {
if (row.hasOwnProperty(key)) {
result[key] = parseWikidataValue(row[key]);
}
}
return result;
});
break;
}
return data;
};
/**
* Throw an error when called
*/
function alwaysFail() {
throw new Error('Disabled');
}
},{"domain-validator":2,"wd-type-parser":4}],4:[function(require,module,exports){
'use strict';
/* global module */
module.exports = parseWikidataValue;
/**
* Given a value object as returned from Wikidata Query Service, returns a simplified value
* @param {object} value Original object as sent by the Wikidata query service
* @param {string} value.type SPARQL data type (literal, uri)
* @param {string} value.datatype XMLSchema data type
* @param {*} value.value The actual value sent by the Wikidata query service
* @param {boolean=} ignoreUnknown if false, will return value.value even if it cannot be recognized
* @return {*}
*/
function parseWikidataValue(value, ignoreUnknown) {
var temp;
if (!value || !value.type || value.value === undefined) {
return undefined;
}
switch (value.type) {
case 'literal':
switch (value.datatype) {
case 'http://www.w3.org/2001/XMLSchema#double':
case 'http://www.w3.org/2001/XMLSchema#float':
case 'http://www.w3.org/2001/XMLSchema#decimal':
case 'http://www.w3.org/2001/XMLSchema#integer':
case 'http://www.w3.org/2001/XMLSchema#long':
case 'http://www.w3.org/2001/XMLSchema#int':
case 'http://www.w3.org/2001/XMLSchema#short':
case 'http://www.w3.org/2001/XMLSchema#nonNegativeInteger':
case 'http://www.w3.org/2001/XMLSchema#positiveInteger':
case 'http://www.w3.org/2001/XMLSchema#unsignedLong':
case 'http://www.w3.org/2001/XMLSchema#unsignedInt':
case 'http://www.w3.org/2001/XMLSchema#unsignedShort':
case 'http://www.w3.org/2001/XMLSchema#nonPositiveInteger':
case 'http://www.w3.org/2001/XMLSchema#negativeInteger':
temp = parseFloat(value.value);
if (temp.toString() === value.value) {
// use number only if it is fully round-tripable back to string
// TBD: this might be overcautios, and would cause more problems than solve
return temp;
}
break;
case 'http://www.opengis.net/ont/geosparql#wktLiteral':
// Point(-64.2 -36.62) -- (longitude latitude)
temp = /^Point\(([-0-9.]+) ([-0-9.]+)\)$/.exec(value.value);
if (temp) {
return [parseFloat(temp[1]), parseFloat(temp[2])];
}
break;
}
break;
case 'uri':
// "http://www.wikidata.org/entity/Q12345" -> "Q12345"
temp = /^http:\/\/www\.wikidata\.org\/entity\/(Q[1-9][0-9]*)$/.exec(value.value);
if (temp) {
return temp[1];
}
break;
}
return ignoreUnknown ? undefined : value.value;
}
},{}]},{},[1]);

View File

@@ -0,0 +1,57 @@
/*!
* StyleSheet for JQuery splitter Plugin
* Copyright (C) 2010 Jakub Jankiewicz <http://jcubic.pl>
*
* Same license as plugin
*/
.splitter_panel {
position: relative;
}
.splitter_panel .vsplitter {
background-color: grey;
cursor: col-resize;
z-index:900;
width: 7px;
}
.splitter_panel .hsplitter {
background-color: #5F5F5F;
cursor: row-resize;
z-index: 800;
height: 7px;
}
.splitter_panel .vsplitter.splitter-invisible,
.splitter_panel .hsplitter.splitter-invisible {
background: none;
}
.splitter_panel .vsplitter, .splitter_panel .left_panel, .splitter_panel .right_panel,
.splitter_panel .hsplitter, .splitter_panel .top_panel, .splitter_panel .bottom_panel {
position: absolute;
overflow: auto;
}
.splitter_panel .vsplitter, .splitter_panel .left_panel, .splitter_panel .right_panel {
height: 100%;
}
.splitter_panel .hsplitter, .splitter_panel .top_panel, .splitter_panel .bottom_panel {
width: 100%;
}
.splitter_panel .top_panel, .splitter_panel .left_panel, .splitter_panel .vsplitter {
top: 0;
}
.splitter_panel .top_panel, .splitter_panel .bottom_panel, .splitter_panel .left_panel, .splitter_panel .hsplitter {
left: 0;
}
.splitter_panel .bottom_panel {
bottom: 0;
}
.splitter_panel .right_panel {
right: 0;
}
.splitterMask {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 1000;
}

View File

@@ -0,0 +1,307 @@
/*!
* JQuery Spliter Plugin version 0.20.1
* Copyright (C) 2010-2016 Jakub Jankiewicz <http://jcubic.pl>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
(function($, undefined) {
var count = 0;
var splitter_id = null;
var splitters = [];
var current_splitter = null;
$.fn.split = function(options) {
var data = this.data('splitter');
if (data) {
return data;
}
var panel_1;
var panel_2;
var settings = $.extend({
limit: 100,
orientation: 'horizontal',
position: '50%',
invisible: false,
onDragStart: $.noop,
onDragEnd: $.noop,
onDrag: $.noop
}, options || {});
this.settings = settings;
var cls;
var children = this.children();
if (settings.orientation == 'vertical') {
panel_1 = children.first().addClass('left_panel');
panel_2 = panel_1.next().addClass('right_panel');
cls = 'vsplitter';
} else if (settings.orientation == 'horizontal') {
panel_1 = children.first().addClass('top_panel');
panel_2 = panel_1.next().addClass('bottom_panel');
cls = 'hsplitter';
}
if (settings.invisible) {
cls += ' splitter-invisible';
}
var width = this.width();
var height = this.height();
var id = count++;
this.addClass('splitter_panel');
var splitter = $('<div/>').addClass(cls).bind('mouseenter touchstart', function() {
splitter_id = id;
}).bind('mouseleave touchend', function() {
splitter_id = null;
}).insertAfter(panel_1);
var position;
function get_position(position) {
if (typeof position === 'number') {
return position;
} else if (typeof position === 'string') {
var match = position.match(/^([0-9\.]+)(px|%)$/);
if (match) {
if (match[2] == 'px') {
return +match[1];
} else {
if (settings.orientation == 'vertical') {
return (width * +match[1]) / 100;
} else if (settings.orientation == 'horizontal') {
return (height * +match[1]) / 100;
}
}
} else {
//throw position + ' is invalid value';
}
} else {
//throw 'position have invalid type';
}
}
var self = $.extend(this, {
refresh: function() {
var new_width = this.width();
var new_height = this.height();
if (width != new_width || height != new_height) {
width = this.width();
height = this.height();
self.position(position);
}
},
position: (function() {
if (settings.orientation == 'vertical') {
return function(n, silent) {
if (n === undefined) {
return position;
} else {
position = get_position(n);
var sw = splitter.width();
var sw2 = sw/2, pw;
if (settings.invisible) {
pw = panel_1.width(position).outerWidth();
panel_2.width(self.width()-pw);
splitter.css('left', pw-sw2);
} else {
pw = panel_1.width(position-sw2).outerWidth();
panel_2.width(self.width()-pw-sw);
splitter.css('left', pw);
}
}
if (!silent) {
self.trigger('splitter.resize');
self.find('.splitter_panel').trigger('splitter.resize');
}
return self;
};
} else if (settings.orientation == 'horizontal') {
return function(n, silent) {
if (n === undefined) {
return position;
} else {
position = get_position(n);
var sw = splitter.height();
var sw2 = sw/2, pw;
if (settings.invisible) {
pw = panel_1.height(position).outerHeight();
panel_2.height(self.height()-pw);
splitter.css('top', pw-sw2);
} else {
pw = panel_1.height(position-sw2).outerHeight();
panel_2.height(self.height()-pw-sw);
splitter.css('top', pw);
}
}
if (!silent) {
self.trigger('splitter.resize');
self.find('.splitter_panel').trigger('splitter.resize');
}
return self;
};
} else {
return $.noop;
}
})(),
orientation: settings.orientation,
limit: settings.limit,
isActive: function() {
return splitter_id === id;
},
destroy: function() {
self.removeClass('splitter_panel');
splitter.unbind('mouseenter');
splitter.unbind('mouseleave');
splitter.unbind('touchstart');
splitter.unbind('touchmove');
splitter.unbind('touchend');
splitter.unbind('touchleave');
splitter.unbind('touchcancel');
if (settings.orientation == 'vertical') {
panel_1.removeClass('left_panel');
panel_2.removeClass('right_panel');
} else if (settings.orientation == 'horizontal') {
panel_1.removeClass('top_panel');
panel_2.removeClass('bottom_panel');
}
self.unbind('splitter.resize');
self.trigger('splitter.resize');
self.find('.splitter_panel').trigger('splitter.resize');
splitters[i] = null;
count--;
splitter.remove();
self.removeData('splitter');
var not_null = false;
for (var i=splitters.length; i--;) {
if (splitters[i] !== null) {
not_null = true;
break;
}
}
//remove document events when no splitters
if (!not_null) {
$(document.documentElement).unbind('.splitter');
$(window).unbind('resize.splitter');
splitters = [];
count = 0;
}
}
});
self.bind('splitter.resize', function(e) {
var pos = self.position();
if (self.orientation == 'vertical' &&
pos > self.width()) {
pos = self.width() - self.limit-1;
} else if (self.orientation == 'horizontal' &&
pos > self.height()) {
pos = self.height() - self.limit-1;
}
if (pos < self.limit) {
pos = self.limit + 1;
}
e.stopPropagation();
self.position(pos, true);
});
//inital position of splitter
var pos;
if (settings.orientation == 'vertical') {
if (pos > width-settings.limit) {
pos = width-settings.limit;
} else {
pos = get_position(settings.position);
}
} else if (settings.orientation == 'horizontal') {
//position = height/2;
if (pos > height-settings.limit) {
pos = height-settings.limit;
} else {
pos = get_position(settings.position);
}
}
if (pos < settings.limit) {
pos = settings.limit;
}
self.position(pos, true);
if (splitters.length === 0) { // first time bind events to document
$(window).bind('resize.splitter', function() {
$.each(splitters, function(i, splitter) {
if (splitter) {
splitter.refresh();
}
});
});
$(document.documentElement).on('mousedown.splitter touchstart.splitter', function(e) {
if (splitter_id !== null) {
current_splitter = splitters[splitter_id];
setTimeout(function() {
$('<div class="splitterMask"></div>').
css('cursor', current_splitter.children().eq(1).css('cursor')).
insertAfter(current_splitter);
});
current_splitter.settings.onDragStart(e);
}
}).bind('mouseup.splitter touchend.splitter touchleave.splitter touchcancel.splitter', function(e) {
if (current_splitter) {
setTimeout(function() {
$('.splitterMask').remove();
});
current_splitter.settings.onDragEnd(e);
current_splitter = null;
}
}).bind('mousemove.splitter touchmove.splitter', function(e) {
if (current_splitter !== null) {
var limit = current_splitter.limit;
var offset = current_splitter.offset();
if (current_splitter.orientation == 'vertical') {
var pageX = e.pageX;
if(e.originalEvent && e.originalEvent.changedTouches){
pageX = e.originalEvent.changedTouches[0].pageX;
}
var x = pageX - offset.left;
if (x <= current_splitter.limit) {
x = current_splitter.limit + 1;
} else if (x >= current_splitter.width() - limit) {
x = current_splitter.width() - limit - 1;
}
if (x > current_splitter.limit &&
x < current_splitter.width()-limit) {
current_splitter.position(x, true);
current_splitter.trigger('splitter.resize');
current_splitter.find('.splitter_panel').
trigger('splitter.resize');
//e.preventDefault();
}
} else if (current_splitter.orientation == 'horizontal') {
var pageY = e.pageY;
if(e.originalEvent && e.originalEvent.changedTouches){
pageY = e.originalEvent.changedTouches[0].pageY;
}
var y = pageY-offset.top;
if (y <= current_splitter.limit) {
y = current_splitter.limit + 1;
} else if (y >= current_splitter.height() - limit) {
y = current_splitter.height() - limit - 1;
}
if (y > current_splitter.limit &&
y < current_splitter.height()-limit) {
current_splitter.position(y, true);
current_splitter.trigger('splitter.resize');
current_splitter.find('.splitter_panel').
trigger('splitter.resize');
//e.preventDefault();
}
}
current_splitter.settings.onDrag(e);
}
});//*/
}
splitters[id] = self;
self.data('splitter', self);
return self;
};
})(jQuery);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
( function ( $, mw ) {
mw.hook( 'wikipage.content' ).add( function ( $content ) {
/**
* Replace a graph image by the vega graph.
*
* If dependencies aren't loaded yet, they are loaded first
* before rendering the graph.
*
* @param {jQuery} $el Graph container.
*/
function loadAndReplaceWithGraph( $el ) {
// TODO, Performance BUG: loading vega and calling api should happen in parallel
// Lazy loading dependencies
mw.loader.using( 'ext.graph.vega2', function () {
new mw.Api().get( {
formatversion: 2,
action: 'graph',
title: mw.config.get( 'wgPageName' ),
hash: $el.data( 'graphId' )
} ).done( function ( data ) {
mw.drawVegaGraph( $el[ 0 ], data.graph, function ( error ) {
var $layover = $el.find( '.mw-graph-layover' );
if ( !error ) {
$el.find( 'img' ).remove();
$layover.remove();
} else {
mw.log.warn( error );
}
$el.removeClass( 'mw-graph-interactable' );
// TODO: handle error by showing some message
} );
} );
} );
}
// Make graph containers clickable
$content.find( '.mw-graph.mw-graph-interactable' ).on( 'click', function () {
var $this = $( this ),
$button = $this.find( '.mw-graph-switch' );
// Prevent multiple clicks
$this.off( 'click' );
// Add a class to decorate loading
$button.addClass( 'mw-graph-loading' );
// Replace the image with the graph
loadAndReplaceWithGraph( $this );
} );
} );
}( jQuery, mediaWiki ) );

View File

@@ -0,0 +1,76 @@
( function ( $, mw ) {
var oldContent, ccw,
resizeCodeEditor = $.noop;
$( function () {
var viewportHeight = $( window ).height(),
sandboxHeight = viewportHeight - 150,
initialPosition = sandboxHeight - 100;
$( '#mw-graph-sandbox' ).width( '100%' ).height( sandboxHeight ).split( {
orientation: 'vertical',
limit: 100,
position: '40%'
} );
$( '#mw-graph-left' ).split( {
orientation: 'horizontal',
limit: 100,
position: initialPosition
} );
} );
mw.hook( 'codeEditor.configure' ).add( function ( session ) {
var $json = $( '#mw-graph-json' )[ 0 ],
$graph = $( '.mw-graph' ),
$graphEl = $graph[ 0 ],
$rightPanel = $( '#mw-graph-right' ),
$editor = $( '.editor' );
if ( ccw ) {
ccw.release();
}
ccw = mw.confirmCloseWindow( {
test: function () {
return session.getValue().length > 0;
},
message: mw.msg( 'editwarning-warning' )
} );
resizeCodeEditor = function () {
$editor.parent().height( $rightPanel.height() - 57 );
$.wikiEditor.instances[ 0 ].data( 'wikiEditor-context' ).codeEditor.resize();
};
// I tried to resize on $( window ).resize(), but that didn't work right
resizeCodeEditor();
session.on( 'change', $.debounce( 300, function () {
var content = session.getValue();
if ( oldContent === content ) {
return;
}
oldContent = content;
$graph.empty();
new mw.Api().post( {
formatversion: 2,
action: 'graph',
text: content
} ).done( function ( data ) {
if ( session.getValue() !== content ) {
// Just in case the content has changed since we made the api call
return;
}
$json.textContent = JSON.stringify( data.graph, null, 2 );
mw.drawVegaGraph( $graphEl, data.graph, function ( error ) {
if ( error ) {
$graphEl.textContent = ( error.exception || error ).toString();
}
} );
} ).fail( function ( errCode, error ) {
$graphEl.textContent = errCode.toString() + ':' + ( error.exception || error ).toString();
} );
} ) );
} );
}( jQuery, mediaWiki ) );

View File

@@ -0,0 +1,27 @@
( function ( $, mw ) {
// mw.hook( 'wikipage.content' ).add( function ( $content ) {
var specs = {} // mw.config.get( 'wgGraphSpecs' );
vg.data.load.sanitizeUrl = function () {
// mw.log.warn( 'Vega 1.x does not allow external URLs. Switch to Vega 2.' );
return false;
};
if ( specs ) {
var $content = $('#content'); // XOWA
$content.find( '.mw-graph.mw-graph-always' ).each( function () {
var graphId = $( this ).data( 'graph-id' ),
el = this;
if ( !specs[ graphId ] ) {
// mw.log.warn( graphId );
} else {
vg.parse.spec( specs[ graphId ], function ( chart ) {
if ( chart ) {
chart( { el: el } ).update();
}
} );
}
} );
}
// } );
}( jQuery, null ) );

View File

@@ -0,0 +1,107 @@
( function ( $, mw, vg ) {
'use strict';
/* global require */
/*
var VegaWrapper = require( 'mw-graph-shared' );
// eslint-disable-next-line no-new
new VegaWrapper( {
datalib: vg.util,
useXhr: true,
isTrusted: true, //mw.config.get( 'wgGraphIsTrusted' ),
domains: '', // mw.config.get( 'wgGraphAllowedDomains' ),
domainMap: false,
logger: function ( warning ) {
// mw.log.warn( warning );
},
parseUrl: function ( opt ) {
// Parse URL
var uri = new mw.Uri( opt.url );
// reduce confusion, only keep expected values
if ( uri.port ) {
uri.host += ':' + uri.port;
delete uri.port;
}
// If url begins with protocol:///... mark it as having relative host
if ( /^[a-z]+:\/\/\//.test( opt.url ) ) {
uri.isRelativeHost = true;
}
if ( uri.protocol ) {
// All other libs use trailing colon in the protocol field
uri.protocol += ':';
}
// Node's path includes the query, whereas pathname is without the query
// Standardizing on pathname
uri.pathname = uri.path;
delete uri.path;
return uri;
},
formatUrl: function ( uri, opt ) {
// Format URL back into a string
// Revert path into pathname
uri.path = uri.pathname;
delete uri.pathname;
if ( location.host.toLowerCase() === uri.host.toLowerCase() ) {
// if ( !mw.config.get( 'wgGraphIsTrusted' ) ) {
// Only send this header when hostname is the same.
// This is broader than the same-origin policy,
// but playing on the safer side.
// opt.headers = { 'Treat-as-Untrusted': 1 };
// }
} else if ( opt.addCorsOrigin ) {
// All CORS api calls require origin parameter.
// It would be better to use location.origin,
// but apparently it's not universal yet.
uri.query.origin = location.protocol + '//' + location.host;
}
uri.protocol = VegaWrapper.removeColon( uri.protocol );
return uri.toString();
},
// languageCode: mw.config.get( 'wgUserLanguage' )
} );
*/
/**
* Set up drawing canvas inside the given element and draw graph data
*
* @param {HTMLElement} element
* @param {Object|string} data graph spec
* @param {Function} [callback] function(error) called when drawing is done
*/
window.drawVegaGraph = function ( element, data, callback ) {
vg.parse.spec( data, function ( error, chart ) {
if ( !error ) {
chart( { el: element } ).update();
}
if ( callback ) {
callback( error );
}
} );
};
// mw.hook( 'wikipage.content' ).add( function ( $content ) {
var $content = $('#content');
var specs = {} // mw.config.get( 'wgGraphSpecs' );
if ( !specs ) {
return;
}
$content.find( '.mw-graph.mw-graph-always' ).each( function () {
var graphId = $( this ).data( 'graph-id' );
if ( !specs.hasOwnProperty( graphId ) ) {
// mw.log.warn( graphId );
} else {
window.drawVegaGraph( this, specs[ graphId ], function ( error ) {
if ( error ) {
// mw.log.warn( error );
}
} );
}
} );
// } );
}( jQuery, null, vg ) );

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g id="graph">
<path id="axes" d="M5 6v12h14v-1H6V6H5z"/>
<path id="data" d="M7 16v-3l4-3 3 2 4-4v8H7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 264 B

View File

@@ -0,0 +1,607 @@
/*!
* VisualEditor MWGraphNode tests.
*/
QUnit.module( 'ext.graph.visualEditor' );
( function () {
'use strict';
/* Sample specs */
var sampleSpecs = {
areaGraph: {
version: 2,
width: 500,
height: 200,
padding: {
top: 10,
left: 30,
bottom: 30,
right: 10
},
data: [
{
name: 'table',
values: [
{ x: 0, y: 28 },
{ x: 1, y: 43 },
{ x: 2, y: 81 },
{ x: 3, y: 19 }
]
}
],
scales: [
{
name: 'x',
type: 'linear',
range: 'width',
zero: false,
domain: {
data: 'table',
field: 'x'
}
},
{
name: 'y',
type: 'linear',
range: 'height',
nice: true,
domain: {
data: 'table',
field: 'y'
}
}
],
axes: [
{
type: 'x',
scale: 'x'
},
{
type: 'y',
scale: 'y'
}
],
marks: [
{
type: 'area',
from: {
data: 'table'
},
properties: {
enter: {
interpolate: {
value: 'monotone'
},
x: {
scale: 'x',
field: 'x'
},
y: {
scale: 'y',
field: 'y'
},
y2: {
scale: 'y',
value: 0
},
fill: {
value: 'steelblue'
}
}
}
}
]
},
stackedAreaGraph: {
version: 2,
width: 500,
height: 200,
padding: {
top: 10,
left: 30,
bottom: 30,
right: 10
},
data: [
{
name: 'table',
values: [
{ x: 0, y: 28, c: 0 },
{ x: 0, y: 55, c: 1 },
{ x: 1, y: 43, c: 0 },
{ x: 1, y: 91, c: 1 },
{ x: 2, y: 81, c: 0 },
{ x: 2, y: 53, c: 1 },
{ x: 3, y: 19, c: 0 },
{ x: 3, y: 87, c: 1 },
{ x: 4, y: 52, c: 0 },
{ x: 4, y: 48, c: 1 },
{ x: 5, y: 24, c: 0 },
{ x: 5, y: 49, c: 1 },
{ x: 6, y: 87, c: 0 },
{ x: 6, y: 66, c: 1 },
{ x: 7, y: 17, c: 0 },
{ x: 7, y: 27, c: 1 },
{ x: 8, y: 68, c: 0 },
{ x: 8, y: 16, c: 1 },
{ x: 9, y: 49, c: 0 },
{ x: 9, y: 15, c: 1 }
]
},
{
name: 'stats',
source: 'table',
transform: [
{
type: 'facet',
keys: [
'x'
]
},
{
type: 'stats',
value: 'y'
}
]
}
],
scales: [
{
name: 'x',
type: 'linear',
range: 'width',
zero: false,
domain: {
data: 'table',
field: 'x'
}
},
{
name: 'y',
type: 'linear',
range: 'height',
nice: true,
domain: {
data: 'stats',
field: 'sum'
}
},
{
name: 'color',
type: 'ordinal',
range: 'category10'
}
],
axes: [
{
type: 'x',
scale: 'x'
},
{
type: 'y',
scale: 'y'
}
],
marks: [
{
type: 'group',
from: {
data: 'table',
transform: [
{
type: 'facet',
keys: [
'c'
]
},
{
type: 'stack',
point: 'x',
height: 'y'
}
]
},
marks: [
{
type: 'area',
properties: {
enter: {
interpolate: {
value: 'monotone'
},
x: {
scale: 'x',
field: 'x'
},
y: {
scale: 'y',
field: 'y'
},
y2: {
scale: 'y',
field: 'y2'
},
fill: {
scale: 'color',
field: 'c'
}
},
update: {
fillOpacity: {
value: 1
}
},
hover: {
fillOpacity: {
value: 0.5
}
}
}
}
]
}
]
},
invalidAxesBarGraph: {
version: 2,
width: 500,
height: 200,
padding: {
top: 10,
left: 30,
bottom: 30,
right: 10
},
data: [
{
name: 'table',
values: [
{ x: 0, y: 28 },
{ x: 1, y: 43 },
{ x: 2, y: 81 },
{ x: 3, y: 19 }
]
}
],
scales: [
{
name: 'x',
type: 'linear',
range: 'width',
zero: false,
domain: {
data: 'table',
field: 'x'
}
},
{
name: 'y',
type: 'linear',
range: 'height',
nice: true,
domain: {
data: 'table',
field: 'y'
}
}
],
axes: [
{
type: 'x',
scale: 'z'
},
{
type: 'y',
scale: 'y'
}
],
marks: [
{
type: 'area',
from: {
data: 'table'
},
properties: {
enter: {
interpolate: {
value: 'monotone'
},
x: {
scale: 'x',
field: 'x'
},
y: {
scale: 'y',
field: 'y'
},
y2: {
scale: 'y',
value: 0
},
fill: {
value: 'steelblue'
}
}
}
}
]
}
};
/* Tests */
QUnit.test( 've.dm.MWGraphNode', function ( assert ) {
var node = new ve.dm.MWGraphNode(),
specString = JSON.stringify( sampleSpecs.areaGraph );
assert.deepEqual( node.getSpec(), ve.dm.MWGraphNode.static.defaultSpec, 'MWGraphNode spec is initialized to the default spec' );
node.setSpecFromString( specString );
assert.deepEqual( node.getSpec(), sampleSpecs.areaGraph, 'Basic valid spec is parsed' );
node.setSpecFromString( 'invalid JSON string' );
assert.deepEqual( node.getSpec(), {}, 'Setting an invalid JSON resets the spec to an empty object' );
node.setSpec( sampleSpecs.stackedAreaGraph );
assert.deepEqual( node.getSpec(), sampleSpecs.stackedAreaGraph, 'Setting the spec by object' );
node.setSpec( null );
assert.deepEqual( node.getSpec(), {}, 'Setting a null spec resets the spec to an empty object' );
} );
QUnit.test( 've.ce.MWGraphNode', function ( assert ) {
var view = ve.test.utils.createSurfaceViewFromHtml(
'<div typeof="mw:Extension/graph"></div>'
),
documentNode = view.getDocument().getDocumentNode(),
node = documentNode.children[ 0 ];
assert.equal( node.type, 'mwGraph', 'Parsoid HTML graphs are properly recognized as graph nodes' );
} );
QUnit.test( 've.ce.MWGraphNode.static', function ( assert ) {
var testElement = document.createElement( 'div' ),
renderValidTest = assert.async(),
renderInvalidTest = assert.async();
$( '#qunit-fixture' ).append( testElement );
ve.ce.MWGraphNode.static.vegaParseSpec( sampleSpecs.areaGraph, testElement ).always(
function () {
assert.ok( this.state() === 'resolved', 'Simple graph gets rendered correctly' );
renderValidTest();
}
);
ve.ce.MWGraphNode.static.vegaParseSpec( sampleSpecs.invalidAxesBarGraph, testElement ).always(
function ( failMessageKey ) {
assert.ok( failMessageKey === 'graph-ve-vega-error', 'Invalid graph triggers an error at rendering' );
renderInvalidTest();
}
);
} );
QUnit.test( 've.dm.MWGraphModel', function ( assert ) {
var model = new ve.dm.MWGraphModel( sampleSpecs.areaGraph ),
updateSpecRemoval = {
marks: undefined,
scales: undefined,
padding: { top: 50 },
axes: [
{ type: 'z' }
]
},
areaGraphRemovalExpected = {
version: 2,
width: 500,
height: 200,
padding: {
top: 50,
left: 30,
bottom: 30,
right: 10
},
data: [
{
name: 'table',
values: [
{ x: 0, y: 28 },
{ x: 1, y: 43 },
{ x: 2, y: 81 },
{ x: 3, y: 19 }
]
}
],
axes: [
{
type: 'z',
scale: 'x'
},
{
type: 'y',
scale: 'y'
}
]
};
assert.equal( model.hasBeenChanged(), false, 'Model changes are correctly initialized' );
model.setSpecFromString( 'invalid json string' );
assert.equal( model.hasBeenChanged(), true, 'Model spec resets to an empty object when fed invalid data' );
model.setSpecFromString( JSON.stringify( sampleSpecs.areaGraph, null, '\t' ) );
assert.equal( model.hasBeenChanged(), false, 'Model doesn\'t throw false positives after applying no changes' );
model.setSpecFromString( JSON.stringify( sampleSpecs.stackedAreaGraph ) );
assert.equal( model.hasBeenChanged(), true, 'Model recognizes valid changes to spec' );
model.setSpecFromString( JSON.stringify( sampleSpecs.areaGraph ) );
model.updateSpec( updateSpecRemoval );
assert.deepEqual( model.getSpec(), areaGraphRemovalExpected, 'Updating the spec and removing properties' );
} );
QUnit.test( 've.dm.MWGraphModel.static', function ( assert ) {
var result,
basicTestObj = {
a: 3,
b: undefined,
c: {
ca: undefined,
cb: 'undefined'
}
},
complexTestObj = {
a: {
aa: undefined,
ab: 3,
ac: [
{
ac0a: undefined,
ac0b: 4
},
{
ac1a: 'ac1a',
ac1b: 5,
ac1c: undefined
}
]
},
b: {
a: undefined,
b: undefined,
c: 2
},
c: 3,
d: undefined
},
undefinedPropertiesBasicExpected = [ 'b', 'c.ca' ],
undefinedPropertiesComplexExpected = [ 'a.aa', 'a.ac.0.ac0a', 'a.ac.1.ac1c', 'b.a', 'b.b', 'd' ],
removePropBasicExpected = {
a: 3,
b: undefined,
c: {
cb: 'undefined'
}
},
removePropComplexExpected = {
a: {
aa: undefined,
ab: 3,
ac: [ {
ac1b: 5,
ac1c: undefined
} ]
},
c: 3,
d: undefined
};
result = ve.dm.MWGraphModel.static.getUndefinedProperties( basicTestObj );
assert.deepEqual( result, undefinedPropertiesBasicExpected, 'Basic deep undefined property scan is successful' );
result = ve.dm.MWGraphModel.static.getUndefinedProperties( complexTestObj );
assert.deepEqual( result, undefinedPropertiesComplexExpected, 'Complex deep undefined property scan is successful' );
result = ve.dm.MWGraphModel.static.removeProperty( basicTestObj, [ 'c', 'ca' ] );
assert.deepEqual( basicTestObj, removePropBasicExpected, 'Basic nested property removal is successful' );
ve.dm.MWGraphModel.static.removeProperty( complexTestObj, [ 'a', 'ac', '0' ] );
ve.dm.MWGraphModel.static.removeProperty( complexTestObj, [ 'a', 'ac', '0', 'ac1a' ] );
ve.dm.MWGraphModel.static.removeProperty( complexTestObj, [ 'b' ] );
assert.deepEqual( complexTestObj, removePropComplexExpected, 'Complex nested property removal is successful' );
assert.throws(
ve.dm.MWGraphModel.static.removeProperty( complexTestObj, [ 'b' ] ),
'Trying to delete an invalid property throws an error'
);
} );
QUnit.test( 've.ui.TableWidget', function ( assert ) {
var widgetA = new ve.ui.TableWidget( {
rows: [
{
key: 'foo',
label: 'Foo'
},
{
key: 'bar',
label: 'Bar'
}
],
cols: [
{
label: 'A'
},
{
label: 'B'
}
]
} ),
widgetB = new ve.ui.TableWidget( {
rows: [
{
key: 'foo'
},
{
key: 'bar'
}
],
cols: [
{}, {}, {}
],
data: [
[ '11', '12', '13' ],
[ '21', '22', '23' ]
]
} ),
widgetAexpectedRowProps = {
index: 1,
key: 'bar',
label: 'Bar'
},
widgetAexpectedInitialData = [
[ '', '' ],
[ '', '' ]
],
widgetAexpectedDataAfterValue = [
[ '', '' ],
[ '3', '' ]
],
widgetAexpectedDataAfterRowColumnInsertions = [
[ '', '', '' ],
[ 'a', 'b', 'c' ],
[ '3', '', '' ]
],
widgetAexpectedDataAfterColumnRemoval = [
[ '', '' ],
[ 'b', 'c' ],
[ '', '' ]
];
assert.deepEqual( widgetA.model.data, widgetAexpectedInitialData, 'Table data is initialized properly' );
assert.deepEqual( widgetA.model.getRowProperties( 'bar' ), widgetAexpectedRowProps, 'Row props are returned successfully' );
widgetA.setValue( 'bar', 0, '3' );
assert.deepEqual( widgetA.model.data, widgetAexpectedDataAfterValue, 'Table data is modified successfully' );
widgetA.insertColumn();
widgetA.insertRow( [ 'a', 'b', 'c' ], 1, 'baz', 'Baz' );
assert.deepEqual( widgetA.model.data, widgetAexpectedDataAfterRowColumnInsertions, 'Row and column are added successfully' );
widgetA.removeColumn( 0 );
assert.deepEqual( widgetA.model.data, widgetAexpectedDataAfterColumnRemoval, 'Columns are removed successfully' );
widgetA.removeRow( -1 );
assert.deepEqual( widgetA.model.data, widgetAexpectedDataAfterColumnRemoval, 'Invalid row removal by index does not change table data' );
widgetA.removeRow( 'qux' );
assert.deepEqual( widgetA.model.data, widgetAexpectedDataAfterColumnRemoval, 'Invalid row removal by key does not change table data' );
assert.deepEqual( widgetB.getItems()[ 0 ].getItems()[ 2 ].getValue(), '13', 'Initial data is populated in text inputs properly' );
} );
}() );

View File

@@ -0,0 +1,9 @@
.mw-graph {
display: inline-block;
border: 1px solid transparent;
position: relative;
}
.ve-ce-mwGraphNode-plot {
position: absolute;
}

View File

@@ -0,0 +1,169 @@
/*!
* VisualEditor ContentEditable MWGraphNode class.
*
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* ContentEditable MediaWiki graph node.
*
* @class
* @extends ve.ce.MWBlockExtensionNode
* @mixins ve.ce.MWResizableNode
*
* @constructor
* @param {ve.dm.MWGraphNode} model Model to observe
* @param {Object} [config] Configuration options
*/
ve.ce.MWGraphNode = function VeCeMWGraphNode( model, config ) {
this.$graph = $( '<div>' ).addClass( 'mw-graph' );
this.$plot = $( '<div>' ).addClass( 've-ce-mwGraphNode-plot' );
// Parent constructor
ve.ce.MWGraphNode.super.apply( this, arguments );
// Mixin constructors
ve.ce.MWResizableNode.call( this, this.$plot, config );
this.$element
.addClass( 'mw-graph-container' )
.append( this.$graph );
this.showHandles( [ 'se' ] );
};
/* Inheritance */
OO.inheritClass( ve.ce.MWGraphNode, ve.ce.MWBlockExtensionNode );
// Need to mix in the base class as well
OO.mixinClass( ve.ce.MWGraphNode, ve.ce.ResizableNode );
OO.mixinClass( ve.ce.MWGraphNode, ve.ce.MWResizableNode );
/* Static Properties */
ve.ce.MWGraphNode.static.name = 'mwGraph';
ve.ce.MWGraphNode.static.primaryCommandName = 'graph';
ve.ce.MWGraphNode.static.tagName = 'div';
/* Static Methods */
/**
* Attempt to render the graph through Vega.
*
* @param {Object} spec The graph spec
* @param {HTMLElement} element Element to render the graph in
* @return {jQuery.Promise} Promise that resolves when the graph is rendered.
* Promise is rejected with an error message key if there was a problem rendering the graph.
*/
ve.ce.MWGraphNode.static.vegaParseSpec = function ( spec, element ) {
var deferred = $.Deferred(),
node = this,
canvasNode, view;
// Check if the spec is currently valid
if ( ve.isEmptyObject( spec ) ) {
deferred.reject( 'graph-ve-no-spec' );
} else if ( !ve.dm.MWGraphModel.static.specHasData( spec ) ) {
deferred.reject( 'graph-ve-empty-graph' );
} else {
vg.parse.spec( spec, function ( chart ) {
try {
view = chart( { el: element } ).update();
// HACK: If canvas is blank, this means Vega didn't render properly.
// Once Vega allows for proper rendering validation, this should be
// swapped for a validation check.
canvasNode = element.children[ 0 ].children[ 0 ];
if ( node.isCanvasBlank( canvasNode ) ) {
deferred.reject( 'graph-ve-vega-error-no-render' );
} else {
deferred.resolve( view );
}
} catch ( err ) {
deferred.reject( 'graph-ve-vega-error' );
}
} );
}
return deferred.promise();
};
/**
* Check if a canvas is blank
*
* @author Austin Brunkhorst http://stackoverflow.com/a/17386803/2055594
* @param {HTMLElement} canvas The canvas to Check
* @return {boolean} The canvas is blank
*/
ve.ce.MWGraphNode.static.isCanvasBlank = function ( canvas ) {
var blank = document.createElement( 'canvas' );
blank.width = canvas.width;
blank.height = canvas.height;
return canvas.toDataURL() === blank.toDataURL();
};
/* Methods */
/**
* Render a Vega graph inside the node
*/
ve.ce.MWGraphNode.prototype.update = function () {
var node = this;
// Clear element
this.$graph.empty();
this.$element.toggleClass( 'mw-graph-vega1', this.getModel().isGraphLegacy() );
mw.loader.using( 'ext.graph.vega2' ).done( function () {
node.$plot.detach();
node.constructor.static.vegaParseSpec( node.getModel().getSpec(), node.$graph[ 0 ] ).then(
function ( view ) {
// HACK: We need to know which padding values Vega computes in case
// of automatic padding, but it isn't properly exposed in the view
node.$graph.append( node.$plot );
// eslint-disable-next-line no-underscore-dangle
node.$plot.css( view._padding );
node.calculateHighlights();
},
function ( failMessageKey ) {
node.$graph.text( ve.msg( failMessageKey ) );
}
);
} );
};
/**
* @inheritdoc
*/
ve.ce.MWGraphNode.prototype.getAttributeChanges = function ( width, height ) {
var attrChanges = {},
newSpec = ve.dm.MWGraphModel.static.updateSpec( this.getModel().getSpec(), {
width: width,
height: height
} );
ve.setProp( attrChanges, 'mw', 'body', 'extsrc', JSON.stringify( newSpec ) );
return attrChanges;
};
/**
* @inheritdoc
*/
ve.ce.MWGraphNode.prototype.getFocusableElement = function () {
return this.$graph;
};
/* Registration */
ve.ce.nodeFactory.register( ve.ce.MWGraphNode );

View File

@@ -0,0 +1,526 @@
/*!
* VisualEditor DataModel MWGraphModel class.
*
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* MediaWiki graph model.
*
* @class
* @mixins OO.EventEmitter
*
* @constructor
* @param {Object} [spec] The Vega specification as a JSON object
*/
ve.dm.MWGraphModel = function VeDmMWGraphModel( spec ) {
// Mixin constructors
OO.EventEmitter.call( this );
// Properties
this.spec = spec || {};
this.originalSpec = ve.copy( this.spec );
this.cachedPadding = ve.copy( this.spec.padding ) || this.getDefaultPaddingObject();
};
/* Inheritance */
OO.mixinClass( ve.dm.MWGraphModel, OO.EventEmitter );
/* Static Members */
ve.dm.MWGraphModel.static.defaultPadding = 30;
ve.dm.MWGraphModel.static.minDimensions = {
width: 60,
height: 60
};
ve.dm.MWGraphModel.static.graphConfigs = {
area: {
mark: {
type: 'area',
properties: {
enter: {
fill: { value: 'steelblue' },
interpolate: { value: 'monotone' },
stroke: undefined,
strokeWidth: undefined,
width: undefined
}
}
},
scale: {
name: 'x',
type: 'linear'
},
fields: [
'x',
'y'
]
},
bar: {
mark: {
type: 'rect',
properties: {
enter: {
fill: { value: 'steelblue' },
interpolate: undefined,
stroke: undefined,
strokeWidth: undefined,
// HACK: Boolean values set to true need to be wrapped
// in strings until T118883 is resolved
width: { scale: 'x', band: 'true', offset: -1 }
}
}
},
scale: {
name: 'x',
type: 'ordinal'
},
fields: [
'x',
'y'
]
},
line: {
mark: {
type: 'line',
properties: {
enter: {
fill: undefined,
interpolate: { value: 'monotone' },
stroke: { value: 'steelblue' },
strokeWidth: { value: 3 },
width: undefined
}
}
},
scale: {
name: 'x',
type: 'linear'
},
fields: [
'x',
'y'
]
}
};
/* Events */
/**
* @event specChange
*
* Change when the JSON specification is updated
*
* @param {Object} The new specification
*/
/* Static Methods */
/**
* Updates a spec with new parameters.
*
* @param {Object} spec The spec to update
* @param {Object} params The new params to update. Properties set to undefined will be removed from the spec.
* @return {Object} The new spec
*/
ve.dm.MWGraphModel.static.updateSpec = function ( spec, params ) {
var undefinedProperty,
undefinedProperties = ve.dm.MWGraphModel.static.getUndefinedProperties( params ),
i;
// Remove undefined properties from spec
for ( i = 0; i < undefinedProperties.length; i++ ) {
undefinedProperty = undefinedProperties[ i ].split( '.' );
ve.dm.MWGraphModel.static.removeProperty( spec, $.extend( [], undefinedProperty ) );
ve.dm.MWGraphModel.static.removeProperty( params, $.extend( [], undefinedProperty ) );
}
// Extend remaining properties
spec = $.extend( true, {}, spec, params );
return spec;
};
/**
* Recursively gets all the keys to properties set to undefined in a JSON object
*
* @author Based on the work on Artyom Neustroev at http://stackoverflow.com/a/15690816/2055594
* @private
* @param {Object} obj The object to iterate
* @param {string} [stack] The parent property of the root property of obj. Used internally for recursion.
* @param {string[]} [list] The list of properties to return. Used internally for recursion.
* @return {string[]} The list of properties to return.
*/
ve.dm.MWGraphModel.static.getUndefinedProperties = function ( obj, stack, list ) {
var property;
list = list || [];
// Append . to the stack if it's defined
stack = ( stack === undefined ) ? '' : stack + '.';
for ( property in obj ) {
if ( obj.hasOwnProperty( property ) ) {
if ( $.type( obj[ property ] ) === 'object' || $.type( obj[ property ] ) === 'array' ) {
ve.dm.MWGraphModel.static.getUndefinedProperties( obj[ property ], stack + property, list );
} else if ( obj[ property ] === undefined ) {
list.push( stack + property );
}
}
}
return list;
};
/**
* Removes a nested property from an object
*
* @param {Object} obj The object
* @param {Array} prop The path of the property to remove
*/
ve.dm.MWGraphModel.static.removeProperty = function ( obj, prop ) {
var firstProp = prop.shift();
try {
if ( prop.length > 0 ) {
ve.dm.MWGraphModel.static.removeProperty( obj[ firstProp ], prop );
} else {
if ( $.type( obj ) === 'array' ) {
obj.splice( parseInt( firstProp ), 1 );
} else {
delete obj[ firstProp ];
}
}
} catch ( err ) {
// We don't need to bubble errors here since hitting a missing property
// will not exist anyway in the object anyway
}
};
/**
* Check if a spec currently has something in its dataset
*
* @param {Object} spec The spec
* @return {boolean} The spec has some data in its dataset
*/
ve.dm.MWGraphModel.static.specHasData = function ( spec ) {
// FIXME: Support multiple pipelines
return !!spec.data[ 0 ].values.length;
};
/* Methods */
/**
* Switch the graph to a different type
*
* @param {string} type Desired graph type. Can be either area, line or bar.
* @fires specChange
*/
ve.dm.MWGraphModel.prototype.switchGraphType = function ( type ) {
var params = {
scales: [ ve.copy( this.constructor.static.graphConfigs[ type ].scale ) ],
marks: [ ve.copy( this.constructor.static.graphConfigs[ type ].mark ) ]
};
this.updateSpec( params );
this.emit( 'specChange', this.spec );
};
/**
* Apply changes to the node
*
* @param {ve.dm.MWGraphNode} node The node to be modified
* @param {ve.dm.Surface} surfaceModel The surface model for the document
*/
ve.dm.MWGraphModel.prototype.applyChanges = function ( node, surfaceModel ) {
var mwData = ve.copy( node.getAttribute( 'mw' ) );
// Send transaction
mwData.body.extsrc = this.getSpecString();
surfaceModel.change(
ve.dm.TransactionBuilder.static.newFromAttributeChanges(
surfaceModel.getDocument(),
node.getOffset(),
{ mw: mwData }
)
);
surfaceModel.applyStaging();
};
/**
* Update the spec with new parameters
*
* @param {Object} params The new parameters to be updated in the spec
* @fires specChange
*/
ve.dm.MWGraphModel.prototype.updateSpec = function ( params ) {
var updatedSpec = ve.dm.MWGraphModel.static.updateSpec( $.extend( true, {}, this.spec ), params );
// Only emit a change event if the spec really changed
if ( !OO.compare( this.spec, updatedSpec ) ) {
this.spec = updatedSpec;
this.emit( 'specChange', this.spec );
}
};
/**
* Sets and validates the specification from a stringified version
*
* @param {string} str The new specification string
* @fires specChange
*/
ve.dm.MWGraphModel.prototype.setSpecFromString = function ( str ) {
var newSpec = ve.dm.MWGraphNode.static.parseSpecString( str );
// Only apply changes if the new spec is valid JSON and if the
// spec truly was modified
if ( !OO.compare( this.spec, newSpec ) ) {
this.spec = newSpec;
this.emit( 'specChange', this.spec );
}
};
/**
* Get the specification
*
* @return {Object} The specification
*/
ve.dm.MWGraphModel.prototype.getSpec = function () {
return this.spec;
};
/**
* Get the stringified specification
*
* @return {string} The specification string
*/
ve.dm.MWGraphModel.prototype.getSpecString = function () {
return ve.dm.MWGraphNode.static.stringifySpec( this.spec );
};
/**
* Get the original stringified specificiation
*
* @return {string} The original JSON string specification
*/
ve.dm.MWGraphModel.prototype.getOriginalSpecString = function () {
return ve.dm.MWGraphNode.static.stringifySpec( this.originalSpec );
};
/**
* Get the graph type
*
* @return {string} The graph type
*/
ve.dm.MWGraphModel.prototype.getGraphType = function () {
var markType = this.spec.marks[ 0 ].type;
switch ( markType ) {
case 'area':
return 'area';
case 'rect':
return 'bar';
case 'line':
return 'line';
default:
return 'unknown';
}
};
/**
* Get graph size
*
* @return {Object} The graph width and height
*/
ve.dm.MWGraphModel.prototype.getSize = function () {
return {
width: this.spec.width,
height: this.spec.height
};
};
/**
* Set the graph width
*
* @param {number} value The new width
* @fires specChange
*/
ve.dm.MWGraphModel.prototype.setWidth = function ( value ) {
this.spec.width = value;
this.emit( 'specChange', this.spec );
};
/**
* Set the graph height
*
* @param {number} value The new height
* @fires specChange
*/
ve.dm.MWGraphModel.prototype.setHeight = function ( value ) {
this.spec.height = value;
this.emit( 'specChange', this.spec );
};
/**
* Get the padding values of the graph
*
* @return {Object} The paddings
*/
ve.dm.MWGraphModel.prototype.getPaddingObject = function () {
return this.spec.padding;
};
/**
* Return the default padding
*
* @return {Object} The default padding values
*/
ve.dm.MWGraphModel.prototype.getDefaultPaddingObject = function () {
var i,
indexes = [ 'top', 'bottom', 'left', 'right' ],
paddingObj = {};
for ( i = 0; i < indexes.length; i++ ) {
paddingObj[ indexes[ i ] ] = ve.dm.MWGraphModel.static.defaultPadding;
}
return paddingObj;
};
/**
* Set a padding value
*
* @param {string} index The index to change. Can be either top, right, bottom or right
* @param {number} value The new value
* @fires specChange
*/
ve.dm.MWGraphModel.prototype.setPadding = function ( index, value ) {
if ( this.isPaddingAutomatic() ) {
this.spec.padding = this.getDefaultPaddingObject();
}
this.spec.padding[ index ] = value;
this.emit( 'specChange', this.spec );
};
/**
* Toggles automatic and manual padding modes
*
* @param {boolean} auto Padding is now automatic
* @fires specChange
*/
ve.dm.MWGraphModel.prototype.setPaddingAuto = function ( auto ) {
if ( auto ) {
this.cachedPadding = ve.copy( this.spec.padding ) || this.getDefaultPaddingObject();
ve.dm.MWGraphModel.static.removeProperty( this.spec, [ 'padding' ] );
} else {
this.spec.padding = ve.copy( this.cachedPadding );
}
this.emit( 'specChange', this.spec );
};
/**
* Get the fields for a data pipeline
*
* @param {number} [id] The pipeline's id
* @return {string[]} The fields for the pipeline
*/
ve.dm.MWGraphModel.prototype.getPipelineFields = function ( id ) {
var firstEntry = ve.getProp( this.spec, 'data', id, 'values', 0 );
// Get the fields directly from the pipeline data if the pipeline exists and
// has data, otherwise default back on the fields intended for this graph type
if ( firstEntry ) {
return Object.keys( firstEntry );
} else {
return ve.dm.MWGraphModel.static.graphConfigs[ this.getGraphType() ].fields;
}
};
/**
* Get a data pipeline
*
* @param {number} [id] The pipeline's id
* @return {Object} The data pipeline within the spec
*/
ve.dm.MWGraphModel.prototype.getPipeline = function ( id ) {
return this.spec.data[ id ];
};
/**
* Set the field value of an entry in a pipeline
*
* @param {number} [entry] ID of the entry
* @param {string} [field] The field to change
* @param {number} [value] The new value
* @fires specChange
*/
ve.dm.MWGraphModel.prototype.setEntryField = function ( entry, field, value ) {
if ( this.spec.data[ 0 ].values[ entry ] === undefined ) {
this.spec.data[ 0 ].values[ entry ] = this.buildNewEntry( 0 );
}
this.spec.data[ 0 ].values[ entry ][ field ] = value;
this.emit( 'specChange', this.spec );
};
/**
* Builds and returns a new entry for a pipeline
*
* @private
* @param {number} [pipelineId] The ID of the pipeline the entry is intended for
* @return {Object} The new entry
*/
ve.dm.MWGraphModel.prototype.buildNewEntry = function ( pipelineId ) {
var fields = this.getPipelineFields( pipelineId ),
newEntry = {},
i;
for ( i = 0; i < fields.length; i++ ) {
newEntry[ fields[ i ] ] = '';
}
return newEntry;
};
/**
* Removes an entry from a pipeline
*
* @param {number} [index] The index of the entry to delete
* @fires specChange
*/
ve.dm.MWGraphModel.prototype.removeEntry = function ( index ) {
// FIXME: Support multiple pipelines
this.spec.data[ 0 ].values.splice( index, 1 );
this.emit( 'specChange', this.spec );
};
/**
* Returns whether the current spec has been modified since the dialog was opened
*
* @return {boolean} The spec was changed
*/
ve.dm.MWGraphModel.prototype.hasBeenChanged = function () {
return !OO.compare( this.spec, this.originalSpec );
};
/**
* Returns whether the padding is set to be automatic or not
*
* @return {boolean} The padding is automatic
*/
ve.dm.MWGraphModel.prototype.isPaddingAutomatic = function () {
return OO.compare( this.spec.padding, undefined );
};

View File

@@ -0,0 +1,264 @@
/*!
* VisualEditor DataModel MWGraphNode class.
*
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* DataModel MediaWiki graph node.
*
* @class
* @extends ve.dm.MWBlockExtensionNode
* @mixins ve.dm.ResizableNode
*
* @constructor
* @param {Object} [element]
*/
ve.dm.MWGraphNode = function VeDmMWGraphNode() {
var mw, extsrc;
// Parent constructor
ve.dm.MWGraphNode.super.apply( this, arguments );
// Mixin constructors
ve.dm.ResizableNode.call( this );
// Properties
this.spec = null;
// Events
this.connect( this, {
attributeChange: 'onAttributeChange'
} );
// Initialize specificiation
mw = this.getAttribute( 'mw' );
extsrc = ve.getProp( mw, 'body', 'extsrc' );
if ( extsrc ) {
this.setSpecFromString( extsrc );
} else {
this.setSpec( ve.dm.MWGraphNode.static.defaultSpec );
}
};
/* Inheritance */
OO.inheritClass( ve.dm.MWGraphNode, ve.dm.MWBlockExtensionNode );
OO.mixinClass( ve.dm.MWGraphNode, ve.dm.ResizableNode );
/* Static Members */
ve.dm.MWGraphNode.static.name = 'mwGraph';
ve.dm.MWGraphNode.static.extensionName = 'graph';
ve.dm.MWGraphNode.static.defaultSpec = {
version: 2,
width: 400,
height: 200,
data: [
{
name: 'table',
values: [
{
x: 0,
y: 1
},
{
x: 1,
y: 3
},
{
x: 2,
y: 2
},
{
x: 3,
y: 4
}
]
}
],
scales: [
{
name: 'x',
type: 'linear',
range: 'width',
zero: false,
domain: {
data: 'table',
field: 'x'
}
},
{
name: 'y',
type: 'linear',
range: 'height',
nice: true,
domain: {
data: 'table',
field: 'y'
}
}
],
axes: [
{
type: 'x',
scale: 'x'
},
{
type: 'y',
scale: 'y'
}
],
marks: [
{
type: 'area',
from: {
data: 'table'
},
properties: {
enter: {
x: {
scale: 'x',
field: 'x'
},
y: {
scale: 'y',
field: 'y'
},
y2: {
scale: 'y',
value: 0
},
fill: {
value: 'steelblue'
},
interpolate: {
value: 'monotone'
}
}
}
}
]
};
/* Static Methods */
/**
* Parses a spec string and returns its object representation.
*
* @param {string} str The spec string to validate. If the string is null or represents an empty object, the spec will be null.
* @return {Object} The object specification. On a failed parsing, the object will be returned empty.
*/
ve.dm.MWGraphNode.static.parseSpecString = function ( str ) {
var result;
try {
result = JSON.parse( str );
// JSON.parse can return other types than Object, we don't want that
// The error will be caught just below as this counts as a failed process
if ( $.type( result ) !== 'object' ) {
throw new Error();
}
return result;
} catch ( err ) {
return {};
}
};
/**
* Return the indented string representation of a spec.
*
* @param {Object} spec The object specificiation.
* @return {string} The stringified version of the spec.
*/
ve.dm.MWGraphNode.static.stringifySpec = function ( spec ) {
var result = JSON.stringify( spec, null, '\t' );
return result || '';
};
/* Methods */
/**
* @inheritdoc
*/
ve.dm.MWGraphNode.prototype.createScalable = function () {
var width = ve.getProp( this.spec, 'width' ),
height = ve.getProp( this.spec, 'height' );
return new ve.dm.Scalable( {
currentDimensions: {
width: width,
height: height
},
minDimensions: ve.dm.MWGraphModel.static.minDimensions,
fixedRatio: false
} );
};
/**
* Get the specification string
*
* @return {string} The specification JSON string
*/
ve.dm.MWGraphNode.prototype.getSpecString = function () {
return this.constructor.static.stringifySpec( this.spec );
};
/**
* Get the parsed JSON specification
*
* @return {Object} The specification object
*/
ve.dm.MWGraphNode.prototype.getSpec = function () {
return this.spec;
};
/**
* Set the specificiation
*
* @param {Object} spec The new spec
*/
ve.dm.MWGraphNode.prototype.setSpec = function ( spec ) {
// Consolidate all falsy values to an empty object for consistency
this.spec = spec || {};
};
/**
* Set the specification from a stringified version
*
* @param {string} str The new specification JSON string
*/
ve.dm.MWGraphNode.prototype.setSpecFromString = function ( str ) {
this.setSpec( this.constructor.static.parseSpecString( str ) );
};
/**
* React to node attribute changes
*
* @param {string} attributeName The attribute being updated
* @param {Object} from The old value of the attribute
* @param {Object} to The new value of the attribute
*/
ve.dm.MWGraphNode.prototype.onAttributeChange = function ( attributeName, from, to ) {
if ( attributeName === 'mw' ) {
this.setSpecFromString( to.body.extsrc );
}
};
/**
* Is this graph using a legacy version of Vega?
*
* @return {boolean}
*/
ve.dm.MWGraphNode.prototype.isGraphLegacy = function () {
return !!( this.spec && this.spec.hasOwnProperty( 'version' ) && this.spec.version < 2 );
};
/* Registration */
ve.dm.modelRegistry.register( ve.dm.MWGraphNode );

View File

@@ -0,0 +1,609 @@
/*!
* VisualEditor UserInterface MWGraphDialog class.
*
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* MediaWiki graph dialog.
*
* @class
* @extends ve.ui.MWExtensionDialog
*
* @constructor
* @param {Object} [element]
*/
ve.ui.MWGraphDialog = function VeUiMWGraphDialog() {
// Parent constructor
ve.ui.MWGraphDialog.super.apply( this, arguments );
// Properties
this.graphModel = null;
this.mode = '';
this.cachedRawData = null;
this.listeningToInputChanges = true;
};
/* Inheritance */
OO.inheritClass( ve.ui.MWGraphDialog, ve.ui.MWExtensionDialog );
/* Static properties */
ve.ui.MWGraphDialog.static.name = 'graph';
ve.ui.MWGraphDialog.static.title = OO.ui.deferMsg( 'graph-ve-dialog-edit-title' );
ve.ui.MWGraphDialog.static.size = 'large';
ve.ui.MWGraphDialog.static.actions = [
{
action: 'done',
label: OO.ui.deferMsg( 'visualeditor-dialog-action-done' ),
flags: [ 'progressive', 'primary' ],
modes: 'edit'
},
{
action: 'done',
label: OO.ui.deferMsg( 'visualeditor-dialog-action-insert' ),
flags: [ 'constructive', 'primary' ],
modes: 'insert'
},
{
label: OO.ui.deferMsg( 'visualeditor-dialog-action-cancel' ),
flags: 'safe',
modes: [ 'edit', 'insert' ]
}
];
ve.ui.MWGraphDialog.static.modelClasses = [ ve.dm.MWGraphNode ];
/* Methods */
/**
* @inheritdoc
*/
ve.ui.MWGraphDialog.prototype.getBodyHeight = function () {
// FIXME: This should depend on the dialog's content.
return 500;
};
/**
* @inheritdoc
*/
ve.ui.MWGraphDialog.prototype.initialize = function () {
var graphTypeField,
sizeFieldset,
paddingAutoField, paddingFieldset,
jsonTextField;
// Parent method
ve.ui.MWGraphDialog.super.prototype.initialize.call( this );
/* Root layout */
this.rootLayout = new OO.ui.BookletLayout( {
classes: [ 've-ui-mwGraphDialog-panel-root' ],
outlined: true
} );
this.generalPage = new OO.ui.PageLayout( 'general' );
this.dataPage = new OO.ui.PageLayout( 'data' );
this.rawPage = new OO.ui.PageLayout( 'raw' );
this.rootLayout.addPages( [
this.generalPage, this.dataPage, this.rawPage
] );
/* General page */
this.generalPage.getOutlineItem()
.setIcon( 'parameter' )
.setLabel( ve.msg( 'graph-ve-dialog-edit-page-general' ) );
this.graphTypeDropdownInput = new OO.ui.DropdownInputWidget();
graphTypeField = new OO.ui.FieldLayout( this.graphTypeDropdownInput, {
label: ve.msg( 'graph-ve-dialog-edit-field-graph-type' )
} );
this.unknownGraphTypeWarningLabel = new OO.ui.LabelWidget( {
label: ve.msg( 'graph-ve-dialog-edit-unknown-graph-type-warning' )
} );
this.sizeWidget = new ve.ui.MediaSizeWidget( null, {
noDefaultDimensions: true,
noOriginalDimensions: true
} );
sizeFieldset = new OO.ui.FieldsetLayout( {
label: ve.msg( 'graph-ve-dialog-edit-size-fieldset' )
} );
sizeFieldset.addItems( [ this.sizeWidget ] );
this.paddingAutoCheckbox = new OO.ui.CheckboxInputWidget( {
value: 'paddingAuto'
} );
paddingAutoField = new OO.ui.FieldLayout( this.paddingAutoCheckbox, {
label: ve.msg( 'graph-ve-dialog-edit-padding-auto' )
} );
this.paddingTable = new ve.ui.TableWidget( {
rows: [
{
key: 'top',
label: ve.msg( 'graph-ve-dialog-edit-padding-table-top' )
},
{
key: 'bottom',
label: ve.msg( 'graph-ve-dialog-edit-padding-table-bottom' )
},
{
key: 'left',
label: ve.msg( 'graph-ve-dialog-edit-padding-table-left' )
},
{
key: 'right',
label: ve.msg( 'graph-ve-dialog-edit-padding-table-right' )
}
],
cols: [
{
key: 'value'
}
],
validate: /^[0-9]+$/,
showHeaders: false,
allowRowInsertion: false,
allowRowDeletion: false
} );
paddingFieldset = new OO.ui.FieldsetLayout( {
label: ve.msg( 'graph-ve-dialog-edit-padding-fieldset' )
} );
paddingFieldset.addItems( [
paddingAutoField,
this.paddingTable
] );
this.generalPage.$element.append(
graphTypeField.$element,
this.unknownGraphTypeWarningLabel.$element,
sizeFieldset.$element,
paddingFieldset.$element
);
/* Data page */
this.dataPage.getOutlineItem()
.setIcon( 'parameter' )
.setLabel( ve.msg( 'graph-ve-dialog-edit-page-data' ) );
this.dataTable = new ve.ui.TableWidget( {
validate: /^[0-9]+$/,
showRowLabels: false
} );
this.dataPage.$element.append( this.dataTable.$element );
/* Raw JSON page */
this.rawPage.getOutlineItem()
.setIcon( 'code' )
.setLabel( ve.msg( 'graph-ve-dialog-edit-page-raw' ) );
this.jsonTextInput = new ve.ui.MWAceEditorWidget( {
autosize: true,
classes: [ 've-ui-mwGraphDialog-json' ],
maxRows: 22,
validate: this.validateRawData
} );
// Make sure JSON is LTR
this.jsonTextInput
.setLanguage( 'json' )
.toggleLineNumbers( false )
.setDir( 'ltr' );
jsonTextField = new OO.ui.FieldLayout( this.jsonTextInput, {
label: ve.msg( 'graph-ve-dialog-edit-field-raw-json' ),
align: 'top'
} );
this.rawPage.$element.append( jsonTextField.$element );
// Events
this.rootLayout.connect( this, { set: 'onRootLayoutSet' } );
this.graphTypeDropdownInput.connect( this, { change: 'onGraphTypeInputChange' } );
this.sizeWidget.connect( this, { change: 'onSizeWidgetChange' } );
this.paddingAutoCheckbox.connect( this, { change: 'onPaddingAutoCheckboxChange' } );
this.paddingTable.connect( this, {
change: 'onPaddingTableChange'
} );
this.dataTable.connect( this, {
change: 'onDataInputChange',
removeRow: 'onDataInputRowDelete'
} );
this.jsonTextInput.connect( this, { change: 'onSpecStringInputChange' } );
// Initialization
this.$body.append( this.rootLayout.$element );
};
/**
* @inheritdoc
*/
ve.ui.MWGraphDialog.prototype.getSetupProcess = function ( data ) {
return ve.ui.MWGraphDialog.super.prototype.getSetupProcess.call( this, data )
.next( function () {
var spec, newElement;
this.getFragment().getSurface().pushStaging();
// Create new graph node if not present (insert mode)
if ( !this.selectedNode ) {
this.setMode( 'insert' );
newElement = this.getNewElement();
this.fragment = this.getFragment().insertContent( [
newElement,
{ type: '/' + newElement.type }
] );
this.getFragment().select();
this.selectedNode = this.getFragment().getSelectedNode();
} else {
this.setMode( 'edit' );
}
// Set up model
spec = ve.copy( this.selectedNode.getSpec() );
this.graphModel = new ve.dm.MWGraphModel( spec );
this.graphModel.connect( this, {
specChange: 'onSpecChange'
} );
// Set up default values
this.setupFormValues();
// If parsing fails here, cached raw data can simply remain null
try {
this.cachedRawData = JSON.parse( this.jsonTextInput.getValue() );
} catch ( err ) {}
this.checkChanges();
}, this );
};
/**
* @inheritdoc
*/
ve.ui.MWGraphDialog.prototype.getTeardownProcess = function ( data ) {
return ve.ui.MWGraphDialog.super.prototype.getTeardownProcess.call( this, data )
.first( function () {
// Kill model
this.graphModel.disconnect( this );
this.graphModel = null;
// Clear data page
this.dataTable.clearWithProperties();
// Kill staging
if ( data === undefined ) {
this.getFragment().getSurface().popStaging();
this.getFragment().update( this.getFragment().getSurface().getSelection() );
}
}, this );
};
/**
* @inheritdoc
*/
ve.ui.MWGraphDialog.prototype.getActionProcess = function ( action ) {
switch ( action ) {
case 'done':
return new OO.ui.Process( function () {
this.graphModel.applyChanges( this.selectedNode, this.getFragment().getSurface() );
this.close( { action: action } );
}, this );
default:
return ve.ui.MWGraphDialog.super.prototype.getActionProcess.call( this, action );
}
};
/**
* Setup initial values in the dialog
*
* @private
*/
ve.ui.MWGraphDialog.prototype.setupFormValues = function () {
var graphType = this.graphModel.getGraphType(),
graphSize = this.graphModel.getSize(),
paddings = this.graphModel.getPaddingObject(),
options = [
{
data: 'bar',
label: ve.msg( 'graph-ve-dialog-edit-type-bar' )
},
{
data: 'area',
label: ve.msg( 'graph-ve-dialog-edit-type-area' )
},
{
data: 'line',
label: ve.msg( 'graph-ve-dialog-edit-type-line' )
}
],
unknownGraphTypeOption = {
data: 'unknown',
label: ve.msg( 'graph-ve-dialog-edit-type-unknown' )
},
dataFields = this.graphModel.getPipelineFields( 0 ),
padding, i;
// Graph type
if ( graphType === 'unknown' ) {
options.push( unknownGraphTypeOption );
}
this.graphTypeDropdownInput
.setOptions( options )
.setValue( graphType );
// Size
this.sizeWidget.setScalable( new ve.dm.Scalable( {
currentDimensions: {
width: graphSize.width,
height: graphSize.height
},
minDimensions: ve.dm.MWGraphModel.static.minDimensions,
fixedRatio: false
} ) );
// Padding
this.paddingAutoCheckbox.setSelected( this.graphModel.isPaddingAutomatic() );
for ( padding in paddings ) {
if ( paddings.hasOwnProperty( padding ) ) {
this.paddingTable.setValue( padding, 0, paddings[ padding ] );
}
}
// Data
for ( i = 0; i < dataFields.length; i++ ) {
this.dataTable.insertColumn( null, null, dataFields[ i ], dataFields[ i ] );
}
this.updateDataPage();
// JSON text input
this.jsonTextInput.setValue( this.graphModel.getSpecString() ).clearUndoStack();
};
/**
* Update data page widgets based on the current spec
*/
ve.ui.MWGraphDialog.prototype.updateDataPage = function () {
var pipeline = this.graphModel.getPipeline( 0 ),
i, row, field;
for ( i = 0; i < pipeline.values.length; i++ ) {
row = [];
for ( field in pipeline.values[ i ] ) {
if ( pipeline.values[ i ].hasOwnProperty( field ) ) {
row.push( pipeline.values[ i ][ field ] );
}
}
this.dataTable.insertRow( row );
}
};
/**
* Validate raw data input
*
* @private
* @param {string} value The new input value
* @return {boolean} Data is valid
*/
ve.ui.MWGraphDialog.prototype.validateRawData = function ( value ) {
var isValid = !$.isEmptyObject( ve.dm.MWGraphNode.static.parseSpecString( value ) ),
label = ( isValid ) ? '' : ve.msg( 'graph-ve-dialog-edit-json-invalid' );
this.setLabel( label );
return isValid;
};
/**
* Handle spec string input change
*
* @private
* @param {string} value The text input value
*/
ve.ui.MWGraphDialog.prototype.onSpecStringInputChange = function ( value ) {
var newRawData;
try {
// If parsing fails here, nothing more needs to happen
newRawData = JSON.parse( value );
// Only pass changes to model if there was anything worthwhile to change
if ( !OO.compare( this.cachedRawData, newRawData ) ) {
this.cachedRawData = newRawData;
this.graphModel.setSpecFromString( value );
}
} catch ( err ) {}
};
/**
* Handle graph type changes
*
* @param {string} value The new graph type
*/
ve.ui.MWGraphDialog.prototype.onGraphTypeInputChange = function ( value ) {
this.unknownGraphTypeWarningLabel.toggle( value === 'unknown' );
if ( value !== 'unknown' ) {
this.graphModel.switchGraphType( value );
}
};
/**
* Handle data input changes
*
* @private
* @param {number} rowIndex The index of the row that changed
* @param {string} rowKey The key of the row that changed, or `undefined` if it doesn't exist
* @param {number} colIndex The index of the column that changed
* @param {string} colKey The key of the column that changed, or `undefined` if it doesn't exist
* @param {string} value The new value
*/
ve.ui.MWGraphDialog.prototype.onDataInputChange = function ( rowIndex, rowKey, colIndex, colKey, value ) {
if ( !isNaN( value ) ) {
this.graphModel.setEntryField( rowIndex, colKey, +value );
}
};
/**
* Handle data input row deletions
*
* @param {number} [rowIndex] The index of the row deleted
*/
ve.ui.MWGraphDialog.prototype.onDataInputRowDelete = function ( rowIndex ) {
this.graphModel.removeEntry( rowIndex );
};
/**
* Handle page set events on the root layout
*
* @param {OO.ui.PageLayout} page Set page
*/
ve.ui.MWGraphDialog.prototype.onRootLayoutSet = function ( page ) {
if ( page.getName() === 'raw' ) {
// The raw data may have been changed while not visible,
// so recalculate height now it is visible.
// HACK: Invalidate value cache
this.jsonTextInput.valCache = null;
this.jsonTextInput.adjustSize( true );
this.setSize( 'larger' );
} else {
this.setSize( 'large' );
}
};
/**
* Handle auto padding mode changes
*
* @param {boolean} value New mode value
*/
ve.ui.MWGraphDialog.prototype.onPaddingAutoCheckboxChange = function ( value ) {
this.graphModel.setPaddingAuto( value );
};
/**
* Handle size widget changes
*
* @param {Object} dimensions New dimensions
*/
ve.ui.MWGraphDialog.prototype.onSizeWidgetChange = function ( dimensions ) {
if ( this.sizeWidget.isValid() ) {
this.graphModel.setWidth( dimensions.width );
this.graphModel.setHeight( dimensions.height );
}
this.checkChanges();
};
/**
* Handle padding table data changes
*
* @param {number} rowIndex The index of the row that changed
* @param {string} rowKey The key of the row that changed, or `undefined` if it doesn't exist
* @param {number} colIndex The index of the column that changed
* @param {string} colKey The key of the column that changed, or `undefined` if it doesn't exist
* @param {string} value The new value
*/
ve.ui.MWGraphDialog.prototype.onPaddingTableChange = function ( rowIndex, rowKey, colIndex, colKey, value ) {
this.graphModel.setPadding( rowKey, parseInt( value ) );
};
/**
* Handle model spec change events
*
* @private
*/
ve.ui.MWGraphDialog.prototype.onSpecChange = function () {
var padding,
paddingAuto = this.graphModel.isPaddingAutomatic(),
paddingObj = this.graphModel.getPaddingObject();
if ( this.listeningToInputChanges ) {
this.listeningToInputChanges = false;
this.jsonTextInput.setValue( this.graphModel.getSpecString() );
if ( paddingAuto ) {
// Clear padding table if set to automatic
this.paddingTable.clear();
} else {
// Fill padding table with model values if set to manual
for ( padding in paddingObj ) {
if ( paddingObj.hasOwnProperty( padding ) ) {
this.paddingTable.setValue( padding, 0, paddingObj[ padding ] );
}
}
}
this.paddingTable.setDisabled( paddingAuto );
this.listeningToInputChanges = true;
this.checkChanges();
}
};
/**
* Check for overall validity and enables/disables action abilities accordingly
*
* @private
*/
ve.ui.MWGraphDialog.prototype.checkChanges = function () {
var dialog = this;
// Synchronous validation
if ( !this.sizeWidget.isValid() ) {
this.actions.setAbilities( { done: false } );
return;
}
// Asynchronous validation
this.jsonTextInput.getValidity().then(
function () {
dialog.actions.setAbilities( {
done: ( dialog.mode === 'insert' ) || dialog.graphModel.hasBeenChanged()
} );
},
function () {
dialog.actions.setAbilities( { done: false } );
}
);
};
/**
* Sets and caches the mode of the dialog.
*
* @private
* @param {string} mode The new mode, either `edit` or `insert`
*/
ve.ui.MWGraphDialog.prototype.setMode = function ( mode ) {
this.actions.setMode( mode );
this.mode = mode;
};
/* Registration */
ve.ui.windowFactory.register( ve.ui.MWGraphDialog );

View File

@@ -0,0 +1,39 @@
/**
* MediaWiki UserInterface graph tool.
*
* @class
* @extends ve.ui.FragmentWindowTool
* @constructor
* @param {OO.ui.ToolGroup} toolGroup
* @param {Object} [config] Configuration options
*/
ve.ui.MWGraphDialogTool = function VeUiMWGraphDialogTool() {
ve.ui.MWGraphDialogTool.super.apply( this, arguments );
};
/* Inheritance */
OO.inheritClass( ve.ui.MWGraphDialogTool, ve.ui.FragmentWindowTool );
/* Static properties */
ve.ui.MWGraphDialogTool.static.name = 'graph';
ve.ui.MWGraphDialogTool.static.group = 'object';
ve.ui.MWGraphDialogTool.static.icon = 'graph';
ve.ui.MWGraphDialogTool.static.title =
OO.ui.deferMsg( 'graph-ve-dialog-button-tooltip' );
ve.ui.MWGraphDialogTool.static.modelClasses = [ ve.dm.MWGraphNode ];
ve.ui.MWGraphDialogTool.static.commandName = 'graph';
/* Registration */
ve.ui.toolFactory.register( ve.ui.MWGraphDialogTool );
/* Commands */
ve.ui.commandRegistry.register(
new ve.ui.Command(
'graph', 'window', 'open',
{ args: [ 'graph' ], supportedSelections: [ 'linear' ] }
)
);

View File

@@ -0,0 +1,11 @@
/*!
* VisualEditor UserInterface Graph icon styles.
*
* @copyright 2011-2015 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
.oo-ui-icon-graph {
/* @embed */
background-image: url( graph.svg );
}

View File

@@ -0,0 +1,362 @@
/*!
* VisualEditor DataModel RowWidgetModel class
*
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* RowWidget model.
*
* @class
* @mixins OO.EventEmitter
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {Array} [data] An array containing all values of the row
* @cfg {Array} [keys] An array of keys for easy cell selection
* @cfg {RegExp|Function|string} [validate] Validation pattern to apply on every cell
* @cfg {string} [label=''] Row label. Defaults to empty string.
* @cfg {boolean} [showLabel=true] Show row label. Defaults to true.
* @cfg {boolean} [deletable=true] Allow row to be deleted. Defaults to true.
*/
ve.dm.RowWidgetModel = function VeDmRowWidgetModel( config ) {
config = config || {};
// Mixin constructors
OO.EventEmitter.call( this, config );
this.data = config.data || [];
this.validate = config.validate;
this.index = ( config.index !== undefined ) ? config.index : -1;
this.label = ( config.label !== undefined ) ? config.label : '';
this.showLabel = ( config.showLabel !== undefined ) ? !!config.showLabel : true;
this.isDeletable = ( config.deletable !== undefined ) ? !!config.deletable : true;
this.initializeProps( config.keys );
};
/* Inheritance */
OO.mixinClass( ve.dm.RowWidgetModel, OO.EventEmitter );
/* Events */
/**
* @event valueChange
*
* Fired when a value inside the row has changed.
*
* @param {number} The column index of the updated cell
* @param {number} The new value
*/
/**
* @event insertCell
*
* Fired when a new cell is inserted into the row.
*
* @param {Array} The initial data
* @param {number} The index in which to insert the new cell
*/
/**
* @event removeCell
*
* Fired when a cell is removed from the row.
*
* @param {number} The removed cell index
*/
/**
* @event clear
*
* Fired when the row is cleared
*
* @param {boolean} Clear cell properties
*/
/**
* @event labelUpdate
*
* Fired when the row label might need to be updated
*/
/* Methods */
/**
* Initializes and ensures the proper creation of the cell property array.
* If data exceeds the number of cells given, new ones will be created.
*
* @private
* @param {Array} props The initial cell props
*/
ve.dm.RowWidgetModel.prototype.initializeProps = function ( props ) {
var i, len;
this.cells = [];
if ( Array.isArray( props ) ) {
for ( i = 0, len = props.length; i < len; i++ ) {
this.cells.push( {
index: i,
key: props[ i ]
} );
}
}
};
/**
* Triggers the initialization process and builds the initial row.
*
* @fires insertCell
*/
ve.dm.RowWidgetModel.prototype.setupRow = function () {
this.verifyData();
this.buildRow();
};
/**
* Verifies if the table data is complete and synced with
* cell properties, and adds empty strings as cell data if
* cells are missing
*
* @private
*/
ve.dm.RowWidgetModel.prototype.verifyData = function () {
var i, len;
for ( i = 0, len = this.cells.length; i < len; i++ ) {
if ( this.data[ i ] === undefined ) {
this.data.push( '' );
}
}
};
/**
* Build initial row
*
* @private
* @fires insertCell
*/
ve.dm.RowWidgetModel.prototype.buildRow = function () {
var i, len;
for ( i = 0, len = this.cells.length; i < len; i++ ) {
this.emit( 'insertCell', this.data[ i ], i );
}
};
/**
* Refresh the entire row with new data
*
* @private
* @fires insertCell
*/
ve.dm.RowWidgetModel.prototype.refreshRow = function () {
// TODO: Clear existing table
this.buildRow();
};
/**
* Set the value of a particular cell
*
* @param {number|string} handle The index or key of the cell
* @param {mixed} value The new value
* @fires valueChange
*/
ve.dm.RowWidgetModel.prototype.setValue = function ( handle, value ) {
var index;
if ( typeof handle === 'number' ) {
index = handle;
} else if ( typeof handle === 'string' ) {
index = this.getCellProperties( handle ).index;
}
if ( typeof index === 'number' && this.data[ index ] !== undefined &&
this.data[ index ] !== value ) {
this.data[ index ] = value;
this.emit( 'valueChange', index, value );
}
};
/**
* Set the row data
*
* @param {Array} data The new row data
*/
ve.dm.RowWidgetModel.prototype.setData = function ( data ) {
if ( Array.isArray( data ) ) {
this.data = data;
this.verifyData();
this.refreshRow();
}
};
/**
* Set the row index
*
* @param {number} index The new row index
* @fires labelUpdate
*/
ve.dm.RowWidgetModel.prototype.setIndex = function ( index ) {
this.index = index;
this.emit( 'labelChange' );
};
/**
* Set the row label
*
* @param {number} label The new row label
* @fires labelUpdate
*/
ve.dm.RowWidgetModel.prototype.setLabel = function ( label ) {
this.label = label;
this.emit( 'labelChange' );
};
/**
* Inserts a row into the table. If the row isn't added at the end of the table,
* all the following data will be shifted back one row.
*
* @param {number|string} [data] The data to insert to the cell.
* @param {number} [index] The index in which to insert the new cell.
* If unset or set to null, the cell will be added at the end of the row.
* @param {string} [key] A key to quickly select this cell.
* If unset or set to null, no key will be set.
* @fires insertCell
*/
ve.dm.RowWidgetModel.prototype.insertCell = function ( data, index, key ) {
var insertIndex = ( typeof index === 'number' ) ? index : this.cells.length,
insertData, i, len;
// Add the new cell metadata
this.cells.splice( insertIndex, 0, {
index: insertIndex,
key: key || undefined
} );
// Add the new row data
insertData = ( $.type( data ) === 'string' || $.type( data ) === 'number' ) ? data : '';
this.data.splice( insertIndex, 0, insertData );
// Update all indexes in following cells
for ( i = insertIndex + 1, len = this.cells.length; i < len; i++ ) {
this.cells[ i ].index++;
}
this.emit( 'insertCell', data, insertIndex );
};
/**
* Removes a cell from the table. If the cell removed isn't at the end of the table,
* all the following cells will be shifted back one cell.
*
* @param {number|string} handle The key or numerical index of the cell to remove
* @fires removeCell
*/
ve.dm.RowWidgetModel.prototype.removeCell = function ( handle ) {
var cellProps = this.getCellProperties( handle ),
i, len;
// Exit early if the row couldn't be found
if ( cellProps === null ) {
return;
}
this.cells.splice( cellProps.index, 1 );
this.data.splice( cellProps.index, 1 );
// Update all indexes in following cells
for ( i = cellProps.index, len = this.cells.length; i < len; i++ ) {
this.cells[ i ].index--;
}
this.emit( 'removeCell', cellProps.index );
};
/**
* Clears the row data
*
* @fires clear
*/
ve.dm.RowWidgetModel.prototype.clear = function () {
this.data = [];
this.verifyData();
this.emit( 'clear', false );
};
/**
* Clears the row data, as well as all cell properties
*
* @fires clear
*/
ve.dm.RowWidgetModel.prototype.clearWithProperties = function () {
this.data = [];
this.cells = [];
this.emit( 'clear', true );
};
/**
* Get the validation pattern to test cells against
*
* @return {RegExp|Function|string}
*/
ve.dm.RowWidgetModel.prototype.getValidationPattern = function () {
return this.validate;
};
/**
* Get all row properties
*
* @return {Object}
*/
ve.dm.RowWidgetModel.prototype.getRowProperties = function () {
return {
index: this.index,
label: this.label,
showLabel: this.showLabel,
isDeletable: this.isDeletable
};
};
/**
* Get properties of a given cell
*
* @param {string|number} handle The key (or numeric index) of the cell
* @return {Object|null} An object containing the `key` and `index` properties of the cell.
* Returns `null` if the cell can't be found.
*/
ve.dm.RowWidgetModel.prototype.getCellProperties = function ( handle ) {
var cell = null,
i, len;
if ( typeof handle === 'string' ) {
for ( i = 0, len = this.cells.length; i < len; i++ ) {
if ( this.cells[ i ].key === handle ) {
cell = this.cells[ i ];
break;
}
}
} else if ( typeof handle === 'number' ) {
if ( handle < this.cells.length ) {
cell = this.cells[ handle ];
}
}
return cell;
};
/**
* Get properties of all cells
*
* @return {Array} An array of objects containing `key` and `index` properties for each cell
*/
ve.dm.RowWidgetModel.prototype.getAllCellProperties = function () {
return this.cells.slice();
};

View File

@@ -0,0 +1,515 @@
/*!
* VisualEditor DataModel TableWidgetModel class.
*
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* TableWidget model.
*
* @class
* @mixins OO.EventEmitter
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {Array} [rows] An array of objects containing `key` and `label` properties for every row
* @cfg {Array} [cols] An array of objects containing `key` and `label` properties for every column
* @cfg {Array} [data] An array containing all values of the table
* @cfg {RegExp|Function|string} [validate] Validation pattern to apply on every cell
* @cfg {boolean} [showHeaders=true] Show table header row. Defaults to true.
* @cfg {boolean} [showRowLabels=true] Show row labels. Defaults to true.
* @cfg {boolean} [allowRowInsertion=true] Allow row insertion. Defaults to true.
* @cfg {boolean} [allowRowDeletion=true] Allow row deletion. Defaults to true.
*/
ve.dm.TableWidgetModel = function VeDmTableWidgetModel( config ) {
config = config || {};
// Mixin constructors
OO.EventEmitter.call( this, config );
this.data = config.data || [];
this.validate = config.validate;
this.showHeaders = ( config.showHeaders !== undefined ) ? !!config.showHeaders : true;
this.showRowLabels = ( config.showRowLabels !== undefined ) ? !!config.showRowLabels : true;
this.allowRowInsertion = ( config.allowRowInsertion !== undefined ) ? !!config.allowRowInsertion : true;
this.allowRowDeletion = ( config.allowRowDeletion !== undefined ) ? !!config.allowRowDeletion : true;
this.initializeProps( config.rows, config.cols );
};
/* Inheritance */
OO.mixinClass( ve.dm.TableWidgetModel, OO.EventEmitter );
/* Static Methods */
/**
* Get an entry from a props table
*
* @static
* @private
* @param {string|number} handle The key (or numeric index) of the row/column
* @param {Array} table Props table
* @return {Object|null} An object containing the `key`, `index` and `label`
* properties of the row/column. Returns `null` if the row/column can't be found.
*/
ve.dm.TableWidgetModel.static.getEntryFromPropsTable = function ( handle, table ) {
var row = null,
i, len;
if ( typeof handle === 'string' ) {
for ( i = 0, len = table.length; i < len; i++ ) {
if ( table[ i ].key === handle ) {
row = table[ i ];
break;
}
}
} else if ( typeof handle === 'number' ) {
if ( handle < table.length ) {
row = table[ handle ];
}
}
return row;
};
/* Events */
/**
* @event valueChange
*
* Fired when a value inside the table has changed.
*
* @param {number} The row index of the updated cell
* @param {number} The column index of the updated cell
* @param {mixed} The new value
*/
/**
* @event insertRow
*
* Fired when a new row is inserted into the table.
*
* @param {Array} The initial data
* @param {number} The index in which to insert the new row
* @param {string} The row key
* @param {string} The row label
*/
/**
* @event insertColumn
*
* Fired when a new row is inserted into the table.
*
* @param {Array} The initial data
* @param {number} The index in which to insert the new column
* @param {string} The column key
* @param {string} The column label
*/
/**
* @event removeRow
*
* Fired when a row is removed from the table.
*
* @param {number} The removed row index
* @param {string} The removed row key
*/
/**
* @event removeColumn
*
* Fired when a column is removed from the table.
*
* @param {number} The removed column index
* @param {string} The removed column key
*/
/**
* @event clear
*
* Fired when the table data is wiped.
*
* @param {boolean} Clear row/column properties
*/
/* Methods */
/**
* Initializes and ensures the proper creation of the rows and cols property arrays.
* If data exceeds the number of rows and cols given, new ones will be created.
*
* @private
* @param {Array} rowProps The initial row props
* @param {Array} colProps The initial column props
*/
ve.dm.TableWidgetModel.prototype.initializeProps = function ( rowProps, colProps ) {
// FIXME: Account for extra data with missing row/col metadata
var i, len;
this.rows = [];
this.cols = [];
if ( Array.isArray( rowProps ) ) {
for ( i = 0, len = rowProps.length; i < len; i++ ) {
this.rows.push( {
index: i,
key: rowProps[ i ].key,
label: rowProps[ i ].label
} );
}
}
if ( Array.isArray( colProps ) ) {
for ( i = 0, len = colProps.length; i < len; i++ ) {
this.cols.push( {
index: i,
key: colProps[ i ].key,
label: colProps[ i ].label
} );
}
}
};
/**
* Triggers the initialization process and builds the initial table.
*
* @fires insertRow
*/
ve.dm.TableWidgetModel.prototype.setupTable = function () {
this.verifyData();
this.buildTable();
};
/**
* Verifies if the table data is complete and synced with
* row and column properties, and adds empty strings as
* cell data if cells are missing
*
* @private
*/
ve.dm.TableWidgetModel.prototype.verifyData = function () {
var i, j, rowLen, colLen;
for ( i = 0, rowLen = this.rows.length; i < rowLen; i++ ) {
if ( this.data[ i ] === undefined ) {
this.data.push( [] );
}
for ( j = 0, colLen = this.cols.length; j < colLen; j++ ) {
if ( this.data[ i ][ j ] === undefined ) {
this.data[ i ].push( '' );
}
}
}
};
/**
* Build initial table
*
* @private
* @fires insertRow
*/
ve.dm.TableWidgetModel.prototype.buildTable = function () {
var i, len;
for ( i = 0, len = this.rows.length; i < len; i++ ) {
this.emit( 'insertRow', this.data[ i ], i, this.rows[ i ].key, this.rows[ i ].label );
}
};
/**
* Refresh the entire table with new data
*
* @private
* @fires insertRow
*/
ve.dm.TableWidgetModel.prototype.refreshTable = function () {
// TODO: Clear existing table
this.buildTable();
};
/**
* Set the value of a particular cell
*
* @param {number|string} row The index or key of the row
* @param {number|string} col The index or key of the column
* @param {mixed} value The new value
* @fires valueChange
*/
ve.dm.TableWidgetModel.prototype.setValue = function ( row, col, value ) {
var rowIndex, colIndex;
if ( typeof row === 'number' ) {
rowIndex = row;
} else if ( typeof row === 'string' ) {
rowIndex = this.getRowProperties( row ).index;
}
if ( typeof col === 'number' ) {
colIndex = col;
} else if ( typeof col === 'string' ) {
colIndex = this.getColumnProperties( col ).index;
}
if ( typeof rowIndex === 'number' && typeof colIndex === 'number' &&
this.data[ rowIndex ] !== undefined && this.data[ rowIndex ][ colIndex ] !== undefined &&
this.data[ rowIndex ][ colIndex ] !== value ) {
this.data[ rowIndex ][ colIndex ] = value;
this.emit( 'valueChange', rowIndex, colIndex, value );
}
};
/**
* Set the table data
*
* @param {Array} data The new table data
*/
ve.dm.TableWidgetModel.prototype.setData = function ( data ) {
if ( Array.isArray( data ) ) {
this.data = data;
this.verifyData();
this.refreshTable();
}
};
/**
* Inserts a row into the table. If the row isn't added at the end of the table,
* all the following data will be shifted back one row.
*
* @param {Array} [data] The data to insert to the row.
* @param {number} [index] The index in which to insert the new row.
* If unset or set to null, the row will be added at the end of the table.
* @param {string} [key] A key to quickly select this row.
* If unset or set to null, no key will be set.
* @param {string} [label] A label to display next to the row.
* If unset or set to null, the key will be used if there is one.
* @fires insertRow
*/
ve.dm.TableWidgetModel.prototype.insertRow = function ( data, index, key, label ) {
var insertIndex = ( typeof index === 'number' ) ? index : this.rows.length,
newRowData = [],
insertData, insertDataCell, i, len;
// Add the new row metadata
this.rows.splice( insertIndex, 0, {
index: insertIndex,
key: key || undefined,
label: label || undefined
} );
// Add the new row data
insertData = ( Array.isArray( data ) ) ? data : [];
// Ensure that all columns of data for this row have been supplied,
// otherwise fill the remaining data with empty strings
for ( i = 0, len = this.cols.length; i < len; i++ ) {
insertDataCell = '';
if ( $.type( insertData[ i ] ) === 'string' || $.type( insertData[ i ] ) === 'number' ) {
insertDataCell = insertData[ i ];
}
newRowData.push( insertDataCell );
}
this.data.splice( insertIndex, 0, newRowData );
// Update all indexes in following rows
for ( i = insertIndex + 1, len = this.rows.length; i < len; i++ ) {
this.rows[ i ].index++;
}
this.emit( 'insertRow', data, insertIndex, key, label );
};
/**
* Inserts a column into the table. If the column isn't added at the end of the table,
* all the following data will be shifted back one column.
*
* @param {Array} [data] The data to insert to the column.
* @param {number} [index] The index in which to insert the new column.
* If unset or set to null, the column will be added at the end of the table.
* @param {string} [key] A key to quickly select this column.
* If unset or set to null, no key will be set.
* @param {string} [label] A label to display next to the column.
* If unset or set to null, the key will be used if there is one.
* @fires insertColumn
*/
ve.dm.TableWidgetModel.prototype.insertColumn = function ( data, index, key, label ) {
var insertIndex = ( typeof index === 'number' ) ? index : this.cols.length,
insertDataCell, insertData, i, len;
// Add the new column metadata
this.cols.splice( insertIndex, 0, {
index: insertIndex,
key: key || undefined,
label: label || undefined
} );
// Add the new column data
insertData = ( Array.isArray( data ) ) ? data : [];
// Ensure that all rows of data for this column have been supplied,
// otherwise fill the remaining data with empty strings
for ( i = 0, len = this.rows.length; i < len; i++ ) {
insertDataCell = '';
if ( $.type( insertData[ i ] ) === 'string' || $.type( insertData[ i ] ) === 'number' ) {
insertDataCell = insertData[ i ];
}
this.data[ i ].splice( insertIndex, 0, insertDataCell );
}
// Update all indexes in following cols
for ( i = insertIndex + 1, len = this.cols.length; i < len; i++ ) {
this.cols[ i ].index++;
}
this.emit( 'insertColumn', data, index, key, label );
};
/**
* Removes a row from the table. If the row removed isn't at the end of the table,
* all the following rows will be shifted back one row.
*
* @param {number|string} handle The key or numerical index of the row to remove
* @fires removeRow
*/
ve.dm.TableWidgetModel.prototype.removeRow = function ( handle ) {
var rowProps = this.getRowProperties( handle ),
i, len;
// Exit early if the row couldn't be found
if ( rowProps === null ) {
return;
}
this.rows.splice( rowProps.index, 1 );
this.data.splice( rowProps.index, 1 );
// Update all indexes in following rows
for ( i = rowProps.index, len = this.rows.length; i < len; i++ ) {
this.rows[ i ].index--;
}
this.emit( 'removeRow', rowProps.index, rowProps.key );
};
/**
* Removes a column from the table. If the column removed isn't at the end of the table,
* all the following columns will be shifted back one column.
*
* @param {number|string} handle The key or numerical index of the column to remove
* @fires removeColumn
*/
ve.dm.TableWidgetModel.prototype.removeColumn = function ( handle ) {
var colProps = this.getColumnProperties( handle ),
i, len;
// Exit early if the column couldn't be found
if ( colProps === null ) {
return;
}
this.cols.splice( colProps.index, 1 );
for ( i = 0, len = this.data.length; i < len; i++ ) {
this.data[ i ].splice( colProps.index, 1 );
}
// Update all indexes in following columns
for ( i = colProps.index, len = this.cols.length; i < len; i++ ) {
this.cols[ i ].index--;
}
this.emit( 'removeColumn', colProps.index, colProps.key );
};
/**
* Clears the table data
*
* @fires clear
*/
ve.dm.TableWidgetModel.prototype.clear = function () {
this.data = [];
this.verifyData();
this.emit( 'clear', false );
};
/**
* Clears the table data, as well as all row and column properties
*
* @fires clear
*/
ve.dm.TableWidgetModel.prototype.clearWithProperties = function () {
this.data = [];
this.rows = [];
this.cols = [];
this.emit( 'clear', true );
};
/**
* Get all table properties
*
* @return {Object}
*/
ve.dm.TableWidgetModel.prototype.getTableProperties = function () {
return {
showHeaders: this.showHeaders,
showRowLabels: this.showRowLabels,
allowRowInsertion: this.allowRowInsertion,
allowRowDeletion: this.allowRowDeletion
};
};
/**
* Get the validation pattern to test cells against
*
* @return {RegExp|Function|string}
*/
ve.dm.TableWidgetModel.prototype.getValidationPattern = function () {
return this.validate;
};
/**
* Get properties of a given row
*
* @param {string|number} handle The key (or numeric index) of the row
* @return {Object|null} An object containing the `key`, `index` and `label` properties of the row.
* Returns `null` if the row can't be found.
*/
ve.dm.TableWidgetModel.prototype.getRowProperties = function ( handle ) {
return ve.dm.TableWidgetModel.static.getEntryFromPropsTable( handle, this.rows );
};
/**
* Get properties of all rows
*
* @return {Array} An array of objects containing `key`, `index` and `label` properties for each row
*/
ve.dm.TableWidgetModel.prototype.getAllRowProperties = function () {
return this.rows.slice();
};
/**
* Get properties of a given column
*
* @param {string|number} handle The key (or numeric index) of the column
* @return {Object|null} An object containing the `key`, `index` and `label` properties of the column.
* Returns `null` if the column can't be found.
*/
ve.dm.TableWidgetModel.prototype.getColumnProperties = function ( handle ) {
return ve.dm.TableWidgetModel.static.getEntryFromPropsTable( handle, this.cols );
};
/**
* Get properties of all columns
*
* @return {Array} An array of objects containing `key`, `index` and `label` properties for each column
*/
ve.dm.TableWidgetModel.prototype.getAllColumnProperties = function () {
return this.cols.slice();
};

View File

@@ -0,0 +1,33 @@
.ve-ui-rowWidget {
clear: left;
float: left;
margin-bottom: -1px;
width: 100%;
}
.ve-ui-rowWidget-label {
display: block;
margin-right: 5%;
padding-top: 0.5em;
width: 35%;
}
.ve-ui-rowWidget > .ve-ui-rowWidget-label {
float: left;
}
.ve-ui-rowWidget > .ve-ui-rowWidget-cells {
float: left;
}
.ve-ui-rowWidget > .ve-ui-rowWidget-cells > .oo-ui-inputWidget {
float: left;
margin-right: -1px;
width: 8em;
}
.ve-ui-rowWidget > .ve-ui-rowWidget-cells > .oo-ui-inputWidget > input,
.ve-ui-rowWidget > .ve-ui-rowWidget-delete-button > .oo-ui-buttonElement-button {
margin: 0;
border-radius: 0;
}

View File

@@ -0,0 +1,333 @@
/**
* A RowWidget is used in conjunction with {@link ve.ui.TableWidget table widgets}
* and should not be instantiated by themselves. They group together
* {@link OO.ui.TextInputWidget text input widgets} to form a unified row of
* editable data.
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.GroupElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {Array} [data] The data of the cells
* @cfg {Array} [keys] An array of keys for easy cell selection
* @cfg {RegExp|Function|string} [validate] Validation pattern to apply on every cell
* @cfg {number} [index] The row index.
* @cfg {string} [label] The row label to display. If not provided, the row index will
* be used be default. If set to null, no label will be displayed.
* @cfg {boolean} [showLabel=true] Show row label. Defaults to true.
* @cfg {boolean} [deletable=true] Whether the table should provide deletion UI tools
* for this row or not. Defaults to true.
*/
ve.ui.RowWidget = function VeUiRowWidget( config ) {
config = config || {};
// Parent constructor
ve.ui.RowWidget.super.call( this, config );
// Mixin constructor
OO.ui.mixin.GroupElement.call( this, config );
// Set up model
this.model = new ve.dm.RowWidgetModel( config );
// Set up group element
this.setGroupElement(
$( '<div>' )
.addClass( 've-ui-rowWidget-cells' )
);
// Set up label
this.labelCell = new OO.ui.LabelWidget( {
classes: [ 've-ui-rowWidget-label' ]
} );
// Set up delete button
if ( this.model.getRowProperties().isDeletable ) {
this.deleteButton = new OO.ui.ButtonWidget( {
icon: { 'default': 'remove' },
classes: [ 've-ui-rowWidget-delete-button' ],
flags: 'destructive',
title: ve.msg( 'graph-ve-dialog-edit-table-row-delete' )
} );
}
// Events
this.model.connect( this, {
valueChange: 'onValueChange',
insertCell: 'onInsertCell',
removeCell: 'onRemoveCell',
clear: 'onClear',
labelUpdate: 'onLabelUpdate'
} );
this.aggregate( {
change: 'cellChange'
} );
this.connect( this, {
cellChange: 'onCellChange',
disable: 'onDisable'
} );
if ( this.model.getRowProperties().isDeletable ) {
this.deleteButton.connect( this, {
click: 'onDeleteButtonClick'
} );
}
// Initialization
this.$element.addClass( 've-ui-rowWidget' );
this.$element.append(
this.labelCell.$element,
this.$group
);
if ( this.model.getRowProperties().isDeletable ) {
this.$element.append( this.deleteButton.$element );
}
this.setLabel( this.model.getRowProperties().label );
this.model.setupRow();
};
/* Inheritance */
OO.inheritClass( ve.ui.RowWidget, OO.ui.Widget );
OO.mixinClass( ve.ui.RowWidget, OO.ui.mixin.GroupElement );
/* Events */
/**
* @event inputChange
*
* Change when an input contained within the row is updated
*
* @param {number} The index of the cell that changed
* @param {string} The new value of the cell
*/
/**
* @event deleteButtonClick
*
* Fired when the delete button for the row is pressed
*/
/* Methods */
/**
* @private
* @inheritdoc
*/
ve.ui.RowWidget.prototype.addItems = function ( items, index ) {
var i, len;
OO.ui.mixin.GroupElement.prototype.addItems.call( this, items, index );
for ( i = index, len = items.length; i < len; i++ ) {
items[ i ].setData( i );
}
};
/**
* @private
* @inheritdoc
*/
ve.ui.RowWidget.prototype.removeItems = function ( items ) {
var i, len, cells;
OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
cells = this.getItems();
for ( i = 0, len = cells.length; i < len; i++ ) {
cells[ i ].setData( i );
}
};
/**
* Get the row index
*
* @return {number} The row index
*/
ve.ui.RowWidget.prototype.getIndex = function () {
return this.model.getRowProperties().index;
};
/**
* Set the row index
*
* @param {number} index The new index
*/
ve.ui.RowWidget.prototype.setIndex = function ( index ) {
this.model.setIndex( index );
};
/**
* Get the label displayed on the row. If no custom label is set, the
* row index is used instead.
*
* @return {string} The row label
*/
ve.ui.RowWidget.prototype.getLabel = function () {
var props = this.model.getRowProperties();
if ( props.label === null ) {
return '';
} else if ( !props.label ) {
return props.index.toString();
} else {
return props.label;
}
};
/**
* Set the label to be displayed on the widget.
*
* @param {string} label The new label
* @fires labelUpdate
*/
ve.ui.RowWidget.prototype.setLabel = function ( label ) {
this.model.setLabel( label );
};
/**
* Set the value of a particular cell
*
* @param {number} index The cell index
* @param {string} value The new value
*/
ve.ui.RowWidget.prototype.setValue = function ( index, value ) {
this.model.setValue( index, value );
};
/**
* Insert a cell at a specified index
*
* @param {string} data The cell data
* @param {index} index The index to insert the cell at
* @param {string} key A key for easy cell selection
*/
ve.ui.RowWidget.prototype.insertCell = function ( data, index, key ) {
this.model.insertCell( data, index, key );
};
/**
* Removes a column at a specified index
*
* @param {number} index The index to removeColumn
*/
ve.ui.RowWidget.prototype.removeCell = function ( index ) {
this.model.removeCell( index );
};
/**
* Clear the field values
*/
ve.ui.RowWidget.prototype.clear = function () {
this.model.clear();
};
/**
* Handle model value changes
*
* @param {number} index The column index of the updated cell
* @param {number} value The new value
*
* @fires inputChange
*/
ve.ui.RowWidget.prototype.onValueChange = function ( index, value ) {
this.getItems()[ index ].setValue( value );
this.emit( 'inputChange', index, value );
};
/**
* Handle model cell insertions
*
* @param {string} data The initial data
* @param {number} index The index in which to insert the new cell
*/
ve.ui.RowWidget.prototype.onInsertCell = function ( data, index ) {
this.addItems( [
new OO.ui.TextInputWidget( {
data: index,
value: data,
validate: this.model.getValidationPattern()
} )
], index );
};
/**
* Handle model cell removals
*
* @param {number} index The removed cell index
*/
ve.ui.RowWidget.prototype.onRemoveCell = function ( index ) {
this.removeItems( [ index ] );
};
/**
* Handle clear requests
*/
ve.ui.RowWidget.prototype.onClear = function () {
var i, len,
cells = this.getItems();
for ( i = 0, len = cells.length; i < len; i++ ) {
cells[ i ].setValue( '' );
}
};
/**
* Update model label changes
*/
ve.ui.RowWidget.prototype.onLabelUpdate = function () {
this.labelCell.setLabel( this.getLabel() );
};
/**
* React to cell input change
*
* @private
* @param {OO.ui.TextInputWidget} input The input that fired the event
* @param {string} value The value of the input
*/
ve.ui.RowWidget.prototype.onCellChange = function ( input, value ) {
// FIXME: The table itself should know if it contains invalid data
// in order to pass form state to the dialog when it asks if the Apply
// button should be enabled or not. This probably requires the table
// and each individual row to handle validation through an array of promises
// fed from the cells within.
// Right now, the table can't know if it's valid or not because the events
// don't get passed through.
var self = this;
input.getValidity().done( function () {
self.model.setValue( input.getData(), value );
} );
};
/**
* Handle delete button clicks
*
* @private
* @fires deleteButtonClick
*/
ve.ui.RowWidget.prototype.onDeleteButtonClick = function () {
this.emit( 'deleteButtonClick' );
};
/**
* Handle disabled state changes
*
* @param {boolean} disabled The new disabled state
*/
ve.ui.RowWidget.prototype.onDisable = function ( disabled ) {
var i,
cells = this.getItems();
for ( i = 0; i < cells.length; i++ ) {
cells[ i ].setDisabled( disabled );
}
};

View File

@@ -0,0 +1,9 @@
.ve-ui-tableWidget > .ve-ui-tableWidget-rows {
float: left;
clear: left;
width: 100%;
}
.ve-ui-tableWidget.ve-ui-tableWidget-no-labels .ve-ui-rowWidget-label {
display: none;
}

View File

@@ -0,0 +1,579 @@
/**
* A TableWidget groups {@link ve.ui.RowWidget row widgets} together to form a bidimensional
* grid of text inputs.
*
* @class
* @extends OO.ui.Widget
* @mixins OO.ui.mixin.GroupElement
*
* @constructor
* @param {Object} [config] Configuration options
* @cfg {Array} [rows] An array of objects containing `key` and `label` properties for every row
* @cfg {Array} [cols] An array of objects containing `key` and `label` properties for every column
* @cfg {Array} [data] An array containing all values of the table
* @cfg {RegExp|Function|string} [validate] Validation pattern to apply on every cell
* @cfg {boolean} [showHeaders=true] Whether or not to show table headers. Defaults to true.
* @cfg {boolean} [showRowLabels=true] Whether or not to show row labels. Defaults to true.
* @cfg {boolean} [allowRowInsertion=true] Whether or not to enable row insertion. Defaults to true.
* @cfg {boolean} [allowRowDeletion=true] Allow row deletion. Defaults to true.
*/
ve.ui.TableWidget = function VeUiTableWidget( config ) {
var headerRowItems = [],
insertionRowItems = [],
columnProps, prop, i, len;
// Configuration initialization
config = config || {};
// Parent constructor
ve.ui.TableWidget.super.call( this, config );
// Mixin constructors
OO.ui.mixin.GroupElement.call( this, config );
// Set up model
this.model = new ve.dm.TableWidgetModel( config );
// Properties
this.listeningToInsertionRowChanges = true;
// Set up group element
this.setGroupElement(
$( '<div>' )
.addClass( 've-ui-tableWidget-rows' )
);
// Set up static rows
columnProps = this.model.getAllColumnProperties();
if ( this.model.getTableProperties().showHeaders ) {
this.headerRow = new ve.ui.RowWidget( {
deletable: false,
label: null
} );
for ( i = 0, len = columnProps.length; i < len; i++ ) {
prop = columnProps[ i ];
headerRowItems.push( new OO.ui.TextInputWidget( {
value: prop.label ? prop.label : ( prop.key ? prop.key : prop.index ),
// TODO: Allow editing of fields
disabled: true
} ) );
}
this.headerRow.addItems( headerRowItems );
}
if ( this.model.getTableProperties().allowRowInsertion ) {
this.insertionRow = new ve.ui.RowWidget( {
classes: 've-ui-rowWidget-insertionRow',
deletable: false,
label: null
} );
for ( i = 0, len = columnProps.length; i < len; i++ ) {
insertionRowItems.push( new OO.ui.TextInputWidget( {
data: columnProps[ i ].key ? columnProps[ i ].key : columnProps[ i ].index
} ) );
}
this.insertionRow.addItems( insertionRowItems );
}
// Set up initial rows
if ( Array.isArray( config.items ) ) {
this.addItems( config.items );
}
// Events
this.model.connect( this, {
valueChange: 'onValueChange',
insertRow: 'onInsertRow',
insertColumn: 'onInsertColumn',
removeRow: 'onRemoveRow',
removeColumn: 'onRemoveColumn',
clear: 'onClear'
} );
this.aggregate( {
inputChange: 'rowInputChange',
deleteButtonClick: 'rowDeleteButtonClick'
} );
this.connect( this, {
disable: 'onDisabledChange',
rowInputChange: 'onRowInputChange',
rowDeleteButtonClick: 'onRowDeleteButtonClick'
} );
if ( this.model.getTableProperties().allowRowInsertion ) {
this.insertionRow.connect( this, {
inputChange: 'onInsertionRowInputChange'
} );
}
// Initialization
this.$element.addClass( 've-ui-tableWidget' );
if ( this.model.getTableProperties().showHeaders ) {
this.$element.append( this.headerRow.$element );
}
this.$element.append( this.$group );
if ( this.model.getTableProperties().allowRowInsertion ) {
this.$element.append( this.insertionRow.$element );
}
this.$element.toggleClass(
've-ui-tableWidget-no-labels',
!this.model.getTableProperties().showRowLabels
);
this.model.setupTable();
};
/* Inheritance */
OO.inheritClass( ve.ui.TableWidget, OO.ui.Widget );
OO.mixinClass( ve.ui.TableWidget, OO.ui.mixin.GroupElement );
/* Static Properties */
ve.ui.TableWidget.static.patterns = {
validate: /^[0-9]+(\.[0-9]+)?$/,
filter: /[0-9]+(\.[0-9]+)?/
};
/* Events */
/**
* @event change
*
* Change when the data within the table has been updated.
*
* @param {number} The index of the row that changed
* @param {string} The key of the row that changed, or `undefined` if it doesn't exist
* @param {number} The index of the column that changed
* @param {string} The key of the column that changed, or `undefined` if it doesn't exist
* @param {string} The new value
*/
/**
* @event removeRow
*
* Fires when a row is removed from the table
*
* @param {number} The index of the row being deleted
* @param {string} The key of the row being deleted
*/
/* Methods */
/**
* Set the value of a particular cell
*
* @param {number|string} row The row containing the cell to edit. Can be either
* the row index or string key if one has been set for the row.
* @param {number|string} col The column containing the cell to edit. Can be either
* the column index or string key if one has been set for the column.
* @param {mixed} value The new value
*/
ve.ui.TableWidget.prototype.setValue = function ( row, col, value ) {
this.model.setValue( row, col, value );
};
/**
* Set the table data
*
* @param {Array} data The new table data
* @return {boolean} The data has been successfully changed
*/
ve.ui.TableWidget.prototype.setData = function ( data ) {
if ( !Array.isArray( data ) ) {
return false;
}
this.model.setData( data );
return true;
};
/**
* Inserts a row into the table. If the row isn't added at the end of the table,
* all the following data will be shifted back one row.
*
* @param {Array} [data] The data to insert to the row.
* @param {number} [index] The index in which to insert the new row.
* If unset or set to null, the row will be added at the end of the table.
* @param {string} [key] A key to quickly select this row.
* If unset or set to null, no key will be set.
* @param {string} [label] A label to display next to the row.
* If unset or set to null, the key will be used if there is one.
*/
ve.ui.TableWidget.prototype.insertRow = function ( data, index, key, label ) {
this.model.insertRow( data, index, key, label );
};
/**
* Inserts a column into the table. If the column isn't added at the end of the table,
* all the following data will be shifted back one column.
*
* @param {Array} [data] The data to insert to the column.
* @param {number} [index] The index in which to insert the new column.
* If unset or set to null, the column will be added at the end of the table.
* @param {string} [key] A key to quickly select this column.
* If unset or set to null, no key will be set.
* @param {string} [label] A label to display next to the column.
* If unset or set to null, the key will be used if there is one.
*/
ve.ui.TableWidget.prototype.insertColumn = function ( data, index, key, label ) {
this.model.insertColumn( data, index, key, label );
};
/**
* Removes a row from the table. If the row removed isn't at the end of the table,
* all the following rows will be shifted back one row.
*
* @param {number|string} key The key or numerical index of the row to remove.
*/
ve.ui.TableWidget.prototype.removeRow = function ( key ) {
this.model.removeRow( key );
};
/**
* Removes a column from the table. If the column removed isn't at the end of the table,
* all the following columns will be shifted back one column.
*
* @param {number|string} key The key or numerical index of the column to remove.
*/
ve.ui.TableWidget.prototype.removeColumn = function ( key ) {
this.model.removeColumn( key );
};
/**
* Clears all values from the table, without wiping any row or column properties.
*/
ve.ui.TableWidget.prototype.clear = function () {
this.model.clear();
};
/**
* Clears the table data, as well as all row and column properties
*/
ve.ui.TableWidget.prototype.clearWithProperties = function () {
this.model.clearWithProperties();
};
/**
* Filter cell input once it is changed
*
* @param {string} value The input value
* @return {string} The filtered input
*/
ve.ui.TableWidget.prototype.filterCellInput = function ( value ) {
var matches = value.match( ve.ui.TableWidget.static.patterns.filter );
return ( Array.isArray( matches ) ) ? matches[ 0 ] : '';
};
/**
* @private
* @inheritdoc
*/
ve.ui.TableWidget.prototype.addItems = function ( items, index ) {
var i, len;
OO.ui.mixin.GroupElement.prototype.addItems.call( this, items, index );
for ( i = index, len = items.length; i < len; i++ ) {
items[ i ].setIndex( i );
}
};
/**
* @private
* @inheritdoc
*/
ve.ui.TableWidget.prototype.removeItems = function ( items ) {
var i, len, rows;
OO.ui.mixin.GroupElement.prototype.removeItems.call( this, items );
rows = this.getItems();
for ( i = 0, len = rows.length; i < len; i++ ) {
rows[ i ].setIndex( i );
}
};
/**
* Handle model value changes
*
* @private
* @param {number} row The row index of the changed cell
* @param {number} col The column index of the changed cell
* @param {mixed} value The new value
* @fires change
*/
ve.ui.TableWidget.prototype.onValueChange = function ( row, col, value ) {
var rowProps = this.model.getRowProperties( row ),
colProps = this.model.getColumnProperties( col );
this.getItems()[ row ].setValue( col, value );
this.emit( 'change', row, rowProps.key, col, colProps.key, value );
};
/**
* Handle model row insertions
*
* @private
* @param {Array} data The initial data
* @param {number} index The index in which the new row was inserted
* @param {string} key The row key
* @param {string} label The row label
* @fires change
*/
ve.ui.TableWidget.prototype.onInsertRow = function ( data, index, key, label ) {
var colProps = this.model.getAllColumnProperties(),
keys = [],
newRow, i, len;
for ( i = 0, len = colProps.length; i < len; i++ ) {
keys.push( ( colProps[ i ].key ) ? colProps[ i ].key : i );
}
newRow = new ve.ui.RowWidget( {
data: data,
keys: keys,
validate: this.model.getValidationPattern(),
label: label,
showLabel: this.model.getTableProperties().showRowLabels,
deletable: this.model.getTableProperties().allowRowDeletion
} );
// TODO: Handle index parameter. Right now all new rows are inserted at the end
this.addItems( [ newRow ] );
// If this is the first data being added, refresh headers and insertion row
if ( this.model.getAllRowProperties().length === 1 ) {
this.refreshTableMarginals();
}
for ( i = 0, len = data.length; i < len; i++ ) {
this.emit( 'change', index, key, i, colProps[ i ].key, data[ i ] );
}
};
/**
* Handle model column insertions
*
* @private
* @param {Array} data The initial data
* @param {number} index The index in which to insert the new column
* @param {string} key The row key
* @param {string} label The row label
*
* @fires change
*/
ve.ui.TableWidget.prototype.onInsertColumn = function ( data, index, key, label ) {
var tableProps = this.model.getTableProperties(),
items = this.getItems(),
rowProps = this.model.getAllRowProperties(),
i, len;
for ( i = 0, len = items.length; i < len; i++ ) {
items[ i ].insertCell( data[ i ], index, key );
this.emit( 'change', i, rowProps[ i ].key, index, key, data[ i ] );
}
if ( tableProps.showHeaders ) {
this.headerRow.addItems( [
new OO.ui.TextInputWidget( {
value: label || key || index,
// TODO: Allow editing of fields
disabled: true
} )
] );
}
if ( tableProps.handleRowInsertion ) {
this.insertionRow.addItems( [
new OO.ui.TextInputWidget( {
validate: this.model.getValidationPattern()
} )
] );
}
};
/**
* Handle model row removals
*
* @private
* @param {number} index The removed row index
* @param {string} key The removed row key
* @fires removeRow
*/
ve.ui.TableWidget.prototype.onRemoveRow = function ( index, key ) {
this.removeItems( [ this.getItems()[ index ] ] );
this.emit( 'removeRow', index, key );
};
/**
* Handle model column removals
*
* @private
* @param {number} index The removed column index
* @param {string} key The removed column key
* @fires removeColumn
*/
ve.ui.TableWidget.prototype.onRemoveColumn = function ( index, key ) {
var i, items = this.getItems();
for ( i = 0; i < items.length; i++ ) {
items[ i ].removeCell( index );
}
this.emit( 'removeColumn', index, key );
};
/**
* Handle model table clears
*
* @private
* @param {boolean} withProperties Clear row/column properties
*/
ve.ui.TableWidget.prototype.onClear = function ( withProperties ) {
var i, len, rows;
if ( withProperties ) {
this.removeItems( this.getItems() );
} else {
rows = this.getItems();
for ( i = 0, len = rows.length; i < len; i++ ) {
rows[ i ].clear();
}
}
};
/**
* React to input changes bubbled up from event aggregation
*
* @private
* @param {ve.ui.RowWidget} row The row that changed
* @param {number} colIndex The column index of the cell that changed
* @param {string} value The new value of the input
* @fires change
*/
ve.ui.TableWidget.prototype.onRowInputChange = function ( row, colIndex, value ) {
var items = this.getItems(),
i, len, rowIndex;
for ( i = 0, len = items.length; i < len; i++ ) {
if ( row === items[ i ] ) {
rowIndex = i;
break;
}
}
this.model.setValue( rowIndex, colIndex, value );
};
/**
* React to new row input changes
*
* @private
* @param {number} colIndex The column index of the input that fired the change
* @param {string} value The new row value
*/
ve.ui.TableWidget.prototype.onInsertionRowInputChange = function ( colIndex, value ) {
var insertionRowItems = this.insertionRow.getItems(),
newRowData = [],
i, len, lastRow;
if ( this.listeningToInsertionRowChanges ) {
for ( i = 0, len = insertionRowItems.length; i < len; i++ ) {
if ( i === colIndex ) {
newRowData.push( value );
} else {
newRowData.push( '' );
}
}
this.insertRow( newRowData );
// Focus newly inserted row
lastRow = this.getItems().slice( -1 )[ 0 ];
lastRow.getItems()[ colIndex ].focus();
// Reset insertion row
this.listeningToInsertionRowChanges = false;
this.insertionRow.clear();
this.listeningToInsertionRowChanges = true;
}
};
/**
* Handle row deletion input
*
* @private
* @param {ve.ui.RowWidget} row The row that asked for the deletion
*/
ve.ui.TableWidget.prototype.onRowDeleteButtonClick = function ( row ) {
var items = this.getItems(),
i = -1,
len;
for ( i = 0, len = items.length; i < len; i++ ) {
if ( items[ i ] === row ) {
break;
}
}
this.removeRow( i );
};
/**
* Handle disabled state changes
*
* @private
* @param {boolean} disabled The new state
*/
ve.ui.TableWidget.prototype.onDisabledChange = function ( disabled ) {
var rows = this.getItems(),
i;
for ( i = 0; i < rows.length; i++ ) {
rows[ i ].setDisabled( disabled );
}
};
/**
* Refresh table header and insertion row
*/
ve.ui.TableWidget.prototype.refreshTableMarginals = function () {
var tableProps = this.model.getTableProperties(),
columnProps = this.model.getAllColumnProperties(),
rowItems,
i, len;
if ( tableProps.showHeaders ) {
this.headerRow.removeItems( this.headerRow.getItems() );
rowItems = [];
for ( i = 0, len = columnProps.length; i < len; i++ ) {
rowItems.push( new OO.ui.TextInputWidget( {
value: columnProps[ i ].key ? columnProps[ i ].key : columnProps[ i ].index,
// TODO: Allow editing of fields
disabled: true
} ) );
}
this.headerRow.addItems( rowItems );
}
if ( tableProps.allowRowInsertion ) {
this.insertionRow.clear();
this.insertionRow.removeItems( this.insertionRow.getItems() );
for ( i = 0, len = columnProps.length; i < len; i++ ) {
this.insertionRow.insertCell( '', columnProps[ i ].index, columnProps[ i ].key );
}
}
};