Skip to content

Instantly share code, notes, and snippets.

@audreyt
Created January 9, 2012 14:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save audreyt/1583190 to your computer and use it in GitHub Desktop.
Save audreyt/1583190 to your computer and use it in GitHub Desktop.
CKEditor Row/Column Movement plugin
CKEDITOR.on 'instanceCreated', ({editor}) -> editor.on 'pluginsLoaded', ->
{TRISTATE_OFF: ENABLED, TRISTATE_DISABLED: DISABLED} = CKEDITOR
RedrawSubMenuName = null
OffsetsByLevel = []
row = editor.getMenuItem('tablerow')
rowItems = row.getItems()
row.getItems = ->
sel = editor.getSelection()
CKEDITOR.tools.extend {
tablerow_moveBefore: if isFirstRow(sel) then DISABLED else ENABLED
tablerow_moveAfter: if isLastRow(sel) then DISABLED else ENABLED
}, rowItems
col = editor.getMenuItem('tablecolumn')
colItems = col.getItems()
col.getItems = ->
sel = editor.getSelection()
CKEDITOR.tools.extend {
tablecolumn_moveBefore: if isFirstColumn(sel) then DISABLED else ENABLED
tablecolumn_moveAfter: if isLastColumn(sel) then DISABLED else ENABLED
}, colItems
editor.addMenuItems
tablerow_moveBefore:
label: 'Move Row Before'
group: 'tablerow'
command: 'rowMoveBefore'
order: 11
tablerow_moveAfter:
label: 'Move Row After'
group: 'tablerow'
command: 'rowMoveAfter'
order: 12
tablecolumn_moveBefore:
label: 'Move Column Before'
group: 'tablecolumn'
command: 'columnMoveBefore'
order: 11
tablecolumn_moveAfter:
label: 'Move Column After'
group: 'tablecolumn'
command: 'columnMoveAfter'
order: 12
editor.addCommand "rowMoveBefore",
exec: (editor) -> moveRowBefore editor.getSelection()
editor.addCommand "rowMoveAfter",
exec: (editor) -> moveRowAfter editor.getSelection()
editor.addCommand "columnMoveBefore",
exec: (editor) -> moveColumnBefore editor.getSelection()
editor.addCommand "columnMoveAfter",
exec: (editor) -> moveColumnAfter editor.getSelection()
for key in ['rowInsertBefore', 'rowInsertAfter', 'columnInsertBefore', 'columnInsertAfter']
cmd = editor._.commands[key]
continue if cmd._origExec
subMenuName = "table#{ key.replace(/Insert.*/, '') }"
do (cmd, subMenuName) ->
cmd._origExec = cmd.exec
cmd.exec = (args...) ->
rv = @_origExec(args...)
redrawContextMenu subMenuName
return rv
MenuElement = null
CKEDITOR.ui.on 'ready', ({data}) ->
MenuElement = data if data._?.panel
editor.on 'menuShow', ({data}) ->
return unless RedrawSubMenuName
panel = data[0]
level = panel._.definition.level
setTimeout((->
panel.element.setStyles OffsetsByLevel[level]
for item, idx in MenuElement.items
continue unless item.name == RedrawSubMenuName
do (MenuElement, idx) -> setTimeout((-> MenuElement._.showSubMenu idx), 201)
RedrawSubMenuName = null
return
), 1)
# Utility functions to detect edge of tables.
isFirstRow = (selection) ->
cells = getSelectedCells( selection )
firstCell = cells[0]
startRow = firstCell.getParent()
startRowIndex = startRow.$.rowIndex
return true if startRowIndex == 0
table = firstCell.getAscendant( 'table' )
rowCells = table.$.rows[0].cells
maxRowSpan = Math.max((mapCell.rowSpan for mapCell in rowCells)...)
return startRowIndex <= (maxRowSpan-1)
isLastRow = (selection) ->
cells = getSelectedCells( selection )
lastCell = cells[ cells.length - 1 ]
table = lastCell.getAscendant( 'table' )
endRow = lastCell.getParent()
rowCells = endRow.$.cells
endRowIndex = endRow.$.rowIndex
maxRowSpan = Math.max((mapCell.rowSpan for mapCell in rowCells)...)
return ((endRowIndex + maxRowSpan) >= table.$.rows.length)
isFirstColumn = (selection) ->
cells = getSelectedCells( selection )
startColIndex = getColumnsIndices( cells, 1 )
return (startColIndex == 0)
isLastColumn = (selection) ->
cells = getSelectedCells( selection )
endColIndex = getColumnsIndices( cells )
lastRow = cells[ cells.length - 1 ].getParent()
rowCells = lastRow.$.cells
colIndex = -1
for mapCell in rowCells
colIndex += mapCell.colSpan
return false if colIndex > endColIndex
return true
$$ = (elm) -> new CKEDITOR.dom.element(elm)
moveRowBefore = (selection) ->
cells = getSelectedCells( selection )
endRow = cells[ cells.length - 1 ].getParent()
firstCell = cells[ 0 ]
startRowIndex = firstCell.getParent().$.rowIndex
table = firstCell.getAscendant( 'table' )
prevRow = table.$.rows[startRowIndex - 1]
$$(prevRow).insertAfter endRow
redrawContextMenu 'tablerow'
moveRowAfter = (selection) ->
cells = getSelectedCells( selection )
startRow = cells[ 0 ].getParent()
lastCell = cells[ cells.length - 1 ]
endRowIndex = lastCell.getParent().$.rowIndex + lastCell.$.rowSpan - 1
table = lastCell.getAscendant( 'table' )
nextRow = table.$.rows[endRowIndex + 1]
$$(nextRow).insertBefore startRow
redrawContextMenu 'tablerow'
moveColumn = (selection, isBefore) ->
cells = getSelectedCells( selection )
table = cells[0].getAscendant( 'table' )
startColIndex = getColumnsIndices( cells, 1 )
endColIndex = getColumnsIndices( cells )
for row in table.$.rows
rowCells = row.cells
if isBefore
$$(rowCells[startColIndex - 1]).insertAfter $$(rowCells[endColIndex])
else
$$(rowCells[endColIndex + 1]).insertBefore $$(rowCells[startColIndex])
redrawContextMenu 'tablecolumn'
moveColumnBefore = (selection) ->
moveColumn selection, true
moveColumnAfter = (selection) ->
moveColumn selection, false
redrawContextMenu = (subMenuName) ->
RedrawSubMenuName = subMenuName
for elm, idx in getElementsByClassName('cke_contextmenu')
$parent = new CKEDITOR.dom.element(elm.parentNode)
OffsetsByLevel[idx] =
top: $parent.getComputedStyle('top')
left: $parent.getComputedStyle('left')
editor.execCommand 'contextMenu'
# Everything below is copied from tabletools/plugin.js #
cellNodeRegex = /^(?:td|th)$/
getSelectedCells = (selection) ->
bookmarks = selection.createBookmarks()
ranges = selection.getRanges()
retval = []
database = {}
i = 0
moveOutOfCellGuard = (node) ->
return if retval.length > 0
if node.type is CKEDITOR.NODE_ELEMENT and cellNodeRegex.test(node.getName()) and not node.getCustomData("selected_cell")
CKEDITOR.dom.element.setMarker database, node, "selected_cell", true
retval.push node
return
for range in ranges
if range.collapsed
startNode = range.getCommonAncestor()
nearestCell = startNode.getAscendant("td", true) or startNode.getAscendant("th", true)
retval.push nearestCell if nearestCell
else
walker = new CKEDITOR.dom.walker(range)
node = undefined
walker.guard = moveOutOfCellGuard
while (node = walker.next())
parent = node.getAscendant("td") or node.getAscendant("th")
if parent and not parent.getCustomData("selected_cell")
CKEDITOR.dom.element.setMarker database, parent, "selected_cell", true
retval.push parent
CKEDITOR.dom.element.clearAllMarkers database
selection.selectBookmarks bookmarks
return retval
getColumnsIndices = (cells, isStart) ->
retval = (if isStart then Infinity else 0)
for cell in cells
colIndex = getCellColIndex(cell, isStart)
if isStart
retval = colIndex if colIndex < retval
else
retval = colIndex if colIndex > retval
return retval
getCellColIndex = (cell, isStart) ->
row = cell.getParent()
rowCells = row.$.cells
colIndex = 0
for mapCell in rowCells
colIndex += (if isStart then 1 else mapCell.colSpan)
break if mapCell is cell.$
return colIndex - 1
###
[MIT License Code Inclusion]
Developed by Robert Nyman, http://www.robertnyman.com
Code/licensing: http://code.google.com/p/getelementsbyclassname/
###
getElementsByClassName = (className, tag, elm) ->
if document.getElementsByClassName
getElementsByClassName = (className, tag, elm) ->
elm = elm or document
elements = elm.getElementsByClassName(className)
nodeName = (if (tag) then new RegExp("\\b" + tag + "\\b", "i") else null)
returnElements = []
current = undefined
i = 0
il = elements.length
while i < il
current = elements[i]
returnElements.push current if not nodeName or nodeName.test(current.nodeName)
i += 1
returnElements
else if document.evaluate
getElementsByClassName = (className, tag, elm) ->
tag = tag or "*"
elm = elm or document
classes = className.split(" ")
classesToCheck = ""
xhtmlNamespace = "http://www.w3.org/1999/xhtml"
namespaceResolver = (if (document.documentElement.namespaceURI is xhtmlNamespace) then xhtmlNamespace else null)
returnElements = []
elements = undefined
node = undefined
j = 0
jl = classes.length
while j < jl
classesToCheck += "[contains(concat(' ', @class, ' '), ' " + classes[j] + " ')]"
j += 1
try
elements = document.evaluate(".//" + tag + classesToCheck, elm, namespaceResolver, 0, null)
catch e
elements = document.evaluate(".//" + tag + classesToCheck, elm, null, 0, null)
returnElements.push node while (node = elements.iterateNext())
returnElements
else
getElementsByClassName = (className, tag, elm) ->
tag = tag or "*"
elm = elm or document
classes = className.split(" ")
classesToCheck = []
elements = (if (tag is "*" and elm.all) then elm.all else elm.getElementsByTagName(tag))
current = undefined
returnElements = []
match = undefined
k = 0
kl = classes.length
while k < kl
classesToCheck.push new RegExp("(^|\\s)" + classes[k] + "(\\s|$)")
k += 1
l = 0
ll = elements.length
while l < ll
current = elements[l]
match = false
m = 0
ml = classesToCheck.length
while m < ml
match = classesToCheck[m].test(current.className)
break unless match
m += 1
returnElements.push current if match
l += 1
returnElements
getElementsByClassName className, tag, elm
(function() {
var __slice = Array.prototype.slice;
CKEDITOR.on('instanceCreated', function(_arg) {
var editor;
editor = _arg.editor;
return editor.on('pluginsLoaded', function() {
var $$, DISABLED, ENABLED, MenuElement, OffsetsByLevel, RedrawSubMenuName, cellNodeRegex, cmd, col, colItems, getCellColIndex, getColumnsIndices, getElementsByClassName, getSelectedCells, isFirstColumn, isFirstRow, isLastColumn, isLastRow, key, moveColumn, moveColumnAfter, moveColumnBefore, moveRowAfter, moveRowBefore, redrawContextMenu, row, rowItems, subMenuName, _fn, _i, _len, _ref;
ENABLED = CKEDITOR.TRISTATE_OFF, DISABLED = CKEDITOR.TRISTATE_DISABLED;
RedrawSubMenuName = null;
OffsetsByLevel = [];
row = editor.getMenuItem('tablerow');
rowItems = row.getItems();
row.getItems = function() {
var sel;
sel = editor.getSelection();
return CKEDITOR.tools.extend({
tablerow_moveBefore: isFirstRow(sel) ? DISABLED : ENABLED,
tablerow_moveAfter: isLastRow(sel) ? DISABLED : ENABLED
}, rowItems);
};
col = editor.getMenuItem('tablecolumn');
colItems = col.getItems();
col.getItems = function() {
var sel;
sel = editor.getSelection();
return CKEDITOR.tools.extend({
tablecolumn_moveBefore: isFirstColumn(sel) ? DISABLED : ENABLED,
tablecolumn_moveAfter: isLastColumn(sel) ? DISABLED : ENABLED
}, colItems);
};
editor.addMenuItems({
tablerow_moveBefore: {
label: 'Move Row Before',
group: 'tablerow',
command: 'rowMoveBefore',
order: 11
},
tablerow_moveAfter: {
label: 'Move Row After',
group: 'tablerow',
command: 'rowMoveAfter',
order: 12
},
tablecolumn_moveBefore: {
label: 'Move Column Before',
group: 'tablecolumn',
command: 'columnMoveBefore',
order: 11
},
tablecolumn_moveAfter: {
label: 'Move Column After',
group: 'tablecolumn',
command: 'columnMoveAfter',
order: 12
}
});
editor.addCommand("rowMoveBefore", {
exec: function(editor) {
return moveRowBefore(editor.getSelection());
}
});
editor.addCommand("rowMoveAfter", {
exec: function(editor) {
return moveRowAfter(editor.getSelection());
}
});
editor.addCommand("columnMoveBefore", {
exec: function(editor) {
return moveColumnBefore(editor.getSelection());
}
});
editor.addCommand("columnMoveAfter", {
exec: function(editor) {
return moveColumnAfter(editor.getSelection());
}
});
_ref = ['rowInsertBefore', 'rowInsertAfter', 'columnInsertBefore', 'columnInsertAfter'];
_fn = function(cmd, subMenuName) {
cmd._origExec = cmd.exec;
return cmd.exec = function() {
var args, rv;
args = 1 <= arguments.length ? __slice.call(arguments, 0) : [];
rv = this._origExec.apply(this, args);
redrawContextMenu(subMenuName);
return rv;
};
};
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
key = _ref[_i];
cmd = editor._.commands[key];
if (cmd._origExec) continue;
subMenuName = "table" + (key.replace(/Insert.*/, ''));
_fn(cmd, subMenuName);
}
MenuElement = null;
CKEDITOR.ui.on('ready', function(_arg2) {
var data, _ref2;
data = _arg2.data;
if ((_ref2 = data._) != null ? _ref2.panel : void 0) {
return MenuElement = data;
}
});
editor.on('menuShow', function(_arg2) {
var data, level, panel;
data = _arg2.data;
if (!RedrawSubMenuName) return;
panel = data[0];
level = panel._.definition.level;
return setTimeout((function() {
var idx, item, _fn2, _len2, _ref2;
panel.element.setStyles(OffsetsByLevel[level]);
_ref2 = MenuElement.items;
_fn2 = function(MenuElement, idx) {
return setTimeout((function() {
return MenuElement._.showSubMenu(idx);
}), 201);
};
for (idx = 0, _len2 = _ref2.length; idx < _len2; idx++) {
item = _ref2[idx];
if (item.name !== RedrawSubMenuName) continue;
_fn2(MenuElement, idx);
RedrawSubMenuName = null;
return;
}
}), 1);
});
isFirstRow = function(selection) {
var cells, firstCell, mapCell, maxRowSpan, rowCells, startRow, startRowIndex, table;
cells = getSelectedCells(selection);
firstCell = cells[0];
startRow = firstCell.getParent();
startRowIndex = startRow.$.rowIndex;
if (startRowIndex === 0) return true;
table = firstCell.getAscendant('table');
rowCells = table.$.rows[0].cells;
maxRowSpan = Math.max.apply(Math, (function() {
var _j, _len2, _results;
_results = [];
for (_j = 0, _len2 = rowCells.length; _j < _len2; _j++) {
mapCell = rowCells[_j];
_results.push(mapCell.rowSpan);
}
return _results;
})());
return startRowIndex <= (maxRowSpan - 1);
};
isLastRow = function(selection) {
var cells, endRow, endRowIndex, lastCell, mapCell, maxRowSpan, rowCells, table;
cells = getSelectedCells(selection);
lastCell = cells[cells.length - 1];
table = lastCell.getAscendant('table');
endRow = lastCell.getParent();
rowCells = endRow.$.cells;
endRowIndex = endRow.$.rowIndex;
maxRowSpan = Math.max.apply(Math, (function() {
var _j, _len2, _results;
_results = [];
for (_j = 0, _len2 = rowCells.length; _j < _len2; _j++) {
mapCell = rowCells[_j];
_results.push(mapCell.rowSpan);
}
return _results;
})());
return (endRowIndex + maxRowSpan) >= table.$.rows.length;
};
isFirstColumn = function(selection) {
var cells, startColIndex;
cells = getSelectedCells(selection);
startColIndex = getColumnsIndices(cells, 1);
return startColIndex === 0;
};
isLastColumn = function(selection) {
var cells, colIndex, endColIndex, lastRow, mapCell, rowCells, _j, _len2;
cells = getSelectedCells(selection);
endColIndex = getColumnsIndices(cells);
lastRow = cells[cells.length - 1].getParent();
rowCells = lastRow.$.cells;
colIndex = -1;
for (_j = 0, _len2 = rowCells.length; _j < _len2; _j++) {
mapCell = rowCells[_j];
colIndex += mapCell.colSpan;
if (colIndex > endColIndex) return false;
}
return true;
};
$$ = function(elm) {
return new CKEDITOR.dom.element(elm);
};
moveRowBefore = function(selection) {
var cells, endRow, firstCell, prevRow, startRowIndex, table;
cells = getSelectedCells(selection);
endRow = cells[cells.length - 1].getParent();
firstCell = cells[0];
startRowIndex = firstCell.getParent().$.rowIndex;
table = firstCell.getAscendant('table');
prevRow = table.$.rows[startRowIndex - 1];
$$(prevRow).insertAfter(endRow);
return redrawContextMenu('tablerow');
};
moveRowAfter = function(selection) {
var cells, endRowIndex, lastCell, nextRow, startRow, table;
cells = getSelectedCells(selection);
startRow = cells[0].getParent();
lastCell = cells[cells.length - 1];
endRowIndex = lastCell.getParent().$.rowIndex + lastCell.$.rowSpan - 1;
table = lastCell.getAscendant('table');
nextRow = table.$.rows[endRowIndex + 1];
$$(nextRow).insertBefore(startRow);
return redrawContextMenu('tablerow');
};
moveColumn = function(selection, isBefore) {
var cells, endColIndex, row, rowCells, startColIndex, table, _j, _len2, _ref2;
cells = getSelectedCells(selection);
table = cells[0].getAscendant('table');
startColIndex = getColumnsIndices(cells, 1);
endColIndex = getColumnsIndices(cells);
_ref2 = table.$.rows;
for (_j = 0, _len2 = _ref2.length; _j < _len2; _j++) {
row = _ref2[_j];
rowCells = row.cells;
if (isBefore) {
$$(rowCells[startColIndex - 1]).insertAfter($$(rowCells[endColIndex]));
} else {
$$(rowCells[endColIndex + 1]).insertBefore($$(rowCells[startColIndex]));
}
}
return redrawContextMenu('tablecolumn');
};
moveColumnBefore = function(selection) {
return moveColumn(selection, true);
};
moveColumnAfter = function(selection) {
return moveColumn(selection, false);
};
redrawContextMenu = function(subMenuName) {
var $parent, elm, idx, _len2, _ref2;
RedrawSubMenuName = subMenuName;
_ref2 = getElementsByClassName('cke_contextmenu');
for (idx = 0, _len2 = _ref2.length; idx < _len2; idx++) {
elm = _ref2[idx];
$parent = new CKEDITOR.dom.element(elm.parentNode);
OffsetsByLevel[idx] = {
top: $parent.getComputedStyle('top'),
left: $parent.getComputedStyle('left')
};
}
return editor.execCommand('contextMenu');
};
cellNodeRegex = /^(?:td|th)$/;
getSelectedCells = function(selection) {
var bookmarks, database, i, moveOutOfCellGuard, nearestCell, node, parent, range, ranges, retval, startNode, walker, _j, _len2;
bookmarks = selection.createBookmarks();
ranges = selection.getRanges();
retval = [];
database = {};
i = 0;
moveOutOfCellGuard = function(node) {
if (retval.length > 0) return;
if (node.type === CKEDITOR.NODE_ELEMENT && cellNodeRegex.test(node.getName()) && !node.getCustomData("selected_cell")) {
CKEDITOR.dom.element.setMarker(database, node, "selected_cell", true);
retval.push(node);
}
};
for (_j = 0, _len2 = ranges.length; _j < _len2; _j++) {
range = ranges[_j];
if (range.collapsed) {
startNode = range.getCommonAncestor();
nearestCell = startNode.getAscendant("td", true) || startNode.getAscendant("th", true);
if (nearestCell) retval.push(nearestCell);
} else {
walker = new CKEDITOR.dom.walker(range);
node = void 0;
walker.guard = moveOutOfCellGuard;
while ((node = walker.next())) {
parent = node.getAscendant("td") || node.getAscendant("th");
if (parent && !parent.getCustomData("selected_cell")) {
CKEDITOR.dom.element.setMarker(database, parent, "selected_cell", true);
retval.push(parent);
}
}
}
}
CKEDITOR.dom.element.clearAllMarkers(database);
selection.selectBookmarks(bookmarks);
return retval;
};
getColumnsIndices = function(cells, isStart) {
var cell, colIndex, retval, _j, _len2;
retval = (isStart ? Infinity : 0);
for (_j = 0, _len2 = cells.length; _j < _len2; _j++) {
cell = cells[_j];
colIndex = getCellColIndex(cell, isStart);
if (isStart) {
if (colIndex < retval) retval = colIndex;
} else {
if (colIndex > retval) retval = colIndex;
}
}
return retval;
};
getCellColIndex = function(cell, isStart) {
var colIndex, mapCell, rowCells, _j, _len2;
row = cell.getParent();
rowCells = row.$.cells;
colIndex = 0;
for (_j = 0, _len2 = rowCells.length; _j < _len2; _j++) {
mapCell = rowCells[_j];
colIndex += (isStart ? 1 : mapCell.colSpan);
if (mapCell === cell.$) break;
}
return colIndex - 1;
};
/*
[MIT License Code Inclusion]
Developed by Robert Nyman, http://www.robertnyman.com
Code/licensing: http://code.google.com/p/getelementsbyclassname/
*/
return getElementsByClassName = function(className, tag, elm) {
if (document.getElementsByClassName) {
getElementsByClassName = function(className, tag, elm) {
var current, elements, i, il, nodeName, returnElements;
elm = elm || document;
elements = elm.getElementsByClassName(className);
nodeName = (tag ? new RegExp("\\b" + tag + "\\b", "i") : null);
returnElements = [];
current = void 0;
i = 0;
il = elements.length;
while (i < il) {
current = elements[i];
if (!nodeName || nodeName.test(current.nodeName)) {
returnElements.push(current);
}
i += 1;
}
return returnElements;
};
} else if (document.evaluate) {
getElementsByClassName = function(className, tag, elm) {
var classes, classesToCheck, elements, j, jl, namespaceResolver, node, returnElements, xhtmlNamespace;
tag = tag || "*";
elm = elm || document;
classes = className.split(" ");
classesToCheck = "";
xhtmlNamespace = "http://www.w3.org/1999/xhtml";
namespaceResolver = (document.documentElement.namespaceURI === xhtmlNamespace ? xhtmlNamespace : null);
returnElements = [];
elements = void 0;
node = void 0;
j = 0;
jl = classes.length;
while (j < jl) {
classesToCheck += "[contains(concat(' ', @class, ' '), ' " + classes[j] + " ')]";
j += 1;
}
try {
elements = document.evaluate(".//" + tag + classesToCheck, elm, namespaceResolver, 0, null);
} catch (e) {
elements = document.evaluate(".//" + tag + classesToCheck, elm, null, 0, null);
}
while ((node = elements.iterateNext())) {
returnElements.push(node);
}
return returnElements;
};
} else {
getElementsByClassName = function(className, tag, elm) {
var classes, classesToCheck, current, elements, k, kl, l, ll, m, match, ml, returnElements;
tag = tag || "*";
elm = elm || document;
classes = className.split(" ");
classesToCheck = [];
elements = (tag === "*" && elm.all ? elm.all : elm.getElementsByTagName(tag));
current = void 0;
returnElements = [];
match = void 0;
k = 0;
kl = classes.length;
while (k < kl) {
classesToCheck.push(new RegExp("(^|\\s)" + classes[k] + "(\\s|$)"));
k += 1;
}
l = 0;
ll = elements.length;
while (l < ll) {
current = elements[l];
match = false;
m = 0;
ml = classesToCheck.length;
while (m < ml) {
match = classesToCheck[m].test(current.className);
if (!match) break;
m += 1;
}
if (match) returnElements.push(current);
l += 1;
}
return returnElements;
};
}
return getElementsByClassName(className, tag, elm);
};
});
});
}).call(this);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment