├── .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 | [![npm](https://img.shields.io/npm/v/codejar?color=brightgreen)](https://www.npmjs.com/package/codejar) 6 | [![npm bundle size](https://img.shields.io/bundlephobia/minzip/codejar?label=size)](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 | --------------------------------------------------------------------------------