Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix block cursor #17

Merged
merged 7 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading