primrose
Version:
Syntax-highlighting text editor that renders to an HTML5 Canvas element
1,388 lines (1,186 loc) • 76.8 kB
JavaScript
import {
multiLineInput, multiLineOutput,
singleLineInput, singleLineOutput
} from "./controlTypes.js";
import { Cursor } from "./cursor.js";
import { EventBase } from "./eventBase.js";
import {
isApple,
isDebug, isFirefox
} from "./flags.js";
import { monospaceFamily } from "./fonts.js";
import {
grammars, JavaScript
} from "./grammars.js";
import {
assignAttributes, canvas, clear,
isCanvas,
offscreenCanvas,
resizeContext, setContextSize
} from "./html.js";
import {
MacOS, Windows
} from "./os.js";
import { Point } from "./point.js";
import { Rectangle } from "./rectangle.js";
import { Row } from "./row.js";
import { Size } from "./size.js";
import { Dark as DefaultTheme } from "./themes.js";
import { TimedEvent } from "./timedEvent.js";
//>>>>>>>>>> PRIVATE STATIC FIELDS >>>>>>>>>>
let elementCounter = 0,
focusedControl = null,
hoveredControl = null,
publicControls = [];
const wheelScrollSpeed = 4,
vScrollWidth = 2,
scrollScale = isFirefox ? 3 : 100,
optionDefaults = Object.freeze({
readOnly: false,
multiLine: true,
wordWrap: true,
scrollBars: true,
lineNumbers: true,
padding: 0,
fontSize: 16,
language: "JavaScript",
scaleFactor: devicePixelRatio
}),
controls = [],
elements = new WeakMap(),
ready = (document.readyState === "complete"
? Promise.resolve("already")
: new Promise((resolve) => {
document.addEventListener("readystatechange", (evt) => {
if (document.readyState === "complete") {
resolve("had to wait for it");
}
}, false);
}))
.then(() => {
for (let element of document.getElementsByTagName("primrose")) {
new Primrose({
element
});
}
});
//<<<<<<<<<< PRIVATE STATIC FIELDS <<<<<<<<<<
export class Primrose extends EventBase {
constructor(options) {
super();
const debugEvt = (name, callback, debugLocal) => {
return (evt) => {
if (isDebug || debugLocal) {
console.log(`Primrose #${elementID}`, name, evt);
}
if (!!callback) {
callback(evt);
}
};
};
//>>>>>>>>>> VALIDATE PARAMETERS >>>>>>>>>>
options = options || {};
if (options.element === undefined) {
options.element = null;
}
if (options.element !== null
&& !(options.element instanceof HTMLElement)) {
throw new Error("element must be null, an instance of HTMLElement, an instance of HTMLCanvaseElement, or an instance of OffscreenCanvas");
}
options = Object.assign({}, optionDefaults, options);
//<<<<<<<<<< VALIDATE PARAMETERS <<<<<<<<<<
//>>>>>>>>>> PRIVATE METHODS >>>>>>>>>>
//>>>>>>>>>> RENDERING >>>>>>>>>>
let render = () => {
// do nothing, disabling rendering until the object is fully initialized;
};
const fillRect = (gfx, fill, x, y, w, h) => {
gfx.fillStyle = fill;
gfx.fillRect(
x * character.width,
y * character.height,
w * character.width + 1,
h * character.height + 1);
};
const strokeRect = (gfx, stroke, x, y, w, h) => {
gfx.strokeStyle = stroke;
gfx.strokeRect(
x * character.width,
y * character.height,
w * character.width + 1,
h * character.height + 1);
};
const renderCanvasBackground = () => {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor);
bgfx.clearRect(0, 0, canv.width, canv.height);
if (theme.regular.backColor) {
bgfx.fillStyle = theme.regular.backColor;
bgfx.fillRect(0, 0, canv.width, canv.height);
}
bgfx.save();
bgfx.scale(scaleFactor, scaleFactor);
bgfx.translate(
(gridBounds.x - scroll.x) * character.width + padding,
-scroll.y * character.height + padding);
// draw the current row highlighter
if (focused) {
fillRect(bgfx, theme.currentRowBackColor ||
DefaultTheme.currentRowBackColor,
0, minCursor.y,
gridBounds.width,
maxCursor.y - minCursor.y + 1);
}
const minY = scroll.y | 0,
maxY = minY + gridBounds.height,
minX = scroll.x | 0,
maxX = minX + gridBounds.width;
tokenFront.setXY(rows, 0, minY);
tokenBack.copy(tokenFront);
for (let y = minY; y <= maxY && y < rows.length; ++y) {
// draw the tokens on this row
const row = rows[y].tokens;
for (let i = 0; i < row.length; ++i) {
const t = row[i];
tokenBack.x += t.length;
tokenBack.i += t.length;
// skip drawing tokens that aren't in view
if (minX <= tokenBack.x && tokenFront.x <= maxX) {
// draw the selection box
const inSelection = minCursor.i <= tokenBack.i
&& tokenFront.i < maxCursor.i;
if (inSelection) {
const selectionFront = Cursor.max(minCursor, tokenFront),
selectionBack = Cursor.min(maxCursor, tokenBack),
cw = selectionBack.i - selectionFront.i;
fillRect(bgfx, theme.selectedBackColor ||
DefaultTheme.selectedBackColor,
selectionFront.x, selectionFront.y,
cw, 1);
}
}
tokenFront.copy(tokenBack);
}
tokenFront.x = 0;
++tokenFront.y;
tokenBack.copy(tokenFront);
}
// draw the cursor caret
if (focused) {
const cc = theme.cursorColor || DefaultTheme.cursorColor,
w = 1 / character.width;
fillRect(bgfx, cc, minCursor.x, minCursor.y, w, 1);
fillRect(bgfx, cc, maxCursor.x, maxCursor.y, w, 1);
}
bgfx.restore();
};
const renderCanvasForeground = () => {
fgfx.clearRect(0, 0, canv.width, canv.height);
fgfx.save();
fgfx.scale(scaleFactor, scaleFactor);
fgfx.translate(
(gridBounds.x - scroll.x) * character.width + padding,
padding);
const minY = scroll.y | 0,
maxY = minY + gridBounds.height,
minX = scroll.x | 0,
maxX = minX + gridBounds.width;
tokenFront.setXY(rows, 0, minY);
tokenBack.copy(tokenFront);
for (let y = minY; y <= maxY && y < rows.length; ++y) {
// draw the tokens on this row
const row = rows[y].tokens,
textY = (y - scroll.y) * character.height;
for (let i = 0; i < row.length; ++i) {
const t = row[i];
tokenBack.x += t.length;
tokenBack.i += t.length;
// skip drawing tokens that aren't in view
if (minX <= tokenBack.x && tokenFront.x <= maxX) {
// draw the text
const style = theme[t.type] || {},
fontWeight = style.fontWeight
|| theme.regular.fontWeight
|| DefaultTheme.regular.fontWeight
|| "",
fontStyle = style.fontStyle
|| theme.regular.fontStyle
|| DefaultTheme.regular.fontStyle
|| "",
font = `${fontWeight} ${fontStyle} ${context.font}`;
fgfx.font = font.trim();
fgfx.fillStyle = style.foreColor || theme.regular.foreColor;
fgfx.fillText(
t.value,
tokenFront.x * character.width,
textY);
}
tokenFront.copy(tokenBack);
}
tokenFront.x = 0;
++tokenFront.y;
tokenBack.copy(tokenFront);
}
fgfx.restore();
};
const renderCanvasTrim = () => {
tgfx.clearRect(0, 0, canv.width, canv.height);
tgfx.save();
tgfx.scale(scaleFactor, scaleFactor);
tgfx.translate(padding, padding);
if (showLineNumbers) {
fillRect(tgfx,
theme.selectedBackColor ||
DefaultTheme.selectedBackColor,
0, 0,
gridBounds.x, this.width - padding * 2);
strokeRect(tgfx,
theme.regular.foreColor ||
DefaultTheme.regular.foreColor,
0, 0,
gridBounds.x, this.height - padding * 2);
}
let maxRowWidth = 2;
tgfx.save();
{
tgfx.translate((lineCountWidth - 0.5) * character.width, -scroll.y * character.height);
let lastLineNumber = -1;
const minY = scroll.y | 0,
maxY = minY + gridBounds.height,
minX = scroll.x | 0,
maxX = minX + gridBounds.width;
tokenFront.setXY(rows, 0, minY);
tokenBack.copy(tokenFront);
for (let y = minY; y <= maxY && y < rows.length; ++y) {
const row = rows[y];
maxRowWidth = Math.max(maxRowWidth, row.stringLength);
if (showLineNumbers) {
// draw the left gutter
if (row.lineNumber > lastLineNumber) {
lastLineNumber = row.lineNumber;
tgfx.font = "bold " + context.font;
tgfx.fillStyle = theme.regular.foreColor;
tgfx.fillText(
row.lineNumber,
0, y * character.height);
}
}
}
}
tgfx.restore();
// draw the scrollbars
if (showScrollBars) {
tgfx.fillStyle = theme.selectedBackColor ||
DefaultTheme.selectedBackColor;
// horizontal
if (!wordWrap && maxRowWidth > gridBounds.width) {
const drawWidth = gridBounds.width * character.width - padding,
scrollX = (scroll.x * drawWidth) / maxRowWidth + gridBounds.x * character.width,
scrollBarWidth = drawWidth * (gridBounds.width / maxRowWidth),
by = this.height - character.height - padding,
bw = Math.max(character.width, scrollBarWidth);
tgfx.fillRect(scrollX, by, bw, character.height);
tgfx.strokeRect(scrollX, by, bw, character.height);
}
//vertical
if (rows.length > gridBounds.height) {
const drawHeight = gridBounds.height * character.height,
scrollY = (scroll.y * drawHeight) / rows.length,
scrollBarHeight = drawHeight * (gridBounds.height / rows.length),
bx = this.width - vScrollWidth * character.width - 2 * padding,
bw = vScrollWidth * character.width,
bh = Math.max(character.height, scrollBarHeight);
tgfx.fillRect(bx, scrollY, bw, bh);
tgfx.strokeRect(bx, scrollY, bw, bh);
}
}
tgfx.restore();
if (!focused) {
tgfx.fillStyle = theme.unfocused || DefaultTheme.unfocused;
tgfx.fillRect(0, 0, canv.width, canv.height);
}
};
const doRender = () => {
if (theme) {
const textChanged = lastText !== value,
focusChanged = focused !== lastFocused,
fontChanged = context.font !== lastFont,
paddingChanged = padding !== lastPadding,
themeChanged = theme.name !== lastThemeName,
boundsChanged = gridBounds.toString() !== lastGridBounds,
characterWidthChanged = character.width !== lastCharacterWidth,
characterHeightChanged = character.height !== lastCharacterHeight,
cursorChanged = frontCursor.i !== lastFrontCursor
|| backCursor.i !== lastBackCursor,
scrollChanged = scroll.x !== lastScrollX
|| scroll.y !== lastScrollY,
layoutChanged = resized
|| boundsChanged
|| textChanged
|| characterWidthChanged
|| characterHeightChanged
|| paddingChanged
|| scrollChanged
|| themeChanged,
backgroundChanged = layoutChanged
|| cursorChanged,
foregroundChanged = layoutChanged
|| fontChanged,
trimChanged = layoutChanged
|| focusChanged;
if (backgroundChanged) {
renderCanvasBackground();
}
if (foregroundChanged) {
renderCanvasForeground();
}
if (trimChanged) {
renderCanvasTrim();
}
context.clearRect(0, 0, canv.width, canv.height);
context.save();
context.translate(vibX, vibY);
context.drawImage(bg, 0, 0);
context.drawImage(fg, 0, 0);
context.drawImage(tg, 0, 0);
context.restore();
lastGridBounds = gridBounds.toString();
lastText = value;
lastCharacterWidth = character.width;
lastCharacterHeight = character.height;
lastPadding = padding;
lastFrontCursor = frontCursor.i;
lastBackCursor = backCursor.i;
lastFocused = focused;
lastFont = context.font;
lastThemeName = theme.name;
lastScrollX = scroll.x;
lastScrollY = scroll.y;
resized = false;
this.dispatchEvent(updateEvt);
}
};
//<<<<<<<<<< RENDERING <<<<<<<<<<
const refreshControlType = () => {
const lastControlType = controlType;
if (readOnly && multiLine) {
controlType = multiLineOutput;
}
else if (readOnly && !multiLine) {
controlType = singleLineOutput;
}
else if (!readOnly && multiLine) {
controlType = multiLineInput;
}
else {
controlType = singleLineInput;
}
if (controlType !== lastControlType) {
refreshAllTokens();
}
};
const refreshGutter = () => {
if (!showScrollBars) {
bottomRightGutter.set(0, 0);
}
else if (wordWrap) {
bottomRightGutter.set(vScrollWidth, 0);
}
else {
bottomRightGutter.set(vScrollWidth, 1);
}
};
const setValue = (txt, setUndo) => {
txt = txt || "";
txt = txt.replace(/\r\n/g, "\n");
if (txt !== value) {
value = txt;
if (setUndo) {
pushUndo();
}
refreshAllTokens();
this.dispatchEvent(changeEvt);
}
};
const setSelectedText = (txt) => {
txt = txt || "";
txt = txt.replace(/\r\n/g, "\n");
if (frontCursor.i !== backCursor.i || txt.length > 0) {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor),
startRow = rows[minCursor.y],
endRow = rows[maxCursor.y],
unchangedLeft = value.substring(0, startRow.startStringIndex),
unchangedRight = value.substring(endRow.endStringIndex),
changedStartSubStringIndex = minCursor.i - startRow.startStringIndex,
changedLeft = startRow.substring(0, changedStartSubStringIndex),
changedEndSubStringIndex = maxCursor.i - endRow.startStringIndex,
changedRight = endRow.substring(changedEndSubStringIndex),
changedText = changedLeft + txt + changedRight;
value = unchangedLeft + changedText + unchangedRight;
pushUndo();
refreshTokens(minCursor.y, maxCursor.y, changedText);
frontCursor.setI(rows, minCursor.i + txt.length);
backCursor.copy(frontCursor);
scrollIntoView(frontCursor);
this.dispatchEvent(changeEvt);
}
};
const refreshAllTokens = () => {
refreshTokens(0, rows.length - 1, value);
}
const refreshTokens = (startY, endY, txt) => {
while (startY > 0
&& rows[startY].lineNumber === rows[startY - 1].lineNumber) {
--startY;
txt = rows[startY].text + txt;
}
while (endY < rows.length - 1 && rows[endY].lineNumber === rows[endY + 1].lineNumber) {
++endY;
txt += rows[endY].text;
}
const newTokens = language.tokenize(txt),
startRow = rows[startY],
startTokenIndex = startRow.startTokenIndex,
startLineNumber = startRow.lineNumber,
startStringIndex = startRow.startStringIndex,
endRow = rows[endY],
endTokenIndex = endRow.endTokenIndex,
tokenRemoveCount = endTokenIndex - startTokenIndex,
oldTokens = tokens.splice(startTokenIndex, tokenRemoveCount, ...newTokens);
// figure out the width of the line count gutter
lineCountWidth = 0;
if (showLineNumbers) {
for (let token of oldTokens) {
if (token.type === "newlines") {
--lineCount;
}
}
for (let token of newTokens) {
if (token.type === "newlines") {
++lineCount;
}
}
lineCountWidth = Math.max(1, Math.ceil(Math.log(lineCount) / Math.LN10)) + 1;
}
// measure the grid
const x = Math.floor(lineCountWidth + padding / character.width),
y = Math.floor(padding / character.height),
w = Math.floor((this.width - 2 * padding) / character.width) - x - bottomRightGutter.width,
h = Math.floor((this.height - 2 * padding) / character.height) - y - bottomRightGutter.height;
gridBounds.set(x, y, w, h);
// Perform the layout
const tokenQueue = newTokens.map(t => t.clone()),
rowRemoveCount = endY - startY + 1,
newRows = [];
let currentString = "",
currentTokens = [],
currentStringIndex = startStringIndex,
currentTokenIndex = startTokenIndex,
currentLineNumber = startLineNumber;
for (let i = 0; i < tokenQueue.length; ++i) {
const t = tokenQueue[i],
widthLeft = gridBounds.width - currentString.length,
wrap = wordWrap && t.type !== "newlines" && t.length > widthLeft,
breakLine = t.type === "newlines" || wrap;
if (wrap) {
const split = t.length > gridBounds.width
? widthLeft
: 0;
tokenQueue.splice(i + 1, 0, t.splitAt(split));
}
currentTokens.push(t);
currentString += t.value;
if (breakLine
|| i === tokenQueue.length - 1) {
newRows.push(new Row(currentString, currentTokens, currentStringIndex, currentTokenIndex, currentLineNumber));
currentStringIndex += currentString.length;
currentTokenIndex += currentTokens.length;
currentTokens = [];
currentString = "";
if (t.type === "newlines") {
++currentLineNumber;
}
}
}
rows.splice(startY, rowRemoveCount, ...newRows);
// renumber rows
for (let y = startY + newRows.length; y < rows.length; ++y) {
const row = rows[y];
row.lineNumber = currentLineNumber;
row.startStringIndex = currentStringIndex;
row.startTokenIndex += currentTokenIndex;
currentStringIndex += row.stringLength;
currentTokenIndex += row.numTokens;
if (row.tokens[row.tokens.length - 1].type === "newlines") {
++currentLineNumber;
}
}
// provide editing room at the end of the buffer
if (rows.length === 0) {
rows.push(Row.emptyRow(0, 0, 0));
}
else {
const lastRow = rows[rows.length - 1];
if (lastRow.text.endsWith('\n')) {
rows.push(Row.emptyRow(lastRow.endStringIndex, lastRow.endTokenIndex, lastRow.lineNumber + 1));
}
}
maxVerticalScroll = Math.max(0, rows.length - gridBounds.height);
render();
};
const refreshBuffers = () => {
resized = true;
setContextSize(fgfx, canv.width, canv.height);
setContextSize(bgfx, canv.width, canv.height);
setContextSize(tgfx, canv.width, canv.height);
refreshAllTokens();
};
const minDelta = (v, minV, maxV) => {
const dvMinV = v - minV,
dvMaxV = v - maxV + 5;
let dv = 0;
if (dvMinV < 0 || dvMaxV >= 0) {
// compare the absolute values, so we get the smallest change
// regardless of direction.
dv = Math.abs(dvMinV) < Math.abs(dvMaxV)
? dvMinV
: dvMaxV;
}
return dv;
};
const clampScroll = () => {
const toHigh = scroll.y < 0 || maxVerticalScroll === 0,
toLow = scroll.y > maxVerticalScroll;
if (toHigh) {
scroll.y = 0;
}
else if (toLow) {
scroll.y = maxVerticalScroll;
}
render();
return toHigh || toLow;
};
const scrollIntoView = (currentCursor) => {
const dx = minDelta(currentCursor.x, scroll.x, scroll.x + gridBounds.width),
dy = minDelta(currentCursor.y, scroll.y, scroll.y + gridBounds.height);
this.scrollBy(dx, dy);
};
const pushUndo = () => {
if (historyIndex < history.length - 1) {
history.splice(historyIndex + 1);
}
history.push({
value,
frontCursor: frontCursor.i,
backCursor: backCursor.i
});
historyIndex = history.length - 1;
};
const moveInHistory = (dh) => {
const nextHistoryIndex = historyIndex + dh;
if (0 <= nextHistoryIndex && nextHistoryIndex < history.length) {
const curFrame = history[historyIndex];
historyIndex = nextHistoryIndex;
const nextFrame = history[historyIndex];
setValue(nextFrame.value, false);
frontCursor.setI(rows, curFrame.frontCursor);
backCursor.setI(rows, curFrame.backCursor);
}
}
//<<<<<<<<<< PRIVATE METHODS <<<<<<<<<<
//>>>>>>>>>> PUBLIC METHODS >>>>>>>>>>
/// <summary>
/// Removes focus from the control.
/// </summary>
this.blur = () => {
if (focused) {
focused = false;
this.dispatchEvent(blurEvt);
render();
}
};
/// <summary>
/// Sets the control to be the focused control. If all controls in the app have been properly registered with the Event Manager, then any other, currently focused control will first get `blur`red.
/// </summary>
this.focus = () => {
if (!focused) {
focused = true;
this.dispatchEvent(focusEvt);
render();
}
};
/// <summary>
/// </summary>
this.resize = () => {
if (!this.isInDocument) {
console.warn("Can't automatically resize a canvas that is not in the DOM tree");
}
else if (resizeContext(context, scaleFactor)) {
refreshBuffers();
}
};
/// <summary>
/// Sets the scale-independent width and height of the editor control.
/// </summary>
this.setSize = (w, h) => {
if (setContextSize(context, w, h, scaleFactor)) {
refreshBuffers();
}
};
/// <summary>
/// Move the scroll window to a new location. Values get clamped to the text contents of the editor.
/// </summary>
this.scrollTo = (x, y) => {
if (!wordWrap) {
scroll.x = x;
}
scroll.y = y;
return clampScroll();
};
/// <summary>
/// Move the scroll window by a given amount to a new location. The final location of the scroll window gets clamped to the text contents of the editor.
/// </summary>
this.scrollBy = (dx, dy) => {
return this.scrollTo(scroll.x + dx, scroll.y + dy);
};
//<<<<<<<<<< PUBLIC METHODS <<<<<<<<<<
//>>>>>>>>>> KEY EVENT HANDLERS >>>>>>>>>>
const keyDownCommands = Object.freeze(new Map([
["CursorUp", () => {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor);
minCursor.up(rows);
maxCursor.copy(minCursor);
scrollIntoView(frontCursor);
}],
["CursorDown", () => {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor);
maxCursor.down(rows);
minCursor.copy(maxCursor);
scrollIntoView(frontCursor);
}],
["CursorLeft", () => {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor);
if (minCursor.i === maxCursor.i) {
minCursor.left(rows);
}
maxCursor.copy(minCursor);
scrollIntoView(frontCursor);
}],
["CursorRight", () => {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor);
if (minCursor.i === maxCursor.i) {
maxCursor.right(rows);
}
minCursor.copy(maxCursor);
scrollIntoView(frontCursor);
}],
["CursorPageUp", () => {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor);
minCursor.incY(rows, -gridBounds.height);
maxCursor.copy(minCursor);
scrollIntoView(frontCursor);
}],
["CursorPageDown", () => {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor);
maxCursor.incY(rows, gridBounds.height);
minCursor.copy(maxCursor);
scrollIntoView(frontCursor);
}],
["CursorSkipLeft", () => {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor);
if (minCursor.i === maxCursor.i) {
minCursor.skipLeft(rows);
}
maxCursor.copy(minCursor);
scrollIntoView(frontCursor);
}],
["CursorSkipRight", () => {
const minCursor = Cursor.min(frontCursor, backCursor),
maxCursor = Cursor.max(frontCursor, backCursor);
if (minCursor.i === maxCursor.i) {
maxCursor.skipRight(rows);
}
minCursor.copy(maxCursor);
scrollIntoView(frontCursor);
}],
["CursorHome", () => {
frontCursor.home();
backCursor.copy(frontCursor);
scrollIntoView(frontCursor);
}],
["CursorEnd", () => {
frontCursor.end(rows);
backCursor.copy(frontCursor);
scrollIntoView(frontCursor);
}],
["CursorFullHome", () => {
frontCursor.fullHome(rows);
backCursor.copy(frontCursor);
scrollIntoView(frontCursor);
}],
["CursorFullEnd", () => {
frontCursor.fullEnd(rows);
backCursor.copy(frontCursor);
scrollIntoView(frontCursor);
}],
["SelectDown", () => {
backCursor.down(rows);
scrollIntoView(frontCursor);
}],
["SelectLeft", () => {
backCursor.left(rows);
scrollIntoView(backCursor);
}],
["SelectRight", () => {
backCursor.right(rows);
scrollIntoView(backCursor);
}],
["SelectUp", () => {
backCursor.up(rows);
scrollIntoView(backCursor);
}],
["SelectPageDown", () => {
backCursor.incY(rows, gridBounds.height);
scrollIntoView(backCursor);
}],
["SelectPageUp", () => {
backCursor.incY(rows, -gridBounds.height);
scrollIntoView(backCursor);
}],
["SelectSkipLeft", () => {
backCursor.skipLeft(rows);
scrollIntoView(backCursor);
}],
["SelectSkipRight", () => {
backCursor.skipRight(rows);
scrollIntoView(backCursor);
}],
["SelectHome", () => {
backCursor.home();
scrollIntoView(backCursor);
}],
["SelectEnd", () => {
backCursor.end(rows);
scrollIntoView(backCursor);
}],
["SelectFullHome", () => {
backCursor.fullHome(rows);
scrollIntoView(backCursor);
}],
["SelectFullEnd", () => {
backCursor.fullEnd(rows);
scrollIntoView(backCursor);
}],
["SelectAll", () => {
frontCursor.fullHome();
backCursor.fullEnd(rows);
render();
}],
["ScrollDown", () => {
if (scroll.y < rows.length - gridBounds.height) {
this.scrollBy(0, 1);
}
}],
["ScrollUp", () => {
if (scroll.y > 0) {
this.scrollBy(0, -1);
}
}],
["DeleteLetterLeft", () => {
if (frontCursor.i === backCursor.i) {
backCursor.left(rows);
}
setSelectedText("");
}],
["DeleteLetterRight", () => {
if (frontCursor.i === backCursor.i) {
backCursor.right(rows);
}
setSelectedText("");
}],
["DeleteWordLeft", () => {
if (frontCursor.i === backCursor.i) {
frontCursor.skipLeft(rows);
}
setSelectedText("");
}],
["DeleteWordRight", () => {
if (frontCursor.i === backCursor.i) {
backCursor.skipRight(rows);
}
setSelectedText("");
}],
["DeleteLine", () => {
if (frontCursor.i === backCursor.i) {
frontCursor.home();
backCursor.end(rows);
backCursor.right(rows);
}
setSelectedText("");
}],
["Undo", () => {
moveInHistory(-1);
}],
["Redo", () => {
moveInHistory(1);
}],
["InsertTab", () => {
tabPressed = true;
setSelectedText(tabString);
}],
["RemoveTab", () => {
const row = rows[frontCursor.y],
toDelete = Math.min(frontCursor.x, tabWidth);
for (let i = 0; i < frontCursor.x; ++i) {
if (row.text[i] !== ' ') {
// can only remove tabs at the beginning of a row
return;
}
}
backCursor.copy(frontCursor);
backCursor.incX(rows, -toDelete);
setSelectedText("");
}]
]));
this.readKeyDownEvent = debugEvt("keydown", (evt) => {
const command = os.makeCommand(evt);
if (keyDownCommands.has(command.command)) {
evt.preventDefault();
keyDownCommands.get(command.command)(evt);
}
});
const keyPressCommands = Object.freeze(new Map([
["AppendNewline", () => {
if (multiLine) {
let indent = "";
const row = rows[frontCursor.y].tokens;
if (row.length > 0
&& row[0].type === "whitespace") {
indent = row[0].value;
}
setSelectedText("\n" + indent);
}
else {
this.dispatchEvent(changeEvt);
}
}],
["PrependNewline", () => {
if (multiLine) {
let indent = "";
const row = rows[frontCursor.y].tokens;
if (row.length > 0
&& row[0].type === "whitespace") {
indent = row[0].value;
}
frontCursor.home();
backCursor.copy(frontCursor);
setSelectedText(indent + "\n");
}
else {
this.dispatchEvent(changeEvt);
}
}],
["Undo", () => {
moveInHistory(-1);
}]
]));
this.readKeyPressEvent = debugEvt("keypress", (evt) => {
const command = os.makeCommand(evt);
if (!this.readOnly) {
evt.preventDefault();
if (keyPressCommands.has(command.command)) {
keyPressCommands.get(command.command)();
}
else if (command.type === "printable"
|| command.type === "whitespace") {
setSelectedText(command.text);
}
clampScroll();
render();
}
});
this.readKeyUpEvent = debugEvt("keyup");
//<<<<<<<<<< KEY EVENT HANDLERS <<<<<<<<<<
//>>>>>>>>>> CLIPBOARD EVENT HANDLERS >>>>>>>>>>
const copySelectedText = (evt) => {
if (focused && frontCursor.i !== backCursor.i) {
evt.clipboardData.setData("text/plain", this.selectedText);
evt.returnValue = false;
return true;
}
return false;
};
this.readCopyEvent = debugEvt("copy", (evt) => {
copySelectedText(evt);
});
this.readCutEvent = debugEvt("cut", (evt) => {
if (copySelectedText(evt)
&& !this.readOnly) {
setSelectedText("");
}
});
this.readPasteEvent = debugEvt("paste", (evt) => {
if (focused && !this.readOnly) {
evt.returnValue = false;
const clipboard = evt.clipboardData || window.clipboardData,
str = clipboard.getData(window.clipboardData ? "Text" : "text/plain");
if (str) {
setSelectedText(str);
}
}
});
//<<<<<<<<<< CLIPBOARD EVENT HANDLERS <<<<<<<<<<
//>>>>>>>>>> POINTER EVENT HANDLERS >>>>>>>>>>
const pointerOver = () => {
hovered = true;
this.dispatchEvent(overEvt);
};
const pointerOut = () => {
hovered = false;
this.dispatchEvent(outEvt);
};
const pointerDown = () => {
this.focus();
pressed = true;
};
const startSelecting = () => {
dragging = true;
moveCursor(frontCursor);
};
const pointerMove = () => {
if (dragging) {
moveCursor(backCursor);
}
else if (pressed) {
dragScroll();
}
};
const moveCursor = (cursor) => {
pointer.toCell(character, scroll, gridBounds);
const gx = pointer.x - scroll.x,
gy = pointer.y - scroll.y,
onBottom = gy >= gridBounds.height,
onLeft = gx < 0,
onRight = pointer.x >= gridBounds.width;
if (!scrolling && !onBottom && !onLeft && !onRight) {
cursor.setXY(rows, pointer.x, pointer.y);
backCursor.copy(cursor);
}
else if (scrolling || onRight && !onBottom) {
scrolling = true;
const scrollHeight = rows.length - gridBounds.height;
if (gy >= 0 && scrollHeight >= 0) {
const sy = gy * scrollHeight / gridBounds.height;
this.scrollTo(scroll.x, sy);
}
}
else if (onBottom && !onLeft) {
let maxWidth = 0;
for (let dy = 0; dy < rows.length; ++dy) {
maxWidth = Math.max(maxWidth, rows[dy].stringLength);
}
const scrollWidth = maxWidth - gridBounds.width;
if (gx >= 0 && scrollWidth >= 0) {
const sx = gx * scrollWidth / gridBounds.width;
this.scrollTo(sx, scroll.y);
}
}
else if (onLeft && !onBottom) {
// clicked in number-line gutter
}
else {
// clicked in the lower-left corner
}
render();
}
let lastScrollDX = null,
lastScrollDY = null;
const dragScroll = () => {
if (lastScrollDX !== null
&& lastScrollDY !== null) {
let dx = (lastScrollDX - pointer.x) / character.width,
dy = (lastScrollDY - pointer.y) / character.height;
this.scrollBy(dx, dy);
}
lastScrollDX = pointer.x;
lastScrollDY = pointer.y;
};
const mouseLikePointerDown = (setPointer) => {
return (evt) => {
setPointer(evt);
pointerDown();
startSelecting();
}
};
const mouseLikePointerUp = () => {
pressed = false;
dragging = false;
scrolling = false;
};
const mouseLikePointerMove = (setPointer) => {
return (evt) => {
setPointer(evt);
pointerMove();
};
};
const touchLikePointerDown = (setPointer) => {
return (evt) => {
setPointer(evt);
tx = pointer.x;
ty = pointer.y;
pointerDown();
longPress.start();
};
};
const touchLikePointerUp = () => {
if (longPress.cancel() && !dragging) {
startSelecting();
}
mouseLikePointerUp();
lastScrollDX = null;
lastScrollDY = null;
};
const touchLikePointerMove = (setPointer) => {
return (evt) => {
setPointer(evt);
if (longPress.isRunning) {
const dx = pointer.x - tx,
dy = pointer.y - ty,
lenSq = dx * dx + dy * dy;
if (lenSq > 25) {
longPress.cancel();
}
}
if (!longPress.isRunning) {
pointerMove();
}
};
};
//>>>>>>>>>> MOUSE EVENT HANDLERS >>>>>>>>>>
const setMousePointer = (evt) => {
pointer.set(
evt.offsetX,
evt.offsetY);
};
this.readMouseOverEvent = debugEvt("mouseover", pointerOver);
this.readMouseOutEvent = debugEvt("mouseout", pointerOut);
this.readMouseDownEvent = debugEvt("mousedown", mouseLikePointerDown(setMousePointer));
this.readMouseUpEvent = debugEvt("mouseup", mouseLikePointerUp);
this.readMouseMoveEvent = debugEvt("mousemove", mouseLikePointerMove(setMousePointer));
this.readWheelEvent = debugEvt("wheel", (evt) => {
if (hovered || focused) {
if (!evt.ctrlKey
&& !evt.altKey
&& !evt.shiftKey
&& !evt.metaKey) {
const dy = Math.floor(evt.deltaY * wheelScrollSpeed / scrollScale);
if (!this.scrollBy(0, dy) || focused) {
evt.preventDefault();
}
}
else if (!evt.ctrlKey
&& !evt.altKey
&& !evt.metaKey) {
evt.preventDefault();
this.fontSize += -evt.deltaY / scrollScale;
}
render();
}
});
//<<<<<<<<<< MOUSE EVENT HANDLERS <<<<<<<<<<
//>>>>>>>>>> TOUCH EVENT HANDLERS >>>>>>>>>>
let vibX = 0,
vibY = 0;
const vibrate = (len) => {
longPress.cancel();
if (len > 0) {
vibX = (Math.random() - 0.5) * 10;
vibY = (Math.random() - 0.5) * 10;
setTimeout(() => vibrate(len - 10), 10);
}
else {
vibX = 0;
vibY = 0;
}
render();
};
const longPress = new TimedEvent(1000);
longPress.addEventListener("tick", () => {
startSelecting();
backCursor.copy(frontCursor);
frontCursor.skipLeft(rows);
backCursor.skipRight(rows);
render();
navigator.vibrate(20);
if (isDebug) {
vibrate(320);
}
});
let tx = 0,
ty = 0,
currentTouchID = null;
const findTouch = (touches) => {
for (let touch of touches) {
if (currentTouchID === null
|| touch.identifier === currentTouchID) {
return touch;
}
}
return null;
}
const withPrimaryTouch = (callback) => {
return (evt) => {
evt.preventDefault();
callback(findTouch(evt.touches)
|| findTouch(evt.changedTouches))
};
};
const setTouchPointer = (touch) => {
const cb = canv.getBoundingClientRect();
pointer.set(
touch.clientX - cb.left,
touch.clientY - cb.top);
};
this.readTouchStartEvent = debugEvt("touchstart", withPrimaryTouch(touchLikePointerDown(setTouchPointer)));
this.readTouchMoveEvent = debugEvt("touchmove", withPrimaryTouch(touchLikePointerMove(setTouchPointer)));
this.readTouchEndEvent = debugEvt("touchend", withPrimaryTouch(touchLikePointerUp));
//<<<<<<<<<< TOUCH EVENT HANDLERS <<<<<<<<<<
//>>>>>>>>>> UV POINTER EVENT HANDLERS >>>>>>>>>>
const setUVPointer = (evt) => {
pointer.set(
evt.uv.x * this.width,
(1 - evt.uv.y) * this.height);
}
this.mouse = Object.freeze({
/// <summary>
/// Read's a THREE.js Raycast intersection to perform the hover gestures.
// </summary>
readOverEventUV: debugEvt("mouseuvover", pointerOver),
/// <summary>
/// Read's a THREE.js Raycast intersection to perform the end of the hover gesture.
// </summary>
readOutEventUV: debugEvt("mouseuvout", pointerOut),
/// <summary>
/// Read's a THREE.js Raycast intersection to perform mouse-like behavior for primary-button-down gesture.
// </summary>
readDownEventUV: debugEvt("mouseuvdown", mouseLikePointerDown(setUVPointer)),
/// <summary>
/// Read's a THREE.js Raycast intersection to perform mouse-like behavior for primary-button-up gesture.
// </summary>
readUpEventUV: debugEvt("mouseuvup", mouseLikePointerUp),
/// <summary>
/// Read's a THREE.js Raycast intersection to perform mouse-like behavior for move gesture, whether the primary button is pressed or not.
// </summary>
readMoveEventUV: debugEvt("mouseuvmove", mouseLikePointerMove(setUVPointer))
});
this.touch = Object.freeze({
/// <summary>
/// Read's a THREE.js Raycast intersection to perform the end of the hover gesture. This is the same as mouse.readOverEventUV, included for completeness.