@@ -110,9 +122,9 @@ const editor = new Buffee(document.getElementById("editor"), {});
Editor auto-fits to its container size. For fixed dimensions:
```javascript
-new Buffee(el, { rows: 20 }); // Fixed row count
-new Buffee(el, { cols: 80 }); // Fixed column width
-new Buffee(el, { rows: 20, cols: 80 }); // Both fixed
+new Buffee(el, { h: 20 }); // Fixed row count
+new Buffee(el, { w: 80 }); // Fixed column width
+new Buffee(el, { h: 20, w: 80 }); // Both fixed
```
Container should have explicit height inherit some percentage from parent.
@@ -125,24 +137,22 @@ Container should have explicit height inherit some percentage from parent.
**View** `editor.View` represents the virtual viewport
-`editor.View._` is the buffer lines in view
-
**Span** `editor.Span` represents a text selection. Cursors are the special case of this where the
anchor and the head/dot are the same. Text editing operations are defined relative to this selection.
-The controller are keyboard event handlers which route to operations on the selection. In the future, the basic controller will be refactored out of Buffee.js as an Extension such that you will have to bring-your-own controller by default. e.g. a "vim normal mode controller".
+The controller are keyboard event handlers which route to operations on the selection. In the future, the basic controller will be refactored out of Buffee.js as a Combinator such that you will have to bring-your-own controller by default. e.g. a "vim normal mode controller".
-See: [API Reference](docs/api.md) | [Getting Started](docs/onboarding.md)
+See: [API Reference](docs/api.txt) | [Getting Started](docs/onboarding.md)
-## Extensibility
+## Combinators
-Extensions use the decorator pattern - pure functions that wrap the editor, being an editor instance themselves, meaning they can be combined:
+Combinators use the decorator pattern - pure functions that wrap the editor, being an editor instance themselves, meaning they can be combined:
```javascript
-// Single extension
+// Single combinator
const editor = BuffeeHistory(new Buffee(container, config));
-// Multiple extensions (compose by nesting)
+// Multiple combinators (compose by nesting)
const editor = BuffeeElementals(
BuffeeSyntax(
BuffeeHistory(
@@ -151,23 +161,24 @@ const editor = BuffeeElementals(
)
);
-// Extensions expose APIs on the editor instance
+// Combinators expose APIs on the editor instance
editor.History.undo();
editor.Syntax.setLanguage('javascript');
editor.Elementals.addButton({ row: 0, col: 0, label: 'OK' });
```
-Available extensions:
+Available combinators:
- **History** - Undo/redo with operation coalescing
-- **UndoTree** - Tree-based undo that preserves all branches
- **Syntax** - Regex-based syntax highlighting
- **Elementals** - DOM-based UI elements (buttons, inputs)
- **TUI** - Terminal UI via text manipulation
- **FileLoader** - Multiple strategies for large file loading
- **UltraHighCapacity** - Gzip-compressed storage for 1B+ lines
- **iOS** - Touch and on-screen keyboard support
+- **Sanitize** - Tab/Unicode normalization for programmatic content
-See: [Extensions](docs/extensions.md)
+See: [Dev Guide on Combinators](docs/combinators.md)
+See: [Combinator Gallery](web/combinators.html)
## Versioning
diff --git a/buffee.js b/buffee.js
index fea208f1..66eab0fd 100644
--- a/buffee.js
+++ b/buffee.js
@@ -7,43 +7,40 @@
/**
* Creates a new Buffee editor instance bound to $.
* @constructor
- * @param {HTMLElement} $ - Container element
- * @param {Object} [config={}] - Configuration options
- * @param {number} [config.rows] - Fixed visible lines (omit to auto-fit)
- * @param {number} [config.cols] - Fixed text columns (omit to fill parent)
+ * @param {HTMLElement} $ - Container element
+ * @param {Object} [config={} ] - Configuration options
+ * @param {number} [config.h ] - Fixed visible lines (omit to auto-fit)
+ * @param {number} [config.w ] - Fixed text columns (omit to fill parent)
* @param {number} [config.s=4] - Spaces per tab/indentation
* @example
- * const editor = new Buffee(document.getElementById('editor'), { rows: 25 });
- * editor.Model.s = 'Hello, World!';
+ * const editor = new Buffee(document.getElementById('editor'), { h: 25 });
+ * editor.Model._ = ['Hello, World!'];
+ * editor.View.render();
*/
-function Buffee($, { rows, cols, s = 4 } = {}) {
- this.v = '14.40.0-alpha.1';
+function Buffee($, { h, w, s = 4 } = {}) {
+ this.v = '15.10.0-alpha.1';
this.$ = $;
- const expandTabs = s => Mode.s ? s.replace(/\t/g, ' '.repeat(Mode.s)) : s; // 0 = retain tabs
- const spaceRe = /\s/, wordRe = /[\p{L}\p{Nd}_]/u;
- // head.y and tail.y are ABSOLUTE line numbers (Model indices, not viewport-relative).
+ // head.y and anchor.y are ABSOLUTE line numbers (Model indices, not viewport-relative).
// This allows selections to span beyond the viewport.
- // In case where we have cursor, we want head === tail.
- const detachedHead = { y: 0, x: 0};
- let head = { y: 0, x: 0 };
- let tail = head;
- let maxCol = head.x;
+ // In case where we have cursor, we want head === anchor.
+ const anchor = { y: 0, x: 0 }, detached = {};
+ let head = anchor;
// Interface with HTML and CSS.
const [ch , padding , railInit , railPad ] =
['cell','padding','rail-init','rail-pad']
.map(p => parseFloat(getComputedStyle($).getPropertyValue('--buffee-' + p)));
- const [$pane ,$lines ,$caret ,$clip ,$rail ,$ztxt ,$zsel ] =
- ['pane','lines','caret','clip','rail','ztxt','zsel']
+ const [$pane ,$lines ,$caret ,$rail ,$ztxt ,$zsel ] =
+ ['pane','lines','caret','rail','ztxt','zsel']
.map(q => $.querySelector('.buffee-' + q));
let lRect = $lines.getBoundingClientRect();
// [array, fragment, parent, updateFn]
const viewportLayers = [
- [$ztxt, (el, i) => el.textContent = Model._[View.start + i] ?? null],
- [$rail, (el, i) => el.textContent = View.start + i + 1],
- [$zsel, (el) => el.style.width = 0]
- ].map(([p, fn]) => [[], document.createDocumentFragment(), p, fn]);
+ [$ztxt, (el, i) => el.textContent = Model._[View.first + i] ?? null],
+ [$rail, (el, i) => el.textContent = View.first + i + 1],
+ [$zsel, (el) => el.style.width = 0]
+ ].map(([e, f]) => [[], document.createDocumentFragment(), e, f]);
/**
* Span management for cursor and text selection operations.
@@ -52,23 +49,23 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
*/
const Span = this.Span = {
/**
- * Returns selection bounds. Pass truthy for document order, falsy for [head, tail].
+ * Returns selection bounds. Pass truthy for document order, falsy for [head, anchor].
* @param {boolean} [ordered] - If true, returns [start, end] in document order
* @returns {[Position, Position]} Array of positions
*/
- bounds: ordered => ordered && Span.dir > 0 ? [tail, head] : [head, tail],
+ bounds: ordered => ordered && Span.dir > 0 ? [anchor, head] : [head, anchor],
/**
* Moves the cursor/selection head vertically.
* @param {number} dir - Direction: positive for down, negative for up
- * @param {boolean} [toEdge] - If truthy, go to edge (start if down, end if up) and update maxCol
+ * @param {boolean} [toEdge] - If truthy, go to edge (start if down, end if up) and update Mode.mx
*/
mvY(dir, toEdge) {
- if (dir > 0 ? head.y < Model.end : head.y > 0) {
+ if (dir > 0 ? head.y < Model.end.y : head.y > 0) {
const len = Model._[dir > 0 ? ++head.y : --head.y].length;
- head.x = toEdge ? (dir > 0 ? 0 : len) : Math.min(maxCol, len);
- if (toEdge) maxCol = head.x;
- if (head.y < View.start || head.y > View.end) View.set(dir > 0 ? head.y - View.n + 1 : head.y);
+ head.x = toEdge ? (dir > 0 ? 0 : len) : Math.min(Mode.mx, len);
+ if (toEdge) Mode.mx = head.x;
+ if (head.y < View.first || head.y > View.last) View.first = dir > 0 ? head.y - View.n + 1 : head.y;
else render();
}
},
@@ -79,8 +76,8 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
*/
mvX(dir) {
const right = dir > 0;
- if (right ? head.x < Model._[head.y].length : head.x) { maxCol = right ? ++head.x : --head.x; render(); }
- else if (right ? head.y < Model.end : head.y) this.mvY(dir, 1);
+ if (right ? head.x < Model._[head.y].length : head.x) { Mode.mx = right ? ++head.x : --head.x; render(); }
+ else if (right ? head.y < Model.end.y : head.y) this.mvY(dir, 1);
},
/**
@@ -89,7 +86,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
*/
mvLn(toEnd) {
const line = Model._[head.y];
- maxCol = head.x = toEnd ? line.length : (c => c > 0 && c < head.x ? c : 0)(line.search(/[^ ]/));
+ Mode.mx = head.x = toEnd ? line.length : (c => c > 0 && c < head.x ? c : 0)(line.search(/[^ ]/));
render();
},
@@ -100,20 +97,22 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
const s = Model._[head.y], n = s.length, fwd = dir > 0;
if (head.x !== (fwd ? n : 0)) {
// Move within line
- let j = head.x;
- const ok = fwd ? () => j
j>0 ;
- const step = fwd ? () => j++ : () => j-- ;
- if (spaceRe.test(s[j])) { while (ok() && spaceRe.test(s[j])) step(); while (ok() && wordRe.test(s[j])) step(); }
- else if (wordRe.test(s[j])) while (ok() && wordRe.test(s[j])) step();
+ let j = head.x;
+ const spaceRe = /\s/;
+ const wordRe = /[\p{L}\p{Nd}_]/u;
+ const ok = fwd ? () => j j>0 ;
+ const step = fwd ? () => j++ : () => j-- ;
+ if (spaceRe.test(s[j])) { while (ok() && spaceRe.test(s[j])) step(); while (ok() && wordRe.test(s[j])) step(); }
+ else if (wordRe.test(s[j])) while (ok() && wordRe.test(s[j])) step();
else { const c = s[j]; step(); while (ok() && s[j] === c) step(); }
head.x = j;
render();
- } else if (fwd ? head.y < Model.end : head.y > 0) {
- // At edge - move to adjacent line
- head.x = fwd ? 0 : Model._[--head.y].length;
- if (fwd && ++head.y > View.end) View.set(head.y - View.n + 1);
- else if (!fwd && head.y < View.start) View.set(head.y);
- else render();
+ } else if (fwd ? head.y < Model.end.y : head.y > 0) {
+ // At edge - move to adjacent line
+ head.x = fwd ? 0 : Model._[--head.y].length;
+ if (fwd && ++head.y > View.last) View.first = head.y - View.n + 1;
+ else if (!fwd && head.y < View.first) View.first = head.y;
+ else render();
}
},
@@ -123,7 +122,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
* @returns {-1|0|1}
*/
get dir() {
- return head === tail ? 0 : (tail.y === head.y && tail.x < head.x || tail.y < head.y) ? 1 : -1;
+ return head === anchor ? 0 : (anchor.y === head.y && anchor.x < head.x || anchor.y < head.y) ? 1 : -1;
},
/**
@@ -132,39 +131,39 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
*/
get _() {
const [left, right] = Span.bounds(1);
- if(left.y === right.y) {
+ const fwd = this.dir > 0;
+ if (left.y === right.y) {
const text = Model._[left.y];
- const slice = text.slice(left.x, right.x + (this.dir > 0));
- return right.x >= text.length && left.y < Model.end ? [slice, ''] : [slice];
+ const slice = text.slice(left.x, right.x + fwd);
+ return right.x >= text.length && left.y < Model.end.y ? [slice, ''] : [slice];
}
return [
- Model._[left.y].slice(left.x),
- ...Model._.slice(left.y + 1, right.y),
- Model._[right.y].slice(0, right.x + (this.dir > 0))
+ Model._[left.y].slice(left.x),
+ ...Model._.slice(left.y + 1, right.y),
+ Model._[right.y].slice(0, right.x + fwd)
];
},
/** Collapses selection to a cursor. Optionally sets position first. */
cursor(p) {
- if (p) { head.y = p.y; head.x = p.x; }
- tail.y = head.y;
- tail.x = head.x;
- head = tail;
+ if (p) head.y = p.y, head.x = p.x;
+ anchor.y = head.y;
+ anchor.x = head.x;
+ head = anchor;
},
- /** Begins a new selection by detaching head from tail allowing independent movement. */
- select() {
- head = detachedHead;
- head.y = tail.y;
- head.x = tail.x;
+ /** Begins a new selection by detaching head from anchor. Optionally sets head position. */
+ select(p = anchor) {
+ head = detached;
+ head.y = p.y;
+ head.x = p.x;
},
/**
- * Inserts a string at cursor position, replacing any selection.
- * @param {string} s - String to insert
+ * Inserts lines at cursor position, replacing any selection.
+ * @param {string[]} lines - Array of lines to insert
*/
- ins(s) {
- const lines = expandTabs(s).split('\n');
+ ins(lines) {
if (this.dir) {
const [first, second] = Span.bounds(1);
Model.del(first.y, first.x, second.y, second.x + (this.dir > 0));
@@ -175,7 +174,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
if (lines.length > 1) {
head.y += lines.length - 1;
head.x = lines[lines.length - 1].length;
- } else head.x = first.x + s.length;
+ } else head.x = first.x + (lines[0]?.length || 0);
this.cursor();
} else {
@@ -183,17 +182,17 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
// Update cursor
if (lines.length > 1) {
- head.y += lines.length - 1;
- maxCol = head.x = lines[lines.length - 1].length;
- } else maxCol = head.x += s.length;
+ head.y += lines.length - 1;
+ Mode.mx = head.x = lines[lines.length - 1].length;
+ } else Mode.mx = head.x += lines[0]?.length || 0;
}
- if (head.y > View.end) View.set(head.y - View.n + 1);
+ if (head.y > View.last) View.first = head.y - View.n + 1;
else render();
},
/** Deletes the character before cursor or the current selection. */
del() {
- if (this.dir) this.ins('');
+ if (this.dir) this.ins(['']);
else if (head.x > 0) {
// Delete character before cursor
Model.del(head.y, head.x - 1, head.y, head.x);
@@ -203,7 +202,7 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
// At start of line - delete newline (join with previous line)
head.x = Model._[head.y - 1].length;
Model.del(head.y - 1, head.x, head.y, 0);
- if (--head.y < View.start) View.set(head.y);
+ if (--head.y < View.first) View.first = head.y;
else render();
}
},
@@ -220,15 +219,15 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
dent(n) {
// Indent requires selection; unindent can work on current line without selection
if (n > 0 && !this.dir) return;
- const [first, second] = Span.bounds(1);
+ const [first, second] = Span.bounds(1);
for (let i = first.y; i <= second.y; i++) {
- const line = Model._[i];
+ const line = Model._[i];
if (n > 0) Model._[i] = ' '.repeat(n) + line;
else {
- const cursor = i === first.y ? first : i === second.y ? second : null;
+ const cursor = first.y === i && first || second.y === i && second;
if (cursor) {
- const right = line.slice(cursor.x).search(/[^ ]|$/);
- const toRemove = Math.min(-n, line.slice(0, cursor.x).search(/[^ ]|$/) + right);
+ const right = line.slice(cursor.x).search(/[^ ]|$/);
+ const toRemove = Math.min(-n, line.slice(0, cursor.x).search(/[^ ]|$/) + right);
Model._[i] = line.slice(toRemove);
if (right < toRemove) cursor.x -= toRemove - right;
} else Model._[i] = line.slice(Math.min(-n, line.search(/[^ ]|$/)));
@@ -239,12 +238,14 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
},
};
+
+ const cw = $caret.getBoundingClientRect().width;
/**
* Editor mode settings (shared between internal and external code).
* @namespace Mode
*/
const Mode = this.Mode = {
- s, /** spaces */
+ s, /** spaces */
/**
* Interactive mode: 1 (normal), 0 (navigation-only), -1 (read-only)
* - 1: Full editing (default)
@@ -252,11 +253,13 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
* - -1: Read-only (no cursor/selection rendering, no navigation) - used by TUI
* @type {-1|0|1}
*/
- i: 1,
- f: 0, /** framecount */
+ i: 1,
+ f: 0, /** framecount */
+ mx: 0, /** max column for vertical movement */
ch, /** line and character height */
- cw: $caret.getBoundingClientRect().width, /** computed character width */
- sub: []
+ cw, /** computed character width */
+ sub: [], /** render callbacks */
+ ext: [] /** registered extensions */
};
/**
@@ -268,19 +271,10 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
_: [''],
/**
- * Index of the last line in the document.
- * @returns {number} Zero-based index of the last line
+ * Last coordinate in the document.
+ * @returns {Position} Position of last character {y, x}
*/
- get end() { return this._.length - 1 },
-
- /**
- * Sets the document content from a string. Splits on newlines.
- * @param {string} text - The full document text
- */
- set s(text) {
- this._ = expandTabs(text).split('\n');
- render();
- },
+ get end() { const y = this._.length - 1; return { y, x: this._[y].length } },
/**
* Primitive insert operation. Inserts lines at position.
@@ -312,51 +306,33 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
* Virtual viewport dictacting which portion of document is seen and rendered.
* @namespace View
*/
+ let vFirst = 0, vN = h ? 0 : -1;
const View = this.View = {
/** @type {number} Index of the first visible line (0-indexed) */
- start: 0,
+ get first() { return vFirst; },
+ set first(v) { vFirst = Math.max(0, Math.min(v, Model.end.y)); RENDER(); },
/** @type {number} Number of visible lines */
- n: 0,
- /** @type {number} Number of DOM line containers. +1 if auto-fit (no rows specified) */
- get N() { return this.n + !rows; },
-
+ get n() { return vN; },
+ set n(v) { const d = v - vN; vN = v; RENDER(d); },
/**
* Index of the last visible line.
* @returns {number} Index of the last line in the viewport
*/
- get end() { return Math.min(this.start + this.n - 1, Model.end); },
-
- /**
- * Sets the viewport position and optionally size.
- * @param {number} start - Line index to start at (0-indexed)
- * @param {number} [size] - Number of lines to display (optional)
- */
- set(start, size = this.n) {
- const delta = size - this.n;
- this.n = size;
- this.start = Math.max(0, Math.min(start, Model.end));
- RENDER(delta);
- },
-
- /**
- * Gets the lines currently visible in the viewport.
- * @returns {string[]} Array of visible line contents
- */
- get _() { return Model._.slice(this.start, this.end + 1); }
+ get last() { return vFirst + vN - 1 < Model.end.y ? vFirst + vN - 1 : Model.end.y; }
};
// Add / remove lines, selections, rails as row changes
- const RENDER = this.RENDER = delta => {
+ const RENDER = View.RENDER = delta => {
if (delta) {
let d = delta;
- for (; d > 0; d-- ) viewportLayers.forEach(([a, f]) => a.push(f.appendChild(document.createElement('pre'))));
- if (delta > 0 ) viewportLayers.forEach(([, f, p]) => p?.appendChild(f));
- for (d = delta; d < 0; d++) viewportLayers.forEach(([a]) => a.pop()?.remove());
+ for (; d > 0; d-- ) for (const [a, f] of viewportLayers) a.push(f.appendChild(document.createElement('pre')));
+ if (delta > 0 ) for (const [, f, p] of viewportLayers) p?.appendChild(f);
+ for (d = delta; d < 0; d++) for (const [a] of viewportLayers) a.pop()?.remove();
}
if ($rail) {
- const railCols = Math.max(railInit, (View.start + View.N).toString().length) + railPad;
+ const railCols = Math.max(railInit, (View.first + vN + !h).toString().length) + railPad;
$rail.style.width = railCols + 'ch';
- if (cols) $pane.style.width = `calc(${railCols + cols}ch + ${padding * 4}px)`;
+ if (w) $pane.style.width = `calc(${railCols + w}ch + ${padding * 4}px)`;
}
render(delta);
};
@@ -364,88 +340,83 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
/**
* Renders the editor viewport, selection, cursor, and calls extension hooks.
*/
- const render = this.render = (delta = 0) => {
+ const render = View.render = (delta = 0) => {
Mode.f++;
// Update contents of line containers (reset to clean state)
- for (let i = 0; i < View.N; i++) viewportLayers.forEach(([arr, , , update]) => arr[i] && update(arr[i], i));
+ for (let i = 0; i < vN + !h; i++) for (const [arr, , , update] of viewportLayers) arr[i] && update(arr[i], i);
let cursorLeft = -1;
if(Mode.i >= 0) {
// Sels
const [firstEdge, secondEdge] = Span.bounds(1);
- const rEnd = Math.min(View.start + View.n, secondEdge.y + 1);
- for (let r = Math.max(View.start, firstEdge.y); r < rEnd; r++) {
- const f = r === firstEdge.y, l = r === secondEdge.y, n = Model._[r].length, {style} = viewportLayers[2][0][r - View.start];
+ const rEnd = Math.min(View.first + View.n, secondEdge.y + 1);
+ for (let r = Math.max(View.first, firstEdge.y); r < rEnd; r++) {
+ const f = r === firstEdge.y, l = r === secondEdge.y, n = Model._[r].length, {style} = viewportLayers[2][0][r - View.first];
style.left = (f ? firstEdge.x : 0) + 'ch';
style.width = (f && l ? secondEdge.x - firstEdge.x : f ? n - firstEdge.x + 1 : l ? Math.min(secondEdge.x, n) : n + 1) + 'ch';
}
// Cursor
- const headViewRow = head.y - View.start;
+ const headViewRow = head.y - View.first;
if (headViewRow >= 0 && headViewRow < View.n) {
$caret.style.top = headViewRow * ch + 'px';
cursorLeft = head.x;
// Horizontal scroll to keep cursor in view
- const {left: cl, right: cr} = lRect, rl = lRect.left + head.x * Mode.cw - $lines.scrollLeft, rr = rl + Mode.cw;
- $lines.scrollLeft = Math.round(($lines.scrollLeft + (rl < cl ? rl - cl : rr > cr ? rr - cr : 0)) / Mode.cw) * Mode.cw;
+ const {left: cl, right: cr} = lRect, rl = lRect.left + head.x * cw - $lines.scrollLeft, rr = rl + cw;
+ $lines.scrollLeft = Math.round(($lines.scrollLeft + (rl < cl ? rl - cl : rr > cr ? rr - cr : 0)) / cw) * cw;
}
}
$caret.style.left = cursorLeft + 'ch';
- Mode.sub.forEach(hook => hook($lines, View, delta));
+ for (const hook of Mode.sub) hook($lines, View, delta);
}
- // Set container width if cols specified
+ // Set container width if w specified
// Width = rail(ch) + lines(ch) + margins(px): rail has margin*2, lines has margin*2
- cols && !$rail && ($pane.style.width = `calc(${cols}ch + ${padding * 2}px)`);
- // Set container height if rows specified (don't use flex: 1). TODO: perhaps can just set on parent
- rows && viewportLayers.forEach(([, , p]) => p && (p.style.height = rows * ch + 'px'));
+ w && !$rail && ($pane.style.width = `calc(${w}ch + ${padding * 2}px)`);
+ // Set container height if h specified (don't use flex: 1). TODO: perhaps can just set on parent
+ if (h) for (const [, , p] of viewportLayers) p && (p.style.height = h * ch + 'px');
// Initial sizing render
- const resize = delta => {View.n += delta, RENDER(delta)};
- rows ? resize(rows) : new ResizeObserver(() => {lRect = $lines.getBoundingClientRect(); resize(Math.floor($pane.clientHeight / ch) - View.n)}).observe($pane);
+ h ? View.n = h : new ResizeObserver(() => {lRect = $lines.getBoundingClientRect(); View.n = Math.floor($lines.clientHeight / ch)}).observe($pane);
// Reading clipboard from the keydown listener involves a different security model.
$lines.addEventListener('paste', e => {
- e.preventDefault(); // stop browser from inserting raw clipboard text
- const text = e.clipboardData.getData('text/plain');
- if (text) Span.ins(text);
+ e.preventDefault();
+ Span.ins(e.clipboardData.getData('text/plain').split('\n'));
});
- // Triggered by a keydown paste event. a copy event handler can read the clipboard
- // by the standard security model. Meanwhile, we don't have to make the editor "selectable".
- // Listen on $clip since that's where focus moves on Ctrl+C/X.
- $clip.addEventListener('copy', e => {
- e.preventDefault(); // take over the clipboard contents
+ // Modern browsers fire copy/cut events on any focused element.
+ $lines.addEventListener('copy', e => {
+ e.preventDefault();
e.clipboardData.setData('text/plain', Span._.join('\n'));
});
- $clip.addEventListener('cut', e => {
- e.preventDefault(); // take over the clipboard contents
+ $lines.addEventListener('cut', e => {
+ e.preventDefault();
e.clipboardData.setData('text/plain', Span._.join('\n'));
Span.del();
- $lines.focus({ preventScroll: true }); // Return focus to editor
});
// Arrow key encoding: ±1 = horizontal, ±2 = vertical, sign = direction
const arrowMap = { ArrowDown: 2, ArrowUp: -2, ArrowLeft: -1, ArrowRight: 1 };
+
$lines.addEventListener('keydown', e => {
- const cmd = e.metaKey || e.ctrlKey, k = e.key, sh = e.shiftKey;
-
- const metaKeys = {
- v: () => {},
- c: () => { $clip.focus({ preventScroll: true }); $clip.select(); },
- x: () => { $clip.focus({ preventScroll: true }); $clip.select(); },
- z: () => { e.preventDefault(); if (this.History) this.History[sh ? 'redo' : 'undo'](); },
- }, special = {
- Backspace: () => { Span.del() },
- Enter: () => { Span.ins('\n') } ,
- Tab: () => {
- e.preventDefault();
- (Span.dir || sh) ? Span.dent(sh ? -Mode.s : Mode.s) : Span.ins(' '.repeat(Mode.s));
- },
+ const cmd = e.metaKey || e.ctrlKey,
+ k = e.key,
+ sh = e.shiftKey,
+ arrowCode = arrowMap[k] || 0,
+ special = {
+ Backspace: () => Span.del(),
+ Enter: () => Span.ins(['', '']),
+ Tab: () => {
+ e.preventDefault();
+ (Span.dir || sh) ? Span.dent(sh ? -Mode.s : Mode.s) : Span.ins([' '.repeat(Mode.s)]);
+ },
+ },
+ cmdMap = {
+ z: () => this.History?.[sh ? 'redo' : 'undo'](),
};
- const arrowCode = arrowMap[k] || 0;
if (arrowCode) {
e.preventDefault(); // prevents page scroll
if (Mode.i < 0) return; // read-only mode: no navigation
@@ -453,8 +424,8 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
const direction = arrowCode >> 31 | 1;
if(cmd || e.altKey) {
- if(!sh && Span.dir) Span.cursor();
- else if(sh && !Span.dir) Span.select();
+ if(!sh && Span.dir) Span.cursor();
+ else if(sh && !Span.dir) Span.select();
if (arrowCode % 2) cmd ? Span.mvLn(direction > 0) : Span.mvW(direction);
} else if (!sh && Span.dir) { // no meta key, no shift key, selection.
if (arrowCode % 2) {
@@ -463,14 +434,14 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
} else {
const edge = Span.bounds(1)[direction > 0 | 0];
// edge.y is already absolute
- const targetAbsRow = Math.max(0, Math.min(edge.y + direction, Model.end));
+ const targetAbsRow = Math.max(0, Math.min(edge.y + direction, Model.end.y));
- maxCol = Math.min(edge.x, Model._[targetAbsRow].length);
- Span.cursor({ y: targetAbsRow, x: maxCol});
+ Mode.mx = Math.min(edge.x, Model._[targetAbsRow].length);
+ Span.cursor({ y: targetAbsRow, x: Mode.mx});
// Scroll viewport if target is outside visible area
- if (targetAbsRow < View.start) View.set(targetAbsRow);
- else if (targetAbsRow > View.end) View.set(targetAbsRow - View.n + 1);
+ if (targetAbsRow < View.first) View.first = targetAbsRow;
+ else if (targetAbsRow > View.last) View.first = targetAbsRow - View.n + 1;
else render();
}
} else { // no meta key.
@@ -478,10 +449,10 @@ function Buffee($, { rows, cols, s = 4 } = {}) {
Span[arrowCode % 2 ? 'mvX' : 'mvY'](direction);
}
} else if (k.length === 1) {
- if (cmd) metaKeys[k.toLowerCase()]?.();
+ if (cmd) { if (cmdMap[k]) { e.preventDefault(); cmdMap[k](); } }
else if (Mode.i > 0) {
k === ' ' && e.preventDefault();
- Span.ins(k);
+ Span.ins([k]);
}
} else if (special[k] && Mode.i >= 1) { special[k](); }
});
diff --git a/extensions/_template.js b/combinators/_template.js
similarity index 92%
rename from extensions/_template.js
rename to combinators/_template.js
index ea61dd4f..1472eb53 100644
--- a/extensions/_template.js
+++ b/combinators/_template.js
@@ -17,9 +17,10 @@ function Buffee__NAME__(editor) {
const { sub } = editor.Mode;
// === EDITOR PROPERTIES ===
- // Available: Model, Select, View, Mode, render, $
+ // Available: Model, Select, View, Mode, $
// Select.bounds() returns [head, tail], Select.bounds(1) returns ordered
- const { Model, View, Mode, render, $ } = editor;
+ const { Model, View, Mode, $ } = editor;
+ const { render } = View;
const lineHeight = Mode.ch;
const $e = $.querySelector('.buffee-pane');
diff --git a/extensions/elementals.js b/combinators/elementals.js
similarity index 98%
rename from extensions/elementals.js
rename to combinators/elementals.js
index 3fc8defa..18795c61 100644
--- a/extensions/elementals.js
+++ b/combinators/elementals.js
@@ -15,7 +15,8 @@
*/
function BuffeeElementals(editor) {
const { sub, ch: lineHeight } = editor.Mode;
- const { View, render, $ } = editor;
+ const { View, $ } = editor;
+ const { render } = View;
const $elementLayer = $.querySelector('.buffee-layer-elements');
let enabled = false;
@@ -51,7 +52,7 @@ function BuffeeElementals(editor) {
*/
function updatePositions() {
for (const el of elements) {
- const viewportRow = el.row - View.start;
+ const viewportRow = el.row - View.first;
if (viewportRow >= 0 && viewportRow < View.n) {
el.$container.style.top = viewportRow * lineHeight + 'px';
el.$container.style.display = '';
@@ -366,6 +367,7 @@ function BuffeeElementals(editor) {
// Attach to editor instance
editor.Elementals = Elementals;
+ editor.Mode.ext.push('Elementals');
return editor;
}
diff --git a/extensions/fileloader.js b/combinators/fileloader.js
similarity index 98%
rename from extensions/fileloader.js
rename to combinators/fileloader.js
index c2c6f801..a2f4f682 100644
--- a/extensions/fileloader.js
+++ b/combinators/fileloader.js
@@ -14,12 +14,11 @@
* await editor.FileLoader.streamMaterializedLoad(file);
*/
function BuffeeFileLoader(editor) {
- const { Model, Mode, render, $ } = editor;
+ const { Model, Mode, View, $ } = editor;
+ const { render } = View;
function appendLines(newLines, skipRender = false) {
- const spaces = Mode.s;
- const expandTabs = s => spaces ? s.replace(/\t/g, ' '.repeat(spaces)) : s;
- Model._.push(...newLines.map(expandTabs));
+ Model._.push(...newLines);
if (!skipRender) render();
}
@@ -78,7 +77,8 @@ function BuffeeFileLoader(editor) {
async naiveLoad(file) {
const t0 = performance.now();
const text = await file.text();
- Model.s = text;
+ Model._ = text.split('\n');
+ render();
const t1 = performance.now();
return {
lines: Model._.length,
@@ -394,6 +394,7 @@ function BuffeeFileLoader(editor) {
// Attach to editor instance
editor.FileLoader = FileLoader;
+ editor.Mode.ext.push('FileLoader');
return editor;
}
diff --git a/extensions/highlights.js b/combinators/highlights.js
similarity index 96%
rename from extensions/highlights.js
rename to combinators/highlights.js
index 980acde0..a10ebd3f 100644
--- a/extensions/highlights.js
+++ b/combinators/highlights.js
@@ -11,6 +11,9 @@
* @returns {Buffee} The extended editor instance
*/
function BuffeeHighlights(editor) {
+ // Idempotent: return if already applied
+ if (editor.Highlights) return editor;
+
const { $, Mode } = editor;
const lineHeight = Mode.ch;
@@ -110,5 +113,7 @@ function BuffeeHighlights(editor) {
};
editor.Highlights = Highlights;
+ editor.Mode.ext.push('Highlights');
+
return editor;
}
diff --git a/extensions/history.js b/combinators/history.js
similarity index 98%
rename from extensions/history.js
rename to combinators/history.js
index 5ee5a933..c113c97a 100644
--- a/extensions/history.js
+++ b/combinators/history.js
@@ -16,7 +16,7 @@ function BuffeeHistory(editor) {
const Model = editor.Model;
const add = Model.ins.bind(Model);
const del = Model.del.bind(Model);
- const { render } = editor;
+ const { render } = editor.View;
// State
const undoStack = [];
@@ -207,6 +207,7 @@ function BuffeeHistory(editor) {
};
editor.History = History;
+ editor.Mode.ext.push('History');
return editor;
}
diff --git a/extensions/ios.js b/combinators/ios.js
similarity index 91%
rename from extensions/ios.js
rename to combinators/ios.js
index e8421bd5..8c93aaf4 100644
--- a/extensions/ios.js
+++ b/combinators/ios.js
@@ -1,7 +1,12 @@
/**
* @fileoverview BuffeeIOS - iOS/touch support extension for Buffee.
* Enables touch interactions and on-screen keyboard input on iOS devices.
- * @version 1.0.0
+ * @version 1.0.1
+ *
+ * TODO: This extension should dynamically add a clipboard bridge element
+ * (hidden textarea) and override the copy/cut behavior. The clipboard bridge
+ * that was removed from core in v15.9.0 was originally built for iOS,
+ * which requires a real DOM selection to fire clipboard events.
*/
/**
@@ -92,15 +97,15 @@ function BuffeeIOS(editor) {
let row = Math.max(0, Math.floor(y / lineHeight));
const col = Math.max(0, Math.floor(x / ch));
// Bounds check row to last meaningful viewport row
- const linesFromViewStart = Model.end - View.start;
- const lastMeaningfulViewRow = Math.min(View.n - 1, linesFromViewStart);
+ const linesFromViewFirst = Model.end.y - View.first;
+ const lastMeaningfulViewRow = Math.min(View.n - 1, linesFromViewFirst);
row = Math.min(row, lastMeaningfulViewRow);
// Convert to absolute row
- const absRow = View.start + row;
+ const absRow = View.first + row;
// Bounds check col to line length
const lineLength = Model._[absRow].length;
Span.cursor({ y: absRow, x: Math.min(col, lineLength) });
- editor.render();
+ editor.View.render();
}
// Dispatch synthetic keydown to the editor
@@ -169,6 +174,7 @@ function BuffeeIOS(editor) {
// Attach to editor instance
editor.iOS = iOS;
+ editor.Mode.ext.push('iOS');
return editor;
}
diff --git a/combinators/sanitize.js b/combinators/sanitize.js
new file mode 100644
index 00000000..d285e3cf
--- /dev/null
+++ b/combinators/sanitize.js
@@ -0,0 +1,84 @@
+/**
+ * @fileoverview BuffeeSanitize - Text sanitization extension for Buffee.
+ * Converts tabs to spaces and removes/normalizes problematic Unicode characters.
+ * @version 1.0.0
+ */
+
+/**
+ * Decorator: adds text sanitization to a Buffee instance.
+ * Automatically sanitizes text on insert and content set.
+ *
+ * Handles:
+ * - Tabs → spaces (based on Mode.s)
+ * - Zero-width characters (ZWSP, ZWNJ, ZWJ, BOM)
+ * - Multi-width spaces → regular space
+ *
+ * @param {Buffee} editor - The Buffee instance to extend
+ * @returns {Buffee} The extended editor instance
+ * @example
+ * const editor = BuffeeSanitize(Buffee(container, config));
+ */
+function BuffeeSanitize(editor) {
+ const { Model, Mode } = editor;
+ const origIns = Model.ins.bind(Model);
+
+ // Zero-width characters to remove
+ const zeroWidthRe = /[\u200B\u200C\u200D\uFEFF]/g;
+
+ // Multi-width spaces to normalize (em space, en space, figure space, etc.)
+ const multiSpaceRe = /[\u2000-\u200A\u202F\u205F\u3000]/g;
+
+ /**
+ * Sanitize a single line of text.
+ * @param {string} line - Line to sanitize
+ * @returns {string} Sanitized line
+ */
+ function sanitizeLine(line) {
+ let result = line;
+ // Convert tabs to spaces
+ if (Mode.s) result = result.replace(/\t/g, ' '.repeat(Mode.s));
+ // Remove zero-width characters
+ result = result.replace(zeroWidthRe, '');
+ // Normalize multi-width spaces
+ result = result.replace(multiSpaceRe, ' ');
+ return result;
+ }
+
+ /**
+ * Sanitize text (may contain newlines).
+ * @param {string} text - Text to sanitize
+ * @returns {string} Sanitized text
+ */
+ function sanitizeText(text) {
+ return text.split('\n').map(sanitizeLine).join('\n');
+ }
+
+ /**
+ * Sanitize an array of lines.
+ * @param {string[]} lines - Lines to sanitize
+ * @returns {string[]} Sanitized lines
+ */
+ function sanitizeLines(lines) {
+ return lines.map(sanitizeLine);
+ }
+
+ // Wrap Model.ins to sanitize lines
+ Model.ins = function(row, col, lines) {
+ origIns(row, col, sanitizeLines(lines));
+ };
+
+ // API
+ const Sanitize = {
+ /** Sanitize a single line */
+ line: sanitizeLine,
+ /** Sanitize text with newlines */
+ text: sanitizeText,
+ /** Sanitize array of lines */
+ lines: sanitizeLines
+ };
+
+ editor.Sanitize = Sanitize;
+ editor.Mode.ext.push('Sanitize');
+
+ return editor;
+}
diff --git a/extensions/statusline.js b/combinators/statusline.js
similarity index 63%
rename from extensions/statusline.js
rename to combinators/statusline.js
index 0aa7c2e4..7f9d477a 100644
--- a/extensions/statusline.js
+++ b/combinators/statusline.js
@@ -1,7 +1,7 @@
/**
* @fileoverview BuffeeStatusLine - Status line extension for Buffee.
* Updates status elements on each render when values change.
- * @version 1.2.0
+ * @version 1.4.0
*/
/**
@@ -23,32 +23,32 @@ function BuffeeStatusLine(editor, { showSelection = false } = {}) {
const $spaces = $.querySelector('.buffee-spaces');
let lastRow = -1, lastCol = -1, lastEndRow = -1, lastEndCol = -1, lastHasSelection = false;
- let lastLineCount = -1, lastSpaces = -1, lastOriginalLineCount = -1;
- let byteCount = 0, originalLineCount = 0;
+ let lastLineCount = -1, lastByteCount = -1, lastSpaces = -1;
+ let originalLineCount = 0;
+ let originalByteCount = 0;
- // Capture initial state if text was already set before this extension
- if (Model._.length > 1 || Model._[0] !== '') {
- const text = Model._.join('\n');
- byteCount = new TextEncoder().encode(text).length;
- originalLineCount = Model._.length;
+ function computeByteCount() {
+ // UltraHighCapacity uses a Proxy that doesn't support iteration
+ if (editor.UltraHighCapacity?.enabled) return null;
+ let bytes = 0;
+ for (const line of Model._) {
+ bytes += line.length + 1; // +1 for newline
+ }
+ return bytes > 0 ? bytes - 1 : 0; // Remove trailing newline
}
- // Wrap Model.s setter to calculate byteCount and originalLineCount
- const originalTextDescriptor = Object.getOwnPropertyDescriptor(Model, 's');
- Object.defineProperty(Model, 's', {
- set(text) {
- byteCount = new TextEncoder().encode(text).length;
- originalLineCount = text.split('\n').length;
- originalTextDescriptor.set.call(this, text);
- },
- configurable: true
- });
-
function updateStatusLine() {
const [head, tail] = editor.Span.bounds(); // head first, unordered
const [start, end] = editor.Span.bounds(1); // ordered by position
const hasSelection = editor.Span.dir !== 0;
- const lineCount = Model.end + 1;
+ const lineCount = Model.end.y + 1;
+ const byteCount = computeByteCount();
+
+ // Capture original counts on first non-empty content
+ if (originalLineCount === 0 && (byteCount > 0 || lineCount > 1)) {
+ originalLineCount = lineCount;
+ originalByteCount = byteCount ?? 0;
+ }
if (showSelection && hasSelection) {
// Show selection range: "1:5 - 3:10"
@@ -88,10 +88,10 @@ function BuffeeStatusLine(editor, { showSelection = false } = {}) {
lastEndCol = -1;
}
- if ($lineCounter && (lineCount !== lastLineCount || originalLineCount !== lastOriginalLineCount)) {
- $lineCounter.textContent = `${lineCount.toLocaleString()}L, originally: ${originalLineCount}L ${byteCount} bytes`;
+ if ($lineCounter && (lineCount !== lastLineCount || byteCount !== lastByteCount)) {
+ $lineCounter.textContent = `${lineCount}L, originally: ${originalLineCount}L ${originalByteCount} bytes`;
lastLineCount = lineCount;
- lastOriginalLineCount = originalLineCount;
+ lastByteCount = byteCount;
}
if ($spaces && Mode.s !== lastSpaces) {
$spaces.textContent = `Spaces: ${Mode.s}`;
@@ -99,8 +99,29 @@ function BuffeeStatusLine(editor, { showSelection = false } = {}) {
}
}
+ // API to manage original counts
+ editor.StatusLine = {
+ /** Reset original counts (called before loading new file) */
+ resetOriginal() {
+ originalLineCount = 0;
+ originalByteCount = 0;
+ lastLineCount = -1;
+ lastByteCount = -1;
+ },
+ /** Set original byte count from raw file size (before tab expansion, etc.) */
+ setOriginalBytes(bytes) {
+ originalByteCount = bytes;
+ },
+ /** Set original line count */
+ setOriginalLines(lines) {
+ originalLineCount = lines;
+ }
+ };
+
sub.push(updateStatusLine);
updateStatusLine(); // Initial population
+ editor.Mode.ext.push('StatusLine');
+
return editor;
}
diff --git a/extensions/syntax.js b/combinators/syntax.js
similarity index 97%
rename from extensions/syntax.js
rename to combinators/syntax.js
index e49ee8a4..dd2b7856 100644
--- a/extensions/syntax.js
+++ b/combinators/syntax.js
@@ -47,24 +47,6 @@ function BuffeeSyntax(editor) {
}
};
- // Hook Model.s setter for bulk content changes
- const textDescriptor = Object.getOwnPropertyDescriptor(Model, 's');
- if (textDescriptor && textDescriptor.set) {
- const originalTextSetter = textDescriptor.set;
- Object.defineProperty(Model, 's', {
- set: function(text) {
- originalTextSetter.call(this, text);
- if (enabled) {
- // Full document change - reset cache completely
- stateCache.length = 1;
- stateCache[0] = 0;
- }
- },
- get: textDescriptor.get,
- configurable: true
- });
- }
-
// Built-in token types with default colors
const defaultColors = {
keyword: '#C678DD',
@@ -258,13 +240,13 @@ function BuffeeSyntax(editor) {
if (!enabled || !language) return;
// Ensure we have state cache up to viewport end
- ensureStateCache(viewport.start + viewport.n);
+ ensureStateCache(viewport.first + viewport.n);
// Use $textLayer which contains the pre elements (not $container which is $e)
const lineContainer = $textLayer || $container;
for (let i = 0; i < viewport.n; i++) {
- const absLine = viewport.start + i;
+ const absLine = viewport.first + i;
if (absLine >= Model._.length) break;
const lineEl = lineContainer.children[i];
@@ -701,6 +683,7 @@ function BuffeeSyntax(editor) {
// Attach to editor instance
editor.Syntax = Syntax;
+ editor.Mode.ext.push('Syntax');
return editor;
}
diff --git a/extensions/treesitter.js b/combinators/treesitter.js
similarity index 96%
rename from extensions/treesitter.js
rename to combinators/treesitter.js
index 247f0e1e..59c9a746 100644
--- a/extensions/treesitter.js
+++ b/combinators/treesitter.js
@@ -18,7 +18,8 @@
*/
function BuffeeTreeSitter(editor, { parser, query }) {
const { sub } = editor.Mode;
- const { View, Model, render, $ } = editor;
+ const { View, Model, $ } = editor;
+ const { render } = View;
const $e = $.querySelector('.buffee-pane');
/** @type {boolean} */
@@ -110,7 +111,7 @@ function BuffeeTreeSitter(editor, { parser, query }) {
$line.textContent = viewport.lines[i] || null;
// Apply highlighting
- minJ = highlightLine($line, viewport.start + i, minJ);
+ minJ = highlightLine($line, viewport.first + i, minJ);
}
});
@@ -164,6 +165,7 @@ function BuffeeTreeSitter(editor, { parser, query }) {
// Attach to editor instance
editor.TreeSitter = TreeSitter;
+ editor.Mode.ext.push('TreeSitter');
return editor;
}
diff --git a/extensions/tui.js b/combinators/tui.js
similarity index 97%
rename from extensions/tui.js
rename to combinators/tui.js
index 69ed2ab6..f442f8fb 100644
--- a/extensions/tui.js
+++ b/combinators/tui.js
@@ -20,7 +20,8 @@ function BuffeeTUI(editor) {
const Highlights = editor.Highlights;
const { sub } = editor.Mode;
- const { View, Model, render, $ } = editor;
+ const { View, Model, $ } = editor;
+ const { render } = View;
const $textLayer = $.querySelector('.buffee-ztxt');
let enabled = false;
@@ -286,7 +287,7 @@ function BuffeeTUI(editor) {
for (const el of elements) {
for (let i = 0; i < el.contents.length; i++) {
const absRow = el.row + i;
- const viewportRow = absRow - viewport.start;
+ const viewportRow = absRow - viewport.first;
if (viewportRow >= 0 && viewportRow < viewport.n) {
const $line = $textLayer.children[viewportRow];
@@ -317,7 +318,7 @@ function BuffeeTUI(editor) {
for (let i = 0; i < currentEl.contents.length; i++) {
const absRow = currentEl.row + i;
- const viewportRow = absRow - viewport.start;
+ const viewportRow = absRow - viewport.first;
if (viewportRow >= 0 && viewportRow < viewport.n) {
Highlights.create(viewportRow, currentEl.col, currentEl.width);
@@ -326,5 +327,7 @@ function BuffeeTUI(editor) {
});
editor.TUI = TUI;
+ editor.Mode.ext.push('TUI');
+
return editor;
}
diff --git a/extensions/ultrahighcapacity.js b/combinators/ultrahighcapacity.js
similarity index 95%
rename from extensions/ultrahighcapacity.js
rename to combinators/ultrahighcapacity.js
index 3c25a845..5481784c 100644
--- a/extensions/ultrahighcapacity.js
+++ b/combinators/ultrahighcapacity.js
@@ -16,11 +16,12 @@
*/
function BuffeeUltraHighCapacity(editor) {
const { sub } = editor.Mode;
- const { View, Model, Mode, render, $ } = editor;
+ const { View, Model, Mode, $ } = editor;
+ const { render } = View;
const $e = $.querySelector('.buffee-pane');
// Store original methods/getters
- const originalLastIndexGetter = Object.getOwnPropertyDescriptor(Model, 'end').get;
+ const originalEndGetter = Object.getOwnPropertyDescriptor(Model, 'end').get;
// Chunk state
let enabled = false;
@@ -113,7 +114,7 @@ function BuffeeUltraHighCapacity(editor) {
* @private
*/
function loadChunksForView() {
- const startChunkIndex = Math.floor(View.start / chunkSize);
+ const startChunkIndex = Math.floor(View.first / chunkSize);
// Check if we need to load new chunks
if (currentChunkIndex !== startChunkIndex) {
@@ -303,9 +304,12 @@ function BuffeeUltraHighCapacity(editor) {
// Set navigation-only mode (can move cursor, no editing)
editor.Mode.i = 0;
- // Override Model.end
+ // Override Model.end to return chunked total
Object.defineProperty(Model, 'end', {
- get: () => totalLines - 1,
+ get: () => {
+ const y = totalLines - 1;
+ return { y, x: getChunkedLine(y).length };
+ },
configurable: true
});
@@ -320,7 +324,7 @@ function BuffeeUltraHighCapacity(editor) {
// Restore original end getter
Object.defineProperty(Model, 'end', {
- get: originalLastIndexGetter,
+ get: originalEndGetter,
configurable: true
});
@@ -370,6 +374,7 @@ function BuffeeUltraHighCapacity(editor) {
// Attach to editor instance
editor.UltraHighCapacity = UltraHighCapacity;
+ editor.Mode.ext.push('UltraHighCapacity');
return editor;
}
diff --git a/extensions/undotree.js b/combinators/undotree.js
similarity index 99%
rename from extensions/undotree.js
rename to combinators/undotree.js
index 446668ed..5eea14a8 100644
--- a/extensions/undotree.js
+++ b/combinators/undotree.js
@@ -356,6 +356,8 @@ function BuffeeUndoTree(editor) {
set: (v) => { _lastOpTime = v; }
});
+ editor.Mode.ext.push('UndoTree');
+
return editor;
}
diff --git a/dev/backlog.txt b/dev/backlog.txt
index 394592ee..f6caf09c 100644
--- a/dev/backlog.txt
+++ b/dev/backlog.txt
@@ -1,6 +1,8 @@
Website
16. everywhere in web page should use buffee
+Model.end should return coordinate not just last line
+Rename Mode to State
-Model.end should return coordinate not just last line
+TODO: consolidate 182-220 on extensions.md
\ No newline at end of file
diff --git a/dev/changelog.txt b/dev/changelog.txt
index 1e4ab801..6e256776 100644
--- a/dev/changelog.txt
+++ b/dev/changelog.txt
@@ -1,5 +1,115 @@
* Project Devlog
+** 15.10.0-alpha.1 [2026-01-13] gz: 2523 / 2.46 KB (0), br: 2333 / 2.28 KB (0), min: 5602 / 5.47 KB (0)
+- api: add Model.end returning last coordinate {y, x}
+- api: Span.select() accepts optional position for head
+
+** 15.9.11-alpha.1 [2026-01-13] gz: 2506 / 2.45 KB (+10), br: 2317 / 2.26 KB (+9), min: 5560 / 5.43 KB (+14)
+- refactor: reintroduce cmdMap for keyboard shortcuts
+
+** 15.9.10-alpha.1 [2026-01-13] gz: 2496 / 2.44 KB (+1), br: 2308 / 2.25 KB (-6), min: 5546 / 5.42 KB (-7)
+- golf: extract cw to local const, use in render
+
+** 15.9.9-alpha.1 [2026-01-13] gz: 2495 / 2.44 KB (-1), br: 2315 / 2.26 KB (-1), min: 5553 / 5.42 KB (0)
+- golf: merge special into const chain in keydown handler
+
+** 15.9.8-alpha.1 [2026-01-13] gz: 2496 / 2.44 KB (-3), br: 2316 / 2.26 KB (-1), min: 5553 / 5.42 KB (-2)
+- golf: simplify dent cursor detection with && ||
+
+** 15.9.7-alpha.1 [2026-01-13] gz: 2499 / 2.44 KB (+5), br: 2318 / 2.26 KB (+12), min: 5555 / 5.42 KB (+25)
+- api: expose maxCol as Mode.mx for external access
+
+** 15.9.6-alpha.1 [2026-01-13] gz: 2494 / 2.44 KB (+4), br: 2306 / 2.25 KB (-4), min: 5530 / 5.40 KB (-9)
+- golf: simplify Span._ getter (shorter vars, factor out dir check)
+
+** 15.9.5-alpha.1 [2026-01-13] gz: 2490 / 2.43 KB (-7), br: 2311 / 2.26 KB (+1), min: 5539 / 5.41 KB (0)
+- golf: move mvW regexes from default params to local consts
+
+** 15.9.4-alpha.1 [2026-01-13] gz: 2497 / 2.44 KB (-1), br: 2311 / 2.26 KB (-2), min: 5539 / 5.41 KB (-4)
+- golf: remove braces from single-expression arrow functions
+
+** 15.9.3-alpha.1 [2026-01-13] gz: 2498 / 2.44 KB (-18), br: 2314 / 2.26 KB (-8), min: 5543 / 5.41 KB (-36)
+- perf: inline metaKeys map (only has z for undo/redo)
+
+** 15.9.2-alpha.1 [2026-01-13] gz: 2516 / 2.46 KB (-10), br: 2323 / 2.27 KB (-7), min: 5579 / 5.45 KB (-27)
+- perf: remove empty v/c/x handlers (optional chaining handles missing keys)
+
+** 15.9.1-alpha.1 [2026-01-13] gz: 2526 / 2.47 KB (-5), br: 2330 / 2.28 KB (-3), min: 5606 / 5.47 KB (-13)
+- perf: simplify paste handler (empty clipboard paste is no-op)
+
+** 15.9.0-alpha.1 [2026-01-13] gz: 2531 / 2.47 KB (-38), br: 2334 / 2.28 KB (-32), min: 5619 / 5.49 KB (-110)
+- refactor: remove $clip clipboard bridge - modern browsers fire copy/cut on any focused element
+- breaking: buffee-clip textarea no longer required in HTML template
+- update: all wrappers (React, Vue, Svelte), samples, tests, and CSS
+
+** 15.8.3-alpha.1 [2026-01-13] gz: 2569 / 2.51 KB (-1), br: 2366 / 2.31 KB (-2), min: 5729 / 5.59 KB (-3)
+- perf: use preventScroll:1 instead of preventScroll:true
+
+** 15.8.2-alpha.1 [2026-01-13] gz: 2570 / 2.51 KB (0), br: 2368 / 2.31 KB (+1), min: 5732 / 5.60 KB (0)
+- fix(css): rail clipping (add margin + overflow:hidden)
+
+** 15.8.1-alpha.1 [2026-01-13] gz: 2570 / 2.51 KB (-3), br: 2368 / 2.31 KB (+4), min: 5732 / 5.60 KB (-14)
+- fix: viewport scroll regression (use $lines.clientHeight, inline View.N, init vN=-1 for auto-fit)
+
+** 15.8.0-alpha.1 [2026-01-13] gz: 2573 / 2.51 KB (0), br: 2364 / 2.31 KB (-7), min: 5746 / 5.61 KB (0)
+- api: View.first and View.n are now setters (replaces View.set() and View.size())
+
+** 15.7.4-alpha.1 [2026-01-13] gz: 2575 / 2.51 KB (+3), br: 2371 / 2.32 KB (-2), min: 5744 / 5.61 KB (-2)
+- refactor: rename tail→anchor, detachedHead→detached for clarity
+
+** 15.7.3-alpha.1 [2026-01-13] gz: 2572 / 2.51 KB (0), br: 2374 / 2.32 KB (0), min: 5746 / 5.61 KB (-7)
+- perf: remove unnecessary detachedHead initialization
+
+** 15.7.2-alpha.1 [2026-01-13] gz: 2572 / 2.51 KB (+3), br: 2374 / 2.32 KB (+5), min: 5753 / 5.62 KB (12)
+- refactor: use const in for..of loops
+
+** 15.7.1-alpha.1 [2026-01-13] gz: 2569 / 2.51 KB (-3), br: 2369 / 2.31 KB (-3), min: 5741 / 5.61 KB (-10)
+- perf: replace forEach with for..of loops
+
+** 15.7.0-alpha.1 [2026-01-13] gz: 2572 / 2.51 KB (-15), br: 2372 / 2.32 KB (-14), min: 5751 / 5.62 KB (-50)
+- api: remove View._ getter (unused, reduces API surface)
+
+** 15.6.0-alpha.1 [2026-01-13] gz: 2587 / 2.53 KB (-1), br: 2387 / 2.33 KB (+2), min: 5801 / 5.67 KB (-6)
+- api: move render/RENDER to View namespace (editor.View.render(), editor.View.RENDER())
+- docs: convert api.md to api.txt (ASCII plaintext format)
+- wrappers: update React/Svelte/Vue to use h/w/s props (matches core API)
+
+** 15.5.1-alpha.1 [2026-01-13] gz: 2588 / 2.53 KB (+3), br: 2385 / 2.33 KB (-3), min: 5807 / 5.67 KB (0)
+- perf: optimize hotpath, isolate regex for move word until they are needed
+
+** 15.5.0-alpha.1 [2026-01-13] gz: 2585 / 2.52 KB (-4), br: 2388 / 2.33 KB (-4), min: 5807 / 5.67 KB (-6)
+- config: fixed width/height specified with 'w' and 'h' instead 'cols' and 'rows'
+
+** 15.4.0-alpha.1 [2026-01-13] gz: 2589 / 2.53 KB (-17), br: 2393 / 2.34 KB (-12), min: 5813 / 5.68 KB (-35)
+- api: remove Model.s = "foobar". Delegate to user to render on Model._ = ["foobar"]
+
+** 15.3.0-alpha.1 [2026-01-13] gz: 2606 / 2.54 KB (+6), br: 2405 / 2.35 KB (+3), min: 5848 / 5.71 KB (14)
+- api: rename Model.end to Model.last
+- api: rename View.start to View.first
+- api: rename View.end to View.last
+
+** 15.2.0-alpha.1 [2026-01-12] gz: 2600 / 2.54 KB (-1), br: 2402 / 2.35 KB (+7), min: 5834 / 5.70 KB (15)
+- breaking: Span.ins now takes array of lines instead of string
+ - `Span.ins('text')` → `Span.ins(['text'])`
+ - `Span.ins('line1\nline2')` → `Span.ins(['line1', 'line2'])`
+ - Client handles newline splitting, not buffee core
+- FileLoader: remove expandTabs from appendLines helper
+
+** 15.1.0-alpha.1 [2026-01-12] gz: 2601 / 2.54 KB (+4), br: 2395 / 2.34 KB (+3), min: 5819 / 5.68 KB (+7)
+- new: Mode.ext array tracks registered extensions in order
+ - Each extension pushes its name to Mode.ext on initialization
+ - Allows inspection of extension registration order
+
+** 15.0.0-alpha.1 [2026-01-12] gz: 2597 / 2.54 KB (-29), br: 2392 / 2.34 KB (-30), min: 5812 / 5.68 KB (-51)
+- breaking: remove automatic tab expansion from core
+- Buffee no longer sanitizes text - tabs/Unicode inserted as-is
+- Users should sanitize text before passing to Model.s or Span.ins
+- new: BuffeeSanitize extension for opt-in text sanitization
+ - Converts tabs to spaces (based on Mode.s)
+ - Removes zero-width characters (ZWSP, ZWNJ, ZWJ, BOM)
+ - Normalizes multi-width Unicode spaces to regular space
+ - API: Sanitize.line(), Sanitize.text(), Sanitize.lines()
+
** Extensions [2026-01-12]
- fix(treesitter.js 1.0.2): use tree-sitter API row/column, viewport.n not viewport.size
- fix(tui.js 3.0.2): use row/col for elements, viewport.n not viewport.size
diff --git a/dist/buffee.min.js b/dist/buffee.min.js
index 5419dc8e..4d792308 100644
--- a/dist/buffee.min.js
+++ b/dist/buffee.min.js
@@ -1 +1 @@
-function Buffee(t,{rows:e,cols:s,s:n=4}={}){this.v="14.40.0-alpha.1",this.$=t;const i=t=>E.s?t.replace(/\t/g," ".repeat(E.s)):t,l=/\s/,r=/[\p{L}\p{Nd}_]/u,c={y:0,x:0};let h={y:0,x:0},o=h,a=h.x;const[y,d,x,f]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[p,u,g,_,m,v,w]=["pane","lines","caret","clip","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let b=u.getBoundingClientRect();const M=[[v,(t,e)=>t.textContent=L._[C.start+e]??null],[m,(t,e)=>t.textContent=C.start+e+1],[w,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),D=this.Span={bounds:t=>t&&D.dir>0?[o,h]:[h,o],mvY(t,e){if(t>0?h.y0){const s=L._[t>0?++h.y:--h.y].length;h.x=e?t>0?0:s:Math.min(a,s),e&&(a=h.x),h.yC.end?C.set(t>0?h.y-C.n+1:h.y):R()}},mvX(t){const e=t>0;(e?h.x0&&s0;if(h.x!==(n?s:0)){let t=h.x;const i=n?()=>tt>0,c=n?()=>t++:()=>t--;if(l.test(e[t])){for(;i()&&l.test(e[t]);)c();for(;i()&&r.test(e[t]);)c()}else if(r.test(e[t]))for(;i()&&r.test(e[t]);)c();else{const s=e[t];for(c();i()&&e[t]===s;)c()}h.x=t,R()}else(n?h.y0)&&(h.x=n?0:L._[--h.y].length,n&&++h.y>C.end?C.set(h.y-C.n+1):!n&&h.y0));return e.x>=s.length&&t.y0))]},cursor(t){t&&(h.y=t.y,h.x=t.x),o.y=h.y,o.x=h.x,h=o},select(){h=c,h.y=o.y,h.x=o.x},ins(t){const e=i(t).split("\n");if(this.dir){const[s,n]=D.bounds(1);L.del(s.y,s.x,n.y,n.x+(this.dir>0)),L.ins(s.y,s.x,e),h.y=s.y,e.length>1?(h.y+=e.length-1,h.x=e[e.length-1].length):h.x=s.x+t.length,this.cursor()}else L.ins(h.y,h.x,e),e.length>1?(h.y+=e.length-1,a=h.x=e[e.length-1].length):a=h.x+=t.length;h.y>C.end?C.set(h.y-C.n+1):R()},del(){this.dir?this.ins(""):h.x>0?(L.del(h.y,h.x-1,h.y,h.x),h.x--,R()):h.y>0&&(h.x=L._[h.y-1].length,L.del(h.y-1,h.x,h.y,0),--h.y0&&!this.dir)return;const[e,s]=D.bounds(1);for(let n=e.y;n<=s.y;n++){const i=L._[n];if(t>0)L._[n]=" ".repeat(t)+i;else{const l=n===e.y?e:n===s.y?s:null;if(l){const e=i.slice(l.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,l.x).search(/[^ ]|$/)+e);L._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),R()}},E=this.Mode={s:n,i:1,f:0,ch:y,cw:g.getBoundingClientRect().width,sub:[]},L=this.Model={_:[""],get end(){return this._.length-1},set s(t){this._=i(t).split("\n"),R()},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}},C=this.View={start:0,n:0,get N(){return this.n+!e},get end(){return Math.min(this.start+this.n-1,L.end)},set(t,e=this.n){const s=e-this.n;this.n=e,this.start=Math.max(0,Math.min(t,L.end)),$(s)},get _(){return L._.slice(this.start,this.end+1)}},$=this.RENDER=t=>{if(t){let e=t;for(;e>0;e--)M.forEach(([t,e])=>t.push(e.appendChild(document.createElement("pre"))));for(t>0&&M.forEach(([,t,e])=>e?.appendChild(t)),e=t;e<0;e++)M.forEach(([t])=>t.pop()?.remove())}if(m){const t=Math.max(x,(C.start+C.N).toString().length)+f;m.style.width=t+"ch",s&&(p.style.width=`calc(${t+s}ch + ${4*d}px)`)}R(t)},R=this.render=(t=0)=>{E.f++;for(let t=0;te[t]&&s(e[t],t));let e=-1;if(E.i>=0){const[t,s]=D.bounds(1),n=Math.min(C.start+C.n,s.y+1);for(let e=Math.max(C.start,t.y);e=0&&is?l-s:0))/E.cw)*E.cw}}g.style.left=e+"ch",E.sub.forEach(e=>e(u,C,t))};s&&!m&&(p.style.width=`calc(${s}ch + ${2*d}px)`),e&&M.forEach(([,,t])=>t&&(t.style.height=e*y+"px"));const S=t=>{C.n+=t,$(t)};e?S(e):new ResizeObserver(()=>{b=u.getBoundingClientRect(),S(Math.floor(p.clientHeight/y)-C.n)}).observe(p),u.addEventListener("paste",t=>{t.preventDefault();const e=t.clipboardData.getData("text/plain");e&&D.ins(e)}),_.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",D._.join("\n"))}),_.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",D._.join("\n")),D.del(),u.focus({preventScroll:!0})});const B={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};u.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i={v:()=>{},c:()=>{_.focus({preventScroll:!0}),_.select()},x:()=>{_.focus({preventScroll:!0}),_.select()},z:()=>{t.preventDefault(),this.History&&this.History[n?"redo":"undo"]()}},l={Backspace:()=>{D.del()},Enter:()=>{D.ins("\n")},Tab:()=>{t.preventDefault(),D.dir||n?D.dent(n?-E.s:E.s):D.ins(" ".repeat(E.s))}},r=B[s]||0;if(r){if(t.preventDefault(),E.i<0)return;const s=r>>31|1;if(e||t.altKey)!n&&D.dir?D.cursor():n&&!D.dir&&D.select(),r%2&&(e?D.mvLn(s>0):D.mvW(s));else if(!n&&D.dir)if(r%2)D.cursor(D.bounds(1)[s>0|0]),R();else{const t=D.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,L.end));a=Math.min(t.x,L._[e].length),D.cursor({y:e,x:a}),eC.end?C.set(e-C.n+1):R()}else n&&!D.dir&&D.select(),D[r%2?"mvX":"mvY"](s)}else 1===s.length?e?i[s.toLowerCase()]?.():E.i>0&&(" "===s&&t.preventDefault(),D.ins(s)):l[s]&&E.i>=1&&l[s]()})}
\ No newline at end of file
+function Buffee(t,{h:e,w:s,s:n=4}={}){this.v="15.10.0-alpha.1",this.$=t;const i={y:0,x:0},r={};let l=i;const[o,c,y,h]=["cell","padding","rail-init","rail-pad"].map(e=>parseFloat(getComputedStyle(t).getPropertyValue("--buffee-"+e))),[a,f,x,d,u,p]=["pane","lines","caret","rail","ztxt","zsel"].map(e=>t.querySelector(".buffee-"+e));let g=f.getBoundingClientRect();const m=[[u,(t,e)=>t.textContent=w._[L.first+e]??null],[d,(t,e)=>t.textContent=L.first+e+1],[p,t=>t.style.width=0]].map(([t,e])=>[[],document.createDocumentFragment(),t,e]),_=this.Span={bounds:t=>t&&_.dir>0?[i,l]:[l,i],mvY(t,e){if(t>0?l.y0){const s=w._[t>0?++l.y:--l.y].length;l.x=e?t>0?0:s:Math.min(b.mx,s),e&&(b.mx=l.x),l.yL.last?L.first=t>0?l.y-L.n+1:l.y:E()}},mvX(t){const e=t>0;(e?l.x0&&s0;if(l.x!==(n?s:0)){let t=l.x;const i=/\s/,r=/[\p{L}\p{Nd}_]/u,o=n?()=>tt>0,c=n?()=>t++:()=>t--;if(i.test(e[t])){for(;o()&&i.test(e[t]);)c();for(;o()&&r.test(e[t]);)c()}else if(r.test(e[t]))for(;o()&&r.test(e[t]);)c();else{const s=e[t];for(c();o()&&e[t]===s;)c()}l.x=t,E()}else(n?l.y0)&&(l.x=n?0:w._[--l.y].length,n&&++l.y>L.last?L.first=l.y-L.n+1:!n&&l.y0;if(t.y===e.y){const n=w._[t.y],i=n.slice(t.x,e.x+s);return e.x>=n.length&&t.y0)),w.ins(e.y,e.x,t),l.y=e.y,t.length>1?(l.y+=t.length-1,l.x=t[t.length-1].length):l.x=e.x+(t[0]?.length||0),this.cursor()}else w.ins(l.y,l.x,t),t.length>1?(l.y+=t.length-1,b.mx=l.x=t[t.length-1].length):b.mx=l.x+=t[0]?.length||0;l.y>L.last?L.first=l.y-L.n+1:E()},del(){this.dir?this.ins([""]):l.x>0?(w.del(l.y,l.x-1,l.y,l.x),l.x--,E()):l.y>0&&(l.x=w._[l.y-1].length,w.del(l.y-1,l.x,l.y,0),--l.y0&&!this.dir)return;const[e,s]=_.bounds(1);for(let n=e.y;n<=s.y;n++){const i=w._[n];if(t>0)w._[n]=" ".repeat(t)+i;else{const r=e.y===n&&e||s.y===n&&s;if(r){const e=i.slice(r.x).search(/[^ ]|$/),s=Math.min(-t,i.slice(0,r.x).search(/[^ ]|$/)+e);w._[n]=i.slice(s),e0&&(e.x+=t,s.x+=t),E()}},v=x.getBoundingClientRect().width,b=this.Mode={s:n,i:1,f:0,mx:0,ch:o,cw:v,sub:[],ext:[]},w=this.Model={_:[""],get end(){const t=this._.length-1;return{y:t,x:this._[t].length}},ins(t,e,s){const n=this._[t].slice(e);this._[t]=this._[t].slice(0,e)+s[0],1===s.length?this._[t]+=n:this._.splice(t+1,0,...s.slice(1,-1),s[s.length-1]+n)},del(t,e,s,n){this._[t]=this._[t].slice(0,e)+this._[s].slice(n),t!==s&&this._.splice(t+1,s-t)}};let D=0,M=e?0:-1;const L=this.View={get first(){return D},set first(t){D=Math.max(0,Math.min(t,w.end.y)),C()},get n(){return M},set n(t){const e=t-M;M=t,C(e)},get last(){return D+M-1{if(t){let e=t;for(;e>0;e--)for(const[t,e]of m)t.push(e.appendChild(document.createElement("pre")));if(t>0)for(const[,t,e]of m)e?.appendChild(t);for(e=t;e<0;e++)for(const[t]of m)t.pop()?.remove()}if(d){const t=Math.max(y,(L.first+M+!e).toString().length)+h;d.style.width=t+"ch",s&&(a.style.width=`calc(${t+s}ch + ${4*c}px)`)}E(t)},E=L.render=(t=0)=>{b.f++;for(let t=0;t=0){const[t,e]=_.bounds(1),n=Math.min(L.first+L.n,e.y+1);for(let s=Math.max(L.first,t.y);s=0&&ie?r-e:0))/v)*v}}x.style.left=s+"ch";for(const e of b.sub)e(f,L,t)};if(s&&!d&&(a.style.width=`calc(${s}ch + ${2*c}px)`),e)for(const[,,t]of m)t&&(t.style.height=e*o+"px");e?L.n=e:new ResizeObserver(()=>{g=f.getBoundingClientRect(),L.n=Math.floor(f.clientHeight/o)}).observe(a),f.addEventListener("paste",t=>{t.preventDefault(),_.ins(t.clipboardData.getData("text/plain").split("\n"))}),f.addEventListener("copy",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",_._.join("\n"))}),f.addEventListener("cut",t=>{t.preventDefault(),t.clipboardData.setData("text/plain",_._.join("\n")),_.del()});const $={ArrowDown:2,ArrowUp:-2,ArrowLeft:-1,ArrowRight:1};f.addEventListener("keydown",t=>{const e=t.metaKey||t.ctrlKey,s=t.key,n=t.shiftKey,i=$[s]||0,r={Backspace:()=>_.del(),Enter:()=>_.ins(["",""]),Tab:()=>{t.preventDefault(),_.dir||n?_.dent(n?-b.s:b.s):_.ins([" ".repeat(b.s)])}},l={z:()=>this.History?.[n?"redo":"undo"]()};if(i){if(t.preventDefault(),b.i<0)return;const s=i>>31|1;if(e||t.altKey)!n&&_.dir?_.cursor():n&&!_.dir&&_.select(),i%2&&(e?_.mvLn(s>0):_.mvW(s));else if(!n&&_.dir)if(i%2)_.cursor(_.bounds(1)[s>0|0]),E();else{const t=_.bounds(1)[s>0|0],e=Math.max(0,Math.min(t.y+s,w.end.y));b.mx=Math.min(t.x,w._[e].length),_.cursor({y:e,x:b.mx}),eL.last?L.first=e-L.n+1:E()}else n&&!_.dir&&_.select(),_[i%2?"mvX":"mvY"](s)}else 1===s.length?e?l[s]&&(t.preventDefault(),l[s]()):b.i>0&&(" "===s&&t.preventDefault(),_.ins([s])):r[s]&&b.i>=1&&r[s]()})}
\ No newline at end of file
diff --git a/docs/api.md b/docs/api.md
deleted file mode 100644
index c2949933..00000000
--- a/docs/api.md
+++ /dev/null
@@ -1,112 +0,0 @@
-# Buffee API Reference
-
-## Instantiation
-
-```javascript
-const editor = new Buffee(element, { rows, cols, s })
-```
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `rows` | number | auto | Fixed visible lines |
-| `cols` | number | auto | Fixed text columns |
-| `s` | number | 4 | Tab width (0 = hard tabs) |
-
-## Top-level properties
-
-```javascript
-editor
- .v // Version string
- .$ // Root DOM element
- .render(delta) // Render content only (delta = viewport size change)
- .RENDER(delta) // Rebuild containers and render content
- .Model // see Model namespace below
- .View // see View namespace below
- .Span // see Span namespace below
- .Mode // see Mode namespace below
-```
-
-## Model (`editor.Model`)\
-
-```javascript
-Model
- ._ // Array of text lines, without '\n
- .s // Set content (string with \n)
- .end // Last line index
- .ins // primitive insert
- .del // primitive del
-```
-
----
-
-## View (`editor.View`)
-
-```javascript
-View
- ._ // subset of model lines in view
- .start // First visible line index
- .end // Last visible line index
- .n // Number of logical lines
- .N // Number of rendered lines
- .set(line) // Scroll to line
- .set(line, n) // Scroll to line, show n lines
-```
-
----
-
-## Span (`editor.Span`)
-
-Cursor and selection management.
-
-```javascript
-Span
- .bounds(value) // returns bounds of the span : truthy: [start, end], falsey [head, tail]
- .dir // orientation of the selection: 1 (forward), -1 (backward), 0 (cursor)
- .mvX(value) // move horizontally : 1 right , -1 left
- .mvY(value) // move vertically : 1 down , -1 up
- .mvLn(value) // move to '\n' : 1 end , 0 front
- .mvW(value) // move by words : 1 next , -1 prev
- .del() // delete text Span-wise delete
- .ins(text) // insert text Span-wise insert
- .select() // make selection
- .cursor() // make cursor
- .dent(value) // indent or unindent : 1 indent, -1 unident
-```
-
----
-
-## Mode (`editor.Mode`)
-
-Editor state and configuration.
-
-```javascript
-mode
- .s // Tab width
- .i // Edit mode: 1=write, 0=navigate, -1=read
- .f // Render frame counter
- .ch // Line height in pixels
- .cw // Character width in pixels
- .sub // subscriptions for render callback
-```
-
----
-
-## Extension API
-
-For building extensions:
-
-```javascript
-const { Model, View, Span, Mode, render, $ } = editor;
-
-// Register render hook
-Mode.renderHooks.push(($container, viewport, rebuilt) => {
- // Called after each render
-});
-
-// Wrap primitives
-const originalIns = Model.ins.bind(Model);
-Model.ins = function(row, col, lines) {
- // Custom logic
- return originalIns(row, col, lines);
-};
-```
diff --git a/docs/api.txt b/docs/api.txt
new file mode 100644
index 00000000..53b2516c
--- /dev/null
+++ b/docs/api.txt
@@ -0,0 +1,123 @@
+Buffee API Reference
+====================
+
+Instantiation
+-------------
+const instance = new Buffee(element, { h, w, s })
+
+ Option Type Default Description
+ ------ ------ ------- ----------------------
+ h number auto Fixed visible lines
+ w number auto Fixed text columns
+ s number 4 Tab width (0 = hard tabs)
+
+
+Top-level properties
+--------------------
+instance
+ .v Version string
+ .$ Root DOM element
+ .Model see Model namespace below
+ .View see View namespace below
+ .Span see Span namespace below
+ .Mode see Mode namespace below
+
+
+instance.Model
+--------------------
+Model
+ ._ text buffer. Assumes sanitized '\n', '\t', zero/multi-width chars
+ .ins primitive insert
+ .del primitive del
+ .end last coordinate {y, x} in model
+
+When updating buffer, call render if necessary. If you append to ._ and the new
+lines are out of view, then a render would not be necessary. While we could have
+added a setter for the model that would know to call render, this would mean that
+the Model has to be concerned with the view. The philosophy is that Model should
+be agnostic to existence of rendering.
+
+
+instance.View
+------------------
+View
+ .first Get Model index of first line of viewport
+ .first = 5 Set model index of first line of viewport
+ .n Get logical Viewport size - number of lines
+ .n = 20 Set viewport size
+ .last Get Model index of last line viewport
+ .render(delta) Render content only (delta = viewport size change)
+ .RENDER(delta) Rebuild containers and render content
+
+You can set first and n but not last. An alternative
+would have been to make first and last,but not size. The latter
+is a more symmetrical API but not as intuitive and the implementation uglier.
+
+instance.Span
+------------------
+A continuous text span from a starting and end coordinate.
+
+Span
+ ._ Get selected lines
+ .bounds(value) returns bounds: truthy [start, end], falsey [head, tail]
+ .dir orientation: 1 (forward), -1 (backward), 0 (cursor)
+ .mvX(value) move horizontally : 1 right , -1 left
+ .mvY(value) move vertically : 1 down , -1 up
+ .mvLn(value) move to '\n' : 1 end , 0 front
+ .mvW(value) move by words : 1 next , -1 prev
+ .del() delete text Span-wise
+ .ins(lines) insert lines (string[]) Span-wise
+ .select(pos) make selection, optionally set head to {y, x}
+ .cursor(pos) make cursor, optionally at {y, x}
+ .dent(value) indent or unindent : 1 indent, -1 unindent
+
+Direct position manipulation
+----------------------------
+bounds() returns references to the internal position objects {y, x}. You can
+mutate these directly for surgical adjustments without going through the API.
+
+ const [head, anchor] = editor.Span.bounds(); // [head, anchor] order
+ const [start, end] = editor.Span.bounds(1); // document order
+
+ // Move cursor to specific position
+ head.y = 5;
+ head.x = 10;
+ editor.View.render();
+
+ // Expand selection by adjusting head
+ editor.Span.select(); // detach head from anchor
+ const [h, a] = editor.Span.bounds();
+ h.y = 10; // move head to line 10
+ editor.View.render();
+
+ // Or set head position directly via select()
+ editor.Span.select(editor.Model.end); // select to end of document
+ editor.View.render();
+
+When dir === 0 (cursor mode), head and anchor are the same object reference.
+Mutating one automatically mutates the other. After select(), they become
+separate objects and can be moved independently.
+
+ // In cursor mode:
+ const [h, a] = editor.Span.bounds();
+ h === a // true - same object
+ h.x = 5; // anchor.x is also now 5
+
+ // After select():
+ editor.Span.select();
+ const [h2, a2] = editor.Span.bounds();
+ h2 === a2 // false - separate objects
+ h2.x = 10; // anchor.x unchanged
+
+
+instance.Mode
+------------------
+Mode (editor.Mode)
+ .s Tab width
+ .i Edit mode: 1=write, 0=navigate, -1=read
+ .f Render frame counter
+ .mx Max column for vertical cursor movement
+ .ch Line height in pixels
+ .cw Character width in pixels
+ .sub subscriptions for render callback
+ .ext Array of registered extension names (in order)
diff --git a/docs/extensions.md b/docs/combinators.md
similarity index 85%
rename from docs/extensions.md
rename to docs/combinators.md
index 483489ed..dbd57ae5 100644
--- a/docs/extensions.md
+++ b/docs/combinators.md
@@ -1,6 +1,6 @@
-# Buffee Extensions
+# Buffee Combinators
-Extensions use the decorator pattern to add functionality:
+Combinators use the decorator pattern to add functionality:
```javascript
const editor = BuffeeStatusLine(new Buffee(el, opts));
@@ -179,10 +179,30 @@ Enables touch-to-position cursor and virtual keyboard handling.
---
-## Creating Extensions
+## Combinator API
+
+For building combinators:
+
+```javascript
+const { Model, View, Span, Mode, render, $ } = editor;
+
+// Register render hook
+Mode.renderHooks.push(($container, viewport, rebuilt) => {
+ // Called after each render
+});
+
+// Wrap primitives
+const originalIns = Model.ins.bind(Model);
+Model.ins = function(row, col, lines) {
+ // Custom logic
+ return originalIns(row, col, lines);
+};
+```
+
+## Creating Combinators
```javascript
-function MyExtension(editor) {
+function MyCombinator(editor) {
const { Mode, render } = editor;
// Hook into render cycle
@@ -191,7 +211,7 @@ function MyExtension(editor) {
});
// Expose API
- editor.MyExtension = {
+ editor.MyCombinator = {
enable() { /* ... */ },
disable() { /* ... */ }
};
diff --git a/docs/onboarding.md b/docs/onboarding.md
index a6eb31e9..005842e7 100644
--- a/docs/onboarding.md
+++ b/docs/onboarding.md
@@ -15,7 +15,6 @@ See `web/template.html` for the required HTML structure:
```html
-
@@ -40,9 +39,9 @@ See `web/template.html` for the required HTML structure:
```javascript
const editor = new Buffee(document.querySelector('.buffee'), {
- rows: 20, // Optional: fixed height (omit to auto-fit)
- cols: 80, // Optional: fixed width (omit to fill parent)
- s: 4 // Tab width (default: 4)
+ h: 20, // Optional: fixed height (omit to auto-fit)
+ w: 80, // Optional: fixed width (omit to fill parent)
+ s: 4 // Tab width (default: 4)
});
// Optional: add status line updates
@@ -52,15 +51,16 @@ BuffeeStatusLine(editor);
## Set Content
```javascript
-editor.Model.s = "Hello, World!";
+editor.Model._ = ["Hello, World!"]; // Array of lines
+editor.View.render(); // Trigger re-render after setting content
```
## Sizing
| Option | Default | Description |
|--------|---------|-------------|
-| `rows` | auto | Fixed visible lines (omit to auto-fit to container) |
-| `cols` | auto | Fixed text columns (omit to fill parent width) |
+| `h` | auto | Fixed visible lines (omit to auto-fit to container) |
+| `w` | auto | Fixed text columns (omit to fill parent width) |
### Auto-fit (Default)
@@ -73,7 +73,7 @@ The editor auto-fits to its container. Requires the container to have defined di
### Fixed Dimensions
```javascript
-new Buffee(el, { rows: 25, cols: 80 });
+new Buffee(el, { h: 25, w: 80 });
```
## Keybindings
@@ -91,13 +91,13 @@ new Buffee(el, { rows: 25, cols: 80 });
| Cmd/Ctrl+A | Select all | - |
| Cmd/Ctrl+C/X/V | Copy/Cut/Paste | - |
-## Extensions
+## Combinators
-Extensions add functionality via the decorator pattern:
+Combinators add functionality via the decorator pattern:
```javascript
const editor = BuffeeStatusLine(new Buffee(el, opts));
BuffeeHistory(editor); // Adds undo/redo
```
-See [extensions.md](extensions.md) for available extensions.
+See [combinators.md](combinators.md) for available combinators.
diff --git a/index.html b/index.html
index 4f58eba8..b629179a 100644
--- a/index.html
+++ b/index.html
@@ -8,10 +8,10 @@
-
-
-
-
+
+
+
+