Last active
June 13, 2021 04:40
-
-
Save xixixao/7afbe76d4fff2cc2b6617a58eb057403 to your computer and use it in GitHub Desktop.
Mac-like selection background for Codemirror6
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { drawSelection, ViewPlugin, Direction } from "@codemirror/view"; | |
import { combineConfig, Facet, EditorSelection } from "@codemirror/state"; | |
const [_, _2, hideNativeSelection] = drawSelection(); | |
const selectionConfig = Facet.define({ | |
combine(configs) { | |
return combineConfig( | |
configs, | |
{ | |
cursorBlinkRate: 1200, | |
drawRangeCursor: true, | |
}, | |
{ | |
cursorBlinkRate: (a, b) => Math.min(a, b), | |
drawRangeCursor: (a, b) => a || b, | |
} | |
); | |
}, | |
}); | |
export function drawBetterSelection() { | |
return [selectionConfig.of({}), drawSelectionPlugin, hideNativeSelection]; | |
} | |
const nav = | |
typeof navigator != "undefined" | |
? navigator | |
: { userAgent: "", vendor: "", platform: "" }; | |
const safari = /Apple Computer/.test(nav.vendor); | |
const browser = { | |
ios: safari && (/Mobile\/\w+/.test(nav.userAgent) || nav.maxTouchPoints > 2), | |
}; | |
const CanHidePrimary = !browser.ios; | |
const drawSelectionPlugin = ViewPlugin.fromClass( | |
class { | |
constructor(view) { | |
this.view = view; | |
this.rangePieces = []; | |
this.cursors = []; | |
this.measureReq = { | |
read: this.readPos.bind(this), | |
write: this.drawSel.bind(this), | |
}; | |
this.selectionLayer = view.scrollDOM.appendChild( | |
document.createElement("div") | |
); | |
this.selectionLayer.className = "cm-selectionLayer"; | |
this.selectionLayer.setAttribute("aria-hidden", "true"); | |
this.cursorLayer = view.scrollDOM.appendChild( | |
document.createElement("div") | |
); | |
this.cursorLayer.className = "cm-cursorLayer"; | |
this.cursorLayer.setAttribute("aria-hidden", "true"); | |
view.requestMeasure(this.measureReq); | |
this.setBlinkRate(); | |
} | |
setBlinkRate() { | |
this.cursorLayer.style.animationDuration = | |
this.view.state.facet(selectionConfig).cursorBlinkRate + "ms"; | |
} | |
update(update) { | |
let confChanged = | |
update.startState.facet(selectionConfig) != | |
update.state.facet(selectionConfig); | |
if ( | |
confChanged || | |
update.selectionSet || | |
update.geometryChanged || | |
update.viewportChanged | |
) | |
this.view.requestMeasure(this.measureReq); | |
if (update.transactions.some((tr) => tr.scrollIntoView)) | |
this.cursorLayer.style.animationName = | |
this.cursorLayer.style.animationName == "cm-blink" | |
? "cm-blink2" | |
: "cm-blink"; | |
if (confChanged) this.setBlinkRate(); | |
} | |
readPos() { | |
let { state } = this.view, | |
conf = state.facet(selectionConfig); | |
let rangePieces = state.selection.ranges | |
.map((r) => (r.empty ? [] : measureRange(this.view, r))) | |
.reduce((a, b) => a.concat(b)); | |
let cursors = []; | |
for (let r of state.selection.ranges) { | |
let prim = r == state.selection.main; | |
if (r.empty ? !prim || CanHidePrimary : conf.drawRangeCursor) { | |
let piece = measureCursor(this.view, r, prim); | |
if (piece) cursors.push(piece); | |
} | |
} | |
return { rangePieces, cursors }; | |
} | |
drawSel({ rangePieces, cursors }) { | |
if ( | |
rangePieces.length != this.rangePieces.length || | |
rangePieces.some((p, i) => !p.eq(this.rangePieces[i])) | |
) { | |
this.selectionLayer.textContent = ""; | |
for (let p of rangePieces) this.selectionLayer.appendChild(p.draw()); | |
this.rangePieces = rangePieces; | |
} | |
if ( | |
cursors.length != this.cursors.length || | |
cursors.some((c, i) => !c.eq(this.cursors[i])) | |
) { | |
let oldCursors = this.cursorLayer.children; | |
if (oldCursors.length !== cursors.length) { | |
this.cursorLayer.textContent = ""; | |
for (const c of cursors) this.cursorLayer.appendChild(c.draw()); | |
} else { | |
cursors.forEach((c, idx) => c.adjust(oldCursors[idx])); | |
} | |
this.cursors = cursors; | |
} | |
} | |
destroy() { | |
this.selectionLayer.remove(); | |
this.cursorLayer.remove(); | |
} | |
} | |
); | |
class Piece { | |
constructor(left, top, width, height, className) { | |
this.left = left; | |
this.top = top; | |
this.width = width; | |
this.height = height; | |
this.className = className; | |
} | |
draw() { | |
let elt = document.createElement("div"); | |
elt.className = this.className; | |
this.adjust(elt); | |
return elt; | |
} | |
adjust(elt) { | |
elt.style.left = this.left + "px"; | |
elt.style.top = this.top + "px"; | |
if (this.width >= 0) elt.style.width = this.width + "px"; | |
elt.style.height = this.height + "px"; | |
} | |
eq(p) { | |
return ( | |
this.left == p.left && | |
this.top == p.top && | |
this.width == p.width && | |
this.height == p.height && | |
this.className == p.className | |
); | |
} | |
} | |
function getBase(view) { | |
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 wrappedLine(view, pos, inside) { | |
let range = EditorSelection.cursor(pos); | |
return { | |
from: Math.max( | |
inside.from, | |
view.moveToLineBoundary(range, false, true).from | |
), | |
to: Math.min(inside.to, view.moveToLineBoundary(range, true, true).from), | |
}; | |
} | |
function measureRange(view, range) { | |
if (range.to <= view.viewport.from || range.from >= view.viewport.to) | |
return []; | |
let from = Math.max(range.from, view.viewport.from), | |
to = Math.min(range.to, view.viewport.to); | |
let ltr = view.textDirection == Direction.LTR; | |
let content = view.contentDOM, | |
contentRect = content.getBoundingClientRect(), | |
base = getBase(view); | |
let lineStyle = window.getComputedStyle(content.firstChild); | |
let leftSide = contentRect.left + parseInt(lineStyle.paddingLeft); | |
let rightSide = contentRect.right - parseInt(lineStyle.paddingRight); | |
let visualStart = view.visualLineAt(from); | |
let visualEnd = view.visualLineAt(to); | |
if (view.lineWrapping) { | |
visualStart = wrappedLine(view, from, visualStart); | |
visualEnd = wrappedLine(view, to, visualEnd); | |
} | |
if (visualStart.from == visualEnd.from) { | |
return pieces(drawForLine(range.from, range.to, visualStart)); | |
} else { | |
let top = drawForLine(range.from, null, visualStart); | |
let bottom = drawForLine(null, range.to, visualEnd); | |
let between = []; | |
if (visualStart.to < visualEnd.from - 1) { | |
// RECOMPUTER CHANGE START | |
// between.push(piece(leftSide, top.bottom, rightSide, bottom.top)); | |
while (visualStart.to < visualEnd.from - 1) { | |
const line = view.visualLineAt(visualStart.to + 1); | |
between.push(pieces(drawForLine(null, null, line))); | |
visualStart = line; | |
} | |
between = between.flat(); | |
// RECOMPUTER CHANGE END | |
} else if (top.bottom < bottom.top && bottom.top - top.bottom < 4) | |
top.bottom = bottom.top = (top.bottom + bottom.top) / 2; | |
return pieces(top).concat(between).concat(pieces(bottom)); | |
} | |
function piece(left, top, right, bottom) { | |
return new Piece( | |
left - base.left, | |
top - base.top, | |
right - left, | |
bottom - top, | |
"cm-selectionBackground" | |
); | |
} | |
function pieces({ top, bottom, horizontal }) { | |
let pieces = []; | |
for (let i = 0; i < horizontal.length; i += 2) | |
pieces.push(piece(horizontal[i], top, horizontal[i + 1], bottom)); | |
return pieces; | |
} | |
// Gets passed from/to in line-local positions | |
function drawForLine(from, to, line) { | |
let top = 1e9, | |
bottom = -1e9, | |
horizontal = []; | |
function addSpan(from, fromOpen, to, toOpen, dir) { | |
let fromCoords = view.coordsAtPos(from, from == line.to ? -1 : 1); | |
let toCoords = view.coordsAtPos(to, to == line.from ? 1 : -1); | |
top = Math.min(fromCoords.top, toCoords.top, top); | |
bottom = Math.max(fromCoords.bottom, toCoords.bottom, bottom); | |
if (dir == Direction.LTR) | |
horizontal.push( | |
ltr && fromOpen ? leftSide : fromCoords.left, | |
// RECOMPUTER CHANGE START | |
// ltr && toOpen ? rightSide : toCoords.right | |
// TODO: How to determine "one character" size? | |
toCoords.right + (ltr && toOpen ? 5 : 0) | |
// RECOMPUTER CHANGE END | |
); | |
else | |
horizontal.push( | |
!ltr && toOpen ? leftSide : toCoords.left, | |
!ltr && fromOpen ? rightSide : fromCoords.right | |
); | |
} | |
let start = from !== null && from !== void 0 ? from : line.from, | |
end = to !== null && to !== void 0 ? to : line.to; | |
// Split the range by visible range and document line | |
for (let r of view.visibleRanges) | |
if (r.to > start && r.from < end) { | |
for ( | |
let pos = Math.max(r.from, start), endPos = Math.min(r.to, end); | |
; | |
) { | |
let docLine = view.state.doc.lineAt(pos); | |
for (let span of view.bidiSpans(docLine)) { | |
let spanFrom = span.from + docLine.from, | |
spanTo = span.to + docLine.from; | |
if (spanFrom >= endPos) break; | |
if (spanTo > pos) | |
addSpan( | |
Math.max(spanFrom, pos), | |
from == null && spanFrom <= start, | |
Math.min(spanTo, endPos), | |
to == null && spanTo >= end, | |
span.dir | |
); | |
} | |
pos = docLine.to + 1; | |
if (pos >= endPos) break; | |
} | |
} | |
if (horizontal.length == 0) | |
addSpan(start, from == null, end, to == null, view.textDirection); | |
return { top, bottom, horizontal }; | |
} | |
} | |
function measureCursor(view, cursor, primary) { | |
let pos = view.coordsAtPos(cursor.head, cursor.assoc || 1); | |
if (!pos) return null; | |
let base = getBase(view); | |
return new Piece( | |
pos.left - base.left, | |
pos.top - base.top, | |
-1, | |
pos.bottom - pos.top, | |
primary ? "cm-cursor cm-cursor-primary" : "cm-cursor cm-cursor-secondary" | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment