1
0
mirror of https://github.com/flarum/core.git synced 2025-08-03 15:07:53 +02:00

Massive JavaScript cleanup

- Use JSX for templates
- Docblock/comment everything
- Mostly passes ESLint (still some work to do)
- Lots of renaming, refactoring, etc.

CSS hasn't been updated yet.
This commit is contained in:
Toby Zerner
2015-07-15 14:00:11 +09:30
parent 4480e0a83f
commit ab6c03c0cc
220 changed files with 9785 additions and 5919 deletions

61
js/lib/utils/ItemList.js Normal file
View File

@@ -0,0 +1,61 @@
class Item {
constructor(content, priority) {
this.content = content;
this.priority = priority;
}
}
/**
* The `ItemList` class collects items and then arranges them into an array
* by priority.
*/
export default class ItemList {
/**
* Add an item to the list.
*
* @param {String} key A unique key for the item.
* @param {*} content The item's content.
* @param {Integer} [priority] The priority of the item. Items with a higher
* priority will be positioned before items with a lower priority.
* @public
*/
add(key, content, priority) {
this[key] = new Item(content, priority);
}
/**
* Merge another list's items into this one.
*
* @param {ItemList} items
* @public
*/
merge(items) {
for (const i in items) {
if (items.hasOwnProperty(i) && items[i] instanceof Item) {
this[i] = items[i];
}
}
}
/**
* Convert the list into an array of item content arranged by priority. Each
* item's content will be assigned an `itemName` property equal to the item's
* unique key.
*
* @return {Array}
* @public
*/
toArray() {
const items = [];
for (const i in this) {
if (this.hasOwnProperty(i) && this[i] instanceof Item) {
this[i].content.itemName = i;
items.push(this[i]);
}
}
return items.sort((a, b) => b.priority - a.priority).map(item => item.content);
}
}

View File

@@ -0,0 +1,73 @@
const scroll = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.msRequestAnimationFrame ||
window.oRequestAnimationFrame ||
(callback => window.setTimeout(callback, 1000 / 60));
/**
* The `ScrollListener` class sets up a listener that handles window scroll
* events.
*/
export default class ScrollListener {
/**
* @param {Function} callback The callback to run when the scroll position
* changes.
* @public
*/
constructor(callback) {
this.callback = callback;
this.lastTop = -1;
}
/**
* On each animation frame, as long as the listener is active, run the
* `update` method.
*
* @protected
*/
loop() {
if (!this.active) return;
this.update();
scroll(this.loop.bind(this));
}
/**
* Check if the scroll position has changed; if it has, run the handler.
*
* @param {Boolean} [force=false] Whether or not to force the handler to be
* run, even if the scroll position hasn't changed.
* @public
*/
update(force) {
const top = window.pageYOffset;
if (this.lastTop !== top || force) {
this.callback(top);
this.lastTop = top;
}
}
/**
* Start listening to and handling the window's scroll position.
*
* @public
*/
start() {
if (!this.active) {
this.active = true;
this.loop();
}
}
/**
* Stop listening to and handling the window's scroll position.
*
* @public
*/
stop() {
this.active = false;
}
}

View File

@@ -0,0 +1,69 @@
/**
* The `SubtreeRetainer` class represents a Mithril virtual DOM subtree. It
* keeps track of a number of pieces of data, allowing the subtree to be
* retained if none of them have changed.
*
* @example
* // constructor
* this.subtree = new SubtreeRetainer(
* () => this.props.post.freshness,
* () => this.showing
* );
* this.subtree.check(() => this.props.user.freshness);
*
* // view
* this.subtree.retain() || 'expensive expression'
*
* @see https://lhorie.github.io/mithril/mithril.html#persisting-dom-elements-across-route-changes
*/
export default class SubtreeRetainer {
/**
* @param {...callbacks} callbacks Functions returning data to keep track of.
*/
constructor(...callbacks) {
this.invalidate();
this.callbacks = callbacks;
this.data = {};
}
/**
* Return a virtual DOM directive that will retain a subtree if no data has
* changed since the last check.
*
* @return {Object|false}
* @public
*/
retain() {
let needsRebuild = false;
this.callbacks.forEach((callback, i) => {
const result = callback();
if (result !== this.data[i]) {
this.data[i] = result;
needsRebuild = true;
}
});
return needsRebuild ? false : {subtree: 'retain'};
}
/**
* Add another callback to be checked.
*
* @param {...Function} callbacks
* @public
*/
check(...callbacks) {
this.callbacks = this.callbacks.concat(callbacks);
}
/**
* Invalidate the subtree, forcing it to be rerendered.
*
* @public
*/
invalidate() {
this.data = {};
}
}

View File

@@ -1,9 +0,0 @@
export default function(number) {
if (number >= 1000000) {
return Math.floor(number / 1000000)+'M';
} else if (number >= 1000) {
return Math.floor(number / 1000)+'K';
} else {
return number.toString();
}
}

View File

@@ -0,0 +1,20 @@
/**
* The `abbreviateNumber` utility converts a number to a shorter localized form.
*
* @example
* abbreviateNumber(1234);
* // "1.2K"
*
* @param {Integer} number
* @return {String}
*/
export default function abbreviateNumber(number) {
// TODO: translation
if (number >= 1000000) {
return Math.floor(number / 1000000) + 'M';
} else if (number >= 1000) {
return Math.floor(number / 1000) + 'K';
} else {
return number.toString();
}
}

View File

@@ -1,7 +0,0 @@
export default function anchorScroll(element, callback) {
var scrollAnchor = $(element).offset().top - $(window).scrollTop();
callback();
$(window).scrollTop($(element).offset().top - scrollAnchor);
}

View File

@@ -0,0 +1,22 @@
/**
* The `anchorScroll` utility saves the scroll position relative to an element,
* and then restores it after a callback has been run.
*
* This is useful if a redraw will change the page's content above the viewport.
* Normally doing this will result in the content in the viewport being pushed
* down or pulled up. By wrapping the redraw with this utility, the scroll
* position can be anchor to an element that is in or below the viewport, so
* the content in the viewport will stay the same.
*
* @param {DOMElement} element The element to anchor the scroll position to.
* @param {Function} callback The callback to run that will change page content.
*/
export default function anchorScroll(element, callback) {
const $element = $(element);
const $window = $(window);
const relativeScroll = $element.offset().top - $window.scrollTop();
callback();
$window.scrollTop($element.offset().top - relativeScroll);
}

View File

@@ -1,75 +0,0 @@
import ItemList from 'flarum/utils/item-list';
import Alert from 'flarum/components/alert';
import ServerError from 'flarum/utils/server-error';
import Translator from 'flarum/utils/translator';
class App {
constructor() {
this.initializers = new ItemList();
this.translator = new Translator();
this.cache = {};
this.serverError = null;
}
boot() {
this.initializers.toArray().forEach((initializer) => initializer(this));
}
preloadedDocument() {
if (app.preload.document) {
const results = app.store.pushPayload(app.preload.document);
app.preload.document = null;
return results;
}
}
setTitle(title) {
document.title = (title ? title+' - ' : '')+this.forum.attribute('title');
}
request(options) {
var extract = options.extract;
options.extract = function(xhr, xhrOptions) {
if (xhr.status === 500) {
throw new ServerError;
}
return extract ? extract(xhr.responseText) : (xhr.responseText.length === 0 ? null : xhr.responseText);
};
return m.request(options).then(response => {
this.alerts.dismiss(this.serverError);
return response;
}, response => {
this.alerts.dismiss(this.serverError);
if (response instanceof ServerError) {
this.alerts.show(this.serverError = new Alert({ type: 'warning', message: 'Oops! Something went wrong on the server. Please try again.' }))
}
throw response;
});
}
handleApiErrors(response) {
this.alerts.clear();
response.errors.forEach(error =>
this.alerts.show(new Alert({ type: 'warning', message: error.detail }))
);
}
route(name, params) {
var url = this.routes[name][0].replace(/:([^\/]+)/g, function(m, t) {
var value = params[t];
delete params[t];
return value;
});
var queryString = m.route.buildQueryString(params);
return url+(queryString ? '?'+queryString : '');
}
translate(key, input) {
return this.translator.translate(key, input);
}
}
export default App;

View File

@@ -1,12 +0,0 @@
export default function classList(classes) {
var classNames = [];
for (var i in classes) {
var value = classes[i];
if (value === true) {
classNames.push(i);
} else if (value) {
classNames.push(value);
}
}
return classNames.join(' ');
}

20
js/lib/utils/classList.js Normal file
View File

@@ -0,0 +1,20 @@
/**
* The `classList` utility creates a list of class names by joining an object's
* keys, but only for values which are truthy.
*
* @example
* classList({ foo: true, bar: false, qux: 'qaz' });
* // "foo qux"
*
* @param {Object} classes
* @return {String}
*/
export default function classList(classes) {
const classNames = [];
for (const i in classes) {
if (classes[i]) classNames.push(i);
}
return classNames.join(' ');
}

View File

@@ -1,22 +1,37 @@
export default function computed() {
var args = [].slice.apply(arguments);
var keys = args.slice(0, -1);
var compute = args.slice(-1)[0];
/**
* The `computed` utility creates a function that will cache its output until
* any of the dependent values are dirty.
*
* @param {...String} dependentKeys The keys of the dependent values.
* @param {function} compute The function which computes the value using the
* dependent values.
* @return {}
*/
export default function computed(...dependentKeys) {
const keys = dependentKeys.slice(0, -1);
const compute = dependentKeys.slice(-1)[0];
const dependentValues = {};
let computedValue;
var values = {};
var computed;
return function() {
var recompute = false;
keys.forEach(function(key) {
var value = typeof this[key] === 'function' ? this[key]() : this[key];
if (values[key] !== value) {
let recompute = false;
// Read all of the dependent values. If any of them have changed since last
// time, then we'll want to recompute our output.
keys.forEach(key => {
const value = typeof this[key] === 'function' ? this[key]() : this[key];
if (dependentValues[key] !== value) {
recompute = true;
values[key] = value;
dependentValues[key] = value;
}
}.bind(this));
});
if (recompute) {
computed = compute.apply(this, keys.map((key) => values[key]));
computedValue = compute.apply(this, keys.map(key => dependentValues[key]));
}
return computed;
}
};
return computedValue;
};
}

View File

@@ -1,45 +1,79 @@
/**
* The `evented` mixin provides methods allowing an object to trigger events,
* running externally registered event handlers.
*/
export default {
/**
* Arrays of registered event handlers, grouped by the event name.
*
* @type {Object}
* @protected
*/
handlers: null,
/**
* Get all of the registered handlers for an event.
*
* @param {String} event The name of the event.
* @return {Array}
* @protected
*/
getHandlers(event) {
this.handlers = this.handlers || {};
return this.handlers[event] = this.handlers[event] || [];
this.handlers[event] = this.handlers[event] || [];
return this.handlers[event];
},
/**
* Trigger an event.
*
* @param {String} event The name of the event.
* @param {...*} args Arguments to pass to event handlers.
* @public
*/
trigger(event, ...args) {
this.getHandlers(event).forEach((handler) => handler.apply(this, args));
this.getHandlers(event).forEach(handler => handler.apply(this, args));
},
/**
* Register an event handler.
*
* @param {String} event The name of the event.
* @param {function} handler The function to handle the event.
*/
on(event, handler) {
this.getHandlers(event).push(handler);
},
/**
* Register an event handler so that it will run only once, and then
* unregister itself.
*
* @param {String} event The name of the event.
* @param {function} handler The function to handle the event.
*/
one(event, handler) {
var wrapper = function() {
const wrapper = function() {
handler.apply(this, arguments);
this.off(event, wrapper);
};
this.getHandlers(event).push(wrapper);
},
/**
* Unregister an event handler.
*
* @param {String} event The name of the event.
* @param {function} handler The function that handles the event.
*/
off(event, handler) {
var handlers = this.getHandlers(event);
var index = handlers.indexOf(handler);
const handlers = this.getHandlers(event);
const index = handlers.indexOf(handler);
if (index !== -1) {
handlers.splice(index, 1);
}

15
js/lib/utils/extract.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* The `extract` utility deletes a property from an object and returns its
* value.
*
* @param {Object} object The object that owns the property
* @param {String} property The name of the property to extract
* @return {*} The value of the property
*/
export default function extract(object, property) {
const value = object[property];
delete object[property];
return value;
}

View File

@@ -1,3 +0,0 @@
export default function(number) {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

View File

@@ -0,0 +1,14 @@
/**
* The `formatNumber` utility localizes a number into a string with the
* appropriate punctuation.
*
* @example
* formatNumber(1234);
* // 1,234
*
* @param {Number} number
* @return {String}
*/
export default function formatNumber(number) {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

View File

@@ -1,22 +0,0 @@
export default function humanTime(time) {
var m = moment(time);
var minute = 6e4;
var hour = 36e5;
var day = 864e5;
var ago = null;
var diff = m.diff(moment());
if (diff < -30 * day) {
if (m.year() === moment().year()) {
ago = m.format('D MMM');
} else {
ago = m.format('MMM \'YY');
}
} else {
ago = m.fromNow();
}
return ago;
};

28
js/lib/utils/humanTime.js Normal file
View File

@@ -0,0 +1,28 @@
/**
* The `humanTime` utility converts a date to a localized, human-readable time-
* ago string.
*
* @param {Date} time
* @return {String}
*/
export default function humanTime(time) {
const m = moment(time);
const day = 864e5;
const diff = m.diff(moment());
let ago = null;
// If this date was more than a month ago, we'll show the name of the month
// in the string. If it wasn't this year, we'll show the year as well.
if (diff < -30 * day) {
if (m.year() === moment().year()) {
ago = m.format('D MMM');
} else {
ago = m.format('MMM \'YY');
}
} else {
ago = m.fromNow();
}
return ago;
};

View File

@@ -1,70 +0,0 @@
export class Item {
constructor(content, position) {
this.content = content;
this.position = position;
}
}
export default class ItemList {
add(key, content, position) {
this[key] = new Item(content, position);
}
merge(items) {
for (var i in items) {
if (items.hasOwnProperty(i) && items[i] instanceof Item) {
this[i] = items[i];
}
}
}
toArray() {
var items = [];
for (var i in this) {
if (this.hasOwnProperty(i) && this[i] instanceof Item) {
this[i].content.itemName = i;
items.push(this[i]);
}
}
var array = [];
var addItems = function(method, position) {
items = items.filter(function(item) {
if ((position && item.position && item.position[position]) || (!position && !item.position)) {
array[method](item);
} else {
return true;
}
});
};
addItems('unshift', 'first');
addItems('push', false);
addItems('push', 'last');
items.forEach(item => {
var key = item.position.before || item.position.after;
var type = item.position.before ? 'before' : 'after';
// TODO: Allow both before and after to be specified, and multiple keys to
// be specified for each.
// e.g. {before: ['foo', 'bar'], after: ['qux', 'qaz']}
// This way extensions can make sure they are positioned where
// they want to be relative to other extensions.
// Alternatively, it might be better to just have a numbered priority
// system, so extensions don't have to make awkward references to each other.
if (key) {
var index = array.indexOf(this[key]);
if (index === -1) {
array.push(item);
} else {
array.splice(index + (type === 'after' ? 1 : 0), 0, item);
}
}
});
array = array.map(item => item.content);
return array;
}
}

View File

@@ -1,8 +0,0 @@
export default function mapRoutes(routes) {
var map = {};
for (var r in routes) {
routes[r][1].props.routeName = r;
map[routes[r][0]] = routes[r][1];
}
return map;
}

21
js/lib/utils/mapRoutes.js Normal file
View File

@@ -0,0 +1,21 @@
/**
* The `mapRoutes` utility converts a map of named application routes into a
* format that can be understood by Mithril.
*
* @see https://lhorie.github.io/mithril/mithril.route.html#defining-routes
* @param {Object} routes
* @return {Object}
*/
export default function mapRoutes(routes) {
const map = {};
for (const key in routes) {
const route = routes[key];
if (route.component) route.component.props.routeName = key;
map[route.path] = route.component;
}
return map;
}

View File

@@ -1,11 +1,20 @@
/**
* The `mixin` utility assigns the properties of a set of 'mixin' objects to
* the prototype of a parent object.
*
* @example
* class MyClass extends mixin(ExtistingClass, evented, etc) {}
*
* @param {Class} Parent The class to extend the new class from.
* @param {...Object} mixins The objects to mix in.
* @return {Class} A new class that extends Parent and contains the mixins.
*/
export default function mixin(Parent, ...mixins) {
class Mixed extends Parent {}
for (var i in mixins) {
var keys = Object.keys(mixins[i]);
for (var j in keys) {
var prop = keys[j];
Mixed.prototype[prop] = mixins[i][prop];
}
}
mixins.forEach(object => {
Object.assign(Mixed.prototype, object);
});
return Mixed;
}

View File

@@ -1,43 +0,0 @@
var scroll = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.msRequestAnimationFrame ||
window.oRequestAnimationFrame ||
function(callback) { window.setTimeout(callback, 1000/60) };
export default class ScrollListener {
constructor(callback) {
this.callback = callback;
this.lastTop = -1;
}
loop() {
if (!this.active) {
return;
}
this.update();
scroll(this.loop.bind(this));
}
update(force) {
var top = window.pageYOffset;
if (this.lastTop !== top || force) {
this.callback(top);
this.lastTop = top;
}
}
stop() {
this.active = false;
}
start() {
if (!this.active) {
this.active = true;
this.loop();
}
}
}

View File

@@ -1,2 +0,0 @@
export default class ServerError {
}

View File

@@ -1,34 +0,0 @@
function hsvToRgb(h, s, v) {
var r, g, b, i, f, p, q, t;
if (h && s === undefined && v === undefined) {
s = h.s; v = h.v; h = h.h;
}
i = Math.floor(h * 6);
f = h * 6 - i;
p = v * (1 - s);
q = v * (1 - f * s);
t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.floor(r * 255),
g: Math.floor(g * 255),
b: Math.floor(b * 255)
};
}
export default function stringToColor(string) {
var num = 0;
for (var i = 0; i < string.length; i++) {
num += string.charCodeAt(i);
}
var hue = num % 360;
var rgb = hsvToRgb(hue / 360, 0.3, 0.9);
return ''+rgb.r.toString(16)+rgb.g.toString(16)+rgb.b.toString(16);
};

View File

@@ -1,5 +1,38 @@
export function dasherize(string) {
return string.replace(/([A-Z])/g, function ($1) {
return '-' + $1.toLowerCase();
});
/**
* Truncate a string to the given length, appending ellipses if necessary.
*
* @param {String} string
* @param {Number} length
* @param {Number} [start=0]
* @return {String}
*/
export function truncate(string, length, start = 0) {
return (start > 0 ? '...' : '') +
string.substring(start, start + length) +
(string.length > start + length ? '...' : '');
}
/**
* Create a slug out of the given string. Non-alphanumeric characters are
* converted to hyphens.
*
* @param {String} string
* @return {String}
*/
export function slug(string) {
return string.toLowerCase()
.replace(/[^a-z0-9]/gi, '-')
.replace(/-+/g, '-')
.replace(/-$|^-/g, '') || '-';
}
/**
* Strip HTML tags and quotes out of the given string, replacing them with
* meaningful punctuation.
*
* @param {String} string
* @return {String}
*/
export function getPlainContent(string) {
return $('<div/>').html(string.replace(/(<\/p>|<br>)/g, '$1 ')).text();
}

View File

@@ -0,0 +1,45 @@
function hsvToRgb(h, s, v) {
let r;
let g;
let b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.floor(r * 255),
g: Math.floor(g * 255),
b: Math.floor(b * 255)
};
}
/**
* Convert the given string to a unique color.
*
* @param {String} string
* @return {String}
*/
export default function stringToColor(string) {
let num = 0;
for (let i = 0; i < string.length; i++) {
num += string.charCodeAt(i);
}
const hue = num % 360;
const rgb = hsvToRgb(hue / 360, 0.3, 0.9);
return '' + rgb.r.toString(16) + rgb.g.toString(16) + rgb.b.toString(16);
};

View File

@@ -1,38 +0,0 @@
/**
// constructor
this.subtree = new SubtreeRetainer(
() => this.props.post.freshness,
() => this.showing
);
this.subtree.check(() => this.props.user.freshness);
// view
this.subtree.retain() || 'expensive expression'
*/
export default class SubtreeRetainer {
constructor() {
this.invalidate();
this.callbacks = [].slice.call(arguments);
this.old = {};
}
retain() {
var needsRebuild = false;
this.callbacks.forEach((callback, i) => {
var result = callback();
if (result !== this.old[i]) {
this.old[i] = result;
needsRebuild = true;
}
});
return needsRebuild ? false : {subtree: 'retain'};
}
check() {
this.callbacks = this.callbacks.concat([].slice.call(arguments));
}
invalidate() {
this.old = {};
}
}

View File

@@ -1,32 +0,0 @@
export default class Translator {
constructor() {
this.translations = {};
}
plural(count) {
return count == 1 ? 'one' : 'other';
}
translate(key, input) {
var parts = key.split('.');
var translation = this.translations;
parts.forEach(function(part) {
translation = translation && translation[part];
});
if (typeof translation === 'object' && typeof input.count !== 'undefined') {
translation = translation[this.plural(input.count)];
}
if (typeof translation === 'string') {
for (var i in input) {
translation = translation.replace(new RegExp('{'+i+'}', 'gi'), input[i]);
}
return translation;
} else {
return key;
}
}
}

View File

@@ -1,5 +0,0 @@
export default function truncate(string = '', length, start = 0) {
return (start > 0 ? '...' : '') +
string.substring(start, start + length) +
(string.length > start + length ? '...' : '');
}