mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
302 lines
8.8 KiB
TypeScript
302 lines
8.8 KiB
TypeScript
|
/**
|
||
|
* This file is adapted from CodeMirror 6, licensed under the MIT license:
|
||
|
* https://github.com/codemirror/view/blob/main/src/layer.ts
|
||
|
*/
|
||
|
import {
|
||
|
BlockInfo,
|
||
|
BlockType,
|
||
|
Direction,
|
||
|
EditorView,
|
||
|
Rect,
|
||
|
RectangleMarker,
|
||
|
} from '@codemirror/view'
|
||
|
import { EditorSelection, SelectionRange } from '@codemirror/state'
|
||
|
import { isVisual } from '../extensions/visual/visual'
|
||
|
import { round } from 'lodash'
|
||
|
|
||
|
function canAssumeUniformLineHeights(view: EditorView) {
|
||
|
return !isVisual(view)
|
||
|
}
|
||
|
|
||
|
export const rectangleMarkerForRange = (
|
||
|
view: EditorView,
|
||
|
className: string,
|
||
|
range: SelectionRange
|
||
|
): readonly RectangleMarker[] => {
|
||
|
if (range.empty) {
|
||
|
const pos = fullHeightCoordsAtPos(view, range.head, range.assoc || 1)
|
||
|
|
||
|
if (!pos) {
|
||
|
return []
|
||
|
}
|
||
|
|
||
|
const base = getBase(view)
|
||
|
|
||
|
return [
|
||
|
new RectangleMarker(
|
||
|
className,
|
||
|
pos.left - base.left,
|
||
|
pos.top - base.top,
|
||
|
null,
|
||
|
pos.bottom - pos.top
|
||
|
),
|
||
|
]
|
||
|
}
|
||
|
|
||
|
return rectanglesForRange(view, className, range)
|
||
|
}
|
||
|
|
||
|
export function getBase(view: EditorView) {
|
||
|
const rect = view.scrollDOM.getBoundingClientRect()
|
||
|
const 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: EditorView,
|
||
|
pos: number,
|
||
|
inside: { from: number; to: number }
|
||
|
) {
|
||
|
const 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),
|
||
|
type: BlockType.Text,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function blockAt(view: EditorView, pos: number): BlockInfo {
|
||
|
const line = view.lineBlockAt(pos)
|
||
|
if (Array.isArray(line.type))
|
||
|
for (const l of line.type) {
|
||
|
if (
|
||
|
l.to > pos ||
|
||
|
(l.to === pos && (l.to === line.to || l.type === BlockType.Text))
|
||
|
)
|
||
|
return l
|
||
|
}
|
||
|
return line as any
|
||
|
}
|
||
|
|
||
|
// Like coordsAtPos, provides screen coordinates for a document position, but
|
||
|
// unlike coordsAtPos, the top and bottom represent the full height of the
|
||
|
// visual line rather than the top and bottom of the text. To do this, it relies
|
||
|
// on the assumption that all text in the document has the same height and that
|
||
|
// the line contains no widget or decoration that changes the height of the
|
||
|
// line. This is, I am fairly certain, a safe assumption in source mode but not
|
||
|
// in rich text, so in rich text mode this function just returns coordsAtPos.
|
||
|
export function fullHeightCoordsAtPos(
|
||
|
view: EditorView,
|
||
|
pos: number,
|
||
|
side?: -2 | -1 | 1 | 2 | undefined
|
||
|
): Rect | null {
|
||
|
// @ts-ignore CodeMirror has incorrect type on coordsAtPos
|
||
|
const coords = view.coordsAtPos(pos, side)
|
||
|
if (!coords) {
|
||
|
return null
|
||
|
}
|
||
|
|
||
|
if (!canAssumeUniformLineHeights(view)) {
|
||
|
return coords
|
||
|
}
|
||
|
|
||
|
const { left, right } = coords
|
||
|
const halfLeading =
|
||
|
(view.defaultLineHeight - (coords.bottom - coords.top)) / 2
|
||
|
|
||
|
return {
|
||
|
left,
|
||
|
right,
|
||
|
top: round(coords.top - halfLeading, 2),
|
||
|
bottom: round(coords.bottom + halfLeading, 2),
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Added to range rectangle's vertical extent to prevent rounding
|
||
|
// errors from introducing gaps in the rendered content.
|
||
|
const Epsilon = 0.01
|
||
|
|
||
|
function rectanglesForRange(
|
||
|
view: EditorView,
|
||
|
className: string,
|
||
|
range: SelectionRange
|
||
|
): RectangleMarker[] {
|
||
|
if (range.to <= view.viewport.from || range.from >= view.viewport.to) {
|
||
|
return []
|
||
|
}
|
||
|
const from = Math.max(range.from, view.viewport.from)
|
||
|
const to = Math.min(range.to, view.viewport.to)
|
||
|
|
||
|
const ltr = view.textDirection === Direction.LTR
|
||
|
const content = view.contentDOM
|
||
|
const contentRect = content.getBoundingClientRect()
|
||
|
const base = getBase(view)
|
||
|
|
||
|
const lineElt = content.querySelector('.cm-line')
|
||
|
const lineStyle = lineElt && window.getComputedStyle(lineElt)
|
||
|
const leftSide =
|
||
|
contentRect.left +
|
||
|
(lineStyle
|
||
|
? parseInt(lineStyle.paddingLeft) +
|
||
|
Math.min(0, parseInt(lineStyle.textIndent))
|
||
|
: 0)
|
||
|
const rightSide =
|
||
|
contentRect.right - (lineStyle ? parseInt(lineStyle.paddingRight) : 0)
|
||
|
|
||
|
const startBlock = blockAt(view, from)
|
||
|
const endBlock = blockAt(view, to)
|
||
|
let visualStart: { from: number; to: number } | null =
|
||
|
startBlock.type === BlockType.Text ? startBlock : null
|
||
|
let visualEnd: { from: number; to: number } | null =
|
||
|
endBlock.type === BlockType.Text ? endBlock : null
|
||
|
if (view.lineWrapping) {
|
||
|
if (visualStart) visualStart = wrappedLine(view, from, visualStart)
|
||
|
if (visualEnd) visualEnd = wrappedLine(view, to, visualEnd)
|
||
|
}
|
||
|
if (visualStart && visualEnd && visualStart.from === visualEnd.from) {
|
||
|
return pieces(drawForLine(range.from, range.to, visualStart))
|
||
|
} else {
|
||
|
const top = visualStart
|
||
|
? drawForLine(range.from, null, visualStart)
|
||
|
: drawForWidget(startBlock, false)
|
||
|
const bottom = visualEnd
|
||
|
? drawForLine(null, range.to, visualEnd)
|
||
|
: drawForWidget(endBlock, true)
|
||
|
const between = []
|
||
|
|
||
|
if ((visualStart || startBlock).to < (visualEnd || endBlock).from - 1)
|
||
|
between.push(piece(leftSide, top.bottom, rightSide, bottom.top))
|
||
|
else if (
|
||
|
top.bottom < bottom.top &&
|
||
|
view.elementAtHeight((top.bottom + bottom.top) / 2).type ===
|
||
|
BlockType.Text
|
||
|
)
|
||
|
top.bottom = bottom.top = (top.bottom + bottom.top) / 2
|
||
|
return pieces(top).concat(between).concat(pieces(bottom))
|
||
|
}
|
||
|
|
||
|
function piece(left: number, top: number, right: number, bottom: number) {
|
||
|
return new RectangleMarker(
|
||
|
className,
|
||
|
left - base.left,
|
||
|
top - base.top - Epsilon,
|
||
|
right - left,
|
||
|
bottom - top + Epsilon
|
||
|
)
|
||
|
}
|
||
|
|
||
|
function pieces({
|
||
|
top,
|
||
|
bottom,
|
||
|
horizontal,
|
||
|
}: {
|
||
|
top: number
|
||
|
bottom: number
|
||
|
horizontal: number[]
|
||
|
}) {
|
||
|
const 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: null | number,
|
||
|
to: null | number,
|
||
|
line: { from: number; to: number }
|
||
|
) {
|
||
|
let top = 1e9
|
||
|
let bottom = -1e9
|
||
|
const horizontal: number[] = []
|
||
|
|
||
|
function addSpan(
|
||
|
from: number,
|
||
|
fromOpen: boolean,
|
||
|
to: number,
|
||
|
toOpen: boolean,
|
||
|
dir: Direction
|
||
|
) {
|
||
|
// Passing 2/-2 is a kludge to force the view to return
|
||
|
// coordinates on the proper side of block widgets, since
|
||
|
// normalizing the side there, though appropriate for most
|
||
|
// coordsAtPos queries, would break selection drawing.
|
||
|
const fromCoords = fullHeightCoordsAtPos(
|
||
|
view,
|
||
|
from,
|
||
|
(from === line.to ? -2 : 2) as any
|
||
|
)
|
||
|
const toCoords = fullHeightCoordsAtPos(
|
||
|
view,
|
||
|
to,
|
||
|
(to === line.from ? 2 : -2) as any
|
||
|
)
|
||
|
// coordsAtPos can sometimes return null even when the document position
|
||
|
// is within the viewport. It's not clear exactly when this happens;
|
||
|
// sometimes, the editor has previously failed to complete a measure.
|
||
|
if (!fromCoords || !toCoords) {
|
||
|
return
|
||
|
}
|
||
|
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,
|
||
|
ltr && toOpen ? rightSide : toCoords.right
|
||
|
)
|
||
|
else
|
||
|
horizontal.push(
|
||
|
!ltr && toOpen ? leftSide : toCoords.left,
|
||
|
!ltr && fromOpen ? rightSide : fromCoords.right
|
||
|
)
|
||
|
}
|
||
|
|
||
|
const start = from ?? line.from
|
||
|
const end = to ?? line.to
|
||
|
// Split the range by visible range and document line
|
||
|
for (const 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);
|
||
|
;
|
||
|
|
||
|
) {
|
||
|
const docLine = view.state.doc.lineAt(pos)
|
||
|
for (const span of view.bidiSpans(docLine)) {
|
||
|
const spanFrom = span.from + docLine.from
|
||
|
const 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 drawForWidget(block: BlockInfo, top: boolean) {
|
||
|
const y = contentRect.top + (top ? block.top : block.bottom)
|
||
|
return { top: y, bottom: y, horizontal: [] }
|
||
|
}
|
||
|
}
|