master cce7d477740f cached
17 files
73.0 KB
18.1k tokens
3 symbols
1 requests
Download .txt
Repository: grassator/canvas-text-editor-tutorial
Branch: master
Commit: cce7d477740f
Files: 17
Total size: 73.0 KB

Directory structure:
gitextract_31l1_8oj/

├── .gitignore
├── .jshintrc
├── Makefile
├── README.md
├── index.html
├── lib/
│   ├── CanvasTextEditor.js
│   ├── Document.js
│   ├── FontMetrics.js
│   ├── Selection.js
│   └── index.js
├── package.json
├── test/
│   ├── Document.spec.js
│   ├── Editor.spec.js
│   ├── FontMetrics.spec.js
│   ├── Selection.spec.js
│   └── __setup__.js
└── vite.config.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
dist/
.DS_Store
node_modules

================================================
FILE: .jshintrc
================================================
{
  "curly": true,
  "eqeqeq": true,
  "immed": true,
  "latedef": true,
  "newcap": true,
  "noarg": true,
  "sub": true,
  "expr": true,
  "undef": true,
  "unused": true,
  "boss": true,
  "eqnull": true,
  "devel": true,
  "node": true,
  "browser": true
}


================================================
FILE: Makefile
================================================
# Builds library for browser usage
build:
	node ./scripts/build.js

# Starts express server that serves stitched library
serve:
	node ./scripts/serve.js

# These aren't real targets so we need to list them here
.PHONY: build serve

================================================
FILE: README.md
================================================
# Canvas Text Editor Tutorial

1.  [Why write another editor?](#why-write-another-editor)
2.  [Data Structure](#data-structure)
3.  [Dealing with DOM](#dealing-with-dom)
4.  [Font Metrics](#font-metrics)
5.  [Read-only document and naive rendering](#read-only-document-and-naive-rendering)
6.  [Zero-width selection (cursor) support](#zero-width-selection-cursor-support)
7.  [Text insert and delete operations](#text-insert-and-delete-operations)
8.  [Text selection](#text-selection)
9.  [Simple scroll](#simple-scroll)

## Why write another editor?

It’s surprising how little information you can find on the subject of creating a proper, fast, and feature complete plain text editor. All available information is either very old and not indicative of recent trends, or just very vague and unhelpful.

I’m going to try to fix that by creating a series of tutorials explaining all important aspects of text editors while creating a usable application using HTML5 canvas and a lot of JavaScript code.

Here’s a list of feature requirements for the editor in order of priority:

1.  keyboard cursor navigation and selection;
2.  mouse cursor navigation and selection;
3.  copy & paste support;
4.  simple search & replace;
5.  line numbering.

And these are technical requirements:

1.  fast enough to handle at least 100 kb of text;
2.  no external dependencies;
3.  works in all modern browsers;
4.  built with BDD or TDD.

I don’t plan to have any of the following functionality to make task more realistic because each of these topics has enough problems to write a book about:

1.  support for RTL text, hieroglyphs and vertical text;
2.  search;
3.  syntax highlighting.

## Data Structure

Before we can start programming text editor itself we need to decide on a data structure that will hold text document. Plain string won’t work because it will be too slow on any decent text document and additionally string is an immutable data type in JavaScript so any modification will require creating a new copy. Let’s see what our options are.

There’s an [excellent paper](http://ned.rubyforge.org/doc/crowley98data.ps.gz) by Charles Crowley on data structures for text editors, complete with benchmarks and thorough descriptions. If you are serious about writing a good editor I highly recommend reading it.

Here are four viable options for storing document data:

1.  array of lines;
2.  buffer gap;
3.  fixed-size buffers;
4.  piece table.

Each of these methods has it’s strong and weak sides described in a paper mentioned earlier in this post, but due to immutability of the strings in JavaScript I have to exclude numbers 2 and 3. This leaves us with either _array of lines_ or a _piece table_. Latter one is generally more attractive but also is more complex both in terms of understanding and programming.

Array of lines is very straightforward to implement and doesn’t need any explanation on what it is which is ideal for a tutorial.

## Dealing with DOM

Writing text editor in JavaScript has some unfortunate side effects:

*   having to deal with DOM;
*   handling text input and especially copy & paste.

I’m not going into details on creating DOM structure in JavaScript since there are a [lot of tutorials](https://www.google.com/search?q=dom%20manipulation%20javascript) on the subject explaining it much better than I would be able to do. Here’s what we need to create:

```
<div class="canvas-text-editor" tabindex="0">
    <canvas></canvas>
    <textarea></textarea>
</div>
```

Let’s go over interesting parts. We add `class` to wrapper div so it would be easier to find and style our editor inside a real page. `tabindex` is necessary in order for our wrapper to be able to receive input focus. Value of `0` means that it’s real index will be auto calculated based on document structure.

Since our wrapper is able to receive focus and thus any keyboard events you might be wondering why do we need a `textarea`. The answer lies in the fact that browsers have very poor or even non-existent clipboard support. In order to avoid these problem we introduce a native input element that supports clipboard quite nicely. So whenever our wrapper gains focus we just proxy that event to `textarea`. One last trick to make all that work seamlessly is to always have text selected inside our editor copied and selected inside a `textarea`. This may sound very complex and confusing if you are not familiar DOM events, but I will have one or more posts on this very subject explaining everything in detail.

Source code for this part is available on [github](https://github.com/grassator/canvas-text-editor) under `part-2` tag. Don’t forget that in order for demo to work you need to run `npm install` if you haven’t done that already and start **express** server first by running `make serve` in your console. If all is done right you should see a slightly grey canvas element with word _“Test”_ written at top left corner.

There has also been a change in a test framework from [mocha](https://visionmedia.github.com/mocha/) to [jasmine](https://pivotal.github.com/jasmine/) because as it turns out it is impossible to test canvas-related code outside of browser. To run tests start **express** server by running `make serve` and simply open `test/runner.html` in your browser. Since there’s no algorithmic code right now the only test is to make sure that `CanvasTextEditor` object can be created:

```
describe("CanvasTextEditor", function () {
    var CanvasTextEditor = require('editor');

    it("should be possible to instatiate", function () {
        var editor = new CanvasTextEditor;
        expect(typeof editor).toEqual('object');
    });
});
```

## Font Metrics

HTML 5 canvas doesn’t offer much when it comes to rendering text. It has no layouting engine and very poor text metrics support. On top of that the only vertical alignment option well supported is _baseline_ which is rather inconvenient to work with. To summarize all that in order for us to render text properly a custom font metrics interface is required.

I’m assuming that we are only going to be dealing with monospace fonts to make this as simple as possible. That means that when presented with font name and size we need to be able to calculate line height, character width and [baseline](https://en.wikipedia.org/wiki/Typeface#Font_metrics) offset from top of the line.

Getting these font characteristics is a tricky business. To get line height and character width we can create an absolutely positioned `<div>` with a single character inside and then measure it’s dimensions. Absolute positioning is necessary to get `<div>` width to be only the width of it’s content instead of full wind width. Here’s excerpt from `FontMetrics.js` that does just that:

```
var line = document.createElement('div'),
    body = document.body;
line.style.position = 'absolute';
line.style.whiteSpace = 'nowrap';
line.style.font = size + 'px ' + family;
body.appendChild(line);

line.innerHTML = 'm'; // It doesn't matter what text goes here
this._width = line.offsetWidth;
this._height = line.offsetHeight;
```

Baseline offset requires even more elaborate hack — using an empty `inline-block` element that acts like a character (and thus gets aligned to text baseline) and also gets correct `offsetTop` property that gives you offset from parent element. By adding to that height of element itself we get what we wanted — text baseline.

Here’s the code that does the calculation:

```
var span = document.createElement('span');
span.style.display = 'inline-block';
span.style.overflow = 'hidden';
span.style.width = '1px';
span.style.height = '1px';
line.appendChild(span);

this._baseline = span.offsetTop + span.offsetHeight;
```

You can see full source code for `FontMetrics.js` on [github](https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js). Now that we have all necessary metrics can instantiate **FontMetrics** object for desired font:

```
this._metrics = new FontMetrics('"Courier New", Courier, monospace', 14);
```

And we can use to replace hard-coded offset in our canvas test with calculated values:

```
this.context.font = this._metrics.getSize() + 'px ' + this._metrics.getFamily()
this.context.fillText('Test', 0, this._metrics.getBaseline());
```

Result is the word “Test” rendered at appropriate offset form the top of the canvas.

Source code for this part is available on [github](https://github.com/grassator/canvas-text-editor) under `part-3` tag.

## Read-only document and naive rendering

Now that we have [font metrics](#part2) we can actually render something meaningful text. But before that we need to store that text somewhere, so it’s finally time to actually start implementing the line-based data structure I described earlier.

We want to store our document as an array lines, so first and foremost we need a function that would split input text accordingly. While `split('\n')` seems like an obvious and simple choice it does remove `\n` from the lines and we want to keep it to make position calculations and editing of the document simpler in the future, so here’s a substitute that keeps line breaks in place.

```
var lines = [],
    index = 0,
    newIndex;
do {
    newIndex = text.indexOf('\n', index);
    lines.push(text.substr(index, newIndex !== -1 ? newIndex - index + 1 : void 0));
    index = newIndex + 1;
} while (newIndex !== -1);
return lines;
```

Basically we just search for a newline here and copy push the part from previous index to new one to `lines` array including `\n` itself. The rest of the class interface at this point is just getters for character, line, line count and document length. All of these are few lines long maximum and don’t require an explanation.

Now that we have a document class we need to update `CanvasTextEditor` constructor to accept an instance of that class as only argument or create instance on itself when none is passed:

```
var CanvasTextEditor = function (doc) {
this._document = doc || (new Document);
```

Lets also implement a very simple and naive version of rendering of our document. All we need to do here is calculate amount of lines that can be shown on the editor canvas. It will be based either canvas height or number of lines in the document if it is short. After that it’s just simple loop that retrieves lines from the document and renders at offsets calculated based on the info we got from [FontMetrics class](/web/writing-a-text-editor-part-3-font-metrics/):

```
var baselineOffset = this._metrics.getBaseline(),
    lineHeight = this._metrics.getHeight(),
    characterWidth = this._metrics.getWidth(),
    maxHeight = Math.ceil(640 / lineHeight),
    lineCount = this._document.getLineCount();
if (lineCount < maxHeight) maxHeight = lineCount;
for (var i = 0; i < maxHeight; ++i) {
    this.context.fillText(
        this._document.getLine(i), 2, lineHeight * i + baselineOffset
    );
}
```

Now we can update demo code to see how this works:

```
var CanvasTextEditor = require('CanvasTextEditor'),
Document = require('Document'),
doc = new Document('Line1\nLine that is little bit longer\nLine4'),
editor = new CanvasTextEditor(doc);
```

Source code for this part is available on [github](https://github.com/grassator/canvas-text-editor) under `part-4` tag.

## Zero-width selection (cursor) support

It’s finally time to add some interactivity to our text editor. First thing that we need in order to allow user to edit a document is a cursor, which is also called a zero-width selection because well, that’s what it is, really. Let’s go.

Before we proceed any further I should mention that I intentionally ignore any scrolling issues and mouse interactions because they will bloat the code quite significantly without providing any useful information. I will deal with both of them later on.

Since we are dealing with new entity here let’s create a new class called `Selection` with following constructor:

```
Selection = function (editor) {
this.editor = editor;
this.blinkInterval = 500;

this.el = document.createElement('div');
this.el.style.position = 'absolute';
this.el.style.width = '1px';
this.el.style.height = this.editor.getFontMetrics().getHeight() + 'px';
this.el.style.backgroundColor = '#000';

this.editor.getEl().appendChild(this.el);

this.start = {
    line: 0,
    character: 0
};

this.end = {
    line: 0,
    character: 0
};

this.setPosition(0, 0);
};
```

There’s quite a bit of code here but in a nutshell it just creates a new DOM element that will be positioned on top of the editor to represent cursor position. Then we just initialize some defaults (start and end of the selection).

The reason I chose to use a DOM element for cursor and not just paint it on canvas is because otherwise we would be required to update canvas each time the cursor moves or blinks which would cause high CPU load or usage of some kind of cache. It might be a good idea in a real editor but will only slow us down right now.

Let’s take a look at `setPosition` method, which is the heart of this class:

```
Selection.prototype.setPosition = function (line, character) {
// Providing defaults for both line and character parts of position
if (typeof line === 'undefined') line = this.end.line
if (typeof character === 'undefined') character = this.end.character

// Checking lower bounds
line >= 0 || (line = 0);
character >= 0 || (character = 0);

// Checking upper bounds
var lineCount = this.editor.getDocument().getLineCount();
line < lineCount || (line = lineCount - 1);
var characterCount = this.editor.getDocument().getLine(line).trim('\n').length;
character <= characterCount || (character = characterCount);

// Saving new value
this.start.line = this.end.line = line;
this.start.character = this.end.character = character;

// Calculating new position on the screen
var metrics = this.editor.getFontMetrics(),
    offsetX = character * metrics.getWidth(),
    offsetY = line * metrics.getHeight();
this.el.style.left = offsetX + 'px';
this.el.style.top = offsetY + 'px';

// This helps to see moving cursor when it is always in blink on
// state on a new position. Try to move cursor in any editor and you
// will see this in action.
if (this.isVisible()) {
    this.el.style.opacity = 1;
    clearInterval(this.interval);
    this.interval = setInterval(this.blink.bind(this), this.blinkInterval);
}
};
```

Important part here is that we always force position to be a valid one. This may not be the best user experience but it makes writing code that moves cursor quite easy. I’m saying that it’s not good for the user because it’s not how the most editors work nowadays — they keep invalid character offset between line changes so if you jump from the end of longer line to a shorter one and back you will be in exact same position, whereas in our case cursor will be at offset equal to short line length inside that longer line.

There is not much else to comment here, except may be explain this expression:

```
this.editor.getDocument().getLine(line).trim('\n').length;
```

Since we store our lines with `\n` at the end except for the last line we need to somehow normalize it here, so we just trim that last `\n`. It is also worth mentioning that we are allowing positioning cursor after the last character in lines.

As I mentioned earlier creating code to move cursor is very easy, here’s example for moving cursor down(other three directions are very similar):

```
Selection.prototype.moveDown = function (length) {
arguments.length || (length = 1);
this.setPosition(this.end.line + length);
};
```

There is a bunch of support methods inside `Selection` but I’m not going to list it here to save time and instead I highly recommend checking out [source code for part-5](https://github.com/grassator/canvas-text-editor/tags). What is important is to create an instance of `Selection` in our main editor class like this:

```
this._selection = new Selection(this);
```

Then we need to listen to user keyboard input:

```
this.inputEl.addEventListener('keydown', this.keydown.bind(this), false);
```

And finally we map arrow keys to method calls on our selection object:

```
CanvasTextEditor.prototype.keydown = function (e) {
var handled = true;
switch (e.keyCode) {
    case 37: // Left arrow
        this._selection.moveLeft();
        break;
    case 38: // Up arrow
        this._selection.moveUp();
        break;
    case 39: // Up arrow
        this._selection.moveRight();
        break;
    case 40: // Down arrow
        this._selection.moveDown();
        break;
    default:
        handled = false;
}
return !handled;
};
```

That’s it — it’s now possible to move cursor inside text editor. I’ve created a live [demo page](https://grassator.github.io/canvas-text-editor-tutorial/) that will showcase current state of the project from now on.

Source code for this part is available on [github](https://github.com/grassator/canvas-text-editor) under `part-5` tag.

## Text insert and delete operations

This part six of this series of tutorials on creating a text editor but sadly we haven’t done any text editing yet. Let’s change that by implementing inserting and deleting functionality in our text editor.

### Inserting Text

Since our text is split into lines for storage insertion algorithm is not as easy as splicing some one string into another but it is pretty straightforward:

1.  split input into lines;
2.  join first line to the current line under cursor;
3.  join last line of new text to the appropriate line in storage;
4.  add lines in-between to the storage.

This is pretty much line for lines what happens in new `insertText` method of the `Document` class:

```
// First we need to split inserting text into array lines
text = Document.prepareText(text);
// First we calculate new column position because
// text array will be changed in the process
var newColumn = text[text.length - 1].length;
if (text.length === 1) newColumn += column;
// append remainder of the current line to last line in new text
text[text.length - 1] += this.storage[row].substr(column);
// append first line of the new text to current line up to "column" position
this.storage[row] = this.storage[row].substr(0, column) + text[0];
// now we are ready to splice other new lines
// (not first and not last) into our storage
var args = [row + 1, 0].concat(text.slice(1));
this.storage.splice.apply(this.storage, args);
// Finally we calculate new position
column = newColumn;
row += text.length - 1;
return [column, row];
```

We return new position here because usually after inserting text we want to move cursor that position.

Now we need to proxy user keyboard input to our method. It’s done by listening to `input` event on our `textarea`. Great thing about `input` event is that it captures any kind of input into the `textarea` whether it’s regular keyboard input, text drag’n’drop or pasting from clipboard. All we have to is create a simple handler and bind it:

```
this.inputEl.addEventListener('input', this.handleInput.bind(this), false);
```

`handleInput` method simply reads new input, passes it to another method that will get position of the cursor, passes it to our `insertText` method and empties `textarea` for any future input:

```
CanvasTextEditor.prototype.handleInput = function (e) {
    this.insertTextAtCurrentPosition(e.target.value);
    e.target.value = '';
};
CanvasTextEditor.prototype.insertTextAtCurrentPosition = function (text) {
    var pos = this._selection.getPosition();
    // Inserting new text and changing position of cursor to a new one
    this._selection.setPosition.apply(
        this._selection, this._document.insertText(text, pos[0], pos[1])
    );
    this.render();
};
```

This is done in two stages because there are other scenarios besides direct input where we would want to insert something at current cursor position. Additionally due to a certain browser behavior we have to process `Enter` key inside our `keydown` handler and not here and we would want to reuse `insertTextAtCurrentPosition` like this:

```
case 13: // Enter
this.insertTextAtCurrentPosition('\n');
break;
```

You may have also noticed a new `render` method. It’s doesn’t contain any new code — I just moved rendering loop from `_createCanvas` method into a separate one. At this point we should be able to insert text without any problems.

### Deleting text

Text removal is even easier than inserting. We just take start and end position, glue them together and remove everything in-between:

```
this.storage[startRow] = this.storage[startRow].substr(0, startColumn) +
this.storage[endRow].substr(endColumn);
this.storage.splice(startRow + 1, endRow - startRow);
```

The rest is just support stuff like checking for bounds. Here’s full version of `deleteRange` methods on our `Document` class:

```
Document.prototype.deleteRange = function (startColumn, startRow, endColumn, endRow) {

// Check bounds
startRow >= 0 || (startRow = 0);
startColumn >= 0 || (startColumn = 0);
endRow < this.storage.length || (endRow = this.storage.length - 1);
endColumn <= this.storage[endRow].length || (
    endColumn = this.storage[endRow].length
);

// Little optimization that does nothing if there's nothing to delete
if (startColumn === endColumn && startRow === endRow) {
    return [startColumn, startRow];
}

// Now we append start of start row to the remainder of endRow
this.storage[startRow] = this.storage[startRow].substr(0, startColumn) +
    this.storage[endRow].substr(endColumn);

// And remove everything in-between
this.storage.splice(startRow + 1, endRow - startRow);

// Return new position
return [startColumn, startRow];
};
```

This method is cool and all but it doesn’t help much with most common delete operations which are deleting one character forward or backward (usually done by `delete` and `backspace` keys). To handle that we create a wrapper method called `deleteChar` which calculates other side of the range from current cursor position when deleting a single character:

```
Document.prototype.deleteChar = function (forward, startColumn, startRow) {
var endRow = startRow,
    endColumn = startColumn;

if (forward) {
    // If there are characters after cursor on this line we remove one
    if (startColumn < this.storage[startRow].trim('\n').length) {
        ++endColumn;
    }
    // if there are rows after this one we append it
    else if (startRow < this.storage.length - 1) {
        ++endRow;
        endColumn = 0;
    }
}
// Deleting backwards
else {
    // If there are characters before the cursor on this line we remove one
    if (startColumn > 0) {
        --startColumn;
    }
    // if there are rows before we append current to previous one
    else if (startRow > 0) {
        --startRow;
        startColumn = this.storage[startRow].length - 1;
    }
}

return this.deleteRange(startColumn, startRow, endColumn, endRow);
};
```

Now we need a `deleteCharAtCurrentPosition` method that is similar to one we did for insertion:

```
CanvasTextEditor.prototype.deleteCharAtCurrentPosition = function (forward) {
var pos = this._selection.getPosition();
// Deleting text and changing position of cursor to a new one
this._selection.setPosition.apply(
    this._selection,
    this._document.deleteChar(forward, pos[0], pos[1])
);
this.render();
};
```

All that’s left to do is to add to new keys to our `keydown` handler:

```
case 8: // backspace
this.deleteCharAtCurrentPosition(false);
break;
case 46: // delete
this.deleteCharAtCurrentPosition(true);
break;
```

That’s all – deletion should be working now.

### Summary

We now have ability insert (and paste from clipboard) and delete text. There’s a live [demo page](https://grassator.github.io/canvas-text-editor-tutorial/) that you can play with.

Source code for this part is available on [github](https://github.com/grassator/canvas-text-editor) under `part-6` tag.

## Text selection

Now that we can use cursor and insert text it’s time to tackle the most complex aspect of text editing – selection. Handling the selection can be split into two major parts that need to work together:

*   calculations and adjustment of selection range within document;
*   rendering selection highlight and handling user input.

I will show you how to implement code that handles both of this aspects.

### Selection range

Most of heavy lifting is going to be done inside `setPosition` method of a `Selection` class that we started to write in a [previous part](/web/writing-a-text-editor-part-6-text-insert-and-delete-operations/). This is the new version:

```
Selection.prototype.setPosition = function (character, line, keepSelection) {

var position = this._forceBounds(character, line);

// Calling private methods that do the heavy lifting
this._doSetPosition(position[0], position[1], keepSelection);
this._updateCursorStyle();

// Making a callback if necessary
if (typeof this.onchange === 'function') {
    this.onchange(this, this.start, this.end);
}
};
```

As you can see `setPosition` is now split into a couple of private functions because it has gotten quite big while writing this part and i had to refactor. Additionally there’s a new parameter called `keepSelection` that determines if moving to a new position should keep one of the range edges (start or end) in place (extending selection) or move both of them (moving a cursor). `onchange` callback is going to be used by main editor class for and ability to copy selected text to the buffer.

`_updateCursorStyle` is just an excerpt from original `setPosition` that handles positioning of the cursor relative to the wrapper and it doesn’t contain any new code. `_forceBounds` is an enhanced version of bounds control code that was in place in `setPosition` before that also handles ability to jump to next or previous line when moving cursor left or right at line edges. `_doSetPosition` is where all the magic happens:

```
Selection.prototype._doSetPosition = function (character, line, keepSelection) {
// If this is a selection range
if (keepSelection) {

    compare = this.comparePosition({
        line: line,
        character: character
    }, this.start);

    // Determining whether we should make the start side of the range active
    // (have a cursor). This happens when we start the selection be moving
    // left, or moving up.
    if (compare === -1 && (this.isEmpty() || line < this.start.line)) {
        this.activeEndSide = false;
    }

    // Assign new value to the side that is active
    if (this.activeEndSide) {
        this.end.line = line;
        this.end.character = character;
    } else {
        this.start.line = line;
        this.start.character = character;
    }

    // Making sure that end is further than start and swap if necessary
    if (this.comparePosition(this.start, this.end) > 0) {
        this.activeEndSide = !this.activeEndSide;
        var temp = {
            line: this.start.line,
            character: this.start.character
        }
        this.start.line = this.end.line;
        this.start.character = this.end.character;
        this.end.line = temp.line;
        this.end.character = temp.character;
    }
} else { // Simple cursor move
    this.activeEndSide = true;
    this.start.line = this.end.line = line;
    this.start.character = this.end.character = character;
}
};
```

Here’s what’s happening here. Whenever you have a selection in a text editor it has an active side (edge) that will be moving when you move your cursor while holding down `shift` key and an opposite stationary side — this what the `activeEndSide` property is for.

`this.comparePosition` is a typical comparator that returns `-1` if first position is smaller than second one, `0` if they are equal and `1` if second is greater than the first therefore looking at the result of this function helps us determine direction .

After we’ve determined currently active side we assign new values to it. Since we distinguish selection sides we also need to make sure start is always smaller than end in order for the rest of the logic to work.

This all for `setPosition`. Now the only other thing we need to add to `Selection` class is `keepSelection` parameter to our move helper functions like this:

```
Selection.prototype.moveRight = function (length, keepSelection) {
arguments.length || (length = 1);
var position = this.getPosition();
this.setPosition(position[0] + length, position[1], keepSelection);
};
```

### Rendering selection and handling user input

First of all we need to a way to determine if `shift` key is pressed at the moment because it is that key that determines whether we move cursor or create a selection. This quite simple – we just create a flag property called `shiftPressed` and keep it in sync with real state of `shift` key by listening to `keydown` and `keyup` events on the `document` object. So we just add subscribing code to the constructor:

```
document.addEventListener('keydown', this.addKeyModifier.bind(this), true);
document.addEventListener('keyup', this.removeKeyModfier.bind(this), true);
```

And implement simple handlers:

```
CanvasTextEditor.prototype.addKeyModifier = function (e) {
    if (e.keyCode === 16) {
        this.shiftPressed = true;
    }
};
CanvasTextEditor.prototype.removeKeyModfier = function (e) {
    if (e.keyCode === 16) {
        this.shiftPressed = false;
    }
};
```

Now we can update main keydown handler to make a selection whenever shift key is pressed:

```
case 37: // Left arrow
this._selection.moveLeft(1, this.shiftPressed);
break;
case 38: // Up arrow
this._selection.moveUp(1, this.shiftPressed);
break;
case 39: // Right arrow
this._selection.moveRight(1, this.shiftPressed);
break;
case 40: // Down arrow
this._selection.moveDown(1, this.shiftPressed);
break;
```

Since we are able to pass user input to the selection object, it’s time to adjust the rendering loop to highlight selected range:

```
var selectionRanges = this._selection.lineRanges();
// Looping over document lines
for (var i = 0; i < maxHeight; ++i) {
    var topOffset = lineHeight * i;
    // Rendering selection for this line if one is present
    if (selectionRanges[i]) {
        this.context.fillStyle = '#cce6ff';
        // Check whether we should select to the end of the line or not
        if (selectionRanges[i][1] === true) {
            selectionWidth = this.canvas.width;
        } else {
            selectionWidth = (selectionRanges[i][1] - selectionRanges[i][0]) *
                characterWidth;
        }
        // Drawing selection
        this.context.fillRect(
            selectionRanges[i][0] * characterWidth,
            i * lineHeight,
            selectionWidth,
            lineHeight
        )
        // Restoring fill color for the text
        this.context.fillStyle = '#000';
    }
    // Drawing text
    this.context.fillText(
        this._document.getLine(i), 0, topOffset + baselineOffset
    );
}
```

We should re-render canvas on every selection change so we subscribe to `onchange` callback on our selection inside the `CanvasTextEditor` constructor:

```
this._selection.onchange = this.selectionChange.bind(this);
```

Handler for selection change must do two things:

1.  update contents of our proxy textarea with contents of the selection and make browser selection inside that textarea so that when user tries to copy selection to the buffer it will work as expected;
2.  call `render` method.

Here’s how it’s done:

```
CanvasTextEditor.prototype.selectionChange = function () {
// Assume that selection is empty
var selectedText = '';

// if it's not we put together selected text from document
if (!this._selection.isEmpty()) {
    var ranges = this._selection.lineRanges(),
        line = '';
    for (var key in ranges) {
        selectedText += this._document.getLine(parseInt(key)).slice(
            ranges[key][0], ranges[key][1] === true ? undefined : ranges[key][1]
        );
    }
}

this.setInputText(selectedText, true);

// Updating canvas to show selection
this.render();
};
```

The implementation details of `setInputText` aren’t really important because ideally it should just set value textarea to selected text and select everything inside but due to bugs in various browser code there is very obscure and won’t help understanding overall picture in any way. There’s a lot of other changes that had to be made in order to overcome problems of browser environment.

### Summary

If you want to see the latest version of the editor in action – visit a live [demo page](https://grassator.github.io/canvas-text-editor-tutorial/).

Please note that code for this part also contains a lot of fixes for compatibility issues in Opera and Firefox that made text editor previously unusable there.

Source code for this part is available on [github](https://github.com/grassator/canvas-text-editor) under `part-7` tag.

## Simple scroll

The only thing left that keeps editor from doing all the things it should is lack of ability to scroll contents when they don’t fit into the view. Let’s implement a simple console-style scroll that just always keeps cursor incised visible area.

We are going to start by adding two private properties with getters for scroll offsets:

```
CanvasTextEditor.prototype._scrollTop = 0;
CanvasTextEditor.prototype._scrollLeft = 0;
CanvasTextEditor.prototype.scrollTop = function () {
return this._scrollTop;
};
CanvasTextEditor.prototype.scrollLeft = function () {
return this._scrollLeft;
};
```

Now we need to change the code that renders the cursor so it’s aware of possible scroll:

```
Selection.prototype.updateCursorStyle = function () {
// Calculating new position on the screen
var metrics = this.editor.getFontMetrics(),
    position = this.getPosition(),
    offsetX = (position[0] - this.editor.scrollLeft()) * metrics.getWidth(),
    offsetY = (position[1] - this.editor.scrollTop()) * metrics.getHeight();
```

Basically here we are just subtracting scroll offset from real cursor position when rendering it. It’s also necessary to remove call to `updateCursorStyle` from `setPosition` because scroll will be calculated in `CanvasTextEditor` class upon cursor change by calling a private function that will do the calculation inside our `selectionChange` handler in main editor class:

```
this._checkScroll();
this.setInputText(selectedText, true);
// Updating canvas to show selection
this.render();
```

And here’s the implementation of `_checkScroll` which is pretty straightforward — we just calculate bounds based on canvas size and make sure cursor is visible. After that we update it’s position on the screen by calling `updateCursorStyle` selection method:

```
CanvasTextEditor.prototype._checkScroll = function () {
var maxHeight = Math.ceil(this.canvas.height / this._metrics.getHeight()) - 1,
    maxWidth = Math.ceil(this.canvas.width / this._metrics.getWidth()) - 1,
    cursorPosition = this._selection.getPosition();
// Horizontal bounds
if (cursorPosition[0] > this._scrollLeft + maxWidth) {
    this._scrollLeft = cursorPosition[0] - maxWidth;
} else if (cursorPosition[0] < this._scrollLeft) {
    this._scrollLeft = cursorPosition[0];
}
// Vertical bounds
if (cursorPosition[1] > this._scrollTop + maxHeight) {
    this._scrollTop = cursorPosition[1] - maxHeight;
} else if (cursorPosition[1] < this._scrollTop) {
    this._scrollTop = cursorPosition[1];
}
this._selection.updateCursorStyle();
};
```

The only thing left is adjust a render loop a little bit by making sure we respect scroll offset when choosing lines to start from and also making a slice of the document string from necessary position. I’ve marked changed lines with comments with arrows:

```
CanvasTextEditor.prototype.render = function () {
var baselineOffset = this._metrics.getBaseline(),
    lineHeight = this._metrics.getHeight(),
    characterWidth = this._metrics.getWidth(),
    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ v
    maxHeight = Math.ceil(this.canvas.height / lineHeight) + this._scrollTop,
    lineCount = this._document.getLineCount(),
    selectionRanges = this._selection.lineRanges(),
    selectionWidth = 0;

// Making sure we don't render something that we won't see
if (lineCount < maxHeight) maxHeight = lineCount;

// Clearing previous iteration
this.context.fillStyle = this.options.backgroundColor;
this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.context.fillStyle = this.options.textColor;

// Looping over document lines
for (var i = this._scrollTop; i < maxHeight; ++i) {
    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ v
    var topOffset = lineHeight * (i - this._scrollTop);

    // Rendering selection for this line if one is present
    if (selectionRanges[i]) {
        this.context.fillStyle = this.options.selectionColor;

        // Check whether we should select to the end of the line or not
        if (selectionRanges[i][1] === true) {
            selectionWidth = this.canvas.width;
        } else {
            selectionWidth = (selectionRanges[i][1] - selectionRanges[i][0]) *
                characterWidth;
        }

        // Drawing selection
        this.context.fillRect(
            // ~~~~~~~~~~~~~~~~~~~~~~ v
            (selectionRanges[i][0] - this._scrollLeft) * characterWidth,
            topOffset,
            selectionWidth,
            lineHeight
        );

        // Restoring fill color for the text
        this.context.fillStyle = this.options.textColor;
    }

    // Drawing text
    this.context.fillText(
        // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ v
        this._document.getLine(i).slice(this._scrollLeft), 0, topOffset + baselineOffset
    );
}
};
```

## Afterword

At this point all the major parts are working properly, all that’s left is UI and probably some refactoring and optimization and both of these things are very project and platform specific. If you have any questions on specific issues or you notice a bug in the code, please don’t hesitate to contact me.


## License

Copyright (c) 2012 Dmitriy Kubyshkin

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


================================================
FILE: index.html
================================================
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
  <title>Canvas Text Editor</title>
  <meta name="description" content="">
  <meta name="keywords" content="">
  <script src="./lib/index.js" type="module"></script>
</head>
<body>
</body>
</html>


================================================
FILE: lib/CanvasTextEditor.js
================================================
"use strict";

var FontMetrics = require('./FontMetrics'),
    Document = require('./Document'),
    Selection = require('./Selection');

/**
 * Simple plain-text text editor using html5 canvas.
 * @constructor
 */
var CanvasTextEditor = function(doc, options) {
  this._document = doc || (new Document());

  this.options = {
    textColor: 'WindowText',
    backgroundColor: 'Window',
    selectionColor: 'Highlight',
    focusColor: '#09f',
    fontFamily: '"Courier New", Courier, monospace',
    fontSize: 14,
    padding: 5,
    width: 640,
    height: 480,
    dpr: window.devicePixelRatio || 1,
  };

  if (typeof options === 'object') {
    for(var key in options) {
      this.options[key] = options[key];
    }
  }

  this._metrics = new FontMetrics(this.options.fontFamily, this.options.fontSize);
  this._createWrapper();
  this._selection = new Selection(this, this.options.textColor);
  this._selection.onchange = this.selectionChange.bind(this);
  this._createCanvas();
  this._createInput();
  document.addEventListener('keydown', this.addKeyModifier.bind(this), true);
  document.addEventListener('keyup', this.removeKeyModfier.bind(this), true);
  window.addEventListener('focus', this.clearKeyModifiers.bind(this), true);
  window.addEventListener('focus', this.render.bind(this), true);
};

module.exports = CanvasTextEditor;

/**
 * Top offset in lines
 * @type {Number}
 */
CanvasTextEditor.prototype._scrollTop = 0;

/**
 * Left offset in characters
 * @type {Number}
 */
CanvasTextEditor.prototype._scrollLeft = 0;

/**
 * Determines if current browser is Opera
 * @type {Boolean}
 */
CanvasTextEditor.prototype.isOpera = ('opera' in window) && ('version' in window.opera);

/**
 * CSS class that is assigned to the wrapper.
 * @type {String}
 */
CanvasTextEditor.prototype.className = 'canvas-text-editor';

/**
 * Determines if user holds shift key at the moment
 * @type {Boolean}
 */
CanvasTextEditor.prototype.shiftPressed = false;

/**
 * Marks important for us key modfiers as pressed
 * @param {Event} e
 */
CanvasTextEditor.prototype.addKeyModifier = function(e) {
  if (e.keyCode === 16) {
    this.shiftPressed = true;
  }
};

/**
 * Unmarks important for us key modfiers as pressed
 * @param {Event} e
 */
CanvasTextEditor.prototype.removeKeyModfier = function(e) {
  if (e.keyCode === 16) {
    this.shiftPressed = false;
  }
};

/**
 * Clears all key modifiers
 */
CanvasTextEditor.prototype.clearKeyModifiers = function() {
  this.shiftPressed = false;
};

/**
 * Returns selection for this editor
 * @return {Selection}
 */
CanvasTextEditor.prototype.getSelection = function() {
  return this._selection;
};

/**
 * Returns current top offset
 * @return {number}
 */
CanvasTextEditor.prototype.scrollTop = function() {
  return this._scrollTop;
};

/**
 * Returns current left offset
 * @return {number}
 */
CanvasTextEditor.prototype.scrollLeft = function() {
  return this._scrollLeft;
};

/**
 * Handles selection change
 */
CanvasTextEditor.prototype.selectionChange = function() {
  // Assume that selection is empty
  var selectedText = '';

  // if it's not we put together selected text from document
  if (!this._selection.isEmpty()) {
    var ranges = this._selection.lineRanges();
    for(var key in ranges) {
      selectedText += this._document.getLine(parseInt(key)).slice(
        ranges[key][0], ranges[key][1] === true ? undefined : ranges[key][1]
      );
    }
  }

  this._checkScroll();
  this.setInputText(selectedText, true);

  // Updating canvas to show selection
  this.render();
};

/**
 * Creates wrapper element for all parts of the editor
 * @private
 */
CanvasTextEditor.prototype._createWrapper = function() {
  this.wrapper = document.createElement('div');
  this.wrapper.className = this.className;
  this.wrapper.style.display = 'inline-block';
  this.wrapper.style.position = 'relative';
  this.wrapper.style.backgroundColor = this.options.backgroundColor;
  this.wrapper.style.border = this.options.padding + 'px solid ' + this.options.backgroundColor;
  this.wrapper.style.overflow = 'hidden';
  this.wrapper.tabIndex = 0; // tabindex is necessary to get focus
  this.wrapper.addEventListener('focus', this.focus.bind(this), false);
};

/**
 * Creates canvas for drawing
 * @private
 */
CanvasTextEditor.prototype._createCanvas = function() {
  this.canvas = document.createElement('canvas');
  this.canvas.style.display = 'block';
  this.context = this.canvas.getContext('2d');
  this.resize(this.options.width, this.options.height, this.options.dpr);
  this.render();
  this.wrapper.appendChild(this.canvas);
};

/**
 * Makes sure that cursor is visible
 * @return {[type]} [description]
 */
CanvasTextEditor.prototype._checkScroll = function() {
  var maxHeight = Math.ceil(this.canvas.height / this._metrics.getHeight()) - 1,
      maxWidth = Math.ceil(this.canvas.width / this._metrics.getWidth()) - 1,
      cursorPosition = this._selection.getPosition();
  if (cursorPosition[0] > this._scrollLeft + maxWidth ) {
    this._scrollLeft = cursorPosition[0] - maxWidth;
  } else if (cursorPosition[0] < this._scrollLeft) {
    this._scrollLeft = cursorPosition[0];
  }
  if (cursorPosition[1] > this._scrollTop + maxHeight) {
    this._scrollTop = cursorPosition[1] - maxHeight;
  } else if (cursorPosition[1] < this._scrollTop) {
    this._scrollTop = cursorPosition[1];
  }
  this._selection.updateCursorStyle();
};

/**
 * Renders document onto the canvas
 * @return {[type]} [description]
 */
CanvasTextEditor.prototype.render = function() {
  var baselineOffset = this._metrics.getBaseline(),
      lineHeight = this._metrics.getHeight(),
      characterWidth = this._metrics.getWidth(),
      maxHeight = Math.ceil(this.canvas.height / lineHeight) + this._scrollTop,
      lineCount = this._document.getLineCount(),
      selectionRanges = this._selection.lineRanges(),
      selectionWidth = 0;

  // Making sure we don't render something that we won't see
  if (lineCount < maxHeight) {
    maxHeight = lineCount;
  }

  // Clearing previous iteration
  this.context.fillStyle = this.options.backgroundColor;
  this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
  this.context.fillStyle = this.options.textColor;

  // Looping over document lines
  for(var i = this._scrollTop; i < maxHeight; ++i) {
    var topOffset = lineHeight * (i - this._scrollTop);

    // Rendering selection for this line if one is present
    if (selectionRanges[i]) {
      this.context.fillStyle = this.options.selectionColor;

      // Check whether we should select to the end of the line or not
      if(selectionRanges[i][1] === true) {
        selectionWidth = this.canvas.width;
      } else {
        selectionWidth = (selectionRanges[i][1] - selectionRanges[i][0]) * characterWidth;
      }

      // Drawing selection
      this.context.fillRect(
        (selectionRanges[i][0] - this._scrollLeft) * characterWidth,
        topOffset,
        selectionWidth,
        lineHeight
      );

      // Restoring fill color for the text
      this.context.fillStyle = this.options.textColor;
    }

    // Drawing text
    this.context.fillText(
      this._document.getLine(i).slice(this._scrollLeft), 0, topOffset + baselineOffset
    );
  }
};

/**
 * Creates textarea that will handle user input and copy-paste actions
 * @private
 */
CanvasTextEditor.prototype._createInput = function() {
  this.inputEl = document.createElement('textarea');
  this.inputEl.style.position = 'absolute';
  this.inputEl.style.top = '-25px';
  this.inputEl.style.left = '-25px';
  this.inputEl.style.height = '10px';
  this.inputEl.style.width = '10px';
  this.inputEl.addEventListener('input', this.handleInput.bind(this), false);
  this.inputEl.addEventListener('blur', this.blur.bind(this), false);
  this.inputEl.addEventListener('focus', this._inputFocus.bind(this), false);
  this.inputEl.addEventListener('keydown', this.keydown.bind(this), false);
  this.inputEl.addEventListener('keypress', this.setInputText.bind(this, ''), false);
  this.inputEl.tabIndex = -1; // we don't want input to get focus by tabbing
  this.wrapper.appendChild(this.inputEl);
  this.setInputText('', true);
};

/**
 * Handles regular text input into our proxy field
 * @param  {Event} e
 */
CanvasTextEditor.prototype.handleInput = function(e) {
  var value = e.target.value;
  if (this.isOpera) {
    // Opera doesn't need a placeholder
    value = value.substring(0, value.length);
  } else {
    // Compensate for placeholder
    value = value.substring(0, value.length - 1);
  }
  this.insertTextAtCurrentPosition(value);
  this.needsClearing = true;
};

/**
 * Makes input contain only placeholder character and places cursor at start
 */
CanvasTextEditor.prototype.setInputText = function(text, force) {
  if(this.needsClearing || force === true) {
    if (this.isOpera) {
      this.inputEl.value = text;
      this.inputEl.select();
    } else {
      this.inputEl.value = text + '#';
      this.inputEl.selectionStart = 0;
      this.inputEl.selectionEnd = text.length;
    }
  }
  this.needsClearing = false;
};

/**
 * Inserts text at the current cursor position
 * @param  {string} text
 */
CanvasTextEditor.prototype.insertTextAtCurrentPosition = function(text) {
  // If selection is not empty we need to "replace" selected text with inserted
  // one which means deleting old selected text before inserting new one
  if (!this._selection.isEmpty()) {
    this.deleteCharAtCurrentPosition();
  }

  var pos = this._selection.getPosition();

  // Inserting new text and changing position of cursor to a new one
  this._selection.setPosition.apply(
    this._selection,
    this._document.insertText(text, pos[0], pos[1])
  );
  this.render();
};

/**
 * Deletes text at the current cursor position
 * @param  {string} text
 */
CanvasTextEditor.prototype.deleteCharAtCurrentPosition = function(forward) {
  // If there is a selection we just remove it no matter what direction is
  if (!this._selection.isEmpty()) {
    this._selection.setPosition.apply(
      this._selection,
      this._document.deleteRange(
        this._selection.start.character, this._selection.start.line,
        this._selection.end.character, this._selection.end.line
      )
    );
  } else {
    var pos = this._selection.getPosition();
    // Deleting text and changing position of cursor to a new one
    this._selection.setPosition.apply(
      this._selection,
      this._document.deleteChar(forward, pos[0], pos[1])
    );
  }
  this.render();
};

/**
 * Real handler code for editor gaining focus.
 * @private
 */
CanvasTextEditor.prototype._inputFocus = function() {
  this.wrapper.style.outline = '1px solid ' + this.options.focusColor;
  this._selection.setVisible(true);
};

/**
 * Returns main editor node so it can be inserted into document.
 * @return {HTMLElement} 
 */
CanvasTextEditor.prototype.getEl = function() {
  return this.wrapper;
};

/**
 * Returns font metrics used in this editor.
 * @return {FontMetrics} 
 */
CanvasTextEditor.prototype.getFontMetrics = function() {
  return this._metrics;
};

/**
 * Returns current document.
 * @return {Document} 
 */
CanvasTextEditor.prototype.getDocument = function() {
  return this._document;
};

/**
 * Resizes editor to provided dimensions.
 * @param  {Number} width 
 * @param  {Number} height
 * @param  {Number} dpr device pixel ratio
 */
CanvasTextEditor.prototype.resize = function(width, height, dpr) {
  this.canvas.width = width * dpr;
  this.canvas.height = height * dpr;
  this.canvas.style.width = `${width}px`;
  this.canvas.style.height = `${height}px`; 
  this.context.scale(dpr, dpr);
  // We need to update context settings every time we resize
  this.context.font = this._metrics.getSize() + 'px ' + this._metrics.getFamily();
};

/**
 * Main keydown handler
 * @param  {Event} e
 */
CanvasTextEditor.prototype.keydown = function(e) {
  var handled = true;
  switch(e.keyCode) {
    case 8: // Backspace
      this.deleteCharAtCurrentPosition(false);
      break;
    case 46: // Delete
      this.deleteCharAtCurrentPosition(true);
      break;
    case 13: // Enter
      this.insertTextAtCurrentPosition('\n');
      break;
    case 37: // Left arrow
      this._selection.moveLeft(1, this.shiftPressed);
      break;
    case 38: // Up arrow
      this._selection.moveUp(1, this.shiftPressed);
      break;
    case 39: // Right arrow
      this._selection.moveRight(1, this.shiftPressed);
      break;
    case 40: // Down arrow
      this._selection.moveDown(1, this.shiftPressed);
      break;
    default:
      handled = false;
  }
  if(handled) {
    e.preventDefault();
  }
};

/**
 * Blur handler.
 */
CanvasTextEditor.prototype.blur = function() {
  this.wrapper.style.outline = 'none';
  this._selection.setVisible(false);
};

/**
 * Focus handler. Acts as a proxy to input focus.
 */
CanvasTextEditor.prototype.focus = function() {
  this.inputEl.focus();
};



================================================
FILE: lib/Document.js
================================================

/**
 * Creates new document from provided text.
 * @param {string} text Full document text.
 * @constructor
 */
var Document = function(text) {
  text = text || '';
  this.storage = Document.prepareText(text);
};

module.exports = Document;

/**
 * Splits text into array of lines. Can't use .split('\n') because
 * we want to keep trailing \n at the ends of lines.
 * @param  {string} text
 * @return {Array.{string}}
 */
Document.prepareText = function(text) {
  var lines = [],
      index = 0,
      newIndex;
  do {
    newIndex = text.indexOf('\n', index);
    // Adding from previous index to new one or to the end of the string
    lines.push(text.substr(index,  newIndex !== -1 ? newIndex - index + 1 : void 0));
    // next search will be after found newline
    index = newIndex + 1; 
  } while (newIndex !== -1);

  return lines;
};

/**
 * Returns line count for the document
 * @return {number}
 */
Document.prototype.getLineCount = function() {
  return this.storage.length;
};

/**
 * Returns line on the corresponding index.
 * @param  {number} 0-based index of the line
 * @return {string}
 */
Document.prototype.getLine = function(index) {
  return this.storage[index];
};

/**
 * Returns linear length of the document.
 * @return {number}
 */
Document.prototype.getLength = function() {
  var sum = 0;
  for(var i = this.storage.length - 1; i >= 0; --i) {
    sum += this.storage[i].length;
  }
  return sum;
};

/**
 * Returns char at specified offset.
 * @param  {number} offset
 * @return {string|undefined}
 */
Document.prototype.charAt = function(column, row) {
  row = this.storage[row];
  if (row){
    return row.charAt(column);
  }
};

/**
 * Inserts text into arbitrary position in the document
 * @param  {string} text
 * @param  {number} column
 * @param  {number} row
 * @return {Array} new position in the document
 */
Document.prototype.insertText = function(text, column, row) {
  // First we need to split inserting text into array lines
  text = Document.prepareText(text);

  // First we calculate new column position because
  // text array will be changed in the process
  var newColumn = text[text.length - 1].length;
  if (text.length === 1) {
    newColumn += column;
  }

  // append remainder of the current line to last line in new text
  text[text.length - 1] += this.storage[row].substr(column);

  // append first line of the new text to current line up to "column" position
  this.storage[row] = this.storage[row].substr(0, column) + text[0];

  // now we are ready to splice other new lines
  // (not first and not last) into our storage
  var args = [row + 1, 0].concat(text.slice(1));
  this.storage.splice.apply(this.storage, args);

  // Finally we calculate new position
  column = newColumn;
  row += text.length - 1;

  return [column, row];
};

/**
 * Deletes text with specified range from the document.
 * @param  {number} startColumn
 * @param  {number} startRow
 * @param  {number} endColumn
 * @param  {number} endRow
 */
Document.prototype.deleteRange = function(startColumn, startRow, endColumn, endRow) {

  // Check bounds
  startRow >= 0 || (startRow = 0);
  startColumn >= 0 || (startColumn = 0);
  endRow < this.storage.length || (endRow = this.storage.length - 1);
  endColumn <= this.storage[endRow].trim('\n').length || (endColumn = this.storage[endRow].length);

  // Little optimization that does nothing if there's nothing to delete
  if(startColumn === endColumn && startRow === endRow) {
    return [startColumn, startRow];
  }

  // Now we append start of start row to the remainder of endRow
  this.storage[startRow] = this.storage[startRow].substr(0, startColumn) + 
                           this.storage[endRow].substr(endColumn);

  // And remove everything inbetween
  this.storage.splice(startRow + 1, endRow - startRow);

  // Return new position
  return [startColumn, startRow];
};

/**
 * Deletes one char forward or backward
 * @param  {boolean} forward
 * @param  {number}  column
 * @param  {number}  row
 * @return {Array}   new position
 */
Document.prototype.deleteChar = function(forward, startColumn, startRow) {
  var endRow = startRow,
      endColumn = startColumn;

  if (forward) {
    var characterCount = this.storage[startRow].trim('\n').length;
    // If there are characters after cursor on this line we simple remove one
    if (startColumn < characterCount) {
      ++endColumn;
    }
    // if there are rows after this one we append it
    else {
      startColumn = characterCount;
      if (startRow < this.storage.length - 1) {
        ++endRow;
        endColumn = 0;
      }
    }
  }
  // Deleting backwards
  else {
    // If there are characters before the cursor on this line we simple remove one
    if (startColumn > 0) {
      --startColumn;
    }
    // if there are rwos before we append current to previous one
    else if (startRow > 0) {
      --startRow;
      startColumn = this.storage[startRow].length - 1;
    }
  }

  return this.deleteRange(startColumn, startRow, endColumn, endRow);
};


================================================
FILE: lib/FontMetrics.js
================================================
"use strict";

/**
 * A simple wrapper for system fonts to provide
 * @param {String} family Font Family (same as in CSS)
 * @param {Number} size Size in px
 * @constructor
 */
var FontMetrics = function(family, size) {
  this._family = family || (family = "Monaco, 'Courier New', Courier, monospace");
  this._size = parseInt(size) || (size = 12);

  // Preparing container
  var line = document.createElement('div'),
      body = document.body;
  line.style.position = 'absolute';
  line.style.whiteSpace = 'nowrap';
  line.style.font = size + 'px ' + family;
  body.appendChild(line);

  // Now we can measure width and height of the letter
  var text = 'mmmmmmmmmm'; // 10 symbols to be more accurate with width
  line.innerHTML = text;
  this._width = line.offsetWidth / text.length;
  this._height = line.offsetHeight;

  // Now creating 1px sized item that will be aligned to baseline
  // to calculate baseline shift
  var span = document.createElement('span');
  span.style.display = 'inline-block';
  span.style.overflow = 'hidden';
  span.style.width = '1px';
  span.style.height = '1px';
  line.appendChild(span);

  // Baseline is important for positioning text on canvas
  this._baseline = span.offsetTop + span.offsetHeight;

  document.body.removeChild(line);
};

module.exports = FontMetrics;

/**
 * Returns font family
 * @return {String}
 */
FontMetrics.prototype.getFamily = function() {
  return this._family;
};

/**
 * Returns font family
 * @return {Number}
 */
FontMetrics.prototype.getSize = function() {
  return this._size;
};

/**
 * Returns line height in px
 * @return {Number}
 */
FontMetrics.prototype.getHeight = function() {
  return this._height;
};

/**
 * Returns line height in px
 * @return {Number}
 */
FontMetrics.prototype.getWidth = function() {
  return this._width;
};

/**
 * Returns line height in px
 * @return {Number}
 */
FontMetrics.prototype.getBaseline = function() {
  return this._baseline;
};


================================================
FILE: lib/Selection.js
================================================
/**
 * Creates new selection for the editor.
 * @param {Editor} editor.
 * @constructor
 */
var Selection = function(editor, color) {
  this.editor = editor;
  color || (color = '#000');

  this.start = {
    line: 0,
    character: 0
  };

  this.end = {
    line: 0,
    character: 0
  };

  this.el = document.createElement('div');
  this.el.style.position = 'absolute';
  this.el.style.width = '1px';
  this.el.style.height = this.editor.getFontMetrics().getHeight() + 'px';
  this.el.style.backgroundColor = color;

  this.editor.getEl().appendChild(this.el);
  this.setPosition(0, 0);
};

/**
 * Hold blink interval for the cursor
 * @type {Number}
 */
Selection.prototype.blinkInterval = 500;

/**
 * This callback called when selection size has changed
 * @type {Function}
 */
Selection.prototype.onchange = null;

/**
 * If true that means that we currently manipulate right side of the selection
 * @type {Boolean}
 */
Selection.prototype.activeEndSide = true;

/**
 * Responsible for blinking
 * @return {void}
 */
Selection.prototype.blink = function() {
  if (parseInt(this.el.style.opacity, 10)) {
    this.el.style.opacity = 0;
  } else {
    this.el.style.opacity = 1;
  }
};

/**
 * Returns selection split into line ranges
 * @return {Array}
 */
Selection.prototype.lineRanges = function() {
  if (this.isEmpty()) {
    return {};
  }
  var ranges = {},
      character = this.start.character,
      line = this.start.line;
  for(; line <= this.end.line ; line++) {
    ranges[line] = ([character, line !== this.end.line || this.end.character]);
    character = 0;
  }
  return ranges;
};

/**
 * Comparator for two cursor positions
 * @return {number}
 */
Selection.prototype.comparePosition = function(one, two) {
  if (one.line < two.line) {
    return -1;
  } else if (one.line > two.line) {
    return 1;
  } else {
    if (one.character < two.character) {
      return -1;
    } else if (one.character > two.character) {
      return 1;
    } else {
      return 0;
    }
  }
};

/**
 * Determines if selection is emtpy (zero-length)
 * @return {boolean}
 */
Selection.prototype.isEmpty = function() {
  return this.comparePosition(this.start, this.end) === 0;
};

/**
 * Moves both start and end to a specified position inside document.
 * @param {number} line
 * @param {number} character
 */
Selection.prototype.setPosition = function(character, line, keepSelection) {

  var position = this._forceBounds(character, line);

  // Calling private setter that does the heavy lifting
  this._doSetPosition(position[0], position[1], keepSelection);

  // Making a callback if necessary
  if (typeof this.onchange === 'function') {
    this.onchange(this, this.start, this.end);
  }
};

/**
 * Checks and forces bounds for proposed position updates
 * @return {Array}
 */
Selection.prototype._forceBounds = function(character, line) {
  var position = this.getPosition();

  // Checking lower bounds
  line >= 0 || (line = 0);
  if (character < 0) {
    // Wraparound for lines
    if (line === position[1] && line > 0) {
      --line;
      character = this.editor.getDocument().getLine(line).trim('\n').length;
    } else {
      character = 0;
    }
  }

  // Checking upper bounds
  var lineCount = this.editor.getDocument().getLineCount();
  line < lineCount || (line = lineCount - 1);
  var characterCount = this.editor.getDocument().getLine(line).trim('\n').length;
  if (character > characterCount) {
    // Wraparound for lines
    if (line === position[1] && line < this.editor.getDocument().getLineCount() - 1) {
      ++line;
      character = 0;
    } else {
      character = characterCount;
    }
  }
  return [character, line];
};

/**
 * Updates cursor styles so it matches current position
 */
Selection.prototype.updateCursorStyle = function() {
  // Calculating new position on the screen
  var metrics = this.editor.getFontMetrics(),
      position = this.getPosition(),
      offsetX = (position[0] - this.editor.scrollLeft()) * metrics.getWidth(),
      offsetY = (position[1] - this.editor.scrollTop()) * metrics.getHeight();
  this.el.style.left = offsetX + 'px';
  this.el.style.top = offsetY + 'px';

  // This helps to see moving cursor when it is always in blink on
  // state on a new position. Try to move cursror in any editor and you
  // will see this in action.
  if(this.isVisible()) {
    this.el.style.opacity = 1;
    clearInterval(this.interval);
    this.interval = setInterval(this.blink.bind(this), this.blinkInterval);
  }
};

/**
 * Private unconditional setter for cursor position
 * @param  {number} character
 * @param  {number} line
 * @param  {boolean} keepSelection
 */
Selection.prototype._doSetPosition = function(character, line, keepSelection) {
  // If this is a selection range
  if (keepSelection) {

    var compare = this.comparePosition({
      line: line,
      character: character
    }, this.start);

    // Determining whether we should make the start side of the range active
    // (have a cursor). This happens when we start the selection be moving
    // left, or moving up.
    if (compare === -1 && (this.isEmpty() || line < this.start.line)) {
      this.activeEndSide = false;
    } 

    // Assign new value to the side that is active
    if (this.activeEndSide) {
      this.end.line = line;
      this.end.character = character;
    } else {
      this.start.line = line;
      this.start.character = character;
    }

    // Making sure that end is further than start and swap if necessary
    if (this.comparePosition(this.start, this.end) > 0) {
      this.activeEndSide = !this.activeEndSide;
      var temp = {
        line: this.start.line,
        character: this.start.character
      };
      this.start.line = this.end.line;
      this.start.character = this.end.character;
      this.end.line = temp.line;
      this.end.character = temp.character;
    }
  } else { // Simple cursor move
    this.activeEndSide = true;
    this.start.line = this.end.line = line;
    this.start.character = this.end.character = character;
  }
};

/**
 * Returns current position of the end of the selection
 * @return {Array}
 */
Selection.prototype.getPosition = function() {
  if (this.activeEndSide) {
    return [this.end.character, this.end.line];
  } else {
    return [this.start.character, this.start.line];
  }
};

/**
 * Moves up specified amount of lines.
 * @param  {number} length
 */
Selection.prototype.moveUp = function(length, keepSelection) {
  arguments.length || (length = 1);
  var position = this.getPosition();
  this.setPosition(position[0], position[1] - length, keepSelection);
};

/**
 * Moves down specified amount of lines.
 * @param  {number} length
 */
Selection.prototype.moveDown = function(length, keepSelection) {
  arguments.length || (length = 1);
  var position = this.getPosition();
  this.setPosition(position[0], position[1] + length, keepSelection);
};

/**
 * Moves up specified amount of lines.
 * @param  {number} length
 */
Selection.prototype.moveLeft = function(length, keepSelection) {
  arguments.length || (length = 1);
  var position = this.getPosition();
  this.setPosition(position[0] - length, position[1], keepSelection);
};

/**
 * Moves down specified amount of lines.
 * @param  {number} length
 */
Selection.prototype.moveRight = function(length, keepSelection) {
  arguments.length || (length = 1);
  var position = this.getPosition();
  this.setPosition(position[0] + length, position[1], keepSelection);
};

/**
 * Shows or hides cursor.
 * @param {void} visible Whether cursor should be visible
 */
Selection.prototype.setVisible = function(visible) {
  clearInterval(this.interval);
  if(visible) {
    this.el.style.display = 'block';
    this.el.style.opacity = 1;
    this.interval = setInterval(this.blink.bind(this), this.blinkInterval);
  } else {
    this.el.style.display = 'none';
  }
  this.visible = visible;
};

/**
 * Returns visibility of the cursor.
 * @return {Boolean}
 */
Selection.prototype.isVisible = function() {
  return this.visible;
};

module.exports = Selection;


================================================
FILE: lib/index.js
================================================
import CanvasTextEditor from "./CanvasTextEditor.js";
import Document from "./Document.js";

document.addEventListener(
  "DOMContentLoaded",
  function () {
    var text = "",
      characterCount = 0,
      aCharCode = "a".charCodeAt(0);
    for (var i = 0; i < 100; i++) {
      characterCount = Math.floor(Math.random() * 120);
      for (var j = 0; j < characterCount; j++) {
        text += String.fromCharCode(aCharCode + Math.floor(Math.random() * 26));
      }
      text += "\n";
    }
    var doc = new Document(text),
        editor = new CanvasTextEditor(doc);
    document.body.appendChild(editor.getEl());
    editor.focus();
  },
  false
);


================================================
FILE: package.json
================================================
{
  "author": "Dmitriy Kubyshkin <dmitriy@kubyshkin.name>",
  "name": "canvas-text-editor",
  "description": "Simple text editor using html5 canvas",
  "version": "0.1.0",
  "scripts": {
    "build": "vite build",
    "dev": "vite",
    "test": "vitest"
  },
  "devDependencies": {
    "jsdom": "22.1.0",
    "vite": "4.3.9",
    "vite-plugin-commonjs": "0.8.2",
    "vitest": "0.34.1"
  }
}


================================================
FILE: test/Document.spec.js
================================================
describe("Document", function() {
  var Document = require('../lib/Document'),
      testText = 'Line1\n\nLine3\nLine4',
      doc = null;

  beforeEach(function () {
    doc = new Document(testText);
  });

  it("should have static method for parsing text into array of lines", function() {
    var lines = Document.prepareText(testText);
    expect(lines.length).toEqual(4);
    expect(lines[0]).toEqual('Line1\n');
    expect(lines[1]).toEqual('\n');
    expect(lines[2]).toEqual('Line3\n');
    expect(lines[3]).toEqual('Line4');
  });

  it("should support getting lines and characters at positions", function(){
    expect(doc.getLineCount()).toEqual(4);
    expect(doc.charAt(0,2)).toEqual('L');
    expect(doc.charAt(4,2)).toEqual('3');
    expect(doc.charAt(100,2)).toBeFalsy();

    expect(doc.getLine(3)).toEqual('Line4');
    expect(doc.getLine(4)).toBeFalsy();
  });

  it("should support deleting one character forward", function() {
    // Regular delete
    doc.deleteChar(true, 0, 2);
    expect(doc.getLine(2)).toEqual('ine3\n');

    // Delete line break
    doc.deleteChar(true, 6, 2);
    expect(doc.getLine(2)).toEqual('ine3Line4');
  });

  it("should support deleting one character backward", function() {
    // Outside of bounds
    doc.deleteChar(false, 6, 3);
    expect(doc.getLine(3)).toEqual('Line4');

    // Regular delete
    doc.deleteChar(false, 5, 3);
    expect(doc.getLine(3)).toEqual('Line');

    // Delete line break
    doc.deleteChar(false, 0, 3);
    expect(doc.getLine(2)).toEqual('Line3Line');
  });

  it("should support deleting character range", function() {
    doc.deleteRange(2, 2, 1, 3);
    expect(doc.getLine(2)).toEqual('Liine4');
  });

  it("should support inserting text", function() {
    // Empty line
    doc.insertText('', 1, 0);
    expect(doc.getLine(0)).toEqual('Line1\n');

    // Single character
    doc.insertText('$', 1, 0);
    expect(doc.getLine(0)).toEqual('L$ine1\n');

    // Single line break
    doc.insertText('\n', 1, 0);
    expect(doc.getLine(0)).toEqual('L\n');
    expect(doc.getLine(1)).toEqual('$ine1\n');

    // Complex text with line breaks
    doc.insertText('a\n\nb', 1, 0);
    expect(doc.getLine(0)).toEqual('La\n');
    expect(doc.getLine(1)).toEqual('\n');
    expect(doc.getLine(2)).toEqual('b\n');
  });

});

================================================
FILE: test/Editor.spec.js
================================================
describe("CanvasTextEditor", function() {
  var CanvasTextEditor = require('../lib/CanvasTextEditor'),
      editor;

  beforeEach(function(){
    editor = new CanvasTextEditor;
  });

  it("should be possible to instatiate", function() {
    expect(editor).toBeTruthy();
  });

  it("should be possible to get current document", function(){
    expect(editor.getDocument()).toBeTruthy();
  });
});

================================================
FILE: test/FontMetrics.spec.js
================================================
describe("FontMetrics", function() {
  var FontMetrics = require('../lib/FontMetrics');

  it("should support getters for family and size", function() {
    var family = 'Arial, sans-serif',
        size = 18,
        metrics = new FontMetrics(family, size);

    expect(metrics.getFamily()).toEqual(family);
    expect(metrics.getSize()).toEqual(size);

    metrics = new FontMetrics;

    expect(metrics.getFamily()).toBeTruthy();
    expect(metrics.getSize()).toBeTruthy();
  });

});

================================================
FILE: test/Selection.spec.js
================================================
describe("Selection", function() {
  var CanvasTextEditor = require('../lib/CanvasTextEditor'),
      Document = require('../lib/Document'),
      testText = 'Line1\nLine2\nLine3',
      selection;

  beforeEach(function(){
    selection = (new CanvasTextEditor(new Document(testText))).getSelection();
  });

  it("should be possible to move cursor", function() {
    var pos = selection.getPosition();
    // Initial position
    expect(pos[0]).toEqual(0);
    expect(pos[1]).toEqual(0);

    // Moving in all directions clockwise
    selection.moveRight(1);
    pos = selection.getPosition();
    expect(pos[0]).toEqual(1);
    expect(pos[1]).toEqual(0);

    selection.moveDown(1);
    pos = selection.getPosition();
    expect(pos[0]).toEqual(1);
    expect(pos[1]).toEqual(1);

    selection.moveLeft(1);
    pos = selection.getPosition();
    expect(pos[0]).toEqual(0);
    expect(pos[1]).toEqual(1);
  });

  it("should respect document bounds", function() {
    selection.moveUp(1);
    var pos = selection.getPosition();
    expect(pos[0]).toEqual(0);
    expect(pos[1]).toEqual(0);

    selection.moveLeft(1);
    pos = selection.getPosition();
    expect(pos[0]).toEqual(0);
    expect(pos[1]).toEqual(0);
  });

  it("should wrap from one line to another when moving left or right", function(){
    selection.moveDown(1);
    selection.moveLeft(1);
    var pos = selection.getPosition();
    expect(pos[0]).toEqual(5);
    expect(pos[1]).toEqual(0);

    selection.moveRight(1);
    pos = selection.getPosition();
    expect(pos[0]).toEqual(0);
    expect(pos[1]).toEqual(1);
  });

  it("should support text selection", function(){
    selection.moveRight(1);
    selection.moveRight(2, true);
    var ranges = selection.lineRanges();
    expect(ranges[0][0]).toEqual(1);
    expect(ranges[0][1]).toEqual(3);

    selection.moveDown(1, true);
    ranges = selection.lineRanges();
    expect(ranges[1][0]).toEqual(0);
    expect(ranges[1][1]).toEqual(3);

    selection.moveLeft(3, true);
    ranges = selection.lineRanges();
    expect(ranges[1][0]).toEqual(0);
    expect(ranges[1][1]).toEqual(0);

    selection.moveLeft(1, true);
    ranges = selection.lineRanges();
    expect(ranges[0][0]).toEqual(1);
    expect(ranges[0][1]).toEqual(5);
  });

});

================================================
FILE: test/__setup__.js
================================================
window.HTMLCanvasElement.prototype.getContext = function() {
  return {
    scale() {},
    fillRect() {},
    fillText() {},
  };
}

================================================
FILE: vite.config.js
================================================
import { defineConfig } from "vite";
import commonjs from "vite-plugin-commonjs";

export default defineConfig({
  plugins: [commonjs(/* options */)],
  test: {
    setupFiles: [
      './test/__setup__.js'
    ],
    globals: true,
    environment: 'jsdom'
  },
});
Download .txt
gitextract_31l1_8oj/

├── .gitignore
├── .jshintrc
├── Makefile
├── README.md
├── index.html
├── lib/
│   ├── CanvasTextEditor.js
│   ├── Document.js
│   ├── FontMetrics.js
│   ├── Selection.js
│   └── index.js
├── package.json
├── test/
│   ├── Document.spec.js
│   ├── Editor.spec.js
│   ├── FontMetrics.spec.js
│   ├── Selection.spec.js
│   └── __setup__.js
└── vite.config.js
Download .txt
SYMBOL INDEX (3 symbols across 1 files)

FILE: test/__setup__.js
  method scale (line 3) | scale() {}
  method fillRect (line 4) | fillRect() {}
  method fillText (line 5) | fillText() {}
Condensed preview — 17 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (78K chars).
[
  {
    "path": ".gitignore",
    "chars": 28,
    "preview": "dist/\n.DS_Store\nnode_modules"
  },
  {
    "path": ".jshintrc",
    "chars": 261,
    "preview": "{\n  \"curly\": true,\n  \"eqeqeq\": true,\n  \"immed\": true,\n  \"latedef\": true,\n  \"newcap\": true,\n  \"noarg\": true,\n  \"sub\": tru"
  },
  {
    "path": "Makefile",
    "chars": 230,
    "preview": "# Builds library for browser usage\nbuild:\n\tnode ./scripts/build.js\n\n# Starts express server that serves stitched library"
  },
  {
    "path": "README.md",
    "chars": 39071,
    "preview": "# Canvas Text Editor Tutorial\n\n1.  [Why write another editor?](#why-write-another-editor)\n2.  [Data Structure](#data-str"
  },
  {
    "path": "index.html",
    "chars": 327,
    "preview": "<!doctype html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge,c"
  },
  {
    "path": "lib/CanvasTextEditor.js",
    "chars": 12914,
    "preview": "\"use strict\";\n\nvar FontMetrics = require('./FontMetrics'),\n    Document = require('./Document'),\n    Selection = require"
  },
  {
    "path": "lib/Document.js",
    "chars": 5027,
    "preview": "\n/**\n * Creates new document from provided text.\n * @param {string} text Full document text.\n * @constructor\n */\nvar Doc"
  },
  {
    "path": "lib/FontMetrics.js",
    "chars": 1951,
    "preview": "\"use strict\";\n\n/**\n * A simple wrapper for system fonts to provide\n * @param {String} family Font Family (same as in CSS"
  },
  {
    "path": "lib/Selection.js",
    "chars": 8047,
    "preview": "/**\n * Creates new selection for the editor.\n * @param {Editor} editor.\n * @constructor\n */\nvar Selection = function(edi"
  },
  {
    "path": "lib/index.js",
    "chars": 657,
    "preview": "import CanvasTextEditor from \"./CanvasTextEditor.js\";\nimport Document from \"./Document.js\";\n\ndocument.addEventListener(\n"
  },
  {
    "path": "package.json",
    "chars": 392,
    "preview": "{\n  \"author\": \"Dmitriy Kubyshkin <dmitriy@kubyshkin.name>\",\n  \"name\": \"canvas-text-editor\",\n  \"description\": \"Simple tex"
  },
  {
    "path": "test/Document.spec.js",
    "chars": 2306,
    "preview": "describe(\"Document\", function() {\n  var Document = require('../lib/Document'),\n      testText = 'Line1\\n\\nLine3\\nLine4',"
  },
  {
    "path": "test/Editor.spec.js",
    "chars": 398,
    "preview": "describe(\"CanvasTextEditor\", function() {\n  var CanvasTextEditor = require('../lib/CanvasTextEditor'),\n      editor;\n\n  "
  },
  {
    "path": "test/FontMetrics.spec.js",
    "chars": 487,
    "preview": "describe(\"FontMetrics\", function() {\n  var FontMetrics = require('../lib/FontMetrics');\n\n  it(\"should support getters fo"
  },
  {
    "path": "test/Selection.spec.js",
    "chars": 2268,
    "preview": "describe(\"Selection\", function() {\n  var CanvasTextEditor = require('../lib/CanvasTextEditor'),\n      Document = require"
  },
  {
    "path": "test/__setup__.js",
    "chars": 132,
    "preview": "window.HTMLCanvasElement.prototype.getContext = function() {\n  return {\n    scale() {},\n    fillRect() {},\n    fillText("
  },
  {
    "path": "vite.config.js",
    "chars": 267,
    "preview": "import { defineConfig } from \"vite\";\nimport commonjs from \"vite-plugin-commonjs\";\n\nexport default defineConfig({\n  plugi"
  }
]

About this extraction

This page contains the full source code of the grassator/canvas-text-editor-tutorial GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 17 files (73.0 KB), approximately 18.1k tokens, and a symbol index with 3 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!