Last active
April 15, 2021 04:38
-
-
Save angelhdzdev/600de18f5a4ffbe6d4ea63f8bb8eb6d8 to your computer and use it in GitHub Desktop.
Dart HTML Sprite, Sprite Animation and Timer Test
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
import 'dart:html'; | |
import 'dart:async'; | |
class Button extends Sprite { | |
Button() : super() { | |
el = document.createElement("button") as ButtonElement; | |
el.style.left = "0px"; | |
el.style.top = "0px"; | |
} | |
set label(String text) { | |
el.innerHtml = text; | |
} | |
} | |
class Sprite { | |
late HtmlElement el; | |
Sprite() { | |
el = document.createElement("div") as DivElement; | |
el.style.position = "absolute"; | |
el.style.left = "0px"; | |
el.style.top = "0px"; | |
} | |
set x(num val) => el.style.left = "${val}px"; | |
num get x => num.tryParse(el.style.left.replaceAll("px", "")) as num; | |
set y(num val) => el.style.top = "${val}px"; | |
num get y => num.tryParse(el.style.top.replaceAll("px", "")) as num; | |
} | |
class Frame<T> { | |
final num id; | |
final T element; | |
Frame(this.id, this.element); | |
} | |
typedef Fallback = Function(); | |
class Animation<T> { | |
final Map<int, Frame<T>> _timeline = {}; | |
int _cursor = 0; | |
int _to = -1; | |
int _from = 0; | |
Frame<T>? currentFrame; | |
final Fallback fallback; | |
int speed; | |
bool looping; | |
Timer? _timer; | |
Animation({required this.fallback, this.speed = 150, this.looping = false}); | |
final _controller = new StreamController<Frame<T>>(); | |
void addFrame(int id, T element) { | |
_timeline[id] = Frame<T>(id, element); | |
} | |
Stream<Frame<T>> get onChange => _controller.stream; | |
void runUntil(int end, Function progress, Function complete) { | |
_timer = Timer.periodic(Duration(milliseconds: speed), (Timer timer) { | |
_tick(end, progress, complete); | |
}); | |
} | |
void _tick(int end, Function progress, Function complete) { | |
if (_cursor >= end) { | |
progress(); | |
complete(); | |
} else { | |
progress(); | |
_cursor++; | |
} | |
} | |
void stop() { | |
if (_timer != null) { | |
_timer?.cancel(); | |
_timer = null; | |
} | |
} | |
void play({from = 0, to = -1}) { | |
stop(); | |
_from = from; | |
_cursor = _from; | |
_to = to; | |
if (_cursor < 0) { | |
_cursor = 0; | |
} else if (_cursor >= _timeline.length) { | |
_cursor = _timeline.length - 1; | |
} | |
if (_to >= _timeline.length) { | |
_to = _timeline.length - 1; | |
} else if (_to < 0) { | |
_to = _timeline.length - 1; | |
} | |
runUntil(_to, _progress, _complete); | |
} | |
void _progress() { | |
print('Playing frame $_cursor'); | |
playFrame(_cursor); | |
} | |
void _complete() { | |
print("timer stopped."); | |
if (looping) { | |
print("looped."); | |
play(from: _from, to: _to); | |
} | |
} | |
void playFrame(int id) { | |
if (_timeline.isEmpty) return; | |
try { | |
_cursor = id; | |
currentFrame = _timeline[_cursor]; | |
_controller.sink.add(currentFrame!); | |
} catch(error) { | |
currentFrame = Frame<T>(_cursor, this.fallback()); | |
_controller.sink.add(currentFrame!); | |
} | |
} | |
} | |
class AnimatedSprite extends Sprite { | |
final animation = Animation<Sprite>(fallback: () => Sprite(), speed: 580); | |
AnimatedSprite() { | |
animation.onChange.listen((Frame<Sprite> frame) { | |
_draw(frame.element); | |
}); | |
} | |
void _draw(Sprite sprite) { | |
if (el.childNodes.isNotEmpty) { | |
el.firstChild?.remove(); | |
} | |
el.append(sprite.el); | |
} | |
} | |
Sprite factory(String color) { | |
final square = Sprite(); | |
square.el.style.width = "50px"; | |
square.el.style.height = "50px"; | |
square.el.style.backgroundColor = color; | |
return square; | |
} | |
Sprite spriteFrom( | |
ImageElement image, | |
num destX, | |
num destY, | |
int destWidth, | |
int destHeight | |
) { | |
CanvasElement sourceCanvas = document.createElement("canvas") as CanvasElement; | |
sourceCanvas.width = image.width; | |
sourceCanvas.height = image.height; | |
CanvasRenderingContext2D sourceCanvasCtx = sourceCanvas.getContext("2d") as CanvasRenderingContext2D; | |
sourceCanvasCtx.drawImageScaled(image, destX, destY, image.width ?? 0, image.height ?? 0); | |
CanvasElement destCanvas = document.createElement("canvas") as CanvasElement; | |
destCanvas.width = destWidth; | |
destCanvas.height = destHeight; | |
CanvasRenderingContext2D destCanvasCtx = destCanvas.getContext("2d") as CanvasRenderingContext2D; | |
destCanvasCtx.drawImageScaled( | |
sourceCanvas, | |
0, 0, image.width ?? 0, image.height ?? 0 | |
); | |
final sprite = Sprite(); | |
sprite.el.append(destCanvas); | |
return sprite; | |
} | |
late AnimatedSprite link; | |
void main() { | |
final data = ['data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPIA', 'AADHCAMAAAAUAf6IAAAAS1BMVEW1pdVAwPjoSDD4wGj4sDAwiPCo4Pjml1SYUED4+PgAAA', 'D48Mg44IDIeCBIkEj4wIj46Kj4yBBwgDhvMZiY4Gg4UJhIkPD4KFiwwEj9McapAAAAAXRS', 'TlMAQObYZgAADcVJREFUeF7smu1u5LYSRPX7VM2XN8m97/+kmSHaaKh7BhQkA5tMpryQtW', 'WiyUNKtF3m', '8us/p+XX8h', '/TB/lt9EH+', 'IIc+O/Y/z9', '93D+c/vnW+', 'i+XdkTkTYk', 'Bz5s2Rga+v', '/z/09QWcH8', 'zvjTx4U1+D', '+a2R+bpTDh', 'HoD+ZEXgCY', 'I8zbp918Xv', 'n8PPJjjeE2', 'dAJinRN5wb', 'YMFYEXaFiy', '2T4VWHre3l', 'mo1uEQMrfQ', '6S7g8UYnMr', 'ZkJFYIYFsy', 'LKwQwGAbGo', 'IkdTTA7miA', 'JUPtF1vR8T', '5k4LZCvp1I', '5EGMjWQgEQ', 'LYSKyQEQPN', 'iIIgAEFBkw', 'TGBU1ED2Kp', '/UbHO5Evgx', 'gI4sfNgzmQ', 'sRFJnMg5FR', 'U50FR9LJmC', 'ACAJensw1c', 'fZ8V7kQfy4', 'MIgfH3dkro', 'mMTAIvbSra', '6oTEfNWCGQ', 'm5ImuIRG4d', '70aOhxq4X6', '6cbo9rPthI', 'IOoDXKYifT', 'PkhmBckJMZ', 'rdvzqk52fB', 'D5BFwHMZc7', 'Mxdy+0KKf8', 'OYTMVSm2d7', 'PJArghDCTo', 'QoYgCXOqXj', 'PcjXBzNc72', 'J84u7AJSMC', 'LJTfRiZTEc', '3p7cG2oSFI', 'CK99RK9TO9', '6LfAEud9Q/', 'r98Xbncjkc', 'HYJggmU4Gx', 'JLXVjCrdB6', 'm/IBB1il86', '3rVjX7gN5L', '+u3xceL3Mi', 'YwA6MnQfOe', 'yChoyNK3Lw', '0tBwlinIId', 'iHzHPkfLCx', 'kfqQiC8sBR', 'lsG6miEW8z', 'Ddk2erbKNq', '7Iw8fR+R7k', 'BU5cy4N9go', 'LchyQ6coxI', 'ZfsaYvh6hh', 'Bkbee3BND7', 'lTiCvARyKJ', 'CXRB7CHRkb', '6KsPtruP+/', 'aVHTQfSK7a', 'b75rO5FvXC', '9Be71U5AVJ', 'goYsQJLoq/', 'wKAZCgDgMU', 'dvWhIwtA4R', '9AvgvgxSp3', 'BOh+W7T5ao', 'bSnvmk4ADy', '6cTl+q3L+P', '+bZ1+cgG/m', 'C3BieXPkBc', '5nQuP2vXPs', 'gXy+Jz9nTi', 'fO4y6QJzMW', '+vf5r/ReyH', 'P/967yB/mD', 'DAuMy29AZp', 'o/d387Gjyt', 'j0RGrh0Beh', '04hjzJtzMG', '3jhFLIQami', 'TRkQFoyEFG', 'ix8f4hDyJN', '9ewDZszbER', 'thGUob4IgU', 'kVZMtE7ytf', 'hI4i93w7EQ', 'baxhwbYupg', 'FgIHGSABLr', '5lY1fkSKCw', 'YSMyJDL0UJ', 'cjOXZUAoc9', 'D4GNkFBDBp', 'vgWyFLwbwR', 'efRY7ku+vT', '/HDgYkELBG', 'sGW7o2mo+t', 'ggSeg5sjRH', 'TspyX7PVYz', 'm2hSUoKQcD', 'GUOPE6HFiU', 'El44oMBrxx', 'lflfNMv7Hu', 'oezLEHmFBB', 'DmZYtiCTm6', 'C9qoOGCOI5', 'MokDk1B3b4', '5tGyOg+Nim', '18dS8REGS1', 'gk8moF9u/Y', 'Pd8+lGPnq9', 'DQIgaudTCA', 'KcgMMCFhEg', '2ECPcwsgHo', 'yCGLSY6dZZ', 'wE6VsGXLYd', '5CE9Q8725V', '22MLAPeZJv', 'YwnbBjHNsQ', 'MtudKH/Av2', 'dHvMEBAsSh', '0JhO2fQe5D', 'lZAkw+YcW1', 'TkYHaN9cAK', 'mURDLPEJ6I', 'mrHcTHkIfw', '66FuzrHDTr', '9luolmHFpt', 'U+TPSsAKeR', 'BLh5B7vt2n', 'QjJszLFf5t', 'LZPJFlQpaY', 'RwEsEMCHty', '9CfdW2+6Hq', 'p6pP0Qy5Hy', 'H6pCIf5HfV', 'Z5U/yB/kD/', 'IHeX5+G/gB', 'BIDfgjw/jz', '2P9NtXeO5T', 'Q2OzHY2h3c', 'g/fx47axlg', '6iMBqlMKZG', '7DfJzbkX/+', 'PHbWIkp1f4', 'UM6zLp56+Q', 'zMe5Pcf+4f', 'PY61LAzM+8', 'WrRThZIoSW', 'kZ59Ec+/h5', '7CDLUnMfg4', 'QasgwICQK5', 'j3NPjn3sPL', 'ZlniFnqfQp', 'fonuCzLOtI', '/JOHfk2LvP', 'Y9uyoSeWWY', 'qcXkHzAwFq', 'oISMjGQwYj', 'rO7Tn2z5/H', 'RgDOUvluOv', '2C3FYf2QDC', 'NkzGuXnHPn', 'YeG/MsikfC', 'MqH0Tfezvv', 'FqNbGGP5CZ', 'jHM78sHz2B', 'FjY9GRXQ8b', 'Q/fXyb0LMp', 'IENkzGuR35', '4HnsQQCmIP', 's5sjpySe5X', 'D7ZtgNd7BX', 'uCoMPnsS21', 'BwwTaABt9b', 'uPNWRKnYew', '1b5THEA+fB', '4b265DZcFK', 'tyKH33NsEK', 'yRoxazcW5H', 'PnYeG6TXYS', '+1PqL4NceW', '2jsexSbj3I', 'G8/Tz23E/1', '+sWe5tjwtE', '7q73buaLlx', 'FQbAcLaFxv', 'np3bng/d/0', 'YGmqwVGyCo', 'c2cXqi7RSs', 'cTPzjTDG1N', '3XFsGL/NrH', 'tvjtVX6RX2', 'RgNP+8ZFu5', 'WvT5P38u52', 'vF4s403t6Y', 'J8saq3wFlu', 'fPlXx1+buR', 'eXt/f+MZyA', 'CjZL+6U7GY', '9zywrTbjZL', 'G5z1Hz7qYv', 'sLyNxjGy/Z', 'T/HM3v7SZF', 'wfJOHJG92c', 'hilfxOyHRR', '0LwTx2RvNr', 'KIJc/D19hC', 'S1+h+/FGfp', 'eijJPtR40M', 'mofHV3mU7A', 'kR2cWzDWw/', 'UKOB7eLppq', '94OjLxHBm4', 'SGjjx+WD8+', 'duUvFNx8QT', '5OtLiFaDVg', 'cNtQyfP74U', 'iZcWSH6GfH', '2h2CrAu9HE', 'MHr++IIzXk', 'ACHJ6PPP+Y', '8HwDe/5hcP', '/T1z3zr42g', 'F/n3xWsf+0', 'V+kV9kaJ0q', '8XU0dP59ae', 'TMENloPaEg', 'KXFQhDBw/n', '3J5I+PzO1k', 'qBoVuiqgzK', '47dP6d9rFN', '3JtjckGrQ+', 'kJlYoKqHrC', 'yPkBwWozTh', 'ab+xwzx2Sl', '0nAIpSPYIz', '0cjDB8vgVs', 'CCrOY2T7Kf', '85mo/JdulV', 'qHIxGkG6Fn', 'I0fr4XK8GJ', 'Y7I3G1mskr', '+ZfKCWFpVD', 'T+gFejhyvi', 'f3BC+Oyd5s', '5IzmB8j2Nx', 'EBYfx8Cxz5', 'Q4syTrYf9Z', 'vAOyFbJWKy', 'J0RkF7sZ2E', 'iEA9sP1Ghg', 'u9jL9CU6yM', 'H0FUxHTuxp', '+7lJoWTim1', 'R80wluRntZ', 'ipDzaiBeis', 'RLC1zeYk8L', 'TiQy8YKzN+', 'fBfexdPVZk', 'FefBx4Snfn', 'iEnGHwYfDJ', 'twgyvHZFHv', 'jfCL/2sefj', 'tY/NV4u0AK', '0diuHzH0um', '0NN5e3/75W', 'RKKUJEqqzi', 'X04GWNuPTG', '5keVPitw/s', 'hNBzhiepMn', 'wLOQP5Oa5l', 'SmGODNqSn2', 'PGpkDh21dB', 'OyZT7L1LT4', 'OA7Ku6bS0A', '98EWgCNA33', 'fHU2RWMo5s', '4pBM/sgxuV', 'bYHHWAWh2Z', 'lOj758eT1z', 'LXaDdNayqO', 'yWVF20FRs4', 'KLJ1MKff/s', 'eJacEhMzOb', 'LTE5CVqWjl', 'qxmq8h0B+u', '/u+GfISPXm', 'q2xkRbP29B', 'sK9mRjpwSX', 'jn+GjMTMte', 'zNghasmAUs', 'Yk/QWpSSEu', '74Z8h+Xzqe', 'sWOyVFXFIN', '3K38ilRcId', '/xAZJTN1X/', 'ZmalUx2lXx', 'Hsi2Lx2Tg/', 'acrGFuJc8M', 'bLDvs2Rgkn', 'zdXFuYGCN4', 'cwv8scosB5', 'hhmBzvS8+T', 'axcB+QBw5d', 'hcJImZKsf7', '0vPk0iIg37', 'rgpIiZwjc8', 'PCr4269lFa', 'sZM//XBSel', 'rGZp7rqPHZ', 'NNjIrVjJnH', 'F5zWR2JnZC', '/25vGBbWRS', '2pIfFVfEW7', 'KZx5+Xu3s1', 'yP360a+t3o', 'tM0gf+xOPI', 'dx3YJGAlA4', 'lHkSemr/EZ', 'm0TSGTvtjR', 'zfpMZnbCMX', 'KOdktqOCx5', 'Djpcj4FgFJ', 'zCLeko90cZ', 'wlwxTZwpPH', 'twgAUgusrE', 'Y+LhrLcZpM', 'qQTkwceKiS', 'cpSiqANBvy', 'QheLIwM/Qo', '4fHqeflwGs', '3ZI7nyOT0h', 'UyzJPjLQK+', 'b1ckrrKJPc', '3E4+T5jaD5', 'vS+W7lo2cl', '/jK+SJgT25', '3RdvEVyNuM', 'o08WUyA2TA', 'molN3cEtAh', '/xtWzkYB87', 'JhckCnNb9/', 'O/k5qoMhK3', 'kzUKE7+gGV', '5wjl/Lnhzv', 'Y8NKBkf+LM', 'i3mV/DjW8R', 'zFc53semAt', 'RL5BZCvusv', 'W+ev5Xgfm9', 'qCQ0B+6FsE', 'cZWbmYF9bI', 'DDTsnxtWxm', 'BvaxqZenr8', '8WZf9VNnOw', 'jx2RbcrmsA', 'dy/LwMjOxj', 'u7y7qf3yV9', '1+jPAiv97H', '3mOVgQPwNZ', 'dI273E/CvJ', 'R+C4kklg1N', 'ZSELJOdtBy', 'SEghtGO5Tc', '/m2p2SFw4s', 'jUzigGLYkk', '+t5dTIcGqh', '+NYVsuWsJ5', 'bW3zMZI9v7', '2j155SKA5l', 'DqmlOy5fqe', 'iE/PU2VyRl', 'oKaJ1wvL7K', 'WA4UKuInqP', 'Jqpn9fu9Z6', 'jWw54ym5q/', 'c5GYBBMkCY', 'H/8cP2OvYm', '2vDuzzysP5', 'ENcyGxl0C5', 'ARso6Vm/Og', 'X+GCMw4/fW', '3JEmwnMrFj', 'ZBp39PUYOI', 'F8C/MaHDm2', 'L09mgaUjCx', '+0qtsZm1tu', 'Umjw15sUCZ', 'QMXCL7XQ4y', 'SiOzyVc0T+', 'WcvBzblyfD', 'stCTC1Cwdz', 'zXep6s3UxA', '4z0jQyFpOL', 'L9xXhPJmcx', 'n6TT5Wutkp', 'fOGc3E53no', '85Q1sDd5yU', 'BuxxWofNfq', 'iwIkwJNVnD', 'GyisV80g6W', 'r2usee2E05', 'qSW2zIn3wW', '7H1tcgsht/', 'hWciFRUJmR', 'vdjI5AzSbM', 'nUCtI48nG5', 'kfz52Vf51M', 'QnWtvEp0a+', 'eLX6nPXcwF', 'bxSqYkR3Zi', 'zSNUCTYDW6', 'gSBAPbkd0f', 'Bqq50du0K1', 'VurVTZzcmX', '52nrXSZLJN', 'y17P8nF80D', 'ggY4zwsY3P', 'S1khdicj9D', 'Y/WzVshavb', '7XLztcz5HV', 'nBLpVrLeBd', 'EPjPMmu0he', 'JD10X7Y6ak', '9RbGobkQ9I', 'pBaEA1uz+s', 'n4m5TmqdxK', 'Pi7bAQ8HkN', 'a/9ad1wlf0', 'S2w5w18gW5', '3VTDx92QY9', '0mzJtQLSjF', 'bZyEeWlcPC', 'INk/asDfyQ', 'kgvkmZWQLL', 'm1kCy5us/Y', 'urbNfyOuDx', '5O3A9gvOPq', 'e9a2QrcbwU', '0UDQ+LygiV', 'dZ1kNolpep', 'na71ZI3DyK', '6IJ2OLr3DB', '6bJzeb+/bV', 'TW8jvy/ILT', 'UBo72NTluC', 'i59Rz5V273', '2X1Z639P8v', '8u/gW18g2p', 'TFIeSQAAAA', 'BJRU5ErkJg', 'gg==']; | |
ImageElement linkSprite = document.createElement("img") as ImageElement; | |
linkSprite.src = data.join(""); | |
linkSprite.onLoad.listen((Event event) { | |
final stand = spriteFrom(linkSprite, -121, -62, 70, 80); | |
final walk = spriteFrom(linkSprite, -20, -60, 70, 80); | |
link = AnimatedSprite(); | |
link.animation.addFrame(0, walk); | |
link.animation.addFrame(1, stand); | |
link.animation.looping = true; | |
document.body?.append(link.el); | |
}); | |
linkSprite.width = 1000; | |
linkSprite.height = 1000; | |
final stop = Button(); | |
stop.label = "Stop"; | |
stop.el.onClick.listen((MouseEvent event) { | |
link.animation.stop(); | |
}); | |
final play = Button(); | |
play.label = "Play"; | |
play.el.onClick.listen((MouseEvent event) { | |
link.animation.play(); | |
}); | |
final container = Sprite(); | |
container.el.style.backgroundColor = "#000"; | |
container.el.style.width = "100%"; | |
container.el.style.height = "60px"; | |
container.el.style.display = "flex"; | |
container.el.style.flexDirection = "row"; | |
container.el.style.alignItems = "center"; | |
container.el.style.justifyContent = "center"; | |
container.el.style.setProperty("gap", " 20px"); | |
document.body?.append(container.el); | |
container.y = 500; | |
container.el.append(play.el); | |
container.el.append(stop.el); | |
final speed = 10; | |
final keyboard = DefaultKeyboard(); | |
keyboard.left.and([Keyboard.up]).onHold = () { | |
print("Diagonal Left/Up"); | |
}; | |
keyboard.left.onHold = () { | |
link.x -= speed; | |
}; | |
keyboard.right.onHold = () { | |
link.x += speed; | |
}; | |
keyboard.up.onHold = () { | |
link.y -= speed; | |
}; | |
keyboard.down.onHold = () { | |
link.y += speed; | |
}; | |
} | |
class DefaultKeyboard extends Keyboard { | |
final left = Key("ArrowLeft"); | |
final right = Key("ArrowRight"); | |
final up = Key("ArrowUp"); | |
final down = Key("ArrowDown"); | |
final enter = Key("Enter"); | |
DefaultKeyboard() { | |
addKey(left); | |
addKey(right); | |
addKey(up); | |
addKey(down); | |
addKey(enter); | |
} | |
} | |
class Key { | |
List<bool> presses = []; | |
Timer? timer; | |
final String name; | |
Function onPress = () {}; | |
Function onRelease = () {}; | |
Function onHold = () {}; | |
Function onRepeat = () {}; | |
bool multiple = false; | |
Duration repeatDelay = Duration(seconds: 1); | |
bool isPressed = false; | |
bool wasPressed = false; | |
List<List<Key>> _combos = []; | |
Key and(List<Key> combo) { | |
_combos.add(combo); | |
return this; | |
} | |
Key(this.name); | |
} | |
class Keyboard { | |
Keyboard() { | |
init(); | |
} | |
Function? onKeyUp; | |
Function? onKeyDown; | |
List<Key> _keys = []; | |
void addKey(Key key) { | |
_keys.add(key); | |
} | |
void init() { | |
window.addEventListener("keydown", (dynamic event) { | |
event.preventDefault(); | |
late Key key; | |
try { | |
key = _keys.firstWhere((Key item) => item.name == event.key); | |
} catch(error) { | |
key = Key(event.key); | |
} | |
key.isPressed = true; | |
if (onKeyDown != null) onKeyDown!(key); | |
}); | |
window.addEventListener("keyup", (dynamic event) { | |
event.preventDefault(); | |
late Key key; | |
try { | |
key = _keys.firstWhere((Key item) => item.name == event.key); | |
} catch(error) { | |
key = Key(event.key); | |
} | |
key.isPressed = false; | |
key.wasPressed = false; | |
if (onKeyUp != null) onKeyUp!(key); | |
}); | |
Timer.periodic(Duration(milliseconds: 24), (Timer timer) { | |
_keys.forEach((Key key) { | |
_render(key); | |
}); | |
}); | |
} | |
void _render(Key key) { | |
if (key.isPressed) { | |
if (!key.wasPressed) { | |
key.onPress(); | |
key.wasPressed = true; | |
if (key.multiple) { | |
key.presses.add(true); | |
if (key.presses.length > 2) key.onRepeat(key.presses.length); | |
if (key.presses.length > 1) return; | |
key.timer = Timer(key.repeatDelay, () { | |
key.presses = []; | |
if (key.timer != null && key.timer!.isActive) { | |
key.timer!.cancel(); | |
key.timer = null; | |
} | |
}); | |
} | |
} | |
key.onHold(); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment