-
-
Save Firestorm-Graphics/9972533 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* plugin.js | |
* | |
* Copyright, Moxiecode Systems AB | |
* Released under LGPL License. | |
* | |
* License: http://www.tinymce.com/license | |
* Contributing: http://www.tinymce.com/contributing | |
*/ | |
/*global tinymce:true */ | |
/*eslint consistent-this:0 */ | |
tinymce.PluginManager.add('fb_test_lists', function(editor) { | |
var self = this; | |
function isListNode(node) { | |
return node && (/^(OL|UL)$/).test(node.nodeName); | |
} | |
function isFirstChild(node) { | |
return node.parentNode.firstChild == node; | |
} | |
function isLastChild(node) { | |
return node.parentNode.lastChild == node; | |
} | |
function isTextBlock(node) { | |
return node && !!editor.schema.getTextBlockElements()[node.nodeName]; | |
} | |
function isBookmarkNode(node) { | |
return node && node.nodeName === 'SPAN' && node.getAttribute('data-mce-type') === 'bookmark'; | |
} | |
editor.on('init', function() { | |
var dom = editor.dom, selection = editor.selection; | |
/** | |
* Returns a range bookmark. This will convert indexed bookmarks into temporary span elements with | |
* index 0 so that they can be restored properly after the DOM has been modified. Text bookmarks will not have spans | |
* added to them since they can be restored after a dom operation. | |
* | |
* So this: <p><b>|</b><b>|</b></p> | |
* becomes: <p><b><span data-mce-type="bookmark">|</span></b><b data-mce-type="bookmark">|</span></b></p> | |
* | |
* @param {DOMRange} rng DOM Range to get bookmark on. | |
* @return {Object} Bookmark object. | |
*/ | |
function createBookmark(rng) { | |
var bookmark = {}; | |
function setupEndPoint(start) { | |
var offsetNode, container, offset; | |
container = rng[start ? 'startContainer' : 'endContainer']; | |
offset = rng[start ? 'startOffset' : 'endOffset']; | |
if (container.nodeType == 1) { | |
offsetNode = dom.create('span', {'data-mce-type': 'bookmark'}); | |
if (container.hasChildNodes()) { | |
offset = Math.min(offset, container.childNodes.length - 1); | |
if (start) { | |
container.insertBefore(offsetNode, container.childNodes[offset]); | |
} else { | |
dom.insertAfter(offsetNode, container.childNodes[offset]); | |
} | |
} else { | |
container.appendChild(offsetNode); | |
} | |
container = offsetNode; | |
offset = 0; | |
} | |
bookmark[start ? 'startContainer' : 'endContainer'] = container; | |
bookmark[start ? 'startOffset' : 'endOffset'] = offset; | |
} | |
setupEndPoint(true); | |
if (!rng.collapsed) { | |
setupEndPoint(); | |
} | |
return bookmark; | |
} | |
/** | |
* Moves the selection to the current bookmark and removes any selection container wrappers. | |
* | |
* @param {Object} bookmark Bookmark object to move selection to. | |
*/ | |
function moveToBookmark(bookmark) { | |
function restoreEndPoint(start) { | |
var container, offset, node; | |
function nodeIndex(container) { | |
var node = container.parentNode.firstChild, idx = 0; | |
while (node) { | |
if (node == container) { | |
return idx; | |
} | |
// Skip data-mce-type=bookmark nodes | |
if (node.nodeType != 1 || node.getAttribute('data-mce-type') != 'bookmark') { | |
idx++; | |
} | |
node = node.nextSibling; | |
} | |
return -1; | |
} | |
container = node = bookmark[start ? 'startContainer' : 'endContainer']; | |
offset = bookmark[start ? 'startOffset' : 'endOffset']; | |
if (!container) { | |
return; | |
} | |
if (container.nodeType == 1) { | |
offset = nodeIndex(container); | |
container = container.parentNode; | |
dom.remove(node); | |
} | |
bookmark[start ? 'startContainer' : 'endContainer'] = container; | |
bookmark[start ? 'startOffset' : 'endOffset'] = offset; | |
} | |
restoreEndPoint(true); | |
restoreEndPoint(); | |
var rng = dom.createRng(); | |
rng.setStart(bookmark.startContainer, bookmark.startOffset); | |
if (bookmark.endContainer) { | |
rng.setEnd(bookmark.endContainer, bookmark.endOffset); | |
} | |
selection.setRng(rng); | |
} | |
function createNewTextBlock(contentNode, blockName) { | |
var node, textBlock, fragment = dom.createFragment(), hasContentNode; | |
var blockElements = editor.schema.getBlockElements(); | |
if (editor.settings.forced_root_block) { | |
blockName = blockName || editor.settings.forced_root_block; | |
} | |
if (blockName) { | |
textBlock = dom.create(blockName); | |
if (textBlock.tagName === editor.settings.forced_root_block) { | |
dom.setAttribs(textBlock, editor.settings.forced_root_block_attrs); | |
} | |
fragment.appendChild(textBlock); | |
} | |
if (contentNode) { | |
while ((node = contentNode.firstChild)) { | |
var nodeName = node.nodeName; | |
if (!hasContentNode && (nodeName != 'SPAN' || node.getAttribute('data-mce-type') != 'bookmark')) { | |
hasContentNode = true; | |
} | |
if (blockElements[nodeName]) { | |
fragment.appendChild(node); | |
textBlock = null; | |
} else { | |
if (blockName) { | |
if (!textBlock) { | |
textBlock = dom.create(blockName); | |
fragment.appendChild(textBlock); | |
} | |
textBlock.appendChild(node); | |
} else { | |
fragment.appendChild(node); | |
} | |
} | |
} | |
} | |
if (!editor.settings.forced_root_block) { | |
fragment.appendChild(dom.create('br')); | |
} else { | |
// BR is needed in empty blocks on non IE browsers | |
if (!hasContentNode && (!tinymce.Env.ie || tinymce.Env.ie > 10)) { | |
textBlock.appendChild(dom.create('br', {'data-mce-bogus': '1'})); | |
} | |
} | |
return fragment; | |
} | |
function getSelectedListItems() { | |
return tinymce.grep(selection.getSelectedBlocks(), function(block) { | |
return block.nodeName == 'LI'; | |
}); | |
} | |
function splitList(ul, li, newBlock) { | |
var tmpRng, fragment; | |
var bookmarks = dom.select('span[data-mce-type="bookmark"]', ul); | |
newBlock = newBlock || createNewTextBlock(li); | |
tmpRng = dom.createRng(); | |
tmpRng.setStartAfter(li); | |
tmpRng.setEndAfter(ul); | |
fragment = tmpRng.extractContents(); | |
if (!dom.isEmpty(fragment)) { | |
dom.insertAfter(fragment, ul); | |
} | |
dom.insertAfter(newBlock, ul); | |
if (dom.isEmpty(li.parentNode)) { | |
tinymce.each(bookmarks, function(node) { | |
li.parentNode.parentNode.insertBefore(node, li.parentNode); | |
}); | |
dom.remove(li.parentNode); | |
} | |
dom.remove(li); | |
} | |
function mergeWithAdjacentLists(listBlock) { | |
var sibling, node; | |
sibling = listBlock.nextSibling; | |
if (sibling && isListNode(sibling) && sibling.nodeName == listBlock.nodeName) { | |
while ((node = sibling.firstChild)) { | |
listBlock.appendChild(node); | |
} | |
dom.remove(sibling); | |
} | |
sibling = listBlock.previousSibling; | |
if (sibling && isListNode(sibling) && sibling.nodeName == listBlock.nodeName) { | |
while ((node = sibling.firstChild)) { | |
listBlock.insertBefore(node, listBlock.firstChild); | |
} | |
dom.remove(sibling); | |
} | |
} | |
/** | |
* Normalizes the all lists in the specified element. | |
*/ | |
function normalizeList(element) { | |
tinymce.each(tinymce.grep(dom.select('ol,ul', element)), function(ul) { | |
var sibling, parentNode = ul.parentNode; | |
// Move UL/OL to previous LI if it's the only child of a LI | |
if (parentNode.nodeName == 'LI' && parentNode.firstChild == ul) { | |
sibling = parentNode.previousSibling; | |
if (sibling && sibling.nodeName == 'LI') { | |
sibling.appendChild(ul); | |
if (dom.isEmpty(parentNode)) { | |
dom.remove(parentNode); | |
} | |
} | |
} | |
// Append OL/UL to previous LI if it's in a parent OL/UL i.e. old HTML4 | |
if (isListNode(parentNode)) { | |
sibling = parentNode.previousSibling; | |
if (sibling && sibling.nodeName == 'LI') { | |
sibling.appendChild(ul); | |
} | |
} | |
}); | |
} | |
function outdent(li) { | |
var ul = li.parentNode, ulParent = ul.parentNode, newBlock; | |
function removeEmptyLi(li) { | |
if (dom.isEmpty(li)) { | |
dom.remove(li); | |
} | |
} | |
if (isFirstChild(li) && isLastChild(li)) { | |
if (ulParent.nodeName == "LI") { | |
dom.insertAfter(li, ulParent); | |
removeEmptyLi(ulParent); | |
dom.remove(ul); | |
} else if (isListNode(ulParent)) { | |
dom.remove(ul, true); | |
} else { | |
ulParent.insertBefore(createNewTextBlock(li), ul); | |
dom.remove(ul); | |
} | |
return true; | |
} else if (isFirstChild(li)) { | |
if (ulParent.nodeName == "LI") { | |
dom.insertAfter(li, ulParent); | |
li.appendChild(ul); | |
removeEmptyLi(ulParent); | |
} else if (isListNode(ulParent)) { | |
ulParent.insertBefore(li, ul); | |
} else { | |
ulParent.insertBefore(createNewTextBlock(li), ul); | |
dom.remove(li); | |
} | |
return true; | |
} else if (isLastChild(li)) { | |
if (ulParent.nodeName == "LI") { | |
dom.insertAfter(li, ulParent); | |
} else if (isListNode(ulParent)) { | |
dom.insertAfter(li, ul); | |
} else { | |
dom.insertAfter(createNewTextBlock(li), ul); | |
dom.remove(li); | |
} | |
return true; | |
} else { | |
if (ulParent.nodeName == 'LI') { | |
ul = ulParent; | |
newBlock = createNewTextBlock(li, 'LI'); | |
} else if (isListNode(ulParent)) { | |
newBlock = createNewTextBlock(li, 'LI'); | |
} else { | |
newBlock = createNewTextBlock(li); | |
} | |
splitList(ul, li, newBlock); | |
normalizeList(ul.parentNode); | |
return true; | |
} | |
return false; | |
} | |
function indent(li) { | |
var sibling, newList; | |
function mergeLists(from, to) { | |
var node; | |
if (isListNode(from)) { | |
while ((node = li.lastChild.firstChild)) { | |
to.appendChild(node); | |
} | |
dom.remove(from); | |
} | |
} | |
sibling = li.previousSibling; | |
if (sibling && isListNode(sibling)) { | |
sibling.appendChild(li); | |
return true; | |
} | |
if (sibling && sibling.nodeName == 'LI' && isListNode(sibling.lastChild)) { | |
sibling.lastChild.appendChild(li); | |
mergeLists(li.lastChild, sibling.lastChild); | |
return true; | |
} | |
sibling = li.nextSibling; | |
if (sibling && isListNode(sibling)) { | |
sibling.insertBefore(li, sibling.firstChild); | |
return true; | |
} | |
if (sibling && sibling.nodeName == 'LI' && isListNode(li.lastChild)) { | |
return false; | |
} | |
sibling = li.previousSibling; | |
if (sibling && sibling.nodeName == 'LI') { | |
newList = dom.create(li.parentNode.nodeName); | |
sibling.appendChild(newList); | |
newList.appendChild(li); | |
mergeLists(li.lastChild, newList); | |
return true; | |
} | |
return false; | |
} | |
function indentSelection() { | |
var listElements = getSelectedListItems(); | |
if (listElements.length) { | |
var bookmark = createBookmark(selection.getRng(true)); | |
for (var i = 0; i < listElements.length; i++) { | |
if (!indent(listElements[i]) && i === 0) { | |
break; | |
} | |
} | |
moveToBookmark(bookmark); | |
editor.nodeChanged(); | |
return true; | |
} | |
} | |
function outdentSelection() { | |
var listElements = getSelectedListItems(); | |
if (listElements.length) { | |
var bookmark = createBookmark(selection.getRng(true)); | |
var i, y, root = editor.getBody(); | |
i = listElements.length; | |
while (i--) { | |
var node = listElements[i].parentNode; | |
while (node && node != root) { | |
y = listElements.length; | |
while (y--) { | |
if (listElements[y] === node) { | |
listElements.splice(i, 1); | |
break; | |
} | |
} | |
node = node.parentNode; | |
} | |
} | |
for (i = 0; i < listElements.length; i++) { | |
if (!outdent(listElements[i]) && i === 0) { | |
break; | |
} | |
} | |
moveToBookmark(bookmark); | |
editor.nodeChanged(); | |
return true; | |
} | |
} | |
function applyList(listName) { | |
var rng = selection.getRng(true), bookmark = createBookmark(rng); | |
function getSelectedTextBlocks() { | |
var textBlocks = [], root = editor.getBody(); | |
function getEndPointNode(start) { | |
var container, offset; | |
container = rng[start ? 'startContainer' : 'endContainer']; | |
offset = rng[start ? 'startOffset' : 'endOffset']; | |
// Resolve node index | |
if (container.nodeType == 1) { | |
container = container.childNodes[Math.min(offset, container.childNodes.length - 1)] || container; | |
} | |
while (container.parentNode != root) { | |
if (isTextBlock(container)) { | |
return container; | |
} | |
if (/^(TD|TH)$/.test(container.parentNode.nodeName)) { | |
return container; | |
} | |
container = container.parentNode; | |
} | |
return container; | |
} | |
var startNode = getEndPointNode(true); | |
var endNode = getEndPointNode(); | |
var block, siblings = []; | |
for (var node = startNode; node; node = node.nextSibling) { | |
siblings.push(node); | |
if (node == endNode) { | |
break; | |
} | |
} | |
tinymce.each(siblings, function(node) { | |
if (isTextBlock(node)) { | |
textBlocks.push(node); | |
block = null; | |
return; | |
} | |
if (dom.isBlock(node) || node.nodeName == 'BR') { | |
if (node.nodeName == 'BR') { | |
dom.remove(node); | |
} | |
block = null; | |
return; | |
} | |
var nextSibling = node.nextSibling; | |
if (isBookmarkNode(node)) { | |
if (isTextBlock(nextSibling) || (!nextSibling && node.parentNode == root)) { | |
block = null; | |
return; | |
} | |
} | |
if (!block) { | |
block = dom.create('p'); | |
node.parentNode.insertBefore(block, node); | |
textBlocks.push(block); | |
} | |
block.appendChild(node); | |
}); | |
return textBlocks; | |
} | |
var textBlocks = getSelectedTextBlocks(); | |
tinymce.each(textBlocks, function(block) { | |
var listBlock, sibling; | |
sibling = block.previousSibling; | |
if (sibling && isListNode(sibling) && sibling.nodeName == listName) { | |
listBlock = sibling; | |
block = dom.rename(block, 'LI'); | |
sibling.appendChild(block); | |
} else { | |
listBlock = dom.create(listName); | |
block.parentNode.insertBefore(listBlock, block); | |
listBlock.appendChild(block); | |
block = dom.rename(block, 'LI'); | |
} | |
mergeWithAdjacentLists(listBlock); | |
}); | |
moveToBookmark(bookmark); | |
} | |
function removeList() { | |
var bookmark = createBookmark(selection.getRng(true)), root = editor.getBody(); | |
tinymce.each(getSelectedListItems(), function(li) { | |
var node, rootList; | |
if (dom.isEmpty(li)) { | |
outdent(li); | |
return; | |
} | |
for (node = li; node && node != root; node = node.parentNode) { | |
if (isListNode(node)) { | |
rootList = node; | |
} | |
} | |
splitList(rootList, li); | |
}); | |
moveToBookmark(bookmark); | |
} | |
function toggleList(listName) { | |
var parentList = dom.getParent(selection.getStart(), 'OL,UL'); | |
if (parentList) { | |
if (parentList.nodeName == listName) { | |
removeList(listName); | |
} else { | |
var bookmark = createBookmark(selection.getRng(true)); | |
mergeWithAdjacentLists(dom.rename(parentList, listName)); | |
moveToBookmark(bookmark); | |
} | |
} else { | |
applyList(listName); | |
} | |
} | |
self.backspaceDelete = function(isForward) { | |
function findNextCaretContainer(rng, isForward) { | |
var node = rng.startContainer, offset = rng.startOffset; | |
if (node.nodeType == 3 && (isForward ? offset < node.data.length : offset > 0)) { | |
return node; | |
} | |
var walker = new tinymce.dom.TreeWalker(rng.startContainer); | |
while ((node = walker[isForward ? 'next' : 'prev']())) { | |
if (node.nodeType == 3 && node.data.length > 0) { | |
return node; | |
} | |
} | |
} | |
function mergeLiElements(fromElm, toElm) { | |
var node, listNode, ul = fromElm.parentNode; | |
if (isListNode(toElm.lastChild)) { | |
listNode = toElm.lastChild; | |
} | |
node = toElm.lastChild; | |
if (node && node.nodeName == 'BR' && fromElm.hasChildNodes()) { | |
dom.remove(node); | |
} | |
while ((node = fromElm.firstChild)) { | |
toElm.appendChild(node); | |
} | |
if (listNode) { | |
toElm.appendChild(listNode); | |
} | |
dom.remove(fromElm); | |
if (dom.isEmpty(ul)) { | |
dom.remove(ul); | |
} | |
} | |
if (selection.isCollapsed()) { | |
var li = dom.getParent(selection.getStart(), 'LI'); | |
if (li) { | |
var rng = selection.getRng(true); | |
var otherLi = dom.getParent(findNextCaretContainer(rng, isForward), 'LI'); | |
if (otherLi && otherLi != li) { | |
var bookmark = createBookmark(rng); | |
if (isForward) { | |
mergeLiElements(otherLi, li); | |
} else { | |
mergeLiElements(li, otherLi); | |
} | |
moveToBookmark(bookmark); | |
return true; | |
} else if (!otherLi) { | |
if (!isForward && removeList(li.parentNode.nodeName)) { | |
return true; | |
} | |
} | |
} | |
} | |
}; | |
editor.addCommand('Indent', function() { | |
if (!indentSelection()) { | |
return true; | |
} | |
}); | |
editor.addCommand('Outdent', function() { | |
if (!outdentSelection()) { | |
return true; | |
} | |
}); | |
editor.addCommand('InsertUnorderedList', function() { | |
toggleList('UL'); | |
}); | |
editor.addCommand('InsertOrderedList', function() { | |
toggleList('OL'); | |
}); | |
editor.on('keydown', function(e) { | |
if (e.keyCode == 9 && editor.dom.getParent(editor.selection.getStart(), 'LI')) { | |
e.preventDefault(); | |
if (e.shiftKey) { | |
outdentSelection(); | |
} else { | |
indentSelection(); | |
} | |
} | |
}); | |
}); | |
editor.addButton('fb_test_button_lists', { | |
icon: 'indent', | |
title: 'Increase indent', | |
cmd: 'Indent', | |
onPostRender: function() { | |
var ctrl = this; | |
editor.on('nodechange', function() { | |
var li = editor.dom.getParent(editor.selection.getNode(), 'LI,UL,OL'); | |
ctrl.disabled(li && (li.nodeName != 'LI' || isFirstChild(li))); | |
}); | |
} | |
}); | |
editor.on('keydown', function(e) { | |
if (e.keyCode == tinymce.util.VK.BACKSPACE) { | |
if (self.backspaceDelete()) { | |
e.preventDefault(); | |
} | |
} else if (e.keyCode == tinymce.util.VK.DELETE) { | |
if (self.backspaceDelete(true)) { | |
e.preventDefault(); | |
} | |
} | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* plugin.js | |
* | |
* Copyright, Moxiecode Systems AB | |
* Released under LGPL License. | |
* | |
* License: http://www.tinymce.com/license | |
* Contributing: http://www.tinymce.com/contributing | |
*/ | |
/*jshint unused:false */ | |
/*global tinymce:true */ | |
/** | |
* Example plugin that adds a toolbar button and menu item. | |
*/ | |
tinymce.PluginManager.add('fb_test', function(editor, url) { | |
// Add a button that opens a window | |
editor.addButton('fb_test_button_key', { | |
text: 'My button', | |
icon: false, | |
onclick: function() { | |
// Open window | |
editor.windowManager.open({ | |
title: 'Example plugin', | |
body: [ | |
{type: 'textbox', name: 'title', label: 'Title'} | |
], | |
onsubmit: function(e) { | |
// Insert content when the window form is submitted | |
editor.insertContent('Title: ' + e.data.title); | |
} | |
}); | |
} | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
tinymce.PluginManager.add('fb_test_select', function(editor) { | |
editor.addButton('fb_test_button_select', function() { | |
var values = [ | |
{text: 'val1', value: 'value 1'}, | |
{text: 'val2', value: 'value 2'} | |
]; | |
return { | |
type: 'listbox', | |
//name: 'align', | |
text: 'Test Select', | |
label: 'Select :', | |
fixedWidth: true, | |
onselect: function(e) {}, | |
values: values, | |
}; | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
( function() { | |
tinymce.PluginManager.add( 'fb_test', function( editor, url ) { | |
// Add a button that opens a window | |
editor.addButton( 'fb_test_button_key', { | |
text: 'FB Test Button', | |
icon: false, | |
onclick: function() { | |
// Open window | |
editor.windowManager.open( { | |
title: 'Example plugin', | |
body: [{ | |
type: 'textbox', | |
name: 'title', | |
label: 'Title' | |
}], | |
onsubmit: function( e ) { | |
// Insert content when the window form is submitted | |
editor.insertContent( 'Title: ' + e.data.title ); | |
} | |
} ); | |
} | |
} ); | |
} ); | |
} )(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* Plugin Name: TinyMCE 4 @ WP Test | |
* Description: | |
* Plugin URI: | |
* Version: 0.0.1 | |
* Author: Frank Bültge | |
* Author URI: http://bueltge.de | |
* License: GPLv2 | |
* License URI: ./assets/license.txt | |
* Text Domain: | |
* Domain Path: /languages | |
* Network: false | |
*/ | |
add_action( 'admin_head', 'fb_add_tinymce' ); | |
function fb_add_tinymce() { | |
global $typenow; | |
if( ! in_array( $typenow, array( 'post', 'page' ) ) ) | |
return ; | |
add_filter( 'mce_external_plugins', 'fb_add_tinymce_plugin' ); | |
// Add to line 1 form WP TinyMCE | |
add_filter( 'mce_buttons', 'fb_add_tinymce_button' ); | |
add_filter( 'mce_buttons_2', 'fb_add_tinymce_button_2' ); | |
} | |
function fb_add_tinymce_plugin( $plugin_array ) { | |
$plugin_array['fb_test'] = plugins_url( '/example.js', __FILE__ ); | |
$plugin_array['fb_test_lists'] = plugins_url( '/plugin-lists.js', __FILE__ ); | |
$plugin_array['fb_test_select'] = plugins_url( '/plugin-select.js', __FILE__ ); | |
// Print all plugin js path | |
var_dump( $plugin_array ); | |
return $plugin_array; | |
} | |
function fb_add_tinymce_button( $buttons ) { | |
array_push( $buttons, 'fb_test_button_key' ); | |
array_push( $buttons, 'fb_test_button_lists' ); | |
array_push( $buttons, 'fb_test_button_select' ); | |
// Print all buttons | |
var_dump( $buttons ); | |
return $buttons; | |
} | |
function fb_add_tinymce_button_2( $buttons ) { | |
var_dump( $buttons ); | |
return $buttons; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment