diff --git a/index.html b/index.html
index 5e3380f..1e768f2 100644
--- a/index.html
+++ b/index.html
@@ -11,6 +11,9 @@
margin-right: -1px;
animation: blink 1s step-end infinite;
}
+ .selection {
+ background-color: #b3d7ff;
+ }
@keyframes blink {
50% {
border-color: transparent;
diff --git a/input.js b/input.js
deleted file mode 100644
index 056b1f0..0000000
--- a/input.js
+++ /dev/null
@@ -1,71 +0,0 @@
-export function setupInput(buffer, cursor, onChange) {
- document.addEventListener("keydown", (e) => {
- if (e.key === "ArrowUp") {
- e.preventDefault();
- if (cursor.line > 0) cursor.line--;
- cursor.col = Math.min(cursor.col, buffer[cursor.line].length);
- onChange();
- return;
- }
- if (e.key === "ArrowDown") {
- e.preventDefault();
- if (cursor.line < buffer.length - 1) cursor.line++;
- cursor.col = Math.min(cursor.col, buffer[cursor.line].length);
- onChange();
- return;
- }
- if (e.key === "ArrowLeft") {
- e.preventDefault();
- if (cursor.col > 0) cursor.col--;
- else if (cursor.line > 0) {
- cursor.line--;
- cursor.col = buffer[cursor.line].length;
- }
- onChange();
- return;
- }
- if (e.key === "ArrowRight") {
- e.preventDefault();
- if (cursor.col < buffer[cursor.line].length) cursor.col++;
- else if (cursor.line < buffer.length - 1) {
- cursor.line++;
- cursor.col = 0;
- }
- onChange();
- return;
- }
- if (e.key === "Backspace") {
- e.preventDefault();
- if (cursor.col > 0) {
- const line = buffer[cursor.line];
- buffer[cursor.line] = line.slice(0, cursor.col - 1) + line.slice(cursor.col);
- cursor.col--;
- } else if (cursor.line > 0) {
- cursor.col = buffer[cursor.line - 1].length;
- buffer[cursor.line - 1] += buffer[cursor.line];
- buffer.splice(cursor.line, 1);
- cursor.line--;
- }
- onChange();
- return;
- }
- if (e.key === "Enter") {
- e.preventDefault();
- const line = buffer[cursor.line];
- const rest = line.slice(cursor.col);
- buffer[cursor.line] = line.slice(0, cursor.col);
- buffer.splice(cursor.line + 1, 0, rest);
- cursor.line++;
- cursor.col = 0;
- onChange();
- return;
- }
- if (e.key.length === 1) {
- e.preventDefault();
- const line = buffer[cursor.line];
- buffer[cursor.line] = line.slice(0, cursor.col) + e.key + line.slice(cursor.col);
- cursor.col++;
- onChange();
- }
- });
-}
diff --git a/lib/cursor.js b/lib/cursor.js
new file mode 100644
index 0000000..6a322e1
--- /dev/null
+++ b/lib/cursor.js
@@ -0,0 +1,25 @@
+export function moveUp(buffer, cursor) {
+ if (cursor.line > 0) cursor.line--;
+ cursor.col = Math.min(cursor.col, buffer[cursor.line].length);
+}
+
+export function moveDown(buffer, cursor) {
+ if (cursor.line < buffer.length - 1) cursor.line++;
+ cursor.col = Math.min(cursor.col, buffer[cursor.line].length);
+}
+
+export function moveLeft(buffer, cursor) {
+ if (cursor.col > 0) cursor.col--;
+ else if (cursor.line > 0) {
+ cursor.line--;
+ cursor.col = buffer[cursor.line].length;
+ }
+}
+
+export function moveRight(buffer, cursor) {
+ if (cursor.col < buffer[cursor.line].length) cursor.col++;
+ else if (cursor.line < buffer.length - 1) {
+ cursor.line++;
+ cursor.col = 0;
+ }
+}
diff --git a/lib/html.js b/lib/html.js
new file mode 100644
index 0000000..96673e4
--- /dev/null
+++ b/lib/html.js
@@ -0,0 +1,10 @@
+export function escapeChar(c) {
+ if (c === "&") return "&";
+ if (c === "<") return "<";
+ if (c === ">") return ">";
+ return c;
+}
+
+export function escapeHtml(s) {
+ return s.replace(/&/g, "&").replace(//g, ">");
+}
diff --git a/lib/keyboard.js b/lib/keyboard.js
new file mode 100644
index 0000000..7087da6
--- /dev/null
+++ b/lib/keyboard.js
@@ -0,0 +1,215 @@
+import { moveUp, moveDown, moveLeft, moveRight } from "./cursor.js";
+import { hasSelection, selectedRange, clearSelection, deleteSelection } from "./selection.js";
+import { elementToPos } from "./mouse.js";
+
+export function setupInput(buffer, cursor, selection, onChange) {
+ let isMouseDown = false;
+
+ function startDrag(e) {
+ if (!e.target.closest("#text")) return;
+ isMouseDown = true;
+ const pos = elementToPos(e.clientX, e.clientY, buffer);
+ cursor.line = pos.line;
+ cursor.col = pos.col;
+ selection.anchor = { ...pos };
+ selection.head = { ...pos };
+ onChange();
+ }
+
+ function drag(e) {
+ if (!isMouseDown) return;
+ const pos = elementToPos(e.clientX, e.clientY, buffer);
+ cursor.line = pos.line;
+ cursor.col = pos.col;
+ selection.head = { ...pos };
+ onChange();
+ }
+
+ function endDrag() {
+ isMouseDown = false;
+ }
+
+ function selectWord(e) {
+ if (!e.target.closest("#text")) return;
+ const pos = elementToPos(e.clientX, e.clientY, buffer);
+ const line = buffer[pos.line];
+ let start = pos.col;
+ let end = pos.col;
+ while (start > 0 && /\w/.test(line[start - 1])) start--;
+ while (end < line.length && /\w/.test(line[end])) end++;
+ cursor.line = pos.line;
+ cursor.col = end;
+ selection.anchor = { line: pos.line, col: start };
+ selection.head = { line: pos.line, col: end };
+ onChange();
+ }
+
+ function moveCursor(newLine, newCol, extending) {
+ if (extending) {
+ if (selection.anchor === null)
+ selection.anchor = { line: cursor.line, col: cursor.col };
+ selection.head = { line: newLine, col: newCol };
+ } else {
+ clearSelection(selection);
+ }
+ cursor.line = newLine;
+ cursor.col = newCol;
+ }
+
+ function getSelectedText() {
+ const range = selectedRange(selection);
+ if (!range) return "";
+ const { start, end } = range;
+ if (start.line === end.line)
+ return buffer[start.line].slice(start.col, end.col);
+ const lines = [buffer[start.line].slice(start.col)];
+ for (let i = start.line + 1; i < end.line; i++)
+ lines.push(buffer[i]);
+ lines.push(buffer[end.line].slice(0, end.col));
+ return lines.join("\n");
+ }
+
+ function insertText(text) {
+ if (hasSelection(selection)) deleteSelection(buffer, cursor, selection);
+ const pasteLines = text.split("\n");
+ const line = buffer[cursor.line];
+ const before = line.slice(0, cursor.col);
+ const after = line.slice(cursor.col);
+ buffer[cursor.line] = before + pasteLines[0] + after;
+ for (let i = 1; i < pasteLines.length; i++)
+ buffer.splice(cursor.line + i, 0, pasteLines[i]);
+ cursor.line += pasteLines.length - 1;
+ cursor.col = pasteLines[pasteLines.length - 1].length +
+ (pasteLines.length === 1 ? before.length : 0);
+ clearSelection(selection);
+ }
+
+ function selectAll() {
+ selection.anchor = { line: 0, col: 0 };
+ selection.head = { line: buffer.length - 1, col: buffer[buffer.length - 1].length };
+ cursor.line = buffer.length - 1;
+ cursor.col = buffer[buffer.length - 1].length;
+ }
+
+ document.addEventListener("mousedown", startDrag);
+ document.addEventListener("mousemove", drag);
+ document.addEventListener("mouseup", endDrag);
+ document.addEventListener("dblclick", selectWord);
+
+ document.addEventListener("paste", (e) => {
+ e.preventDefault();
+ const text = e.clipboardData.getData("text");
+ if (!text) return;
+ insertText(text);
+ onChange();
+ });
+
+ document.addEventListener("keydown", (e) => {
+ const mod = e.ctrlKey || e.metaKey;
+ const extending = e.shiftKey;
+
+ if (mod) {
+ if (e.key === "c") {
+ e.preventDefault();
+ const text = getSelectedText();
+ if (text) navigator.clipboard.writeText(text);
+ return;
+ }
+ if (e.key === "x") {
+ e.preventDefault();
+ const text = getSelectedText();
+ if (text) navigator.clipboard.writeText(text);
+ deleteSelection(buffer, cursor, selection);
+ onChange();
+ return;
+ }
+ if (e.key === "v") {
+ return;
+ }
+ if (e.key === "a") {
+ e.preventDefault();
+ selectAll();
+ onChange();
+ return;
+ }
+ return;
+ }
+
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ const newLine = Math.max(0, cursor.line - 1);
+ const newCol = Math.min(cursor.col, buffer[newLine].length);
+ moveCursor(newLine, newCol, extending);
+ onChange();
+ return;
+ }
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ const newLine = Math.min(buffer.length - 1, cursor.line + 1);
+ const newCol = Math.min(cursor.col, buffer[newLine].length);
+ moveCursor(newLine, newCol, extending);
+ onChange();
+ return;
+ }
+ if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ let newLine = cursor.line;
+ let newCol = cursor.col;
+ if (newCol > 0) newCol--;
+ else if (newLine > 0) { newLine--; newCol = buffer[newLine].length; }
+ moveCursor(newLine, newCol, extending);
+ onChange();
+ return;
+ }
+ if (e.key === "ArrowRight") {
+ e.preventDefault();
+ let newLine = cursor.line;
+ let newCol = cursor.col;
+ if (newCol < buffer[cursor.line].length) newCol++;
+ else if (newLine < buffer.length - 1) { newLine++; newCol = 0; }
+ moveCursor(newLine, newCol, extending);
+ onChange();
+ return;
+ }
+ if (e.key === "Backspace") {
+ e.preventDefault();
+ if (hasSelection(selection)) {
+ deleteSelection(buffer, cursor, selection);
+ } else if (cursor.col > 0) {
+ const line = buffer[cursor.line];
+ buffer[cursor.line] = line.slice(0, cursor.col - 1) + line.slice(cursor.col);
+ cursor.col--;
+ } else if (cursor.line > 0) {
+ cursor.col = buffer[cursor.line - 1].length;
+ buffer[cursor.line - 1] += buffer[cursor.line];
+ buffer.splice(cursor.line, 1);
+ cursor.line--;
+ }
+ clearSelection(selection);
+ onChange();
+ return;
+ }
+ if (e.key === "Enter") {
+ e.preventDefault();
+ if (hasSelection(selection)) deleteSelection(buffer, cursor, selection);
+ const line = buffer[cursor.line];
+ const rest = line.slice(cursor.col);
+ buffer[cursor.line] = line.slice(0, cursor.col);
+ buffer.splice(cursor.line + 1, 0, rest);
+ cursor.line++;
+ cursor.col = 0;
+ clearSelection(selection);
+ onChange();
+ return;
+ }
+ if (e.key.length === 1) {
+ e.preventDefault();
+ if (hasSelection(selection)) deleteSelection(buffer, cursor, selection);
+ const line = buffer[cursor.line];
+ buffer[cursor.line] = line.slice(0, cursor.col) + e.key + line.slice(cursor.col);
+ cursor.col++;
+ clearSelection(selection);
+ onChange();
+ }
+ });
+}
diff --git a/lib/mouse.js b/lib/mouse.js
new file mode 100644
index 0000000..e0d10af
--- /dev/null
+++ b/lib/mouse.js
@@ -0,0 +1,65 @@
+import { escapeChar } from "./html.js";
+
+function bufferColToDomOffset(lineText, col) {
+ let offset = 0;
+ for (let i = 0; i < col && i < lineText.length; i++)
+ offset += escapeChar(lineText[i]).length;
+ return offset;
+}
+
+function findTextNode(parent, targetOffset) {
+ let remaining = targetOffset;
+ const walker = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
+ let node;
+ while ((node = walker.nextNode())) {
+ if (remaining <= node.textContent.length)
+ return { node, offset: remaining };
+ remaining -= node.textContent.length;
+ }
+ return { node: parent, offset: parent.childNodes.length };
+}
+
+function posToElement(line, col, buffer) {
+ const spans = document.querySelectorAll("#text > span");
+ if (line >= spans.length) return null;
+ const span = spans[line];
+ const range = document.createRange();
+ const lineText = buffer[line] || "";
+ const offset = bufferColToDomOffset(lineText, col);
+ const tn = findTextNode(span, offset);
+ range.setStart(tn.node, tn.offset);
+ range.collapse(true);
+ const rect = range.getBoundingClientRect();
+ return { x: rect.left, y: rect.top, height: rect.height };
+}
+
+export function elementToPos(x, y, buffer) {
+ const spans = document.querySelectorAll("#text > span");
+ let bestLine = 0;
+ let bestDistY = Infinity;
+
+ for (let i = 0; i < spans.length; i++) {
+ const rect = spans[i].getBoundingClientRect();
+ const midY = rect.top + rect.height / 2;
+ const distY = Math.abs(y - midY);
+ if (distY < bestDistY) {
+ bestDistY = distY;
+ bestLine = i;
+ }
+ }
+
+ let bestCol = 0;
+ let bestDistX = Infinity;
+ const lineText = buffer[bestLine] || "";
+ for (let c = 0; c <= lineText.length; c++) {
+ const pos = posToElement(bestLine, c, buffer);
+ if (!pos) continue;
+ const dist = Math.abs(x - pos.x);
+ if (dist < bestDistX) {
+ bestDistX = dist;
+ bestCol = c;
+ }
+ }
+
+ return { line: bestLine, col: bestCol };
+}
diff --git a/lib/render.js b/lib/render.js
new file mode 100644
index 0000000..11ff378
--- /dev/null
+++ b/lib/render.js
@@ -0,0 +1,40 @@
+export function render(buffer, cursor, selection) {
+ const range = selectedRange(selection);
+
+ const html = buffer.map((line, i) => {
+ const chars = [];
+ for (let c = 0; c <= line.length; c++) {
+ if (i === cursor.line && c === cursor.col)
+ chars.push(``);
+ if (c < line.length) {
+ const inSel = range &&
+ (i > range.start.line || (i === range.start.line && c >= range.start.col)) &&
+ (i < range.end.line || (i === range.end.line && c < range.end.col));
+ const ch = escapeChar(line[c]);
+ chars.push(inSel ? `${ch}` : ch);
+ }
+ }
+ if (i === cursor.line && cursor.col >= line.length)
+ chars.push(``);
+ return `${chars.join("")}`;
+ });
+
+ document.getElementById("text").innerHTML = html.join("
");
+}
+
+function selectedRange(selection) {
+ if (selection.anchor === null || selection.head === null) return null;
+ const a = selection.anchor;
+ const h = selection.head;
+ if (a.line === h.line && a.col === h.col) return null;
+ if (a.line < h.line || (a.line === h.line && a.col < h.col))
+ return { start: { ...a }, end: { ...h } };
+ return { start: { ...h }, end: { ...a } };
+}
+
+function escapeChar(c) {
+ if (c === "&") return "&";
+ if (c === "<") return "<";
+ if (c === ">") return ">";
+ return c;
+}
diff --git a/lib/selection.js b/lib/selection.js
new file mode 100644
index 0000000..ee2c501
--- /dev/null
+++ b/lib/selection.js
@@ -0,0 +1,38 @@
+export function hasSelection(selection) {
+ return selection.anchor !== null &&
+ (selection.anchor.line !== selection.head.line ||
+ selection.anchor.col !== selection.head.col);
+}
+
+export function selectedRange(selection) {
+ if (selection.anchor === null || selection.head === null) return null;
+ const a = selection.anchor;
+ const h = selection.head;
+ if (a.line === h.line && a.col === h.col) return null;
+ if (a.line < h.line || (a.line === h.line && a.col < h.col))
+ return { start: { ...a }, end: { ...h } };
+ return { start: { ...h }, end: { ...a } };
+}
+
+export function clearSelection(selection) {
+ selection.anchor = null;
+ selection.head = null;
+}
+
+export function deleteSelection(buffer, cursor, selection) {
+ const range = selectedRange(selection);
+ if (!range) return;
+ const { start, end } = range;
+ if (start.line === end.line) {
+ const line = buffer[start.line];
+ buffer[start.line] = line.slice(0, start.col) + line.slice(end.col);
+ } else {
+ const before = buffer[start.line].slice(0, start.col);
+ const after = buffer[end.line].slice(end.col);
+ buffer[start.line] = before + after;
+ buffer.splice(start.line + 1, end.line - start.line);
+ }
+ cursor.line = start.line;
+ cursor.col = start.col;
+ clearSelection(selection);
+}
diff --git a/main.js b/main.js
index ef67114..633fad6 100644
--- a/main.js
+++ b/main.js
@@ -1,11 +1,12 @@
-import { render } from "./render.js";
-import { setupInput } from "./input.js";
+import { render } from "./lib/render.js";
+import { setupInput } from "./lib/keyboard.js";
const buffer = ["Hello, world!", "Second line."];
const cursor = { line: 0, col: 0 };
+const selection = { anchor: null, head: null };
function onChange() {
- render(buffer, cursor);
+ render(buffer, cursor, selection);
try {
console.log(buffer.map((line) => JSON.parse(App.testing(line))));
} catch (e) {
@@ -14,7 +15,5 @@ function onChange() {
document.getElementById("text").innerHTML += "
";
}
-function escapeHtml(s) {}
-
-setupInput(buffer, cursor, onChange);
-render(buffer, cursor);
+setupInput(buffer, cursor, selection, onChange);
+render(buffer, cursor, selection);
diff --git a/render.js b/render.js
deleted file mode 100644
index d8bd1a2..0000000
--- a/render.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export function render(buffer, cursor) {
- const html = buffer.map((line, i) => {
- if (i !== cursor.line) return `${escapeHtml(line)}`;
- const before = escapeHtml(line.slice(0, cursor.col));
- const after = escapeHtml(line.slice(cursor.col));
- return `${before}${after}`;
- });
- document.getElementById("text").innerHTML = html.join("
");
-}
-
-function escapeHtml(s) {
- return s.replace(/&/g, "&").replace(//g, ">");
-}