import type { Language } from "./keys.ts"; export type CaretMove = "left" | "right" | "up" | "down"; /** * A view that edits text at a caret. The compact key bar drives the focused one, so a phone can * reach keys its on-screen keyboard buries. The CodeMirror editor will implement this too. */ export interface EditingTarget { /** What's being edited, so the key bar can offer that language's symbols. */ readonly language: Language; /** Inserts `text` at the caret, leaving the caret `caret` characters into it (default: after it). */ insert(text: string, caret?: number): void; move(direction: CaretMove): void; indent(): void; undo(): void; redo(): void; /** Gives up focus, which closes an on-screen keyboard. */ blur(): void; } export function isEditingTarget(value: unknown): value is EditingTarget & HTMLElement { return value instanceof HTMLElement && typeof (value as Partial).insert === "function"; } type Field = HTMLTextAreaElement | HTMLInputElement; /** * Turns off every kind of text assistance on a field that holds code or commands. Each platform * reads a different attribute: browsers read autocomplete; iOS keyboards read autocorrect and * autocapitalize; Chrome on Android asks the keyboard for no suggestions when spellcheck is off; * writingsuggestions stops the browser's inline completions. */ export function codeField(field: Field): void { field.spellcheck = false; field.autocomplete = "off"; field.autocapitalize = "off"; field.setAttribute("autocorrect", "off"); field.setAttribute("writingsuggestions", "false"); } /** * Inserts through the browser's own editing command, so the insertion joins the field's undo * history and fires `input` like typing does. Falls back to setRangeText where the command is gone. */ export function insertAtCaret(field: Field, text: string, caret = text.length): void { field.focus(); const start = field.selectionStart ?? field.value.length; if (!document.execCommand("insertText", false, text)) { field.setRangeText(text, start, field.selectionEnd ?? start, "end"); field.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text })); } field.setSelectionRange(start + caret, start + caret); } /** * The column a run of up/down moves aims for, as editors keep it: passing through a short or empty * line doesn't lose it. It lapses as soon as the caret is somewhere the last vertical move didn't put it. */ const goals = new WeakMap(); /** Moves a field's caret one character, or one line keeping its goal column. */ export function moveCaret(field: Field, direction: CaretMove): void { field.focus(); const { value } = field; const at = direction === "left" || direction === "up" ? (field.selectionStart ?? 0) : (field.selectionEnd ?? 0); let next = at; if (direction === "left") next = Math.max(0, at - 1); else if (direction === "right") next = Math.min(value.length, at + 1); else { const lineStart = value.lastIndexOf("\n", at - 1) + 1; const goal = goals.get(field); const column = goal?.at === at ? goal.column : at - lineStart; if (direction === "up") { if (lineStart === 0) next = 0; else { const previousStart = value.lastIndexOf("\n", lineStart - 2) + 1; next = Math.min(previousStart + column, lineStart - 1); } } else { const lineEnd = value.indexOf("\n", at); if (lineEnd === -1) next = value.length; else { const nextEnd = value.indexOf("\n", lineEnd + 1); next = Math.min(lineEnd + 1 + column, nextEnd === -1 ? value.length : nextEnd); } } goals.set(field, { column, at: next }); } field.setSelectionRange(next, next); } /** Runs the browser's undo or redo on a focused field. */ export function history(field: Field, command: "undo" | "redo"): void { field.focus(); document.execCommand(command); }