├── .gitignore
├── LICENSE
├── README.md
├── codejar.ts
├── cursor.ts
├── demo.html
├── package.json
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | package-lock.json
3 | *.js
4 | *.d.ts
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Anton Medvedev
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | CodeJar – an embeddable code editor for the browser
3 |
4 |
5 | [](https://www.npmjs.com/package/codejar)
6 | [](https://bundlephobia.com/result?p=codejar)
7 |
8 | ## Features
9 |
10 | * Lightweight (**2.45 kB** only)
11 | * No dependencies
12 | * Preserves indentation on a new line
13 | * Adds closing brackets, quotes
14 | * Indents line with the **Tab** key
15 | * Supports **undo**/**redo**
16 |
17 | ## Getting Started
18 |
19 | Install CodeJar 🍯 via npm:
20 |
21 | ```bash
22 | npm i codejar
23 | ```
24 |
25 | Create an element and init the CodeJar 🍯:
26 |
27 | ```html
28 |
29 |
32 | ```
33 |
34 | Second argument to `CodeJar` is a highlighting function (like Prism.js, highlight.js):
35 |
36 | ```ts
37 | const highlight = (editor: HTMLElement) => {
38 | const code = editor.textContent
39 | code = code.replace('foo', 'foo ')
40 | editor.innerHTML = code
41 | }
42 |
43 | const jar = CodeJar(editor, highlight)
44 | ```
45 |
46 | Third argument to `CodeJar` is options:
47 | - `tab: string` replaces "tabs" with given string. Default: `\t`.
48 | - Note: use css rule `tab-size` to customize size.
49 | - `indentOn: RegExp` allows auto indent rule to be customized. Default `/[({\[]$/`.
50 | - `moveToNewLine: RegExp` checks in extra newline character need to be added. Default `/^[)}\]]/`.
51 | - `spellcheck: boolean` enables spellchecking on the editor. Default `false`.
52 | - `catchTab: boolean` catches Tab keypress events and replaces it with `tab` string. Default: `true`.
53 | - `preserveIdent: boolean` keeps indent levels on new line. Default `true`.
54 | - `addClosing: boolean` automatically adds closing brackets, quotes. Default `true`.
55 | - `history` records history. Default `true`.
56 | - `window` window object. Default: `window`.
57 | - `autoclose` object
58 | - `open string` characters that triggers the autoclose function
59 | - `close string` characters that correspond to the opening ones and close the object.
60 |
61 |
62 | ```js
63 | const options = {
64 | tab: ' '.repeat(4), // default is '\t'
65 | indentOn: /[(\[]$/, // default is /{$/
66 | autoclose: {
67 | open: `([{*`, // default is `([{'"`
68 | close: `)]}*` // default is `)]}'"`
69 | }
70 | }
71 |
72 | const jar = CodeJar(editor, highlight, options)
73 | ```
74 |
75 | ## API
76 |
77 | #### `updateCode(string)`
78 |
79 | Updates the code.
80 |
81 | ```js
82 | jar.updateCode(`let foo = bar`)
83 | ```
84 |
85 | #### `updateOptions(Partial)`
86 |
87 | Updates the options.
88 |
89 | ```js
90 | jar.updateOptions({tab: '\t'})
91 | ```
92 |
93 |
94 | #### `onUpdate((code: string) => void)`
95 |
96 | Calls callback on code updates.
97 |
98 | ```js
99 | jar.onUpdate(code => {
100 | console.log(code)
101 | })
102 | ```
103 |
104 | #### `toString(): string`
105 |
106 | Return current code.
107 |
108 | ```js
109 | let code = jar.toString()
110 | ```
111 |
112 | #### `save(): string`
113 |
114 | Saves current cursor position.
115 |
116 | ```js
117 | let pos = jar.save()
118 | ```
119 |
120 | #### `restore(pos: Position)`
121 |
122 | Restore cursor position.
123 |
124 | ```js
125 | jar.restore(pos)
126 | ```
127 |
128 | #### `recordHistory()`
129 |
130 | Saves current editor state to history.
131 |
132 | #### `destroy()`
133 |
134 | Removes event listeners from editor.
135 |
136 | ## Related
137 |
138 | * [react-codejar](https://github.com/guilhermelimak/react-codejar) - a React wrapper for CodeJar.
139 | * [ngx-codejar](https://github.com/julianpoemp/ngx-codejar) - an Angular wrapper for CodeJar.
140 | * [codejar-linenumbers](https://github.com/julianpoemp/codejar-linenumbers) - an JS library for line numbers.
141 |
142 | ## License
143 |
144 | [MIT](LICENSE)
145 |
--------------------------------------------------------------------------------
/codejar.ts:
--------------------------------------------------------------------------------
1 | const globalWindow = window
2 |
3 | type Options = {
4 | tab: string
5 | indentOn: RegExp
6 | moveToNewLine: RegExp
7 | spellcheck: boolean
8 | catchTab: boolean
9 | preserveIdent: boolean
10 | addClosing: boolean
11 | history: boolean
12 | window: typeof window
13 | autoclose: {
14 | open: string;
15 | close: string;
16 | }
17 | }
18 |
19 | type HistoryRecord = {
20 | html: string
21 | pos: Position
22 | }
23 |
24 | export type Position = {
25 | start: number
26 | end: number
27 | dir?: '->' | '<-'
28 | }
29 |
30 | export type CodeJar = ReturnType
31 |
32 | export function CodeJar(editor: HTMLElement, highlight: (e: HTMLElement, pos?: Position) => void, opt: Partial = {}) {
33 | const options: Options = {
34 | tab: '\t',
35 | indentOn: /[({\[]$/,
36 | moveToNewLine: /^[)}\]]/,
37 | spellcheck: false,
38 | catchTab: true,
39 | preserveIdent: true,
40 | addClosing: true,
41 | history: true,
42 | window: globalWindow,
43 | autoclose: {
44 | open: `([{'"`,
45 | close: `)]}'"`
46 | },
47 | ...opt,
48 | }
49 |
50 | const window = options.window
51 | const document = window.document
52 |
53 | const listeners: [string, any][] = []
54 | const history: HistoryRecord[] = []
55 | let at = -1
56 | let focus = false
57 | let onUpdate: (code: string) => void | undefined = () => void 0
58 | let prev: string // code content prior keydown event
59 |
60 | editor.setAttribute('contenteditable', 'plaintext-only')
61 | editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false')
62 | editor.style.outline = 'none'
63 | editor.style.overflowWrap = 'break-word'
64 | editor.style.overflowY = 'auto'
65 | editor.style.whiteSpace = 'pre-wrap'
66 |
67 | const doHighlight = (editor: HTMLElement, pos?: Position) => {
68 | highlight(editor, pos)
69 | }
70 |
71 | const matchFirefoxVersion =
72 | window.navigator.userAgent.match(/Firefox\/([0-9]+)\./);
73 | const firefoxVersion = matchFirefoxVersion
74 | ? parseInt(matchFirefoxVersion[1])
75 | : 0;
76 | let isLegacy = false; // true if plaintext-only is not supported
77 | if (editor.contentEditable !== "plaintext-only" || firefoxVersion >= 136)
78 | isLegacy = true;
79 | if (isLegacy) editor.setAttribute("contenteditable", "true");
80 |
81 | const debounceHighlight = debounce(() => {
82 | const pos = save()
83 | doHighlight(editor, pos)
84 | restore(pos)
85 | }, 30)
86 |
87 | let recording = false
88 | const shouldRecord = (event: KeyboardEvent): boolean => {
89 | return !isUndo(event) && !isRedo(event)
90 | && event.key !== 'Meta'
91 | && event.key !== 'Control'
92 | && event.key !== 'Alt'
93 | && !event.key.startsWith('Arrow')
94 | }
95 | const debounceRecordHistory = debounce((event: KeyboardEvent) => {
96 | if (shouldRecord(event)) {
97 | recordHistory()
98 | recording = false
99 | }
100 | }, 300)
101 |
102 | const on = (type: K, fn: (event: HTMLElementEventMap[K]) => void) => {
103 | listeners.push([type, fn])
104 | editor.addEventListener(type, fn)
105 | }
106 |
107 | on('keydown', event => {
108 | if (event.defaultPrevented) return
109 |
110 | prev = toString()
111 | if (options.preserveIdent) handleNewLine(event)
112 | else legacyNewLineFix(event)
113 | if (options.catchTab) handleTabCharacters(event)
114 | if (options.addClosing) handleSelfClosingCharacters(event)
115 | if (options.history) {
116 | handleUndoRedo(event)
117 | if (shouldRecord(event) && !recording) {
118 | recordHistory()
119 | recording = true
120 | }
121 | }
122 | if (isLegacy && !isCopy(event)) restore(save())
123 | })
124 |
125 | on('keyup', event => {
126 | if (event.defaultPrevented) return
127 | if (event.isComposing) return
128 |
129 | if (prev !== toString()) debounceHighlight()
130 | debounceRecordHistory(event)
131 | onUpdate(toString())
132 | })
133 |
134 | on('focus', _event => {
135 | focus = true
136 | })
137 |
138 | on('blur', _event => {
139 | focus = false
140 | })
141 |
142 | on('paste', event => {
143 | recordHistory()
144 | handlePaste(event)
145 | recordHistory()
146 | onUpdate(toString())
147 | })
148 |
149 | on('cut', event => {
150 | recordHistory()
151 | handleCut(event)
152 | recordHistory()
153 | onUpdate(toString())
154 | })
155 |
156 | function save(): Position {
157 | const s = getSelection()
158 | const pos: Position = {start: 0, end: 0, dir: undefined}
159 |
160 | let {anchorNode, anchorOffset, focusNode, focusOffset} = s
161 | if (!anchorNode || !focusNode) throw 'error1'
162 |
163 | // If the anchor and focus are the editor element, return either a full
164 | // highlight or a start/end cursor position depending on the selection
165 | if (anchorNode === editor && focusNode === editor) {
166 | pos.start = (anchorOffset > 0 && editor.textContent) ? editor.textContent.length : 0
167 | pos.end = (focusOffset > 0 && editor.textContent) ? editor.textContent.length : 0
168 | pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-'
169 | return pos
170 | }
171 |
172 | // Selection anchor and focus are expected to be text nodes,
173 | // so normalize them.
174 | if (anchorNode.nodeType === Node.ELEMENT_NODE) {
175 | const node = document.createTextNode('')
176 | anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset])
177 | anchorNode = node
178 | anchorOffset = 0
179 | }
180 | if (focusNode.nodeType === Node.ELEMENT_NODE) {
181 | const node = document.createTextNode('')
182 | focusNode.insertBefore(node, focusNode.childNodes[focusOffset])
183 | focusNode = node
184 | focusOffset = 0
185 | }
186 |
187 | visit(editor, el => {
188 | if (el === anchorNode && el === focusNode) {
189 | pos.start += anchorOffset
190 | pos.end += focusOffset
191 | pos.dir = anchorOffset <= focusOffset ? '->' : '<-'
192 | return 'stop'
193 | }
194 |
195 | if (el === anchorNode) {
196 | pos.start += anchorOffset
197 | if (!pos.dir) {
198 | pos.dir = '->'
199 | } else {
200 | return 'stop'
201 | }
202 | } else if (el === focusNode) {
203 | pos.end += focusOffset
204 | if (!pos.dir) {
205 | pos.dir = '<-'
206 | } else {
207 | return 'stop'
208 | }
209 | }
210 |
211 | if (el.nodeType === Node.TEXT_NODE) {
212 | if (pos.dir != '->') pos.start += el.nodeValue!.length
213 | if (pos.dir != '<-') pos.end += el.nodeValue!.length
214 | }
215 | })
216 |
217 | editor.normalize() // collapse empty text nodes
218 | return pos
219 | }
220 |
221 | function restore(pos: Position) {
222 | const s = getSelection()
223 | let startNode: Node | undefined, startOffset = 0
224 | let endNode: Node | undefined, endOffset = 0
225 |
226 | if (!pos.dir) pos.dir = '->'
227 | if (pos.start < 0) pos.start = 0
228 | if (pos.end < 0) pos.end = 0
229 |
230 | // Flip start and end if the direction reversed
231 | if (pos.dir == '<-') {
232 | const {start, end} = pos
233 | pos.start = end
234 | pos.end = start
235 | }
236 |
237 | let current = 0
238 |
239 | visit(editor, el => {
240 | if (el.nodeType !== Node.TEXT_NODE) return
241 |
242 | const len = (el.nodeValue || '').length
243 | if (current + len > pos.start) {
244 | if (!startNode) {
245 | startNode = el
246 | startOffset = pos.start - current
247 | }
248 | if (current + len > pos.end) {
249 | endNode = el
250 | endOffset = pos.end - current
251 | return 'stop'
252 | }
253 | }
254 | current += len
255 | })
256 |
257 | if (!startNode) startNode = editor, startOffset = editor.childNodes.length
258 | if (!endNode) endNode = editor, endOffset = editor.childNodes.length
259 |
260 | // Flip back the selection
261 | if (pos.dir == '<-') {
262 | [startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset]
263 | }
264 |
265 | {
266 | // If nodes not editable, create a text node.
267 | const startEl = uneditable(startNode)
268 | if (startEl) {
269 | const node = document.createTextNode('')
270 | startEl.parentNode?.insertBefore(node, startEl)
271 | startNode = node
272 | startOffset = 0
273 | }
274 | const endEl = uneditable(endNode)
275 | if (endEl) {
276 | const node = document.createTextNode('')
277 | endEl.parentNode?.insertBefore(node, endEl)
278 | endNode = node
279 | endOffset = 0
280 | }
281 | }
282 |
283 | s.setBaseAndExtent(startNode, startOffset, endNode, endOffset)
284 | editor.normalize() // collapse empty text nodes
285 | }
286 |
287 | function uneditable(node: Node): Element | undefined {
288 | while (node && node !== editor) {
289 | if (node.nodeType === Node.ELEMENT_NODE) {
290 | const el = node as Element
291 | if (el.getAttribute('contenteditable') == 'false') {
292 | return el
293 | }
294 | }
295 | node = node.parentNode!
296 | }
297 | }
298 |
299 | function beforeCursor() {
300 | const s = getSelection()
301 | const r0 = s.getRangeAt(0)
302 | const r = document.createRange()
303 | r.selectNodeContents(editor)
304 | r.setEnd(r0.startContainer, r0.startOffset)
305 | return r.toString()
306 | }
307 |
308 | function afterCursor() {
309 | const s = getSelection()
310 | const r0 = s.getRangeAt(0)
311 | const r = document.createRange()
312 | r.selectNodeContents(editor)
313 | r.setStart(r0.endContainer, r0.endOffset)
314 | return r.toString()
315 | }
316 |
317 | function handleNewLine(event: KeyboardEvent) {
318 | if (event.key === 'Enter') {
319 | const before = beforeCursor()
320 | const after = afterCursor()
321 |
322 | let [padding] = findPadding(before)
323 | let newLinePadding = padding
324 |
325 | // If last symbol is "{" ident new line
326 | if (options.indentOn.test(before)) {
327 | newLinePadding += options.tab
328 | }
329 |
330 | // Preserve padding
331 | if (newLinePadding.length > 0) {
332 | preventDefault(event)
333 | event.stopPropagation()
334 | insert('\n' + newLinePadding)
335 | } else {
336 | legacyNewLineFix(event)
337 | }
338 |
339 | // Place adjacent "}" on next line
340 | if (newLinePadding !== padding && options.moveToNewLine.test(after)) {
341 | const pos = save()
342 | insert('\n' + padding)
343 | restore(pos)
344 | }
345 | }
346 | }
347 |
348 | function legacyNewLineFix(event: KeyboardEvent) {
349 | // Firefox does not support plaintext-only mode
350 | // and puts
on Enter. Let's help.
351 | if (isLegacy && event.key === 'Enter') {
352 | preventDefault(event)
353 | event.stopPropagation()
354 | if (afterCursor() == '') {
355 | insert('\n ')
356 | const pos = save()
357 | pos.start = --pos.end
358 | restore(pos)
359 | } else {
360 | insert('\n')
361 | }
362 | }
363 | }
364 |
365 | function handleSelfClosingCharacters(event: KeyboardEvent) {
366 | const open = options.autoclose.open;
367 | const close = options.autoclose.close;
368 | if (open.includes(event.key)) {
369 | preventDefault(event)
370 | const pos = save()
371 | const wrapText = pos.start == pos.end ? '' : getSelection().toString()
372 | const text = event.key + wrapText + (close[open.indexOf(event.key)] ?? "")
373 | insert(text)
374 | pos.start++
375 | pos.end++
376 | restore(pos)
377 | }
378 | }
379 |
380 | function handleTabCharacters(event: KeyboardEvent) {
381 | if (event.key === 'Tab') {
382 | preventDefault(event)
383 | if (event.shiftKey) {
384 | const before = beforeCursor()
385 | let [padding, start] = findPadding(before)
386 | if (padding.length > 0) {
387 | const pos = save()
388 | // Remove full length tab or just remaining padding
389 | const len = Math.min(options.tab.length, padding.length)
390 | restore({start, end: start + len})
391 | document.execCommand('delete')
392 | pos.start -= len
393 | pos.end -= len
394 | restore(pos)
395 | }
396 | } else {
397 | insert(options.tab)
398 | }
399 | }
400 | }
401 |
402 | function handleUndoRedo(event: KeyboardEvent) {
403 | if (isUndo(event)) {
404 | preventDefault(event)
405 | at--
406 | const record = history[at]
407 | if (record) {
408 | editor.innerHTML = record.html
409 | restore(record.pos)
410 | }
411 | if (at < 0) at = 0
412 | }
413 | if (isRedo(event)) {
414 | preventDefault(event)
415 | at++
416 | const record = history[at]
417 | if (record) {
418 | editor.innerHTML = record.html
419 | restore(record.pos)
420 | }
421 | if (at >= history.length) at--
422 | }
423 | }
424 |
425 | function recordHistory() {
426 | if (!focus) return
427 |
428 | const html = editor.innerHTML
429 | const pos = save()
430 |
431 | const lastRecord = history[at]
432 | if (lastRecord) {
433 | if (lastRecord.html === html
434 | && lastRecord.pos.start === pos.start
435 | && lastRecord.pos.end === pos.end) return
436 | }
437 |
438 | at++
439 | history[at] = {html, pos}
440 | history.splice(at + 1)
441 |
442 | const maxHistory = 300
443 | if (at > maxHistory) {
444 | at = maxHistory
445 | history.splice(0, 1)
446 | }
447 | }
448 |
449 | function handlePaste(event: ClipboardEvent) {
450 | if (event.defaultPrevented) return
451 | preventDefault(event)
452 | const originalEvent = (event as any).originalEvent ?? event
453 | const text = originalEvent.clipboardData.getData('text/plain').replace(/\r\n?/g, '\n')
454 | const pos = save()
455 | insert(text)
456 | doHighlight(editor)
457 | restore({
458 | start: Math.min(pos.start, pos.end) + text.length,
459 | end: Math.min(pos.start, pos.end) + text.length,
460 | dir: '<-',
461 | })
462 | }
463 |
464 | function handleCut(event: ClipboardEvent) {
465 | const pos = save()
466 | const selection = getSelection()
467 | const originalEvent = (event as any).originalEvent ?? event
468 | originalEvent.clipboardData.setData('text/plain', selection.toString())
469 | document.execCommand('delete')
470 | doHighlight(editor)
471 | restore({
472 | start: Math.min(pos.start, pos.end),
473 | end: Math.min(pos.start, pos.end),
474 | dir: '<-',
475 | })
476 | preventDefault(event)
477 | }
478 |
479 | function visit(editor: HTMLElement, visitor: (el: Node) => 'stop' | undefined) {
480 | const queue: Node[] = []
481 | if (editor.firstChild) queue.push(editor.firstChild)
482 | let el = queue.pop()
483 | while (el) {
484 | if (visitor(el) === 'stop') break
485 | if (el.nextSibling) queue.push(el.nextSibling)
486 | if (el.firstChild) queue.push(el.firstChild)
487 | el = queue.pop()
488 | }
489 | }
490 |
491 | function isCtrl(event: KeyboardEvent) {
492 | return event.metaKey || event.ctrlKey
493 | }
494 |
495 | function isUndo(event: KeyboardEvent) {
496 | return isCtrl(event) && !event.shiftKey && getKeyCode(event) === 'Z'
497 | }
498 |
499 | function isRedo(event: KeyboardEvent) {
500 | return isCtrl(event) && event.shiftKey && getKeyCode(event) === 'Z'
501 | }
502 |
503 | function isCopy(event: KeyboardEvent) {
504 | return isCtrl(event) && getKeyCode(event) === 'C'
505 | }
506 |
507 | function getKeyCode(event: KeyboardEvent): string | undefined {
508 | let key = event.key || event.keyCode || event.which
509 | if (!key) return undefined
510 | return (typeof key === 'string' ? key : String.fromCharCode(key)).toUpperCase()
511 | }
512 |
513 | function insert(text: string) {
514 | text = text
515 | .replace(/&/g, '&')
516 | .replace(//g, '>')
518 | .replace(/"/g, '"')
519 | .replace(/'/g, ''')
520 | document.execCommand('insertHTML', false, text)
521 | }
522 |
523 | function debounce(cb: any, wait: number) {
524 | let timeout = 0
525 | return (...args: any) => {
526 | clearTimeout(timeout)
527 | timeout = window.setTimeout(() => cb(...args), wait)
528 | }
529 | }
530 |
531 | function findPadding(text: string): [string, number, number] {
532 | // Find beginning of previous line.
533 | let i = text.length - 1
534 | while (i >= 0 && text[i] !== '\n') i--
535 | i++
536 | // Find padding of the line.
537 | let j = i
538 | while (j < text.length && /[ \t]/.test(text[j])) j++
539 | return [text.substring(i, j) || '', i, j]
540 | }
541 |
542 | function toString() {
543 | return editor.textContent || ''
544 | }
545 |
546 | function preventDefault(event: Event) {
547 | event.preventDefault()
548 | }
549 |
550 | function getSelection() {
551 | // @ts-ignore
552 | return editor.getRootNode().getSelection() as Selection
553 | }
554 |
555 | return {
556 | updateOptions(newOptions: Partial) {
557 | Object.assign(options, newOptions)
558 | },
559 | updateCode(code: string, callOnUpdate: boolean = true) {
560 | editor.textContent = code
561 | doHighlight(editor)
562 | callOnUpdate && onUpdate(code)
563 | },
564 | onUpdate(callback: (code: string) => void) {
565 | onUpdate = callback
566 | },
567 | toString,
568 | save,
569 | restore,
570 | recordHistory,
571 | destroy() {
572 | for (let [type, fn] of listeners) {
573 | editor.removeEventListener(type, fn)
574 | }
575 | },
576 | }
577 | }
578 |
--------------------------------------------------------------------------------
/cursor.ts:
--------------------------------------------------------------------------------
1 | type Position = {
2 | top: string
3 | left: string
4 | }
5 |
6 | /**
7 | * Returns position of cursor on the page.
8 | * @param toStart Position of beginning of selection or end of selection.
9 | */
10 | export function cursorPosition(toStart = true): Position | undefined {
11 | const s = window.getSelection()!
12 | if (s.rangeCount > 0) {
13 | const cursor = document.createElement("span")
14 | cursor.textContent = "|"
15 |
16 | const r = s.getRangeAt(0).cloneRange()
17 | r.collapse(toStart)
18 | r.insertNode(cursor)
19 |
20 | const {x, y, height} = cursor.getBoundingClientRect()
21 | const top = (window.scrollY + y + height) + "px"
22 | const left = (window.scrollX + x) + "px"
23 | cursor.parentNode!.removeChild(cursor)
24 |
25 | return {top, left}
26 | }
27 | return undefined
28 | }
29 |
30 | /**
31 | * Returns selected text.
32 | */
33 | export function selectedText() {
34 | const s = window.getSelection()!
35 | if (s.rangeCount === 0) return ''
36 | return s.getRangeAt(0).toString()
37 | }
38 |
39 | /**
40 | * Returns text before the cursor.
41 | * @param editor Editor DOM node.
42 | */
43 | export function textBeforeCursor(editor: Node) {
44 | const s = window.getSelection()!
45 | if (s.rangeCount === 0) return ''
46 |
47 | const r0 = s.getRangeAt(0)
48 | const r = document.createRange()
49 | r.selectNodeContents(editor)
50 | r.setEnd(r0.startContainer, r0.startOffset)
51 | return r.toString()
52 | }
53 |
54 | /**
55 | * Returns text after the cursor.
56 | * @param editor Editor DOM node.
57 | */
58 | export function textAfterCursor(editor: Node) {
59 | const s = window.getSelection()!
60 | if (s.rangeCount === 0) return ''
61 |
62 | const r0 = s.getRangeAt(0)
63 | const r = document.createRange()
64 | r.selectNodeContents(editor)
65 | r.setStart(r0.endContainer, r0.endOffset)
66 | return r.toString()
67 | }
68 |
--------------------------------------------------------------------------------
/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CodeJar 🍯
6 |
7 |
8 |
40 |
41 |
42 |
43 |
44 |
45 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "codejar",
3 | "description": "An embeddable code editor for the browser",
4 | "version": "4.2.0",
5 | "type": "module",
6 | "main": "./dist/codejar.js",
7 | "types": "./dist/codejar.d.ts",
8 | "exports": {
9 | ".": "./dist/codejar.js",
10 | "./cursor": "./dist/cursor.js"
11 | },
12 | "typesVersions": {
13 | "*": {
14 | ".": [
15 | "./dist/codejar.d.ts"
16 | ],
17 | "cursor": [
18 | "./dist/cursor.d.ts"
19 | ]
20 | }
21 | },
22 | "files": [
23 | "dist"
24 | ],
25 | "scripts": {
26 | "start": "tsc -w",
27 | "build": "tsc",
28 | "size": "minify ./dist/codejar.js --sourceType module | gzip-size",
29 | "release": "release-it"
30 | },
31 | "devDependencies": {
32 | "babel-minify": "^0.5.2",
33 | "gzip-size-cli": "^5.1.0",
34 | "release-it": "^16.1.3",
35 | "typescript": "^5.1.6"
36 | },
37 | "release-it": {
38 | "github": {
39 | "release": true
40 | },
41 | "hooks": {
42 | "after:bump": "npm run build"
43 | }
44 | },
45 | "license": "MIT",
46 | "repository": "antonmedv/codejar",
47 | "author": "Anton Medvedev ",
48 | "homepage": "https://medv.io/codejar/"
49 | }
50 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2021",
4 | "lib": [
5 | "ES2021",
6 | "DOM"
7 | ],
8 | "module": "NodeNext",
9 | "moduleResolution": "NodeNext",
10 | "strict": true,
11 | "declaration": true,
12 | "noUnusedLocals": true,
13 | "outDir": "./dist",
14 | },
15 | "include": [
16 | "**/*.ts"
17 | ],
18 | }
19 |
--------------------------------------------------------------------------------