LeisureEditCore (example editor)
Copyright (C) 2015, Bill Burdick, Roy Riggs, TEAM CTHULHU
Licensed with ZLIB license (see "License", below).
Welcome to LeisureEditCore! Are you trying to make editable documents that are more than just text editors or word processors? This library tries to make it easier to make interesting editable documents. You can find it on Github. LeisureEditCore what Leisure's editor, extracted out into a small HTML5 library. LeisureEditCore is pluggable with an options object that contains customization hooks. Code and examples are in Coffeescript (a JS build is provided as a convenience).
LeisureEditCore edits a doubly-linked list of newline-terminated text "blocks" that can render as DOM nodes (and maybe in interesting ways!)
The rendered DOM tree contains the full text of the block list in the proper order, along with ids from the blocks. Some of the text may not be visible and there may be a lot of items in the rendered DOM that are not in the blocks. Also, the rendered DOM may have a nested tree-structure.
When the user makes a change, the editor:
- maps the cursor location in the DOM to the corresponding location in the blocks
- changes block text, regenerating part of the blocks
- rerenders the DOM corresponding to the changed blocks
- replaces the new DOM into the page
Of course the editor supports custom key bindings.
Make sure your webpage loads the javascript files in the build
directory. Follow
the instructions below to use it.
Here is an example that edits org-mode text.
_id
: the block idtext
: the text of the blockprev
: the id of the previous block (optional)next
: the id of the next block (optional)- EXTRA STUFF: you can store whatever extra things you like in your text blocks
{block: aBlock, offset: aNumber} aBlock can be an id or a block
An instance of LeisureEditCore. You must provide an HTML node to contain the document contents and an options object to configure the editor.
DataStoreEditingOptions is the recommended options object but you can also subclass BasicEditingOptions.
Manages the document. It's responsible for parsing text into blocks, accessing the blocks, making changes, and converting between block locations and document locations.
To use this in the recommended way...
- The code uses AMD style and depends on 'lodash', 'fingertree', and 'immutable' which you will probably need to map. This is so that if you are using any of these packages, you won't have to include them more than once.
- Subclass DataStoreEditingOptions and provide a renderBlock(block) method
- Subclass DataStore and provide a parseBlocks(text) method
- Create an editor object with your options object on your data object
- Call the load(name, text) method on your options object
- DOMCursor -- locating text in DOM trees
- lodash -- collection, FP, and async utilities
- fingertree -- the swiss army knife of data structures
- immutable -- immutable data structures
If you modify LeisureEditCore and want to build it, you can use the Cakefile. It needs the
which
npm package (npm install which
).
Licensed with ZLIB license.
This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.
Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions:
-
The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required.
-
Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
-
This notice may not be removed or altered from any source distribution.
Create a LeisureEditCore object like this: new LeisureEditCore editorElement, options
.
editorElement
is the HTML element that you want to contain editable text.
options
is an object that tells LeisureEditCore things like how to
convert text to a list of block objects (see below). See
BasicEditingOptions and DataStoreEditingOptions for more info.
import {Set} from 'immutable'
import * as _ from 'lodash'
import {DOMCursor} from './domCursor.js'
import {FingerTree} from './fingertree.js'
import {Observable, BasicEditingOptionsNew, spaces, sameCharacter, computeNewStructure,
copyBlock, DataStore, FeatherJQ, $, is$} from './editor-ts.js'
export {copyBlock, DataStore, FeatherJQ, $, is$, set$} from './editor-ts.js'
{selectRange} = DOMCursor
imbeddedBoundary = /.\b./
maxLastKeys = 4
BS = 8
ENTER = 13
DEL = 46
TAB = 9
LEFT = 37
UP = 38
RIGHT = 39
DOWN = 40
HOME = 36
END = 35
PAGEUP = 33
PAGEDOWN = 34
specialKeys = {}
specialKeys[TAB] = 'TAB'
specialKeys[ENTER] = 'ENTER'
specialKeys[BS] = 'BS'
specialKeys[DEL] = 'DEL'
specialKeys[LEFT] = 'LEFT'
specialKeys[RIGHT] = 'RIGHT'
specialKeys[UP] = 'UP'
specialKeys[DOWN] = 'DOWN'
specialKeys[PAGEUP] = 'PAGEUP'
specialKeys[PAGEDOWN] = 'PAGEDOWN'
specialKeys[HOME] = 'HOME'
specialKeys[END] = 'END'
Basic functions used by defaultBindings
export useEvent = (e)->
e.preventDefault()
e.stopPropagation()
keyFuncs =
backwardChar: (editor, e, r)->
useEvent e
editor.moveSelectionBackward r
false
forwardChar: (editor, e, r)->
useEvent e
editor.moveSelectionForward r
false
previousLine: (editor, e, r)->
useEvent e
editor.moveSelectionUp r
false
nextLine: (editor, e, r)->
useEvent e
editor.moveSelectionDown r
false
stabilizeCursor: (editor, e, r)->
setTimeout (-> editor.domCursorForCaret().moveCaret()), 1
false
These are the default bindings. You can set the editor's bindings property to this or your own object (which can inherit from this, of course.)
export defaultBindings =
#'C-S': keyFuncs.save
'C-Z': -> alert 'UNDO not supported yet'
'C-S-Z': -> alert 'REDO not supported yet'
'C-Y': -> alert 'REDO not supported yet'
'UP': keyFuncs.previousLine
'DOWN': keyFuncs.nextLine
'LEFT': keyFuncs.backwardChar
'RIGHT': keyFuncs.forwardChar
'HOME': keyFuncs.stabilizeCursor
'END': keyFuncs.stabilizeCursor
'C-HOME': keyFuncs.stabilizeCursor
'C-END': keyFuncs.stabilizeCursor
#'TAB': keyFuncs.expandTemplate
#'C-C C-C': keyFuncs.swapMarkup
#'M-C': keyFuncs.execute
#'C-F': keyFuncs.forwardChar
#'C-B': keyFuncs.backwardChar
#'C-P': keyFuncs.previousLine
#'C-N': keyFuncs.nextLine
#'C-X C-F': keyFuncs.save
dragRange = null
idCounter
: id number for next created block
idCounter = 0
#FeatherJQ class
#===============
#A featherweight JQuery replacement. Users can use set$ to make it use
#the real jQuery, like this: set$($, (obj)-> obj instanceof $)
detail = (e)-> originalEvent(e).detail
originalEvent = (e)-> (e.originalEvent) || e
Events:
moved
: the cursor moved
export class LeisureEditCore extends Observable
constructor: (@node, @options)->
super()
@editing = false
@node
.attr 'contenteditable', 'true'
.attr 'spellcheck', 'false'
.data 'editor', this
@curKeyBinding = @prevKeybinding = null
@bind()
@lastKeys = []
@modCancelled = false
@clipboardKey = null
@ignoreModCheck = 0
@movementGoal = null
@options.setEditor this
@currentSelectedBlock = null
editWith: (func)->
@editing = true
try
func()
finally
@editing = false
savePosition: (func)->
if @editing then func()
else
pos = @getSelectedDocRange()
try
func()
catch
@selectDocRange pos
getCopy: (id)-> copyBlock @options.getBlock id
getText: -> @options.getText()
blockForCaret: -> @blockForNode @domCursorForCaret().node
blockForNode: (node)-> @options.getBlock @options.idForNode node
blockNodeForNode: (node)-> @options.nodeForId @options.idForNode node
blockTextForNode: (node)->
parent = @blockNodeForNode(node)[0]
if next = @options.getBlock(@options.idForNode node)?.next
nextPos = @domCursorForText @options.nodeForId(next), 0
@domCursorForText(parent, 0, parent).getTextTo nextPos
else @domCursorForText(parent, 0, parent).getText()
verifyNode: (node)->
if typeof node == 'string' then node = @options.nodeForId node
@blockTextForNode(node) == @options.getBlock(@options.idForNode node).text
verifyAllNodes: ->
badIds = []
block = @options.getBlock @options.getFirst()
while block
if (node = @options.nodeForId(block._id)[0]) && !@verifyNode node
badIds.push block._id
block = @options.getBlock block.next
if badIds.length then badIds
domCursor: (node, pos)->
if is$ node
node = node[0]
pos = pos ? 0
else if node instanceof DOMCursor
pos = node.pos
node = node.node
@options.domCursor(node, pos)
makeElementVisible: (node)->
r = DOMCursor.getBoundingRect(node)
view = @node[0].getBoundingClientRect()
if r.top < view.top
@node[0].scrollTop -= view.top - r.top
else if r.bottom > view.bottom
@node[0].scrollTop += r.bottom - view.bottom
domCursorForText: (node, pos, parent)->
c = @domCursor $(node)[0], pos
.filterTextNodes()
.firstText()
if parent? then c.filterParent $(parent)[0] else c
domCursorForTextPosition: (parent, pos, contain)->
@domCursorForText parent, 0, (if contain then parent)
.mutable()
.forwardChars pos, contain
.adjustForNewline()
domCursorForCaret: ->
sel = getSelection()
if sel.type == 'None' then DOMCursor.emptyDOMCursor
else
r = sel.getRangeAt 0
n = @domCursor r.startContainer, r.startOffset
.mutable()
.filterVisibleTextNodes()
.filterParent @node[0]
.firstText()
if n.isEmpty() || n.pos <= n.node.length then n else n.next()
getTextPosition: (parent, target, pos)->
if parent
targ = @domCursorForText target, pos
if !@options.getContainer(targ.node) then targ = targ.prev()
@domCursorForText parent, 0, parent
.mutable()
.countChars targ.node, targ.pos
else -1
loadURL: (url)-> $.get url, (text)=> @options.load url, text
domCursorForDocOffset: (dOff)->
bOff = @options.blockOffsetForDocOffset dOff
node = @options.nodeForId bOff.block
@domCursorForText(node, 0, @node[0]).mutable().forwardChars bOff.offset
docOffsetForCaret: ->
s = getSelection()
if s.type == 'None' then -1
else
range = s.getRangeAt 0
@docOffset range.startContainer, range.startOffset
docOffsetForBlockOffset: (block, offset)->
@options.docOffsetForBlockOffset block, offset
docOffset: (node, offset)->
if node instanceof Range
offset = node.startOffset
node = node.startContainer
else if node instanceof DOMCursor
offset = node.pos
node = node.node
if startHolder = @options.getContainer(node)
@options.docOffsetForBlockOffset @options.idForNode(startHolder), @getTextPosition startHolder, node, offset
getSelectedDocRange: ->
s = getSelection()
if s.type == 'None' then type: 'None'
else
range = s.getRangeAt 0
if start = @docOffset range.startContainer, range.startOffset
if s.type == 'Caret' then length = 0
else
end = @docOffset range.endContainer, range.endOffset
length = Math.abs start - end
start = Math.min start, end
type: s.type
start: start
length: length
scrollTop: @node[0].scrollTop
scrollLeft: @node[0].scrollLeft
else type: 'None'
selectDocRange: (range)->
if range.type != 'None' && !(start = @domCursorForDocOffset(range.start).save()).isEmpty()
selectRange start.range(start.mutable().forwardChars range.length)
@node[0].scrollTop = range.scrollTop
@node[0].scrollLeft = range.scrollLeft
getSelectedBlockRange: ->
s = getSelection()
if s.type != 'None' && p = @blockOffset s.getRangeAt(0)
p.type = s.type
p.length = @selectedText(s).length
p
else type: 'None'
blockOffset: (node, offset)->
if node instanceof Range
offset = node.startOffset
node = node.startContainer
else if node instanceof DOMCursor
offset = node.pos
node = node.node
if startHolder = @options.getContainer(node)
block: @options.getBlock @options.idForNode startHolder
offset: @getTextPosition startHolder, node, offset
blockRangeForOffsets: (start, length)->
{block, offset} = @options.getBlockOffsetForPosition start
{block, offset, length, type: if length == 0 then 'Caret' else 'Range'}
replace: (e, br, text, select)-> if br.type != 'None'
@editWith =>
start = @options.docOffsetForBlockOffset(br)
pos = @getSelectedDocRange()
text = text ? getEventChar e
@options.replaceText {start, end: start + br.length, text, source: 'edit'}
if select
pos.type = if text.length == 0 then 'Caret' else 'Range'
pos.length = text.length
else
pos.type = 'Caret'
pos.length = 0
pos.start += text.length
@selectDocRange pos
backspace: (event, sel, r)->
if sel.type == 'Range' then return @cutText event
holderId = @idAtCaret sel
@currentBlockIds = [holderId]
@handleDelete event, sel, false
del: (event, sel, r)->
if sel.type == 'Range' then return @cutText event
holderId = @idAtCaret sel
@currentBlockIds = [holderId]
@handleDelete event, sel, true
idAtCaret: (sel)-> @options.idForNode @options.getContainer(sel.anchorNode)
selectedText: (s)->
r = s.getRangeAt(0)
if r.collapsed then ''
else @domCursor(r.startContainer, r.startOffset).getTextTo @domCursor(r.endContainer, r.endOffset)
cutText: (e)->
useEvent e
sel = getSelection()
if sel.type == 'Range'
html = _.map(sel.getRangeAt(0).cloneContents().childNodes, htmlForNode).join ''
text = @selectedText sel
@options.simulateCut html: html, text: text
r = @getSelectedDocRange()
@replace e, @getSelectedBlockRange(), ''
@selectDocRange
type: 'Caret'
start: r.start
length: 0
scrollTop: r.scrollTop
scrollLeft: r.scrollLeft
handleDelete: (e, s, forward)->
useEvent e
r = @getSelectedDocRange()
if r.type == 'None' || (r.type == 'Caret' && ((forward && r.start >= @options.getLength() - 1) || (!forward && r.start == 0)))
return
if r.type == 'Caret'
r.length = 1
if !forward then r.start -= 1
@options.replaceText
start: r.start
end: r.start + r.length
text: ''
source: 'edit'
@selectDocRange
type: 'Caret'
start: r.start
length: 0
scrollTop: r.scrollTop
scrollLeft: r.scrollLeft
bind: ->
@bindDragAndDrop()
@bindClipboard()
@bindMouse()
@bindKeyboard()
bindDragAndDrop: ->
@node.on 'dragover', (e)=>
@options.dragOver originalEvent e
true
@node.on 'dragenter', (e)=>
@options.dragEnter originalEvent e
true
@node.on 'drop', (e)=>
useEvent e
oe = originalEvent e
oe.dataTransfer.dropEffect = 'move'
r = DOMCursor.caretPos oe.clientX, oe.clientY
dropPos = @domCursor(r.node, r.offset).moveCaret()
dropContainer = @domCursor @options.getContainer(r.node), 0
blockId = @options.idForNode dropContainer.node
offset = dropContainer.countChars dropPos
insertText = oe.dataTransfer.getData('text/plain')
insert = => @replace e, {type: 'Caret', offset, block: @options.getBlock(blockId), length: 0}, insertText, false
if dragRange
start = @domCursor(@options.nodeForId(dragRange.block._id), 0).forwardChars dragRange.offset
r2 = start.range start.forwardChars dragRange.length
insertOffset = @options.getPositionForBlock(@options.getBlock blockId) + offset
cutOffset = @options.getPositionForBlock(dragRange.block) + dragRange.offset
if cutOffset <= insertOffset <= cutOffset + dragRange.length
useEvent oe
oe.dataTransfer.dropEffect = 'none'
return
dr = dragRange
dragRange = null
if insertOffset <= cutOffset
@replace e, dr, '', false
@replace e, @blockRangeForOffsets(insertOffset, 0), insertText, false
else
insert()
@replace e, @blockRangeForOffsets(cutOffset, dr.length), '', false
else insert()
true
@node.on 'dragstart', (e)=>
sel = getSelection()
if sel.type == 'Range'
dragRange = @getSelectedBlockRange()
clipboard = originalEvent(e).dataTransfer
clipboard.setData 'text/html', _.map(sel.getRangeAt(0).cloneContents().childNodes, htmlForNode).join ''
clipboard.setData 'text/plain', @selectedText sel
clipboard.effectAllowed = 'copyMove'
clipboard.dropEffect = 'move'
true
@node[0].addEventListener 'dragend', (e)=>
if dr = dragRange
dragRange = null
if e.dataTransfer.dropEffect == 'move'
useEvent e
sel = @getSelectedDocRange()
@replace e, dr, ''
@selectDocRange sel
bindClipboard: ->
@node.on 'cut', (e)=>
useEvent e
sel = getSelection()
if sel.type == 'Range'
clipboard = originalEvent(e).clipboardData
clipboard.setData 'text/html', _.map(sel.getRangeAt(0).cloneContents().childNodes, htmlForNode).join ''
clipboard.setData 'text/plain', @selectedText sel
@replace e, @getSelectedBlockRange(), ''
@node.on 'copy', (e)=>
useEvent e
sel = getSelection()
if sel.type == 'Range'
clipboard = originalEvent(e).clipboardData
clipboard.setData 'text/html', _.map(sel.getRangeAt(0).cloneContents().childNodes, htmlForNode).join ''
clipboard.setData 'text/plain', @selectedText sel
@node.on 'paste', (e)=>
useEvent e
@replace e, @getSelectedBlockRange(), originalEvent(e).clipboardData.getData('text/plain'), false
bindMouse: ->
@node.on 'mousedown', (e)=>
if @lastDragRange && detail(e) == 2
@dragRange = @lastDragRange
console.log "double click"
start = @domCursor(@dragRange).mutable()
end = start.copy()
txt = start.char()
while true
start.backwardChar()
if !start.isEmpty() && start.type == 'text' then txt = start.char() + txt
if start.isEmpty() || start.type != 'text' || txt.match imbeddedBoundary
#start.forwardChar()
break
txt = end.char()
while true
end.forwardChar()
if !end.isEmpty() && end.type == 'text' then txt += end.char()
if end.isEmpty() || end.type != 'text' || txt.match imbeddedBoundary
end.backwardChar()
break
s = getSelection()
s.removeAllRanges()
@dragRange.setStart start.node, start.pos
@dragRange.setEnd end.node, end.pos
s.addRange @dragRange
e.preventDefault()
else if @dragRange = @getAdjustedCaretRange e
@domCursor(@dragRange).moveCaret()
e.preventDefault()
setTimeout (=>@trigger 'moved', this), 1
@setCurKeyBinding null
@node.on 'mouseup', (e)=>
@lastDragRange = @dragRange
@dragRange = null
@adjustSelection e
@trigger 'moved', this
@node.on 'mousemove', (e)=>
if @dragRange
s = getSelection()
s.removeAllRanges()
s.addRange @dragRange
r2 = @getAdjustedCaretRange e, true
s.extend r2.startContainer, r2.startOffset
e.preventDefault()
getAdjustedCaretRange: (e, returnUnchanged) ->
{node, offset} = DOMCursor.caretPos e.clientX, e.clientY
r2 = @domCursor(node, offset).backwardChar().range()
rect1 = DOMCursor.getBoundingRect(node)
rect2 = r2.getBoundingClientRect()
if rect1.top == rect2.top && rect1.bottom == rect2.bottom && rect2.left < rect1.left && e.clientX <= (rect1.left + rect2.left) / 2
r2
else if returnUnchanged then r
bindKeyboard: ->
@node.on 'keyup', (e)=> @handleKeyup e
@node.on 'keydown', (e)=>
@modCancelled = false
c = eventChar e
if !@addKeyPress e, c then return
s = getSelection()
r = s.rangeCount > 0 && s.getRangeAt(0)
@currentBlockIds = @blockIdsForSelection s, r
[bound, checkMod] = @findKeyBinding e, r
if bound then @modCancelled = !checkMod
else
@modCancelled = false
if c == ENTER then @enter e
else if c == BS
useEvent e
@backspace e, s, r
else if c == DEL
useEvent e
@del e, s, r
else if (modifyingKey c, e) && !isAlphabetic e
@char = getEventChar e
@keyPress e
@node.on 'keypress', (e)=>
if !e.altKey && !e.metaKey && !e.ctrlKey then @keyPress e
enter: (e)->
useEvent e
@replace e, @getSelectedBlockRange(), '\n', false
keyPress: (e)->
useEvent e
@replace e, @getSelectedBlockRange(), null, false
blockIdsForSelection: (sel, r)->
if !sel then sel = getSelection()
if sel.rangeCount == 1
if !r then r = sel.getRangeAt 0
blocks = if cont = @options.getContainer(r.startContainer)
[@options.idForNode cont]
else []
if !r?.collapsed
cur = blocks[0]
end = @options.idForNode @options.getContainer(r.endContainer)
while cur && cur != end
if cur = (@getCopy cur).next
blocks.push cur
blocks
setCurKeyBinding: (f)->
@prevKeybinding = @curKeyBinding
@curKeyBinding = f
addKeyPress: (e, c)->
if notShift = !shiftKey c
e.DE_editorShiftkey = true
@lastKeys.push modifiers(e, c)
while @lastKeys.length > maxLastKeys
@lastKeys.shift()
@keyCombos = new Array maxLastKeys
for i in [0...Math.min(@lastKeys.length, maxLastKeys)]
@keyCombos[i] = @lastKeys[@lastKeys.length - i - 1 ... @lastKeys.length].join ' '
@keyCombos.reverse()
notShift
findKeyBinding: (e, r)->
for k in @keyCombos
if f = @options.bindings[k]
@lastKeys = []
@keyCombos = []
@setCurKeyBinding f
return [true, f this, e, r]
@setCurKeyBinding null
[false]
handleKeyup: (e)->
if @ignoreModCheck = @ignoreModCheck then @ignoreModCheck--
if @clipboardKey || (!e.DE_shiftkey && !@modCancelled && modifyingKey(eventChar(e), e))
@options.keyUp()
@clipboardKey = null
adjustSelection: (e)->
if detail(e) == 1 then return
s = getSelection()
if s.type == 'Range'
r = s.getRangeAt 0
pos = @domCursor r.endContainer, r.endOffset
.mutable()
.filterVisibleTextNodes()
.firstText()
while !pos.isEmpty() && pos.node != r.startContainer && pos.node.data.trim() == ''
pos = pos.prev()
while !pos.isEmpty() && pos.pos > 0 && pos.node.data[pos.pos - 1] == ' '
pos.pos--
if (pos.node != r.startContainer || pos.pos > r.startOffset) && (pos.node != r.endContainer || pos.pos < r.endOffset)
r.setEnd pos.node, pos.pos
selectRange r
moveSelectionForward: -> @showCaret @moveForward()
moveSelectionDown: -> @showCaret @moveDown()
moveSelectionBackward: -> @showCaret @moveBackward()
moveSelectionUp: -> @showCaret @moveUp()
showCaret: (pos)->
if pos.isEmpty() then pos = pos.prev()
pos = @domCursorForCaret()
pos.moveCaret()
#(if pos.node.nodeType == pos.node.TEXT_NODE then pos.node.parentNode else pos.node).scrollIntoView()
@makeElementVisible pos.node
@trigger 'moved', this
moveForward: ->
sel = getSelection()
offset = if sel.type == 'None' then 0
else
r = sel.getRangeAt(0)
offset = if r.endContainer == r.startContainer
@docOffset r.endContainer, Math.max r.startOffset, r.endOffset
else @docOffset r.endContainer, r.endOffset
start = pos = @domCursorForCaret().firstText().save()
if !pos.isEmpty() && @options.isValidDocOffset(offset) && (@domCursorForCaret().firstText().equals(start) || pos.isCollapsed())
pos = @domCursorForDocOffset offset
while !pos.isEmpty() && (@domCursorForCaret().firstText().equals(start) || pos.isCollapsed())
if pos.isCollapsed()
pos.next().moveCaret()
else pos.forwardChars(1).moveCaret()
if pos.isEmpty()
offset = @options.getLength() - 1
pos = @domCursorForDocOffset(offset).firstText()
while !pos.isEmpty() && pos.isCollapsed()
pos = @domCursorForDocOffset --offset
else if !@options.isValidDocOffset(offset)
pos = start
pos.moveCaret()
moveBackward: ->
sel = getSelection()
offset = if sel.type == 'None' then 0
else
r = sel.getRangeAt(0)
offset = if r.endContainer == r.startContainer
@docOffset r.endContainer, Math.min r.startOffset, r.endOffset
else @docOffset r.startContainer, r.startOffset
start = pos = @domCursorForCaret().firstText().save()
if !pos.isEmpty() && (@domCursorForCaret().firstText().equals(start) || pos.isCollapsed())
pos = @domCursorForDocOffset offset
while !pos.isEmpty() && (@domCursorForCaret().firstText().equals(start) || pos.isCollapsed())
if pos.isCollapsed()
pos.prev()
else pos.backwardChar().moveCaret()
if pos.isEmpty()
offset = 0
pos = @domCursorForDocOffset(offset).firstText()
while !pos.isEmpty() && pos.isCollapsed()
pos = @domCursorForDocOffset ++offset
pos.moveCaret()
firstText: -> @domCursor(@node, 0).firstText().node
moveDown: ->
linePos = prev = pos = @domCursorForCaret().save()
if !(@prevKeybinding in [keyFuncs.nextLine, keyFuncs.previousLine])
@movementGoal = @options.blockColumn pos
line = 0
else line = (if pos.pos == 0 && pos.node == @firstText() && @options.blockColumn(pos) < @movementGoal then 1 else 0)
lineTop = posFor(linePos).top
lastPos = @docOffset(pos) - 1
while !(pos = @moveForward()).isEmpty() && (docPos = @docOffset(pos)) != lastPos
lastPos = docPos
p = posFor(pos)
if lineTop < p.top
line++
pos = linePos = p.pos
lineTop = p.top
if line == 2 then return prev.moveCaret()
if line == 1 && @options.blockColumn(pos) >= @movementGoal
return @moveToBestPosition pos, prev, linePos
prev = pos
pos
moveUp: ->
linePos = prev = pos = @domCursorForCaret().save()
if !(@prevKeybinding in [keyFuncs.nextLine, keyFuncs.previousLine]) then @movementGoal = @options.blockColumn pos
line = 0
lastPos = @options.getLength()
while !(pos = @moveBackward()).isEmpty() && (docPos = @docOffset pos) != lastPos
lastPos = docPos
if linePos.differentLines pos
line++
linePos = pos
if line == 2 then return prev.moveCaret()
if line == 1 && @options.blockColumn(pos) <= @movementGoal
return @moveToBestPosition pos, prev, linePos
prev = pos
pos
moveToBestPosition(pos, prev, linePos)
tries to move the caret to the best position in the HTML text. If pos is closer to the goal, return it, otherwise move to prev and return prev.
moveToBestPosition: (pos, prev, linePos)->
if linePos == pos || Math.abs(@options.blockColumn(pos) - @movementGoal) < Math.abs(@options.blockColumn(prev) - @movementGoal)
pos
else prev.moveCaret()
Set html of an element and evaluate scripts so that document.currentScript is properly set
setHtml: (el, html, outer)->
if outer
prev = el.previousSibling
next = el.nextSibling
par = el.parentNode
el.outerHTML = html
el = prev?.nextSibling ? next?.previousSibling ? par?.firstChild
else el.innerHTML = html
@activateScripts $(el)
el
activateScripts: (jq)->
if !activating
activating = true
try
for script in jq.find('script')
text = if !script.type || script.type.toLowerCase() == 'text/javascript'
script.textContent
else if script.type.toLowerCase() == 'text/coffeescript'
CoffeeScript.compile script.textContent, bare: true
else if script.type.toLowerCase() == 'text/literate-coffeescript'
CoffeeScript.compile script.textContent, bare: true, literate: true
if text
newScript = document.createElement 'script'
newScript.type = 'text/javascript'
if script.src then newScript.src = script.src
newScript.textContent = text
@setCurrentScript newScript
script.parentNode.insertBefore newScript, script
script.parentNode.removeChild script
finally
@setCurrentScript null
activating = false
setCurrentScript: (script)->
LeisureEditCore.currentScript = null
eventChar = (e)-> e.charCode || e.keyCode || e.which
isAlphabetic = (e)-> !e.altKey && !e.ctrlKey && !e.metaKey && (64 < eventChar(e) < 91)
#BasicEditingOptions class #========================= #BasicEditingOptions is an the options base class.
#Events:
#Hook methods (required) #-----------------------
#renderBlock(block) -> [html, next]
: render a block (and potentially its children) and return the HTML and the next blockId if there is one
#Properties of BasicEditingOptions
#---------------------------------
#* blocks {id -> block}
: block table
#* first
: id of first block
#* bindings {keys -> binding(editor, event, selectionRange)}
: a map of bindings (can use LeisureEditCore.defaultBindings)
#Methods of BasicEditingOptions
#------------------------------
#* getBlock(id) -> block?
: get the current block for id
#* getContainer(node) -> Node?
: get block DOM node containing for a node
#* getFirst() -> blockId
: get the first block id
#* domCursor(node, pos) -> DOMCursor
: return a domCursor that skips over non-content
#* keyUp(editor) -> void
: handle keyup after-actions
#* topRect() -> rect?
: returns null or the rectangle of a toolbar at the page top
#* blockColumn(pos) -> colNum
: returns the start column on the page for the current block
#* load(el, text) -> void
: parse text into blocks and replace el's contents with rendered DOM
#Hook methods (optional) #-----------------------
#simulateCut({html, text})
: The editor calls this when the user hits backspace or delete on selected text.
#dragEnter(event)
: alter the drag-enter behavior. If you want to cancel the drag, for
#instance, call event.preventDefault() and set the dropEffect to 'none'
#dragOver(event)
: alter the drag-enter behavior. If you want to cancel the drag, for
#instance, call event.preventDefault() and set the dropEffect to 'none'
#Main code #---------
#blocks {id -> block}
: block table
#first
: id of first block
#getFirst() -> blockId
: get the first block id
#changeStructure(oldBlocks, newText)
: Compute blocks affected by transforming oldBlocks into newText
#getBlock(id) -> block?
: get the current block for id
#bindings {keys -> binding(editor, event, selectionRange)}
: a map of bindings (can use LeisureEditCore.defaultBindings)
#blockColumn(pos) -> colNum
: returns the start column on the page for the current block
#topRect() -> rect?
: returns null or the rectangle of a toolbar at the page top
#keyUp(editor) -> void
: handle keyup after-actions
#domCursor(node, pos) -> DOMCursor
: return a domCursor that skips over non-content
#getContainer(node) -> Node?
: get block DOM node containing for a node
#load(name, text) -> void
: parse text into blocks and trigger a 'load' event
#export BasicEditingOptions = BasicEditingOptionsOld
export BasicEditingOptions = BasicEditingOptionsNew
#spaces = String.fromCharCode( 32, 160)
#export sameCharacter = (c1, c2)-> c1 == c2 || ((c1 in spaces) && (c2 in spaces))
#export computeNewStructure = (access, oldBlocks, newText)->
# prev = oldBlocks[0]?.prev ? 0
# oldBlocks = oldBlocks.slice()
# oldText = null
# offset = 0
# if oldBlocks.length
# while oldText != newText && (oldBlocks[0].prev || last(oldBlocks).next)
# oldText = newText
# if prev = access.getBlock oldBlocks[0].prev
# oldBlocks.unshift prev
# newText = prev.text + newText
# offset += prev.text.length
# if next = access.getBlock last(oldBlocks).next
# oldBlocks.push next
# newText += next.text
# newBlocks = access.parseBlocks newText
# if (!prev || prev.text == newBlocks[0].text) && (!next || next.text == last(newBlocks).text)
# break
# if !newBlocks then newBlocks = access.parseBlocks newText
# while oldBlocks.length && newBlocks.length && oldBlocks[0].text == newBlocks[0].text
# offset -= oldBlocks[0].text.length
# prev = oldBlocks[0]._id
# oldBlocks.shift()
# newBlocks.shift()
# while oldBlocks.length && newBlocks.length && last(oldBlocks).text == last(newBlocks).text
# oldBlocks.pop()
# newBlocks.pop()
# oldBlocks: oldBlocks, newBlocks: newBlocks, offset: offset, prev: prev
#export copyBlock = (block)-> if !block then null else Object.assign {}, block
activating = false
#DataStore #========= #An efficient block storage mechanism used by DataStoreEditingOptions
#Hook methods -- you must define these in your subclass
#------------------------------------------------------
#* parseBlocks(text) -> blocks
: parse text into array of blocks -- DO NOT provide _id, prev, or next, they may be overwritten!
#Events #------ #Data objects support the Observable protocol and emit change events in response to data changes
#change {adds, updates, removes, oldFirst, old}
#Internal API -- provide/override these if you want to change how the store accesses data #----------------------------------------------------------------------------------------
#* getFirst()
#* setFirst(firstId)
#* getBlock(id)
#* setBlock(id, block)
#* deleteBlock(id)
#* eachBlock(func(block [, id]))
-- iterate with func (exit if func returns false)
#* load(first, blocks)
-- should trigger 'load'
#External API -- used from outside; alternative data objects must support these methods. #---------------------------------------------------------------------------------------
#In addition to the methods below, data objects must support the Observable protocol and emit #change events in response to data changes
#* getFirst() -> id
: id of the first block
#* getBlock(id) -> block
: the block for id
#* load(name, text)
: replace the current document
#* newId()
:
#* docOffsetForBlockOffset(args...) -> offset
: args can be a blockOffset or block, offset
#* blockOffsetForDocOffset(offset) -> blockOffset
: the block offset for a position in the document
#* suppressTriggers(func) -> func's return value
: suppress triggers while executing func (inherited from Observable)
#
#parseBlocks(text) -> blocks
: parse text into array of blocks -- DO NOT provide _id, prev, or next, they may be overwritten!
#getLength() -> number
: the length of the entire document
if !first && ((newBlocks.length && !newBlocks[0].prev) || !oldBlocks.length || !@getFirst() || removes[@getFirst()])
if (oldBlock = @getBlock id) && block.text == oldBlock.text && block.next == oldBlock.next && block.prev == oldBlock.prev
!first.isEmpty() && !rest.isEmpty() && rest.peekFirst()?.id == b && first.peekLast()?.id == a && split
#docOffsetForBlockOffset(args...) -> offset
: args can be a blockOffset or block, offset
#getText(): -> string
: the text for the entire document
{adds, updates, old} = result = {adds: {}, updates: {}, removes, old: {}, sets, oldFirst: @getFirst(), first: first, oldBlocks, newBlocks}
console.warn "INDEX ERROR:\nEXPECTED: #{JSON.stringify blockIds}\nBUT GOT: #{JSON.stringify treeIds}"
isEmpty: -> [email protected]
export class DataStoreEditingOptions extends BasicEditingOptions
constructor: (@data)->
super()
@callbacks = {}
@addDataCallbacks
change: (changes)=> @dataChanged changes
load: => @dataLoaded()
addDataCallbacks: (cb)->
for type, func of cb
@data.on type, @callbacks[type] = func
dataChanged: (changes)-> preserveSelection => @changed changes
dataLoaded: -> @trigger 'load'
cleanup: -> @data.off @callbacks
initData: ->
load: (name, text)-> @data.load name, text
replaceText: (repl)-> @data.replaceText repl
getBlock: (id)-> @data.getBlock id
getFirst: (first)-> @data.getFirst()
change: (changes)-> if changes then @data.change changes
changed: (changes)-> @rerenderAll()
offsetForBlock: (blockOrId)-> @data.offsetForBlock blockOrId
export isEditable = (n)->
n = if n.nodeType == n.TEXT_NODE then n.parentNode else n
n.isContentEditable
export blockText = (blocks)-> (block.text for block in blocks).join ''
adapted from Vega on StackOverflow
_to_ascii =
'188': '44'
'109': '45'
'190': '46'
'191': '47'
'192': '96'
'220': '92'
'222': '39'
'221': '93'
'219': '91'
'173': '45'
'187': '61' #IE Key codes
'186': '59' #IE Key codes
'189': '45' #IE Key codes
shiftUps =
"96": "~"
"49": "!"
"50": "@"
"51": "#"
"52": "$"
"53": "%"
"54": "^"
"55": "&"
"56": "*"
"57": "("
"48": ")"
"45": "_"
"61": "+"
"91": "{"
"93": "}"
"92": "|"
"59": ":"
"39": "\""
"44": "<"
"46": ">"
"47": "?"
htmlForNode = (n)->
if n.nodeType == n.TEXT_NODE then escapeHtml n.data
else n.outerHTML
export getEventChar = (e)->
if e.type == 'keypress' then String.fromCharCode eventChar e
else
c = (e.charCode || e.keyCode || e.which)
shifton = e.shiftKey || !!(e.modifiers & 4)
# normalize keyCode
if _to_ascii.hasOwnProperty(c) then c = _to_ascii[c]
if !shifton && (c >= 65 && c <= 90) then c = String.fromCharCode(c + 32)
else if e.shiftKey && shiftUps.hasOwnProperty(c)
# get shifted keyCode value
c = shiftUps[c]
else c = String.fromCharCode(c)
c
shiftKey = (c)-> 15 < c < 19
modifiers = (e, c)->
res = specialKeys[c] || String.fromCharCode(c)
if e.altKey then res = "M-" + res
if e.metaKey then res = "M-" + res
if e.ctrlKey then res = "C-" + res
if e.shiftKey then res = "S-" + res
res
export modifyingKey = (c, e)-> !e.altKey && !e.metaKey && !e.ctrlKey && (
(47 < c < 58) || # number keys
c == 32 || c == ENTER || # spacebar and enter
c == BS || c == DEL || # backspace and delete
(64 < c < 91) || # letter keys
(95 < c < 112) || # numpad keys
(185 < c < 193) || # ;=,-./` (in order)
(218 < c < 223) # [\]' (in order)
)
export last = (array)-> array.length && array[array.length - 1]
export posFor = (pos)->
if result = (if pos.pos == pos.node.length && pos.node.data[pos.pos - 1] == '\n' && !(p = pos.save().next()).isEmpty() then p else pos).textPosition()
result.pos = p ? pos
result
replacements =
'<': "<"
'>': ">"
'&': "&"
export escapeHtml = (str)->
if typeof str == 'string' then str.replace /[<>&]/g, (c)-> replacements[c]
else str
export findEditor = (node)->
target = $(node)
while target.length && !($(target).data().editor instanceof LeisureEditCore)
target = $(target).parent()
target.data()?.editor
preserveSelection
-- restore the current selection after func() completes. This may
work better for you than LeisureEditCore.savePosition because it always preserves the
selection, regardless of the current value of LeisureEditCore.editing.
preservingSelection = null
validatePositions = ->
node = (if $(document.activeElement).is 'input[input-number]'
document.activeElement
else
getSelection().anchorNode)
if editor = (node && findEditor node)
result = editor.options.validatePositions()
if result
console.error("DISCREPENCY AT POSITION #{result.block._id}, #{result.offset}")
export preserveSelection = (func)->
if preservingSelection then func preservingSelection
else if $(document.activeElement).is 'input[input-number]'
num = document.activeElement.getAttribute 'input-number'
parentId = $(document.activeElement).closest('[data-view-block-name]').prop 'id'
input = document.activeElement
start = input.selectionStart
end = input.selectionEnd
try
func
type: 'None'
scrollTop: 0
scrollLeft: 0
finally
setTimeout(validatePositions, 1)
parent = $("##{parentId}")
if input = parent.find("[input-number='#{num}']")
input.selectionStart = start
input.selectionEnd = end
input.focus()
else if editor = findEditor getSelection().anchorNode
preservingSelection = editor.getSelectedDocRange()
try
func preservingSelection
finally
setTimeout(validatePositions, 1)
editor.selectDocRange preservingSelection
preservingSelection = null
else func
type: 'None'
scrollTop: 0
scrollLeft: 0
wrapDiag = (parent)-> (args...)->
r = parent.apply this, args
@diag()
r