Skip to content

Commit

Permalink
first commit
Browse files Browse the repository at this point in the history
  • Loading branch information
antonmedv committed Mar 31, 2020
0 parents commit 9592bc0
Show file tree
Hide file tree
Showing 5 changed files with 387 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/node_modules/
package-lock.json
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p align="center"><a href="https://medv.io/codejar/"><img src="https://medv.io/assets/codejar.svg" width="72"></a></p>
<h1 align="center">CodeJar</h1>
<p align="center"><a href="https://medv.io/codejar/"><img src="https://medv.io/assets/codejar/screenshot.png" width="709"></a></p>
350 changes: 350 additions & 0 deletions codejar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,350 @@
type Options = {
tab: string
}

export class CodeJar {
private readonly editor: HTMLElement
private readonly highlight: (e: HTMLElement) => {}
private options: Options
private history: HistoryRecord[] = []
private historyPointer = -1
private focus = false
private callback?: (code: string) => void

constructor(editor: HTMLElement, highlight: (e: HTMLElement) => {}, options: Partial<Options> = {}) {
this.editor = editor
this.highlight = highlight
this.options = {
tab: '\t',
...options
}

this.editor.setAttribute('contentEditable', 'true')
this.editor.setAttribute('spellcheck', 'false')
this.editor.style.outline = 'none'
this.editor.style.overflowWrap = 'break-word'
this.editor.style.overflowY = 'auto'
this.editor.style.resize = 'vertical'
this.editor.style.whiteSpace = 'pre-wrap'

this.highlight(this.editor)
const debounceHighlight = debounce(() => {
const pos = this.save()
this.highlight(this.editor)
this.restore(pos)
}, 30)

this.editor.addEventListener('keydown', event => {
if (event.key === 'Enter') {
this.handleNewLine(event)
} else if (event.key === 'Tab') {
this.handleTabCharacters(event)
} else if (event.key === 'ArrowLeft' && event.metaKey) {
this.handleJumpToBeginningOfLine(event)
} else {
this.handleSelfClosingCharacters(event)
this.handleUndoRedo(event)
}
})

this.editor.addEventListener('keyup', event => {
debounceHighlight()
this.recordHistory()
})

this.editor.addEventListener('focus', event => {
this.focus = true
this.recordHistory()
})

this.editor.addEventListener('blur', event => {
this.focus = false
})

this.editor.addEventListener('paste', event => {
this.handlePaste(event)
})

this.editor.addEventListener('input', event => {
if (this.callback) {
this.callback(this.toString())
}
})
}

private save(): Position {
const s = window.getSelection()!
const r = s.getRangeAt(0)

const queue: ChildNode[] = []
if (this.editor.firstChild) queue.push(this.editor.firstChild)

const pos: Position = {start: 0, end: 0}

let startFound = false
let el = queue.shift()
while (el) {
if (el === r.startContainer) {
pos.start += r.startOffset
startFound = true
}
if (el === r.endContainer) {
pos.end += r.endOffset
break
}
if (el.nodeType === Node.TEXT_NODE) {
let len = el.nodeValue!.length
if (!startFound) pos.start += len
pos.end += len
}
if (el.nextSibling) queue.push(el.nextSibling)
if (el.firstChild) queue.push(el.firstChild)
el = queue.pop()
}

return pos
}

private restore(pos: Position) {
const s = window.getSelection()!
s.removeAllRanges()

const r = document.createRange()
r.setStart(this.editor, 0)
r.setEnd(this.editor, 0)

const queue: ChildNode[] = []
if (this.editor.firstChild) queue.push(this.editor.firstChild)

let n = 0, startFound = false
let el = queue.shift()
while (el) {
if (el.nodeType === Node.TEXT_NODE) {
let len = (el.nodeValue || '').length
n += len
if (!startFound && n >= pos.start) {
const offset = len - (n - pos.start)
r.setStart(el, offset)
startFound = true
}
if (n >= pos.end) {
const offset = len - (n - pos.end)
r.setEnd(el, offset)
break
}
}
if (el.nextSibling) queue.push(el.nextSibling)
if (el.firstChild) queue.push(el.firstChild)
el = queue.pop()
}
s.addRange(r)
}

private beforeCursor() {
const s = window.getSelection()!
const r0 = s.getRangeAt(0)
const r = document.createRange()
r.selectNodeContents(this.editor)
r.setEnd(r0.startContainer, r0.startOffset)
return r.toString()
}

private afterCursor() {
const s = window.getSelection()!
const r0 = s.getRangeAt(0)
const r = document.createRange()
r.selectNodeContents(this.editor)
r.setStart(r0.endContainer, r0.endOffset)
return r.toString()
}

private handleNewLine(event: KeyboardEvent) {
event.preventDefault()
const before = this.beforeCursor()
const after = this.afterCursor()
let [padding] = findPadding(before)
let doublePadding = padding
if (before[before.length - 1] === '{') doublePadding += this.options.tab
let text = '\n' + doublePadding
// Add extra newline, otherwise Enter will not work at the end.
if (after.length === 0) text += '\n'
document.execCommand('insertHTML', false, text)
if (after[0] === '}') {
const pos = this.save()
document.execCommand('insertHTML', false, '\n' + padding)
this.restore(pos)
}
}

private handleSelfClosingCharacters(event: KeyboardEvent) {
const open = `([{'"`
const close = `)]}'"`
const codeAfter = this.afterCursor()
const pos = this.save()
if (close.includes(event.key) && codeAfter.substr(0, 1) === event.key) {
event.preventDefault()
pos.start = ++pos.end
this.restore(pos)
} else if (open.includes(event.key)) {
event.preventDefault()
const text = event.key + close[open.indexOf(event.key)]
document.execCommand('insertText', false, text)
pos.start = ++pos.end
this.restore(pos)
}
}

private handleTabCharacters(event: KeyboardEvent) {
event.preventDefault()
if (event.shiftKey) {
const before = this.beforeCursor()
let [padding, start,] = findPadding(before)
if (padding.startsWith(this.options.tab)) {
const pos = this.save()
const len = this.options.tab.length
this.restore({start, end: start + len})
document.execCommand('delete')
pos.start -= len
pos.end -= len
this.restore(pos)
}
} else {
document.execCommand('insertText', false, this.options.tab)
}
}

private handleJumpToBeginningOfLine(event: KeyboardEvent) {
event.preventDefault()
const before = this.beforeCursor()
let [padding, start, end] = findPadding(before)
if (before.endsWith(padding)) {
if (event.shiftKey) {
const pos = this.save()
this.restore({start, end: pos.end}) // Select from line start.
} else {
this.restore({start, end: start}) // Jump to line start.
}
} else {
if (event.shiftKey) {
const pos = this.save()
this.restore({start: end, end: pos.end}) // Select from beginning of text.
} else {
this.restore({start: end, end}) // Jump to beginning of text.
}
}
}

handleUndoRedo(event: KeyboardEvent) {
if (event.metaKey && !event.shiftKey && event.key === 'z') {
event.preventDefault()
if (this.historyPointer > 0) {
this.historyPointer--
const record = this.history[this.historyPointer]
if (record) {
this.editor.innerHTML = record.html
this.restore(record.pos)
}
}
}

if (event.metaKey && event.shiftKey && event.key === 'z') {
event.preventDefault()
if (this.historyPointer + 1 < this.history.length) {
this.historyPointer++
const record = this.history[this.historyPointer]
if (record) {
this.editor.innerHTML = record.html
this.restore(record.pos)
}
}
}
}

recordHistory = debounce(() => {
if (!this.focus) {
return
}

const record = this.history[this.historyPointer]
if (record && record.html === this.editor.innerHTML) {
return
}

this.historyPointer++
this.history[this.historyPointer] = {
html: this.editor.innerHTML,
pos: this.save(),
}

if (this.history.length - 1 > this.historyPointer) {
this.history.splice(this.historyPointer)
}

const maxHistory = 300
if (this.historyPointer > maxHistory) {
this.historyPointer = maxHistory
this.history.splice(0, 1)
}
}, 300)

private handlePaste(event: ClipboardEvent) {
event.preventDefault()
const text = ((event as any).originalEvent || event).clipboardData.getData('text/plain')
const pos = this.save()
document.execCommand('insertText', false, text)
let html = this.editor.innerHTML
html = html
.replace(/<div>/g, '\n')
.replace(/<br>/g, '')
.replace(/<\/div>/g, '')
this.editor.innerHTML = html
this.highlight(this.editor)
this.restore({start: pos.end + text.length, end: pos.end + text.length})
}

updateOptions(options: Partial<Options>) {
this.options = {...this.options, ...options}
}

updateCode(code: string) {
this.editor.textContent = code
this.highlight(this.editor)
}

onUpdate(callback: (code: string) => void) {
this.callback = callback
}

toString() {
return this.editor.textContent || ''
}
}

type HistoryRecord = {
html: string
pos: Position
}

type Position = {
start: number
end: number
}

function debounce<T extends Function>(cb: T, wait: number) {
let timeout = 0
return (...args: any) => {
clearTimeout(timeout)
timeout = setTimeout(() => cb(...args), wait)
}
}

function findPadding(text: string): [string, number, number] {
// Find beginning of previous line.
let i = text.length - 1
while (i >= 0 && text[i] !== '\n') i--
i++
// Find padding of the line.
let j = i
while (j < text.length && /[ \t]/.test(text[j])) j++
return [text.substring(i, j) || '', i, j]
}
20 changes: 20 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "codejar",
"version": "1.0.0",
"description": "Micro code editor",
"license": "MIT",
"repository": "antonmedv/codejar",
"author": "Anton Medvedev <[email protected]>",
"homepage": "https://medv.io/codejar/",
"main": "codejar.js",
"scripts": {
"start": "tsc -w",
"prepublishOnly": "tsc",
"size": "minify codejar.js --sourceType module | gzip-size"
},
"devDependencies": {
"babel-minify": "^0.5.1",
"gzip-size-cli": "^3.0.0",
"typescript": "^3.8.3"
}
}
12 changes: 12 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"lib": [
"es2020",
"dom"
],
"strict": true,
"noUnusedLocals": true
}
}

0 comments on commit 9592bc0

Please sign in to comment.