blob: ee4edb132f35b5a4fe5002787724fe8cf40e71b4 [file] [log] [blame]
/*
JSPWiki - a JSP-based WikiWiki clone.
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); fyou may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
*/
/*eslint-env browser*/
/*global $, Class, Options, Events, Undoable, Textarea, Dialog */
/*eslint-disable no-console */
/*
Class: Snipe
The Snipe class decorates a TEXTAREA object with extra capabilities such as
section editing, tab-completion, auto-indentation,
smart typing pairs, suggestion popups, toolbars, undo and redo functionality,
advanced find & replace capabilities etc.
The snip-editor can be configured with a set of snippet commands.
See [getSnippet] for more info on how to define snippets.
Credit:
Snipe (short for Snip-Editor) was inspired by postEditor (by Daniel Mota aka IceBeat,
http://icebeat.bitacoras.com ) and ""textMate"" (http://macromates.com/).
It has been written to fit as wiki markup editor for the JSPWIKI project.
Arguments:
el - textarea element
options - optional, see below
Options:
tab - (string) number of spaces used to insert/remove a tab in the textarea;
default is 4
snippets - (snippet-object) set of snippets, which will be expanded when
clicking a button or pressing the TAB key. See [getSnippet], [tabSnippet]
tabcompletion - (boolean, default false) when set to true,
the tabSnippet keywords will be expanded
when pressing the TAB key. See also [tabSnippet]
directsnips - (snippet-object) set of snippets which are directly expanded
on key-down. See [getSnippet], [directSnippet]
smartpairs - (boolean, default false) when set to true,
the direct snip (aka smart pairs) will be expanded on keypress.
See also [directSnippet]
buttons - (array of Elements), each button elemnet will bind its click-event
with [onButtonClick}. When the click event fires, the {{rel}} attribute
or the text of the element will be used as snippet keyword.
See also [tabSnippet].
dialogs - set of dialogs, consisting of either a Dialog object,
or a set of {dialog-options} for the predefined
dialogs suchs as Font, Color and Special.
See property [initializeDialogs] and [openDialog]
findForm - (object) list of form-controls. See [onFindAndReplace] handler.
onresize - (function, optional), when present, a textarea resize bar
with css class {{resize-bar}} is added after the textarea,
allowing to resize the heigth of the textarea.
This onresize callback function is called whenever
the height of the textarea is changed.
Dependencies:
[Textarea]
[Undoable]
[Snipe.Snips]
[Snipe.Commands]
Example:
(start code)
new Snipe( "mainTextarea", {
tabcompletion: true,
snippets: { bold:"**{bold text}**", italic:"\"\"{italic text}\"\"" },
directsnips: { "(":")", "[" : "]" }
});
(end)
*/
var Snipe = new Class({
Implements: [Options, Events, Undoable],
Binds: ["sync","shortcut","keystroke","suggest","action"],
options: {
tab: " ", //default tab = 4 spaces
//autosuggest:false,
//tabcompletion:false,
//autocompletion:false,
snippets: {},
directsnips: {},
//container: null, //DOM element, container for toolbar buttons
sectionCursor: "all",
sectionParser: function(){ return {}; }
},
initialize: function(element, options){
options = this.setOptions(options).options;
this.initializeUndoable(options.undoBtns);
var self = this,
/*
The textarea is cloned into a main and work textarea.
The work texarea remains visible and is used for the actual editing.
It contains either the full document or a particular section.
The main textarea is hidden and contains always the complete document.
On submit, the mainarea is send back to the server.
*/
//main = self.mainarea = $(el),
//work = main.clone().erase("name").inject( main.hide(), "before" ).addClass("snipe-work"),
container = options.container || $(element).form,
// Augment the textarea element with extra capabilities
// Make sure the content of the mainarea is always in sync with the workarea
//textarea = self.textarea = new Textarea( work );
textarea = self.textarea = new Textarea( element );
self.directsnips = new Snipe.Snips( textarea, options.directsnips );
self.snippets = new Snipe.Snips( textarea, options.snippets );
self.snippets.dialogs.find = [ Dialog.Find, {
data: {
//feed the find dialog with searchable content
selection: function(){
return textarea.getSelection();
},
get: function(){
var selection = textarea.getSelection();
return (selection=="") ? element.value : selection;
},
set: function(v){
var s = textarea.getSelectionRange();
self.fireEvent("beforeChange"); //make undoable
//textarea[ s.thin ? "setValue" : "setSelection" ](v);
s.thin ? element.value = v : textarea.setSelection(v);
self.fireEvent("change");
}
}
}];
//Snipe.Commands takes care of capturing commands.
//Commands are entered via tab-completion, button clicks, or a dialog.
//Snipe.Commands ensures that at most one dialog is open at the same time.
self.commands = new Snipe.Commands( container, {
onOpen: function( /*command*/ ){ element.focus(); },
onClose: function( /*command*/ ){ element.focus(); },
onAction: self.action,
dialogs: self.snippets.dialogs,
relativeTo: textarea
});
//add drag&drop handler: drag or paste data into the textarea
if( options.dragAndDrop ){
textarea.onDragAndDrop(options.dragAndDrop, function(){
self.fireEvent("change")
});
}
self.reset();
element.addEvents({
keydown: self.keystroke,
keypress: self.keystroke,
//fixme: any click outside the suggestion block should clear the context -- blur ?
//blur: self.reset.bind(self),
keyup: self.suggest.debounce(), //(250, true)
click: self.suggest.debounce(),
input: (function( ){
//console.log("change event on input");
self.fireEvent("change");
}).debounce()
});
//catch shortcut keys when focus anywhere on the snipe editor (toolbar, textarea..)
container.addEvent("keydown", self.shortcut);
},
/*
Function: toElement
Retrieve textarea DOM element;
Example:
> var snipe = new Snipe("textarea-element");
> $("textarea-element") == snipe.toElement();
> $("textarea-element") == $(snipe);
*/
toElement: function(){
return this.textarea.toElement();
},
/*
Function: get
Retrieve some of the public properties or options of the snip-editor.
Arguments:
item - mainarea|textarea|snippets|directsnips|autosuggest|tabcompletion|smartpairs
*/
get: function(item){
return( "value" == item ? this.textarea.getValue() :
/mainarea|textarea/.test(item) ? this[item] :
/snippets|directsnips|autosuggest|tabcompletion|smartpairs/.test(item) ? this.options[item] :
null );
},
/*
Function: set
Set/Reset some of the options of the snip-editor.
Also use to set/reset the content of the textarea. (eg section editing)
Arguments:
item - snippets|directsnips|autosuggest|tabcompletion|smartpair
value - new value
Returns
this Snipe object
*/
set: function(item, value){
if( item == "value" ){
this.textarea.setValue( value );
this.fireEvent("beforeChange"); //make undoable
this.textarea.focus();
} else if( /snippets|directsnips|autosuggest|tabcompletion|smartpairs/.test(item) ){
this.options[item] = value;
}
return this.fireEvent("change");
},
/*
Function: shortcut.
Handle shortcut keys: Ctrl/Meta+shortcut key.
This is a "Keypress" event handler connected to the container element
of the snip editor.
Note:
Safari seems to choke on Cmd+b and Cmd+i. All other Cmd+keys are fine. !?
It seems in those cases, the event is fired on document level only.
More info http://unixpapa.com/js/key.html
*/
shortcut: function(e){
var key, cmd;
if( e.shift || e.control || e.meta || e.alt ){
key = (e.shift ? "shift+":"") +
(e.control ? "control+":"") +
(e.meta ? "meta+":"") +
(e.alt ? "alt+":"") +
e.key,
console.log("shortcut ",key);
cmd = this.snippets.keys[key];
if ( cmd ){
//console.log("Snipe shortcut", key, cmd, se.code);
e.stop();
this.commands.action( cmd );
}
}
},
/*
Function: keystroke
This is a cross-browser keystroke handler for keyPress and keyDown
events on the textarea.
Note:
The KeyPress is used to accept regular character keys.
The KeyDown event captures all special keys, such as Enter, Del, Backspace, Esc, ...
To work around some browser incompatibilities, a hack with the {{event.which}}
attribute is used to grab the actual special chars.
Ref. keyboard event paper by Jan Wolter, http://unixpapa.com/js/key.html
Todo: check on Opera
Arguments:
e - (event) keypress or keydown event.
*/
keystroke: function(e){
//console.log(e.type, e.key, e.code, "shift:"+e.shift,"meta:"+e.meta,"ctrl:"+e.control );
if( e.type == "keydown" ){
//Exit if this is a normal key; process special chars with the keydown event
if( e.key.length == 1 ){ return; }
} else { // e.type == "keypress"
//CHECKME
//Only process regular character keys via keypress event
//Note: cross-browser hack with "which" attribute for special chars
if( !e.event.which /*which==0*/ ){ return; }
//CHECKME: Reset faulty "special char" treatment by mootools
//console.log( e.key, String.fromCharCode(e.code).toLowerCase());
e.key = String.fromCharCode(e.code).toLowerCase();
}
var self = this,
txta = self.textarea,
key = e.key,
caret = txta.getSelectionRange();
txta.focus();
if( /up|down|esc/.test(key) ){
self.reset();
} else if( /tab|enter|delete|backspace/.test(key) ){
self[key](e, txta, caret);
} else {
self.smartPairs(e, txta, caret);
}
},
/*
Function: enter
When the Enter key is pressed, the next line will be auto-indented
with the previous line.
Except if the Enter was pressed on an empty line.
Arguments:
e - event
txta - Textarea object
caret - caret object, indicating the start/end of the textarea selection
*/
enter: function(e, txta /*, caret*/ ) {
this.reset();
var prevline = txta.getFromStart().match( /(?:^|\r?\n)([ \t]+)(.*)$/ );
//prevline[1]=sequence of spaces (indentation string)
//prevline[2]=non-blank tail of the prevline
if( !e.shift && prevline && ( prevline[2] != "" ) ){
e.stop();
//console.log("enter key - autoindent", prevline);
txta.insertAfter( "\n" + prevline[1] );
}
},
/*
Function: backspace
Remove single-character directsnips such as {{ (), [], {} }}
Arguments:
e - event
txta - Textarea object
caret - caret object, indicating the start/end of the textarea selection
*/
backspace: function(e, txta, caret) {
if( caret.thin && (caret.start > 0) ){
var key = txta.getValue().charAt(caret.start-1),
snip = this.directsnips.get( key );
if( snip && (snip.snippet == txta.getValue().charAt(caret.start)) ){
// remove the closing pair character
txta.setSelectionRange( caret.start, caret.start+1 )
.setSelection("");
}
}
},
/*
Function: delete
Removes the previous TAB (4spaces) if matched
Arguments:
e - event
txta - Textarea object
caret - caret object, indicating the start/end of the textarea selection
*/
"delete": function(e, txta, caret) {
var tab = this.options.tab;
//console.log("delete key");
if( caret.thin && !txta.getTillEnd().indexOf(tab) /*index==0*/ ){
e.stop();
txta.setSelectionRange(caret.start, caret.start + tab.length)
.setSelection("");
}
},
/*
Function: tab
Perform tab-completion function.
Pressing a tab can lead to :
- expansion of a snippet command cmd and selection of the first parameter
- otherwise, expansion to set of spaces (4)
Arguments:
e - event
txta - Textarea object
caret - caret object, indicating the start/end of the textarea selection
*/
tab: function(e, txta, caret){
var self = this, cmd;
e.stop();
if( self.options.tabcompletion ){
if( caret.thin && ( cmd = self.snippets.match() ) ){
//remove the command
txta.setSelectionRange(caret.start - cmd.length, caret.start)
.setSelection( "" );
return self.commands.action( cmd );
}
}
//if you are still here, convert the tab into spaces
self.tab2spaces(e, txta, caret);
},
/*
Function: tab2spaces
Convert tabs to spaces. When no snippets are detected, the default
treatment of the TAB key is to insert a number of spaces.
Indentation is also applied in case of multi-line selections.
Arguments:
e - event
txta - Textarea object
caret - caret object, indicating the start/end of the textarea selection
*/
tab2spaces: function(e, txta, caret){
var tab = this.options.tab,
selection = txta.getSelection(),
fromStart = txta.getFromStart(),
isCaretAtStart = txta.isCaretAtStartOfLine();
//handle multi-line selection
if( selection.indexOf("\n") > -1 ){
if( isCaretAtStart ){ selection = "\n" + selection; }
if( e.shift ){
//shift-tab: remove leading tab space-block
selection = selection.replace(RegExp("\n"+tab,"g"),"\n");
} else {
//tab: auto-indent by inserting a tab space-block
selection = selection.replace(/\n/g,"\n"+tab);
}
txta.setSelection( isCaretAtStart ? selection.slice(1) : selection );
} else {
if( e.shift ){
//shift-tab: remove "backward" tab space-block
if( fromStart.test( tab + "$" ) ){
txta.setSelectionRange( caret.start - tab.length, caret.start )
.setSelection("");
}
} else {
//tab: insert a tab space-block
txta.setSelection( tab )
.setSelectionRange( caret.start + tab.length );
}
}
},
/*
Function: setContext
Store the active snip. (state)
EG, subsequent handling of dialogs.
Arguments:
snip - snippet object to make active
*/
hasContext: function(){
return !!this.context.snip;
},
setContext: function( snip, suggest ){
//console.log("Snipe.setContext",snip,suggest);
this.context = { snip:snip, suggest:suggest };
},
/*
Function: reset
Clear the context object, and remove the css class from the textarea.
Also make sure that no dialogs are left open.
*/
reset: function(){
console.log("Snipe:reset", this.context);
this.context = null;
this.textarea.focus();
this.commands.close();
},
/*
Function: smartPairs
Direct snippet are invoked immediately when the key is pressed
as opposed to a [tabSnippet] which are expanded after pressing the Tab key.
Direct snippets are typically used for smart typing pairs,
such as {{ (), [] or {}. }}
Direct snippets can also be defined through javascript functions
or restricted to a certain scope. (ref. [getSnippet], [inScope] )
First, the snippet is retrieved based on the entered character.
Then, the opening- and closing- chars are inserted around the selection.
Arguments:
e - event
txta - Textarea object
caret - caret object, indicating the start/end of the textarea selection
Example:
(start code)
directSnippets: {
""" : """,
"(" : ")",
"{" : "}",
"<" : ">",
""" : {
snippet:""",
scope:{
"<javascript":"</javascript",
"<code":"</code",
"<html":"</html"
}
}
}
(end)
*/
smartPairs: function(e, workarea, caret){
var snip, key = e.key;
if( this.options.smartpairs && (snip = this.directsnips.get(key)) ){
e.stop();
//insert the keystroke, retain the selection and insert the snippet outcome
//and keep the original selection (caret)
workarea.setSelection( key, workarea.getSelection(), snip.snippet )
.setSelectionRange( caret.start + 1, caret.end + 1 );
}
},
/*
Method: suggest
Suggestion snippets are dialog-boxes appearing as you type.
When clicking items in the suggest dialogs, content is inserted
in the textarea.
*/
suggest: function( /*event*/ ){
var suggest;
if( this.options.autosuggest ){
if ( (suggest = this.snippets.matchSuggest()) ){
console.log( "Snipe.suggest ",suggest );
this.setContext( null/*snip*/, suggest );
return this.commands.action( suggest.cmd , suggest.lback );
}
this.reset();
}
},
/*
Function: action
This function executes the command action.
It will coordinate to get the snippet inserted and move the
caret to the proper position after insertion.
Actions can be triggered via:
- tab-completion
- click-event from the [data-cmd] DOM element
- short-cut key
- matched suggestion (with look-back and match strings)
Arguments:
cmd - (string) used to loopup the snippet
args - (optional) additional snippet arguments (eg value passed via a dialog)
*/
action: function( cmd /*, more command arguments */ ){
var self = this,
args = Array.slice(arguments, 1),
snip = self.snippets.get( cmd ),
txta = self.textarea,
caret,
snipXL,
snippet = snip.suggest ? args.join() : snip.snippet,
suggest = snip.suggest && self.context && self.context.suggest;
function unesc(s){ return s.replace(/~\{/g, "{"); }
console.log("Snipe:action ", cmd, "snippet=",snippet, "args=",args, "suggest=",suggest );
if( !snip ) return;
if( snip.event ){
//console.log("Snipe:action Event: ",snip.event);
self.fireEvent(snip.event, arguments);
} else {
self.fireEvent("beforeChange"); //make action undoable
if( suggest ){
snippet = self.suggestAction( txta, snippet, suggest.lback, suggest.match);
}
/*
Match "pfx{=copy-selection}sfx" and replace by "pfx<selection>sfx"
snippet = snippet.replace(/..../, function(match, target){
if(selection){
target = selection; selection = "";
}
return target; //alse removing the {= and }
});
*/
/*
Match "pfx{selection}sfx"
snippet = snippet.replace( /regexp/, selection || RegExp.$1 );
and also handle toggle !!?
*/
// match "pfx{selection}sfx" into ["pfx","selection","sfx"]
// do not match "pfx~{do-not-match}sfx"
if(( snipXL = snippet.match( /(^|[\S\s]*[^~])\{([^!{}][^{}]*)\}([\S\s]*)/ ) )){
//if( snipXL = snippet.match( /(^|[\S\s]*[^~])\{([^\{\}]+)\}([\S\s]*)/ ) ){
//console.log("Snipe:action complex snippet 'pfx{selection}sfx' ", pfx, sel, sfx, caret.thin );
self.injectXL( txta,
snippet,
unesc( snipXL[1] ), //pfx
snipXL[2], //sel
unesc( snipXL[3] ) //sfx
);
} else {
//if no selection, just insert and move caret after inserted snippet
caret = txta.getSelectionRange();
snippet = unesc(snippet);
//console.log("Snipe:action simple snippet", caret.thin, snippet );
self.inject( txta,
snippet,
caret.start + (caret.thin ? snippet.length : 0),
caret.thin ? 0 : snippet.length );
}
}
self.reset();
self.fireEvent("change");
self.suggest.delay(1, self); //allow some time to finish any actions, before opening a new suggestion dialog
},
/*
Function: suggestAction
Adjust the snippet and the caret based on the suggestion context.
The selection is set to the matched suggestion string, to prepare for the later
snippet replacement.
When the snippet starts with the look-back string; only the last part
of the matched suggestion string should be replaced by the snippet.
Returns a adjusted snippet.
Example: ($==caret position)
[te$st] => lback = "te", match = "test", snippet ="wiki-page"
Selection will become "test", and snippet will become "wiki-page"
[te$st] => lback = "te", match = "test", snippet ="team-page"
Selection will become "st", and snippet will become "am-page"
*/
suggestAction: function( txta, snippet, lback, match){
var start = txta.getSelectionRange().start,
len = match.length;
//console.log("Snipe:suggestAction ",snippet,lback,match );
if( snippet.startsWith( lback ) ){
//remove the look-back part of the snippet
snippet = snippet.slice( lback.length );
len -= lback.length;
} else {
//move the cursor to the start of the match
start -= lback.length;
}
txta.setSelectionRange(start, start + len); //move the cursor
return snippet;
},
/*
Function: injectXL
Inject a complex snippet.
Complex snippet match this pattern: "pfx{selection}sfx".
The part inside the "{..}" should be replaced by the selection.
If the selection has already the pfx/sfx strings, they should be toggled.
Example
snippet: "__{bold}__", no selection
Snippet "__bold__" will be inserted, selection will become "bold"
snippet: "__{bold}__", selection:"pipo"
Snippet "__pipo__" will be inserted, selection will become "pipo"
snippet: "__{bold}__", selection:__"pipo"__
Snippet "pipo" will replace "__pipo__", selection will become "pipo"
snippet: "__{bold}__", selection:"__pipo__"
Snippet "pipo" will replace selection, selection will become "pipo"
*/
injectXL: function( txta, snippet, pfx, sel, sfx){
var caret = txta.getSelectionRange(),
start = caret.start;
if( !caret.thin ) {
sel = txta.getSelection();
console.log("injectXL: ", sel, pfx, sfx);
if( sel.startsWith(pfx) && sel.endsWith(sfx) ){
console.log("TOGGLE: pfx/sfx matched inside the selection",caret.start,caret.end);
sel = sel.slice( pfx.length, -sfx.length );
pfx = sfx = "";
} else if( txta.getFromStart().endsWith(pfx)
&& txta.getTillEnd().startsWith(sfx) ){
console.log("TOGGLE: pfx/sfx matched outside the selection",caret.start,caret.end);
start -= pfx.length;
txta.setSelectionRange(start , caret.end + sfx.length);
pfx = sfx = "";
}
}
this.inject( txta, pfx + sel + sfx, start + pfx.length, sel.length);
},
/*
Function: inject
Replace the selection by the snippet and set a new selection.
Collapse leading and trailing \n characters
Autoindent the (multi-line) snippet.
*/
inject: function( txta, snippet, start, selectionLen ){
var fromStart = txta.getFromStart(),
prevline = fromStart.split(/\r?\n/).pop(),
indent = prevline.match(/^\s+/);
console.log("inject: ", snippet, start, selectionLen );
if( snippet.test(/^\n/) && ( fromStart.test( /(^|[\n\r]\s*)$/ ) ) ) {
console.log("collapse leading \\n", snippet);
snippet = snippet.slice( 1 );
start--;
}
if( snippet.test(/\n$/) && ( txta.getTillEnd().test( /^\s*[\n\r]/ ) ) ) {
console.log("collapse trailing \\n", snippet);
snippet = snippet.slice(0, -1);
start--;
}
if( indent ){
//console.log("auto-indent internal newlines \n");
snippet = snippet.replace( /\n/g, "\n" + indent[0] );
}
txta.setSelection( snippet )
.setSelectionRange( start, start + selectionLen );
},
/*
Interface: Undoable
Implemenent the "undoable" interface.
Function: getState
State contains the value of the main and work area, the cursor and scroll position
*/
getState: function(){
var txta = this.textarea,
el = txta.toElement();
return {
//main: this.mainarea.value,
value: el.get("value"),
cursor: txta.getSelectionRange(),
scrollTop: el.scrollTop,
scrollLeft: el.scrollLeft
};
},
/*
Function: putState
Set a state of the Snip editor. This works in conjunction with the getState function.
Argument:
state - object originally created by the getState function
*/
putState: function(state){
var self = this,
txta = self.textarea,
el = txta.toElement();
self.reset();
//self.mainarea.value = state.main;
el.value = state.value;
el.scrollTop = state.scrollTop;
el.scrollLeft = state.scrollLeft;
txta.setSelectionRange( state.cursor.start, state.cursor.end );
self.fireEvent("change");
self.suggest();
}
});