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, ">"); -}