Skip to content

Commit

Permalink
Fix block cursor (#17)
Browse files Browse the repository at this point in the history
* fix broken C-Space

* add tests

* test

* fixing markring

* fix killline

* more tests

* update blockcursor to match version from vim plugin
  • Loading branch information
nightwing authored Jul 12, 2024
1 parent 06c5870 commit 52a466e
Show file tree
Hide file tree
Showing 6 changed files with 634 additions and 66 deletions.
2 changes: 1 addition & 1 deletion dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { emacs } from "../src/index"
import * as commands from "@codemirror/commands";
(window as any)._commands = commands

const doc = `
const doc = `// 🌞 אבג
import { basicSetup, EditorView } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { emacs } from "../src/"
Expand Down
4 changes: 1 addition & 3 deletions dev/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
<meta charset=utf8>
<title>CodeMirror view tests</title>

<link rel=stylesheet href="mocha/mocha.css">

<h1>CodeMirror view tests</h1>

<div id="workspace" style="opacity: 0; position: fixed; top: 0; left: 0; width: 20em;"></div>
Expand All @@ -28,7 +26,7 @@ <h1>CodeMirror view tests</h1>
})
}
mocha.setup(options)
await import("../test/webtest-vim")
await import("../test/webtest-emacs")
mocha.run()
</script>

89 changes: 86 additions & 3 deletions dev/web-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<style>
#editor {
height: min(540px, 50vh); width: min(960px, 90vw);
position: absolute; resize: both; overflow: hidden
resize: both; overflow: hidden
}
#editor>.cm-editor {width: 100%; height: 100%}
#editor.split>.cm-editor {height: 50%}
Expand Down Expand Up @@ -469,7 +469,7 @@
if (!/^\w+:/.test(path)) path = host + path;
var onLoad = function(e, val) {
if (e) return processLoadQueue({id: id, path: path});
if (path.slice(-3) == ".ts") {
if (path.slice(-3) == ".ts" || /^import \{/m.test(val)) {
val = ts.transpileModule(val, {
compilerOptions: {
target: "ES2022",
Expand Down Expand Up @@ -520,7 +520,12 @@
}
} else {
var url = require.toUrl(module, ".js");
if (define.fetchedUrls[url] & 1) return false;
if (define.fetchedUrls[url] & 1) {
setTimeout(function() {
processLoadQueue(null, module);
});
return false;
}
define.fetchedUrls[url] |= 1;
loadScript(url, module, processLoadQueue);
}
Expand Down Expand Up @@ -604,6 +609,9 @@
<input type="checkbox" id="wrap"> <label for="wrap">wrap</label>
<input type="checkbox" id="html"> <label for="html">html</label>
<div id="editor"></div>
<br>
<br>
<a href="#test.html" onclick="runTests()">tests</a><br>
<script>
// console.log(ts)
require.config({
Expand Down Expand Up @@ -638,5 +646,80 @@
"dev/index.ts",
], function() {})
</script>
<script>
var it, describe
var old
function runTests() {
if (!old) {
old = {};
Object.keys(define.modules).forEach(k=> old[k] = 1)
} else {
Object.keys(define.modules)
.concat(Object.keys(define.errors))
.forEach(k=> {
if (!old[k])
require.undef(k)
})
}

require.config({
paths: {
"test/webtest-emacs": require.config.baseUrl.replace("dev", "test/webtest-emacs.js"),
}
});
define("test/..", function(require, exports, module) {
module.exports = require("src/index")
});
var suites = Object.create(null)
describe = function(name, fn) {
var suite = {
name,
construct: fn,
tests: Object.create(null),
skipped: Object.create(null),
it: function (name, fn) {
this.tests[name] = fn;
},
skip: function (name, fn) {
this.skipped[name] = fn;
},
};
suites[name] = suite;
it = suite.it = suite.it.bind(suite);
it.skip = suite.skip.bind(suite);
suite.construct();
}

require(["test/webtest-emacs"], async function() {
var total = 0;
var skipped = 0;
var failed = 0;
var passed = 0;
for (var suite of Object.values(suites)) {
var skippedFromSuite = Object.keys(suite.skipped).length;
skipped += skippedFromSuite
total += Object.keys(suite.tests).length
+ skippedFromSuite;
for (var i in suite.tests) {
console.log(i)
try {
await suite.tests[i]()
passed++
} catch(e) {
failed++
console.error(e)
}
}
}
console.log(`
failed: ${failed}
passed: ${passed},
skipped: ${skipped},
from: ${total}
`)
});
}
if (/#test.html/.test(location.hash)) runTests()
</script>
</body>
</html>
123 changes: 95 additions & 28 deletions src/block-cursor.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
import type {EmacsHandler} from "./index"
import { SelectionRange, Prec } from "@codemirror/state"
import { ViewUpdate, EditorView, Direction } from "@codemirror/view"

import * as View from "@codemirror/view"
// backwards compatibility for old versions not supporting getDrawSelectionConfig
let getDrawSelectionConfig = View.getDrawSelectionConfig || function() {
let defaultConfig = {cursorBlinkRate: 1200};
return function() {
return defaultConfig;
}
}();

type Measure = {cursors: Piece[]}

class Piece {
constructor(readonly left: number, readonly top: number,
readonly height: number,
readonly fontFamily: string,
readonly fontSize: string,
readonly fontWeight: string,
readonly color: string,
readonly className: string,
readonly letter: string,
readonly partial: boolean) {}
Expand All @@ -22,15 +36,21 @@ class Piece {
elt.style.top = this.top + "px"
elt.style.height = this.height + "px"
elt.style.lineHeight = this.height + "px"
elt.style.color = this.partial ? "transparent" : ""
elt.style.fontFamily = this.fontFamily;
elt.style.fontSize = this.fontSize;
elt.style.fontWeight = this.fontWeight;
elt.style.color = this.partial ? "transparent" : this.color;

elt.className = this.className
elt.textContent = this.letter
elt.className = this.className;
elt.textContent = this.letter;
}

eq(p: Piece) {
return this.left == p.left && this.top == p.top && this.letter == p.letter && this.height == p.height &&
this.className == p.className
return this.left == p.left && this.top == p.top && this.height == p.height &&
this.fontFamily == p.fontFamily && this.fontSize == p.fontSize &&
this.fontWeight == p.fontWeight && this.color == p.color &&
this.className == p.className &&
this.letter == p.letter;
}
}

Expand All @@ -39,8 +59,10 @@ export class BlockCursorPlugin {
cursors: readonly Piece[] = []
measureReq: {read: () => Measure, write: (value: Measure) => void}
cursorLayer: HTMLElement
em: EmacsHandler;

constructor(readonly view: EditorView) {
constructor(readonly view: EditorView, em: EmacsHandler) {
this.em = em;
this.measureReq = {read: this.readPos.bind(this), write: this.drawSel.bind(this)}
this.cursorLayer = view.scrollDOM.appendChild(document.createElement("div"))
this.cursorLayer.className = "cm-cursorLayer cm-vimCursorLayer"
Expand All @@ -50,14 +72,17 @@ export class BlockCursorPlugin {
}

setBlinkRate() {
this.cursorLayer.style.animationDuration = 1200 + "ms"
let config = getDrawSelectionConfig(this.view.state);
let blinkRate = config.cursorBlinkRate;
this.cursorLayer.style.animationDuration = blinkRate + "ms";
}

update(update: ViewUpdate) {
if (update.selectionSet || update.geometryChanged || update.viewportChanged) {
this.view.requestMeasure(this.measureReq)
this.cursorLayer.style.animationName = this.cursorLayer.style.animationName == "cm-blink" ? "cm-blink2" : "cm-blink"
}
if (configChanged(update)) this.setBlinkRate();
}

scheduleRedraw() {
Expand All @@ -66,11 +91,11 @@ export class BlockCursorPlugin {

readPos(): Measure {
let {state} = this.view
let cursors = []
let cursors: Piece[] = []
for (let r of state.selection.ranges) {
let prim = r == state.selection.main
let piece = measureCursor(null, this.view, r, prim)
if (piece) cursors.push(piece)
let piece = measureCursor(this.em, this.view, r, prim)
if (piece) cursors.push(piece)
}
return {cursors}
}
Expand All @@ -93,6 +118,9 @@ export class BlockCursorPlugin {
this.cursorLayer.remove()
}
}
function configChanged(update: ViewUpdate) {
return getDrawSelectionConfig(update.startState) != getDrawSelectionConfig(update.state)
}

const themeSpec = {
".cm-line": {
Expand All @@ -108,33 +136,72 @@ export class BlockCursorPlugin {
},
"&:not(.cm-focused) .cm-fat-cursor": {
background: "none",
outline: "solid 1px #ff9696"
outline: "solid 1px #ff9696",
color: "transparent !important",
},
}

export const hideNativeSelection = Prec.highest(EditorView.theme(themeSpec))



function getBase(view: EditorView) {
let rect = view.scrollDOM.getBoundingClientRect()
let left = view.textDirection == Direction.LTR ? rect.left : rect.right - view.scrollDOM.clientWidth
return {left: left - view.scrollDOM.scrollLeft, top: rect.top - view.scrollDOM.scrollTop}
}

function measureCursor(cm: any, view: EditorView, cursor: SelectionRange, primary: boolean): Piece | null {
let head = cursor.head
var hCoeff = 1


let pos = view.coordsAtPos(head, 1)
if (!pos) return null
let base = getBase(view)
let letter = head < view.state.doc.length && view.state.sliceDoc(head, head + 1)
if (!letter || letter == "\n" || letter == "\r") letter = "\xa0"
let h = (pos.bottom - pos.top)
return new Piece(pos.left - base.left, pos.top - base.top + h * (1-hCoeff), h * hCoeff,
primary ? "cm-fat-cursor cm-cursor-primary" : "cm-fat-cursor cm-cursor-secondary",
letter, hCoeff != 1)

function measureCursor(em: EmacsHandler, view: EditorView, cursor: SelectionRange, primary: boolean): Piece | null {
let head = cursor.head;
let fatCursor = true;
let hCoeff = 1;
if (em.$data.count || em.$data.keyChain) {
hCoeff = 0.5;
}

if (fatCursor) {
let letter = head < view.state.doc.length && view.state.sliceDoc(head, head + 1);
if (letter && (/[\uDC00-\uDFFF]/.test(letter) && head > 1)) {
// step back if cursor is on the second half of a surrogate pair
head--;
letter = view.state.sliceDoc(head, head + 1);
}
let pos = view.coordsAtPos(head, 1);
if (!pos) return null;
let base = getBase(view);
let domAtPos = view.domAtPos(head);
let node = domAtPos ? domAtPos.node : view.contentDOM;
while (domAtPos && domAtPos.node instanceof HTMLElement) {
node = domAtPos.node;
domAtPos = {node: domAtPos.node.childNodes[domAtPos.offset], offset: 0};
}
if (!(node instanceof HTMLElement)) {
if (!node.parentNode) return null;
node = node.parentNode;
}
let style = getComputedStyle(node as HTMLElement);
let left = pos.left;
// TODO remove coordsAtPos when all supported versions of codemirror have coordsForChar api
let charCoords = (view as any).coordsForChar?.(head);
if (charCoords) {
left = charCoords.left;
}
if (!letter || letter == "\n" || letter == "\r") {
letter = "\xa0";
} else if (letter == "\t") {
letter = "\xa0";
var nextPos = view.coordsAtPos(head + 1, -1);
if (nextPos) {
left = nextPos.left - (nextPos.left - pos.left) / parseInt(style.tabSize);
}
} else if ((/[\uD800-\uDBFF]/.test(letter) && head < view.state.doc.length - 1)) {
// include the second half of a surrogate pair in cursor
letter += view.state.sliceDoc(head + 1, head + 2);
}
let h = (pos.bottom - pos.top);
return new Piece(left - base.left, pos.top - base.top + h * (1 - hCoeff), h * hCoeff,
style.fontFamily, style.fontSize, style.fontWeight, style.color,
primary ? "cm-fat-cursor cm-cursor-primary" : "cm-fat-cursor cm-cursor-secondary",
letter, hCoeff != 1)
} else {
return null;
}
}
Loading

0 comments on commit 52a466e

Please sign in to comment.