diff --git a/editor/index.html b/editor/index.html
new file mode 100644
index 0000000..3347583
--- /dev/null
+++ b/editor/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ Stackline web editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/editor/index.js b/editor/index.js
new file mode 100644
index 0000000..1054a5f
--- /dev/null
+++ b/editor/index.js
@@ -0,0 +1,194 @@
+import init, {
+ World,
+ Pane,
+ FullTile,
+ Signal,
+ available_tiles,
+} from "/stackline-wasm/pkg/stackline_wasm.js";
+
+let promises = [];
+promises.push(init());
+
+const canvas = document.getElementById("main-canvas");
+const ctx = canvas.getContext("2d");
+
+let font = new FontFace("Stackline Classic", "url(\"/font/StacklineClassic-Medium.otf\")");
+promises.push(font.load());
+await Promise.all(promises);
+
+font = await font.loaded;
+document.fonts.add(font);
+
+let world = World.new();
+let cx = 0;
+let cy = 0;
+let zoom = 1;
+
+let pane = Pane.empty(5, 5);
+world.set_pane("main", pane);
+
+let available = available_tiles();
+console.log(available);
+for (let n = 0; n < available.length; n++) {
+ world.set(
+ n % 5,
+ ~~(n / 5),
+ new FullTile(available[n])
+ );
+}
+
+console.log(world.toString());
+
+resize();
+window.addEventListener("resize", resize);
+window.requestAnimationFrame(loop);
+
+function draw() {
+ let zoom_factor = Math.ceil(Math.pow(2, zoom));
+ let tile_size = 10 * zoom_factor;
+ ctx.fillStyle = "#202027";
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ let panes = [];
+
+ for (let name of world.panes()) {
+ let pane = world.get_pane(name);
+
+ ctx.strokeStyle = "rgba(128, 128, 128, 0.5)";
+ ctx.lineWidth = zoom_factor;
+
+ panes.push({
+ x: pane.x,
+ y: pane.y,
+ width: pane.width,
+ height: pane.height
+ });
+
+ ctx.strokeRect(
+ Math.round(pane.x * tile_size + cx + canvas.width / 2) - zoom_factor * 1.5,
+ Math.round(pane.y * tile_size + cy + canvas.height / 2) - zoom_factor * 1.5,
+ Math.round(pane.width * tile_size) + zoom_factor * 3,
+ Math.round(pane.height * tile_size) + zoom_factor * 3,
+ );
+
+ pane.free();
+ }
+
+ let from_y = Math.floor((-cy - canvas.height / 2) / tile_size);
+ let to_y = Math.ceil((-cy + canvas.height / 2) / tile_size);
+ let from_x = Math.floor((-cx - canvas.width / 2) / tile_size);
+ let to_x = Math.ceil((-cx + canvas.width / 2) / tile_size);
+
+ let width = to_x - from_x + 1;
+ let height = to_y - from_y + 1;
+
+ // TODO: memoize
+ let chars = world.draw(-from_x, -from_y, width, height);
+ ctx.textAlign = "left";
+ ctx.textBaseline = "top";
+ ctx.font = `${tile_size}px Stackline Classic`;
+
+ for (let y = from_y; y <= to_y; y++) {
+ for (let x = from_x; x <= to_x; x++) {
+ let x2 = x * tile_size + cx + canvas.width / 2;
+ let y2 = y * tile_size + cy + canvas.height / 2;
+
+ let index = 3 * (width * (y - from_y) + x - from_x);
+
+ let ch = String.fromCharCode(chars[index]);
+ let fg = to_color(chars[index + 1]);
+ let bg = chars[index + 2] === 0 ? null : to_color(chars[index + 2]);
+
+ if (ch !== " ") {
+ if (bg) {
+ ctx.fillStyle = `rgba(${bg.join(",")})`;
+ ctx.fillRect(
+ Math.round(x2),
+ Math.round(y2),
+ Math.round(x2 + tile_size) - Math.round(x2),
+ Math.round(y2 + tile_size) - Math.round(y2),
+ );
+ }
+ ctx.fillStyle = `rgba(${fg.join(",")})`;
+ ctx.fillText(
+ ch,
+ Math.round(x2),
+ Math.round(y2),
+ );
+ } else {
+ // Draw grid
+ let inside = false;
+
+ for (let pane of panes) {
+ if (x >= pane.x && x < pane.x + pane.width && y >= pane.y && y < pane.y + pane.height) {
+ inside = true;
+ break;
+ }
+ }
+
+ ctx.fillStyle = inside ? "#606060" : "#404040";
+ ctx.fillRect(
+ Math.round(x2 + tile_size / 2) - zoom_factor,
+ Math.round(y2 + tile_size / 2) - zoom_factor,
+ Math.max(zoom_factor, 2.0),
+ Math.max(zoom_factor, 2.0)
+ );
+ }
+ }
+ }
+}
+
+function resize() {
+ canvas.width = canvas.clientWidth;
+ canvas.height = canvas.clientHeight;
+
+ draw();
+}
+
+function loop() {
+ draw();
+ window.requestAnimationFrame(loop);
+}
+
+let mouse_x = 0, mouse_y = 0;
+let mouse_down = false;
+
+canvas.addEventListener("mousedown", (evt) => {
+ mouse_x = evt.clientX;
+ mouse_y = evt.clientY;
+ mouse_down = true;
+});
+
+canvas.addEventListener("mousemove", (evt) => {
+ if (mouse_down) {
+ // TODO: numerically stable solution
+ cx += evt.clientX - mouse_x;
+ cy += evt.clientY - mouse_y;
+
+ mouse_x = evt.clientX;
+ mouse_y = evt.clientY;
+ }
+});
+
+canvas.addEventListener("mouseup", (evt) => {
+ mouse_down = false;
+});
+
+canvas.addEventListener("mouseleave", (evt) => {
+ mouse_down = false;
+});
+
+canvas.addEventListener("wheel", (event) => {
+ const ZOOM_STRENGTH = -0.005;
+ zoom += event.deltaY * ZOOM_STRENGTH;
+ zoom = Math.min(Math.max(zoom, 0.0), 4.0);
+});
+
+function to_color(num) {
+ let alpha = (num >> 24) & 0xff;
+ let red = (num >> 16) & 0xff;
+ let green = (num >> 8) & 0xff;
+ let blue = num & 0xff;
+
+ return [red, green, blue, alpha];
+}
diff --git a/editor/style.css b/editor/style.css
new file mode 100644
index 0000000..7d70bb1
--- /dev/null
+++ b/editor/style.css
@@ -0,0 +1,21 @@
+body {
+ margin: 0;
+ height: 100vh;
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ overflow-y: hidden;
+}
+
+#left-pane, #right-pane {
+ max-width: 25vw;
+}
+
+#middle-pane {
+ flex-grow: 1;
+}
+
+#main-canvas {
+ width: 100%;
+ height: 100vh;
+}
diff --git a/font/StacklineClassic-Medium.otf b/font/StacklineClassic-Medium.otf
new file mode 100644
index 0000000..ab86693
Binary files /dev/null and b/font/StacklineClassic-Medium.otf differ
diff --git a/font/StacklineClassic-Medium.pfs b/font/StacklineClassic-Medium.pfs
new file mode 100644
index 0000000..dfaa353
--- /dev/null
+++ b/font/StacklineClassic-Medium.pfs
@@ -0,0 +1,108 @@
+Stackline Classic
+Shad Amethyst
+Medium
+11:11:10:9:-1:0:10
+65:AAPAzBhGCMEf4wRgnjgAAA==
+77:ABwZxj3G6MkYIwRgnjgAAA==
+66:AB/hgjBGCP4YIwRgn+AAAA==
+67:AAfhwjAGAMAYAwBwh+AAAA==
+68:AB/hgjBGCMEYIwRgn+AAAA==
+69:AB/xgjAGAPgYAwBgn/AAAA==
+70:AB/xgjAGAPgYAwBgHgAAAA==
+71:AAfhwjAGAMAY4wRwh+AAAA==
+72:AB45gjBGCP8YIwRgnjgAAA==
+73:AAPAMAYAwBgDAGAMA8AAAA==
+74:AAHgGAMAYAwBgjBGB4AAAA==
+75:AB5xhDCGIPgYgwhhHnAAAA==
+76:AB4BgDAGAMAYAwBgn/AAAA==
+78:AB5xhDiHkNoZwxhhHnAAAA==
+79:AAfhxjBGCMEYIwRxh+AAAA==
+80:AB/BhDCGEPwYAwBgHgAAAA==
+81:AAfhxjBGCMEZIzRzh/ADAA==
+82:AB/hgjBGCP4ZAxBhHjAAAA==
+83:AAfhwjAGAH4AYAxDh+AAAA==
+84:AA/xMgYAwBgDAGAMA8AAAA==
+85:AB45gjBGCMEYIwRgh+AAAA==
+86:AB45gjBGCMEYIYgaAYAAAA==
+87:AB45gjBGCMkZIyQ1A0AAAA==
+88:ABxxhBkBwBAHATBDHHAAAA==
+89:AB45gjBHEHQHAGAMA8AAAA==
+90:AB/yDAMAwDAMAwBgn/AAAA==
+112:AAAAAD8DEGIMQfAwBgHgAA==
+62:AAAAYAYAYAcBgGAYAAAAAA==
+60:AAAAGAYBgOAGAGAGAAAAAA==
+43:AAEAIAQAgf8CAEAIAQAAAA==
+9532:AACAEAIAQP+BACAEAIAAAA==
+9474:AACAEAIAQAgBACAEAIAAAA==
+9472:AAAAAAAAAP+AAAAAAAAAAA==
+118:AAAAAD3DEGIMQcgeAYAAAA==
+115:AAAAAA4DIGAHADAmA4AAAA==
+8853:AA/jBkRIiX0iJETBj+AAAA==
+8854:AA/jBkBICX0gJATBj+AAAA==
+8855:AA/jBlFJSRElJRTBj+AAAA==
+8856:AA/jBkFISREkJQTBj+AAAA==
+8857:AA/jBllKSREkpTTBj+AAAA==
+45:AAAAAAAAAf8AAAAAAAAAAA==
+124:AAEAIAQAgBACAEAIAQAAAA==
+109:AAAAAH2GSMkZIyRknrgAAA==
+110:AAAAAD8DEGIMQYgxD3AAAA==
+111:AAAAAA8DEGIMQYgxA8AAAA==
+113:AAAAAA+DIGQMgPACAEAcAA==
+114:AAAAAB0B0DAGAMAYB4AAAA==
+11363:AR/BlDSHEPwYBwBgHgAAAA==
+9013:AAAAAAAEEMYNgOAIAQAAAA==
+116:AAAAYAwD4DAGAMAaAYAAAA==
+117:AAAAADiDEGIMQYgxA8AAAA==
+94:AAEAIA4DYMYQQAAAAAAAAA==
+97:AAAAAA8CEAIHwcgxA9AAAA==
+98:AAABwBgDAHwMQYgxD8AAAA==
+99:AAAAAA8DEGAMAYAxA8AAAA==
+100:AAAADgCAED4MQYgxA/AAAA==
+101:AAAAAA8DEGIPwYAxA8AAAA==
+102:AAAAOAyBgDAPAMAYB4AAAA==
+103:AAAAAA/DEGIMQPgBBCB4AA==
+104:AAABwBgDAHwMQYgxDnAAAA==
+108:AAAA4AwBgDAGAMAYAYAAAA==
+107:AAABwBnDEHQNAZAxDnAAAA==
+105:AAAAMAYAABgDAGAMAcAAAA==
+106:AAAAGAMAAAwBgDAGBMBwAA==
+119:AAAAAHjmCMEZIyQ1A8AAAA==
+120:AAAAADmDIDgCAOAmDOAAAA==
+121:AAAAAD3DEGIGgGAEAcAAAA==
+122:AAAAAB/CMAwDAMAYh/AAAA==
+9673:AAAA+CCF0LoXQgg+AAAAAA==
+9675:AAAA+CCEEIIQQgg+AAAAAA==
+9677:AAAA+CCFUKoVQgg+AAAAAA==
+9678:AAAA+CCF0KoXQgg+AAAAAA==
+9676:AAAAqCCAAIIAAggqAAAAAA==
+9679:AAAA+D+H8P4fw/g+AAAAAA==
+9680:AAAA+DyHkPIeQ8g+AAAAAA==
+9681:AAAA+CeE8J4Twng+AAAAAA==
+9682:AAAA+CCEEP4fw/g+AAAAAA==
+9683:AAAA+D+H8P4QQgg+AAAAAA==
+9684:AAAA+CeE8J4QQgg+AAAAAA==
+9685:AAAA+CeE8P4fw/g+AAAAAA==
+9686:AAAA4DwHgPAeA8A4AAAAAA==
+9687:AAAAOAeA8B4DwHgOAAAAAA==
+9716:AAAA+CSEkPIQQgg+AAAAAA==
+9717:AAAA+CCEEPISQkg+AAAAAA==
+9718:AAAA+CCEEJ4SQkg+AAAAAA==
+9719:AAAA+CSEkJ4QQgg+AAAAAA==
+9655:AAABgCwEYIMRgsBgAAAAAA==
+9661:AAAB/CCCIEQFAKAIAQAAAA==
+9665:AAAABgNBiMEGIDQBgAAAAA==
+9651:AAAAIAQBQCgIgRBBD+AAAA==
+8593:AAEAcB8GsJICAEAIAQAAAA==
+8592:AAAAYBgGAf8YAYAYAAAAAA==
+8594:AAAAMAMAMf8AwDAMAAAAAA==
+8595:AAEAIAQAgJIawfAcAQAAAA==
+8280:AAOAIAQICccgIEAIA4AAAA==
+8860:AA/jBkBL6QEvpATBj+AAAA==
+63:AAPAjAGAMAwDAAAMAYAAAA==
+191:AAGAMAAAwDAMAYAxA8AAAA==
+934:AAEAIA4CoFQKgOAIAQAAAA==
+42826:AAAAAA4CIf8IgOAAAAAAAA==
+10689:AA/jBkhIiQkiJITBj+AAAA==
+10688:AA/jBkJIiSEiJCTBj+AAAA==
+8805:AA/jBkhIiQkiJOTBj+AAAA==
+8804:AA/jBkJIiSEiJOTBj+AAAA==
\ No newline at end of file