1
import { Position } from '@sourcegraph/extension-api-types'
2
import { getElementOffset, getTextNodes } from './dom'
3

4
export interface CharacterRange {
5
    code: number
6
    start: number
7
    end: number
8
}
9

10
export interface TokenRange {
11
    start: number
12
    end: number
13
}
14

15
interface CharacterData {
16
    code: number
17
    width: number
18
}
19

20
export interface Token {
21
    /** The start character of the token (0-indexed) */
22
    start: number
23
    /** The end character of the token (0-indexed) */
24
    end: number
25
    /** The value of the token */
26
    value: string
27
    /** The left position in pixels of the token */
28
    left: number
29
    /** The width in pixels of the token */
30
    width: number
31
}
32

33 1
export const FULL_LINE = Infinity
34

35 1
const isAlphanumeric = (code: number) =>
36 1
    (code >= 48 && code <= 57) || // 0-9
37 1
    (code >= 65 && code <= 90) || // A-Z
38 1
    (code >= 97 && code <= 122) // a-z
39

40 1
const isWhitespace = (code: number) =>
41 1
    code === 9 || // tab
42 1
    code === 32 || // space
43 1
    code === 10 || // LF
44 1
    code === 12 || // FF
45 1
    code === 13 // CR
46

47 1
export const findWordEdge = (codes: number[], at: number, delta: -1 | 1): number => {
48
    // Group alphanumeric characters together. These are identities.
49 1
    if (isAlphanumeric(codes[at])) {
50 1
        let i = at
51

52 1
        while (isAlphanumeric(codes[i + delta])) {
53 1
            i += delta
54
        }
55

56 1
        return i
57
    }
58

59
    // Group whitespace just in case it is needed.
60 1
    if (isWhitespace(codes[at])) {
61 1
        let i = at
62

63 1
        while (isWhitespace(codes[i + delta])) {
64 1
            i += delta
65
        }
66

67 1
        return i
68
    }
69

70
    // Anything else is by itself. Think '{}|(),.' etc.
71 1
    return at
72
}
73

74
export class Characters {
75
    private container: HTMLElement
76

77 1
    private widths = new Map<number, number>()
78

79 1
    constructor(container: HTMLElement, getLineNumber?: () => void) {
80 1
        this.container = container
81
    }
82

83 1
    public getCharacterRanges = (elem: HTMLElement): CharacterRange[] => {
84 1
        const ranges: CharacterRange[] = []
85

86 1
        let left = 0
87 1
        for (const { code, width } of this.getCharacterWidths(elem)) {
88 1
            ranges.push({
89
                code,
90
                start: left,
91
                end: left + width,
92
            })
93

94 1
            left += width
95
        }
96

97 1
        return ranges
98
    }
99

100 1
    public getCharacterOffset = (character: number, elem: HTMLElement, atStart: boolean, line?: number): number => {
101 1
        const ranges = this.getCharacterRanges(elem)
102 1
        if (ranges.length === 0) {
103 0
            return 0
104
        }
105

106 1
        let at: 'start' | 'end' = atStart ? 'start' : 'end'
107

108 1
        let range = ranges[character]
109
        // Be lenient for requests for characters after the end of the line. Language servers sometimes send
110
        // this as the end of a range.
111 1
        if ((!range && character === ranges.length) || character === FULL_LINE) {
112 0
            range = ranges[ranges.length - 1]
113 0
            at = 'end'
114 1
        } else if (!range) {
115 0
            throw new Error(
116
                `Out of bounds: attempted to get range of character ${character} for line ${
117 0
                    line ? line : ''
118
                } (line length ${ranges.length})`
119
            )
120
        }
121

122 1
        return range[at]
123
    }
124

125 1
    public getCharacter = (elem: HTMLElement, event: MouseEvent): number => {
126 1
        const paddingLeft = getElementOffset(elem, true)
127

128 1
        const x = event.clientX - paddingLeft
129

130 1
        const character = this.getCharacterRanges(elem).findIndex(
131
            // In the future, we should think about how to handle events at a position that lies exectly on
132
            // the line between two characters. Right now, it'll go to the first character.
133 1
            range => x >= range.start && x <= range.end
134
        )
135

136 1
        return character
137
    }
138

139 1
    public getToken(elem: HTMLElement, event: MouseEvent): { token: Token | null; character: number } {
140 1
        const paddingLeft = getElementOffset(elem, true)
141

142 1
        const x = event.clientX - paddingLeft
143

144 1
        const ranges = this.getCharacterRanges(elem)
145

146 1
        const character = ranges.findIndex(
147
            // In the future, we should think about how to handle events at a position that lies exectly on
148
            // the line between two characters. Right now, it'll go to the first character.
149 1
            range => x >= range.start && x <= range.end
150
        )
151

152 1
        if (character === -1) {
153 1
            return {
154
                character,
155
                token: null,
156
            }
157
        }
158

159 1
        const characterCodes = this.getCharacterRanges(elem).map(({ code }) => code)
160

161 1
        const start = findWordEdge(characterCodes, character, -1)
162 1
        const end = findWordEdge(characterCodes, character, 1)
163

164 1
        const left = this.getCharacterOffset(start, elem, true)
165 1
        const right = this.getCharacterOffset(end, elem, false)
166

167 1
        return {
168
            character,
169
            token: {
170
                start,
171
                end,
172
                value: characterCodes
173
                    .slice(start, end + 1)
174 1
                    .map(c => String.fromCharCode(c))
175
                    .join(''),
176
                left,
177
                width: right - left,
178
            },
179
        }
180
    }
181

182 1
    public getTokenRangeFromPosition = (elem: HTMLElement, position: Position): TokenRange => {
183 1
        const characterCodes = this.getCharacterRanges(elem).map(({ code }) => code)
184

185 1
        const range = {
186
            start: findWordEdge(characterCodes, position.character, -1),
187
            end: findWordEdge(characterCodes, position.character, 1),
188
        }
189

190 1
        return range
191
    }
192

193 1
    private getCharacterWidths(elem: HTMLElement): CharacterData[] {
194 1
        const nodes = getTextNodes(elem as Node)
195

196 1
        const data: CharacterData[] = []
197 1
        for (const node of nodes) {
198 1
            if (!node.nodeValue) {
199 0
                continue
200
            }
201

202 1
            for (let i = 0; i < node.nodeValue.length; i++) {
203 1
                const code = node.nodeValue.charCodeAt(i)
204

205 1
                data.push({ width: this.getCharacterWidth(code), code })
206
            }
207
        }
208

209 1
        return data
210
    }
211

212 1
    private getCharacterWidth(charCode: number): number {
213 1
        if (this.widths.has(charCode)) {
214 1
            return this.widths.get(charCode) as number
215
        }
216

217 1
        const elem = document.createElement('div')
218

219 1
        elem.innerHTML = String.fromCharCode(charCode)
220

221
        // Ensure we preserve whitespace and only get the width of the character
222 1
        elem.style.visibility = 'hidden'
223 1
        elem.style.height = '0'
224 1
        elem.style.cssFloat = 'left'
225

226 1
        this.container.appendChild(elem)
227

228 1
        const width = elem.getBoundingClientRect().width
229

230 1
        this.container.removeChild(elem)
231

232 1
        this.widths.set(charCode, width)
233

234 1
        return width
235
    }
236
}

Read our documentation on viewing source code .

Loading