You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@jspwiki.apache.org by br...@apache.org on 2014/03/02 19:27:51 UTC

svn commit: r1573337 [2/5] - in /jspwiki/trunk: ./ jspwiki-war/src/main/config/wro/ jspwiki-war/src/main/java/org/apache/wiki/ jspwiki-war/src/main/resources/templates/ jspwiki-war/src/main/scripts/dialog/ jspwiki-war/src/main/scripts/dynamic-styles/ j...

Added: jspwiki/trunk/jspwiki-war/src/main/scripts/wiki-edit/Snipe.js
URL: http://svn.apache.org/viewvc/jspwiki/trunk/jspwiki-war/src/main/scripts/wiki-edit/Snipe.js?rev=1573337&view=auto
==============================================================================
--- jspwiki/trunk/jspwiki-war/src/main/scripts/wiki-edit/Snipe.js (added)
+++ jspwiki/trunk/jspwiki-war/src/main/scripts/wiki-edit/Snipe.js Sun Mar  2 18:27:50 2014
@@ -0,0 +1,1232 @@
+/*
+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.
+    next - (Element), when pressing Shift-Enter, the textarea will ''blur'' and
+        this ''next'' element will ge the focus.
+        This compensates the overwritting default TAB handling of the browser.
+    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]
+    [UndoRedo]
+    [Snipe.Commands]
+
+Example:
+(start code)
+    new Snipe( "mainTextarea", {
+        snippets: { bold:"**{bold text}**", italic:"''{italic text}''" },
+        tabcompletion:true,
+        directsnips: { '(':')', '[' : ']' },
+        buttons: $$('a.tool'),
+        next:'nextInputField'
+    });
+(end)
+
+*/
+var Snipe = new Class({
+
+    Implements: [Options, Events],
+
+    Binds: ['sync','shortcut','keystroke','suggest'],
+
+    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(el, options){
+
+        options = this.setOptions(options).options;
+
+        var self = this,
+
+            /*
+            The textarea is cloned into a mainarea and workarea.
+            The workarea is visible and used for the actual editing.
+            It contains either the full document or a particular section.
+            The mainarea is hidden and contains always the full document.
+            On submit, the mainarea is send back to the server.
+            */
+            main = self.mainarea = $(el),
+            work = main.clone().erase('name') //.clone(true,false), dont copy ID and name
+                .inject( main.hide(), 'before' ),
+            container = options.container || work.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 );
+
+        self.undoredo = new UndoRedo( self, {
+            undo:container.getElement('[data-cmd=undo]'), 
+            redo:container.getElement('[data-cmd=redo]')
+        });
+
+
+        //The Commands class processes all commands
+        //   entered via tab-completion, button clicks, dialogs or suggestion dialogs.
+        //   Valid commands are given back to the Snipe editor via the onAction event.
+        self.commands = new Snipe.Commands( container, {
+
+            onOpen: function(/*cmd, eventHdl*/){ /*work.focus();*/ },
+
+            onClose: function(){ work.focus(); },
+
+            onAction: function(cmd){ self.action(cmd, Array.slice(arguments,1) ); },
+
+            //predefined dialogs
+            dialogs: {
+
+                find: [ Dialog.Find, {
+
+                    //dialog: container.getElement('.dialog.find'),
+
+                    data: {
+                        //feed the find dialog with searchable content: selection or all
+                        get: function(){
+                            var s = textarea.getSelection();
+                            return (s=='') ? work.value : s;
+                        },
+                        set: function(v){
+                            var s = textarea.getSelectionRange();
+                            self.undoredo.onChange();
+                            s.thin ? work.value = v : textarea.setSelection(v);
+                        }
+                    }
+
+                }]
+            }
+        });
+
+        self.initSnippets( options.snippets );
+        self.clearContext();
+
+        work.addEvents({
+            keydown: self.keystroke,
+            keypress: self.keystroke,
+            //fixme: any click outside the suggestion block should clear the active snip -- blur ?
+            //blur: self.clearContext.bind(self), //(and hide any open dialogs)
+            keyup: self.suggest,
+            click: self.suggest,
+            change: function(parm){ self.fireEvent('change',parm); }
+        });
+
+        //catch shortcut keys when focus on toolbar or textarea
+        container.addEvent('keypress', self.shortcut);
+
+    },
+
+    /*
+    Function: initSnippets
+        Initialize the snippets and collect all shortcut keys and suggestion snips
+    */
+    initSnippets: function( snips ){
+
+        var self = this, 
+            cmd, snip, key, dialogs = {},
+            ismac = Browser.Platform.mac, //navigator.userAgent.indexOf('Mac OS X')!=-1
+            shortcut = (ismac ? 'meta+' : 'control+');
+
+        self.keys = {};
+        self.suggestions = {};
+
+        for( cmd in snips ){
+        
+            snip = snips[cmd];
+
+            if( typeOf(snip)=='string' ){ snip = {snippet:snip}; }
+
+            Function.from( snip.initialize )(cmd, snip);
+
+            if( key = snip.key ){ 
+                if( key.indexOf('+')<0 ){ key = shortcut+key; }
+                self.keys[key.toLowerCase()] = cmd; 
+            }
+
+            if( typeOf(snip.suggest)=='function' ){ self.suggestions[cmd] = snip; }
+
+            //check for default snip dialogs -- they have the same name as the command
+            //EG:  find:{find:[Dialog.Find,{options}] }
+            if( snip[cmd] ){ dialogs[cmd] = snip[cmd]; }
+
+        }
+
+        //initialize all detected dialogs
+        console.log("snip dialogs",Object.keys(dialogs).length);
+        self.commands.addDialogs(dialogs, self.textarea);
+        
+    },
+
+
+    /*
+    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);
+    },
+
+    /*
+    Function: get
+        Retrieve some of the public properties of the snip-editor.
+
+    Arguments:
+        item - textarea|snippets|tabcompletion|directsnips|smartpairs|autosuggest
+    */
+    get: function(item){
+
+        return( /mainarea|textarea/.test(item) ? this[item] : 
+                    /snippets|directsnips|autosuggest|tabcompletion|smartpairs/.test(item) ? this.options[item] : 
+                        null );
+
+    },
+
+    /*
+    Function: set
+        Set/Reset some of the public options of the snip-editor.
+
+    Arguments:
+        item - snippets|tabcompletion|directsnips|smartpairs|autosuggest
+        value - new value
+    Returns
+        this Snipe object
+    */
+    set: function(item, value){
+
+        if( /snippets|directsnips|autosuggest|tabcompletion|smartpairs/.test(item) ){
+            this.options[item] = value;
+        }
+        return this;
+    },
+
+    /*
+    Function: shortcut.
+        Handle shortcut keys: Ctrl+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.
+    */
+    shortcut: function(e){
+
+        var key = (e.shift ? 'shift+':'') + 
+                    (e.control ? 'control+':'') + 
+                      (e.meta ? 'meta+':'') + 
+                        (e.alt ? 'alt+':'') + 
+                          e.key,
+
+            keycmd = this.keys[key];
+
+        //console.log(key);
+        if ( keycmd ){
+            console.log(this.keys,'shortcut',key,keycmd);
+            e.stop();
+            this.commands.action( keycmd );
+        }
+
+    },
+
+    /*
+    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.key, e.code + " keystroke "+e.shift+" "+e.type+"+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,
+            el = $(txta),
+            key = e.key,
+            caret = txta.getSelectionRange(),
+            scroll = el.getScroll();
+
+        el.focus();
+
+        if( /up|down|esc/.test(key) ){
+
+            self.clearContext();
+
+        } else if( /tab|enter|delete|backspace/.test(key) ){
+
+            self[key](e, txta, caret);
+
+        } else {
+
+            self.directSnippet(e, txta, caret);
+
+        }
+
+        el.scrollTo(scroll);
+
+    },
+
+    /*
+    Function: enter
+        When the Enter key is pressed, the next line will be ''auto-indented''
+        or space-aligned 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) {
+
+        //if( this.hasContext() ){
+            //fixme
+            //how to 'continue previous snippet ??
+            //eg '\n* {unordered list item}' followed by TAB or ENTER
+            //snippet should always start with \n;
+            //snippet should have a 'continue on enter' flag ?
+        //}
+
+        this.clearContext();
+
+        if( caret.thin ){
+
+            var prevline = txta.getFromStart().split(/\r?\n/).pop(),
+                indent = prevline.match( /^\s+/ );
+
+            if( indent && (indent != prevline) ){
+                e.stop();
+                txta.insertAfter( '\n' + indent[0] );
+            }
+
+        }
+    },
+
+    /*
+    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.getSnippet( this.options.directsnips, 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 next 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;
+
+        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
+        - selection of the next snippet parameter (if active snippet)
+        - 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,
+            snips = self.options.snippets,
+            fromStart = txta.getFromStart(),
+            len = fromStart.length,
+            cmd, cmdlen; // ok = false;
+
+        e.stop();
+
+        if( self.options.tabcompletion ){
+
+            if( self.hasContext() ){
+
+                return self.nextAction(txta, caret);
+
+            }
+
+            if( caret.thin ){
+
+                //lookup the command backwards from the text preceeding the caret
+                for( cmd in snips ){
+
+                    cmdlen = cmd.length;
+
+                    if( (len >= cmdlen) && (cmd == fromStart.slice( - cmdlen )) ){
+
+                        //first remove the command
+                        txta.setSelectionRange(caret.start - cmdlen, caret.start)
+                            .setSelection('');
+
+                        return self.commands.action( cmd );
+
+                    }
+                }
+            }
+
+        }
+
+        //if you are still here, convert the tab into spaces
+        self.convertTabToSpaces(e, txta, caret);
+
+    },
+
+    /*
+    Function: convertTabToSpaces
+        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
+    */
+    convertTabToSpaces: 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.
+        As long as a snippet is active, the textarea gets the css class {{.activeSnip}}.
+
+    Arguments:
+        snip - snippet object to make active
+    */
+    hasContext: function(){
+        return !!this.context.snip;
+    },
+
+    setContext: function( snip, suggest ){
+
+        this.context = {snip:snip, suggest:suggest};
+        $(this).addClass('activeSnip');
+
+    },
+
+    /*
+    Function: clearContext
+        Clear the context object, and remove the css class from the textarea.
+        Also make sure that no dialogs are left open.
+    */
+    clearContext: function(){
+
+        this.context = {};
+        this.commands.close();
+        $(this).removeClass('activeSnip').focus();
+
+    },
+
+    /*
+    Function: getSnippet
+        Retrieve and validate the snippet. Returns false when the snippet is not
+        found or not in scope.
+
+    About snippets:
+    In the simplest case, you can use snippets to insert plain text that you do not
+    want to type again and again. The snippet is expanded when hitting
+    the Tab key: the ''snippet'' is replaced by ''snippet expansion text''.
+
+    (start code)
+    var tabSnippets = {
+        <snippet1> : <snippet expansion text>,
+        <snippet2> : <snippet expansion text>
+    }
+    (end)
+
+    See also [DirectSnippets].
+
+    For example, following snippet will expand the ''toc'' text into the
+    TableOfContents wiki plugin call. Don't forget to escape '{' and '}'
+    with a backslash, because they have a special meaning. (see below)
+    Use the '\n' charater to define multi-line snippets. Start the snippet
+    with '\n' to make sure the snippet starts on a new line.
+
+    (start code)
+    "toc": "\n[\{TableOfContents \}]\n"
+    (end)
+
+    After tab-completion, the caret is placed just after the expanded snippet.
+
+    Snippet parameters:
+    If you want, you can put ''{parameters}'' inside the snippet. Pressing the tab
+    will jump to the next parameter. If you are ok with the default value,
+    just tab over it. If not, start typing to overwrite it.
+
+    (start code)
+    "bold": "__{some bold text}__"
+    (end)
+
+    You can have multiple ''{parameters}'' too. Pressing more tabs will get you there.
+
+    (start code)
+    "link": "[{link text}|{pagename}]"
+    (end)
+
+    Extended snippet syntax:
+    So far we discussed the simple snippet syntax. In order to unlock more advanced
+    snippet features, you'll need to use the extended snippet syntax.
+
+    (start code)
+    "toc": {
+        snippet : "\n[\{TableOfContents \}]\n"
+    }
+    (end)
+
+    which is actually the same as
+
+    (start code)
+    "toc": "\n[\{TableOfContents \}]\n"
+    (end)
+
+    Snippet synonyms:
+    Instead of defining the snippet text, you can also refer to another snippet.
+    This allows you to create synonyms.
+
+    (start code)
+    "allow": {
+        synonym: "acl"
+    }
+    (end)
+
+    Dynamic snippets:
+    Next to static snippet texts, you can also dynamically generate
+    the snippet text through a javascript function. For example, you could
+    use ajax calls to populate the snippet on the fly. The function should return
+    either the string (simple snippet syntax) or a snippet object.
+    (eg return {{ { snippet:"..." } }} )
+
+    (start code)
+    "date": function(e, textarea){
+        return new Date().toLocaleString();
+    }
+    (end)
+
+    or
+
+    (start code)
+    "date": function(e, textarea){
+        var d = new Date().toLocaleString();
+        return { 'snippet': d };
+    }
+    (end)
+
+    Snippet scope:
+    See [inScope] to see how to restrict the scope of a snippet.
+
+    Parameter dialog boxes:
+    To help the entry of parameters, you can specify a predefined set of choices
+    for a ''{parameter}'', as a string (with | separator), js array or js object.
+    A parameter dialog box will be displayed to provide easy selection of
+    one of the choices.  See [Dialog.Selection].
+
+    Example of parameter suggestion-list:
+
+    (start code)
+    "acl": {
+        snippet: "[\{ALLOW {permission} {principal(,principal)} \}]",
+        permission: "view|edit|modify|comment|rename|upload|delete",
+        "principal(,principal)": "Anonymous|Asserted|Authenticated|All"
+        }
+    }
+    "acl": {
+        snippet: "[\{ALLOW {permission} {principal(,principal)} \}]",
+        permission: [view,edit,modify]
+        }
+    }
+    "acl": {
+        snippet: "[\{ALLOW {permission} {principal(,principal)} \}]",
+        permission: {'Only read access':'view','Read and write access':'edit','R/W, rename, delete access':'modify' }
+        }
+    }
+    (end)
+
+
+    Arguments:
+        snips - snippet collection object for lookup of the key
+        key - snippet key. If not present, retreive the key from
+            the textarea just to the left of the caret. (i.e. tab-completion)
+
+    Returns:
+        Return a snippet object or false.
+        (start code)
+        returned_object = false || {
+                key: "snippet-key",
+                snippet: " snippet-string ",
+                text: " converted snippet-string, no-parameter braces, auto-indented ",
+                parms: [parm1, parm2, "last-snippet-string" ]
+            }
+        (end)
+    */
+    getSnippet: function( snips, cmd ){
+
+        var self = this,
+            txta = self.textarea,
+            fromStart = txta.getFromStart(),
+            snip = snips[cmd],
+            tab = this.options.tab,
+            parms = [],
+            s,last;
+
+        if( snip && snip.synonym ){ snip = snips[snip.synonym]; }
+
+        snip = Function.from(snip)(self, [cmd]);
+
+        if( typeOf(snip) == 'string' ){ snip = { snippet:snip }; }
+
+        if( !snip || !self.inScope(snip, fromStart) ){ return false; }
+
+        s = snip.snippet || '';
+
+        //parse snippet and build the parms[] array with all {parameters}
+        s = s.replace( /\\?\{([^{}]+)\}/g, function(match, name){
+
+            if( match.charAt(0) == '\\' ){ return match.slice(1); }
+            parms.push(name);
+            return name;
+
+        }).replace( /\\\{/g, '{' );
+        //and finally, replace the escaped '\{' by real '{' chars
+
+        //also push the last piece of the snippet onto the parms[] array
+        last = parms.getLast();
+        if(last){ parms.push( s.slice(s.lastIndexOf(last) + last.length) ); }
+
+        //collapse \n of previous line if the snippet starts with \n
+        if( s.test(/^\n/) && ( fromStart.test( /(^|[\n\r]\s*)$/ ) ) ) {
+            s = s.slice(1);
+            //console.log("remove leading \\n");
+        }
+
+        //collapse \n of subsequent line when the snippet ends with a \n
+        if( s.test(/\n$/) && ( txta.getTillEnd().test( /^\s*[\n\r]/ ) ) ) {
+            s = s.slice(0,-1);
+            //console.log("remove trailing \\n");
+        }
+
+        //auto-indent the snippet's internal newlines \n
+        var prevline = fromStart.split(/\r?\n/).pop(),
+            indent = prevline.match(/^\s+/);
+        if( indent ){ s = s.replace( /\n/g, '\n' + indent[0] ); }
+
+        //complete the snip object
+        snip.text = s;
+        snip.parms = parms;
+
+        return snip;
+    },
+
+    /*
+    Function: inScope
+        Sometimes it is useful to restrict the scope of a snippet, and only allow
+        the snippet expansion in specific parts of the text. The scope parameter allows
+        you to do that by defining start and end delimiting strings.
+        For example, the following "fn" snippet will only expands when it appears
+        inside the scope of a script tag.
+
+        (start code)
+        "fn": {
+            snippet: "function( {args} )\{ \n    {body}\n\}\n",
+            scope: {"<script":"</script"} //should be inside this bracket
+        }
+        (end)
+
+        The opposite is possible too. Use the 'nScope' or not-in-scope parameter
+        to make sure the snippet is only inserted when not in scope.
+
+        (start code)
+        "special": {
+            snippet: "{special}",
+            nScope: { "%%(":")" } //should not be inside this bracket
+        },
+        (end)
+
+    Arguments:
+        snip - Snippet Object
+        text - (string) used to check for open scope items
+
+    Returns:
+        True when the snippet is in scope, false otherwise.
+    */
+    inScope: function(snip, text){
+
+        var pattern, pos, scope=snip.scope, nscope=snip.nscope;
+
+        if( scope ){
+
+            if( typeOf(scope)=='function' ){
+
+                return scope( this.textarea );
+
+            } else {
+
+                for( pattern in scope ){
+
+                    pos = text.lastIndexOf(pattern);
+                    if( (pos > -1) && (text.indexOf( scope[pattern], pos ) == -1) ){ return 1 /*true*/; }
+
+                }
+                return false;
+            }
+        }
+
+        if( nscope ){
+
+            for( pattern in nscope ){
+
+                pos = text.lastIndexOf(pattern);
+                if( (pos > -1) && (text.indexOf( nscope[pattern], pos ) == -1) ){ return !1 /*false*/; }
+
+            }
+
+        }
+        return 1 /*true*/;
+    },
+
+
+    /*
+    Function: directSnippet
+        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)
+
+    */
+    directSnippet: function(e, txta, caret){
+
+        var self = this,
+            options = self.options,
+
+            snip = self.getSnippet( options.directsnips, e.key );
+
+        if( snip && options.smartpairs ){
+
+            e.stop();
+
+            txta.setSelection( e.key, txta.getSelection(), snip.snippet )
+                .setSelectionRange( caret.start+1, caret.end+1 );
+
+        }
+
+    },
+
+    /*
+    Function: action
+        This function executes the proper action.
+        The command can be given throug TAB-completion or by pressing a button.
+
+        It looks up the snippet and inserts its value in the textarea.
+
+        When text was selected prior to the click event, the selection will
+        be injected in one of the snippet {parameter}.
+
+        Additionally, when the snippet only contains one {parameter},
+        the snippet will toggle: i.e. remove the snippet when already present,
+        otherwise insert the snippet.
+
+        TODO:
+        Prior to the insertion of the snippet, the caret will be moved to the beginning of the line.
+        Prior to the insertion of the snippet, the caret will be moved to the beginning of the next line.
+
+    Arguments:
+        e - (event) keypress or keydown event.
+    */
+    action: function( cmd, args ){
+
+        var self = this,
+            txta = self.textarea,
+            caret = txta.getSelectionRange(),
+            snip = self.context.snip || self.getSnippet(self.options.snippets, cmd),
+            s;
+
+        //console.log("Action: "+cmd+" ("+args+") text=["+snip.text+"] parms=["+snip.parms+"] "+!!snip);
+
+        if( snip ){
+
+            s = snip.text;
+
+            if( snip.action ){    //eg undo, redo
+
+                return snip.action.call(self, cmd, snip, args );
+
+            }
+
+            self.undoredo.onChange();
+
+            if( snip.event ){
+
+                return self.fireEvent(snip.event, [cmd, args]);
+
+            }
+
+
+            $(txta).focus();
+
+            if( self.options.autosuggest && self.context.suggest ){
+
+                return self.suggestAction( cmd, args );
+
+            }
+
+
+            if( !caret.thin && (snip.parms.length==2) ){
+                s = self.toggleSnip(txta, snip, caret);
+                //console.log("toggle snippet: "+s+" parms:"+snip.parms);
+            }
+
+            //inject args into the snippet parms
+            if( args ){
+                args.each( function(arg){
+                    if(snip.parms.length > 1){
+                        s = s.replace( snip.parms.shift(), arg );
+                    }
+                });
+                //console.log("inject args: "+s+" "+snip.parms);
+            }
+
+            //inject selected text into first snippet parm
+            if( !caret.thin && (snip.parms[1] /*length>1*/) ){
+                s = s.replace( snip.parms.shift(), txta.getSelection() );
+                //console.log("inject selection: "+s+" "+snip.parms);
+            }
+
+            //now insert the snippet text
+            txta.setSelection( s );
+
+            if( !snip.parms.length/*length==0*/ ){
+
+                //when no selection, move caret after the inserted snippet,
+                //otherwise leave the selection unchanged
+                if( caret.thin ){ txta.setSelectionRange( caret.start + s.length ); }
+                //console.log("action:: should we clear this ? " + self.hasContext() );
+                self.clearContext();
+
+            } else {
+
+                //this snippet has one or more parameters
+                //store the active snip and process the next {parameter}
+                //checkme !!
+                if( !self.hasContext() ){ self.setContext( snip ); }
+                caret = txta.getSelectionRange(); //update new caret
+                self.nextAction(txta, caret);
+
+            }
+
+        }
+
+    },
+
+    /*
+    Function: toggleSnip
+        Toggle the prefix and suffix of a snippet.
+        Eg. toggle between {{__text__}} and {{text}}.
+        The selection will be matched against the snippet.
+
+    Precondition:
+        - the selection is not empty (caret.thin = false)
+        - the snippet has exatly one {parameter}
+
+    Arguments:
+        txta - Textarea object
+        snip - Snippet object
+        caret - Caret object {start, end, thin}
+
+    Returns:
+        - (string) replacement string for the selection.
+            By default, returns snip.text
+        - the snip.parms will be set to [] is toggle was executed successfully
+        Eventually the selection will be extended if the
+        prefix and suffix were just outside the selection.
+    */
+    toggleSnip: function(txta, snip, caret){
+
+        var s = snip.text,
+            //get the first and last textual parts of the snippet
+            arr = s.trim().split( snip.parms[0] ),
+            fst = arr[0],
+            lst = arr[1],
+            re = new RegExp( '^\\s*' + fst.trim().escapeRegExp() + '\\s*(.*)\\s*' + lst.trim().escapeRegExp() + '\\s*$' );
+
+        if( (fst+lst)!='' ){
+
+            s = txta.getSelection();
+            snip.parms = [];
+
+            // if pfx & sfx (with optional whitespace) are matched: remove them
+            if( s.test(re) ){
+
+                s = s.replace( re, '$1' );
+
+            // if pfx/sfx are matched just outside the selection: extend selection
+            } else if( txta.getFromStart().test(fst+'$') && txta.getTillEnd().test('^'+lst) ){
+
+                txta.setSelectionRange(caret.start-fst.length, caret.end+lst.length);
+
+            // otherwise, insert snippet and set caret between pfx and sfx
+            } else {
+
+                txta.setSelection( fst+lst ).setSelectionRange( caret.start + fst.length );
+            }
+        }
+        return s;
+    },
+
+    /*
+    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(){
+
+        var self = this,
+            txta = self.textarea,
+            caret = txta.getSelectionRange(),
+            fromStart = txta.getFromStart(),
+            suggestions = self.suggestions,
+            cmd, suggest, snip;
+
+        if( !self.options.autosuggest ) return;
+
+        for( cmd in suggestions ){
+
+            snip = suggestions[cmd];
+            suggest = snip.suggest(txta, caret);
+
+            if( suggest /*&& self.inScope(snip, fromStart)*/ ){
+
+                if(!suggest.tail) suggest.tail = 0; //ensure default value
+
+                //console.log( "Suggest: "+ cmd + " [" + JSON.encode(suggest)+"]" );
+                self.setContext( snip, suggest );
+                return self.commands.action(cmd, suggest.match);
+
+            }
+        }
+
+        //if you got here, no suggestions
+        this.clearContext();
+
+    },
+
+    /*
+    Method: suggestAction
+        <todo>
+        suggest = { start: start-position , match:'string', tail: length }
+    */
+    suggestAction: function( cmd, value ){
+
+        var self = this,
+            txta = self.textarea,
+            suggest = self.context.suggest,
+            end = suggest.start + suggest.match.length + suggest.tail;
+
+
+        //console.log('SuggestAction: '+ cmd+' (' +value + ') [' + JSON.encode(suggest) + ']');
+
+        //set the selection to the replaceable text, and inject the new value
+        txta.setSelectionRange( suggest.start, end ).setSelection( value );
+
+        //if tail, set the selection on the tail --why ??
+        if( suggest.tail>0 ){
+            txta.setSelectionRange( end - suggest.tail, txta.getSelectionRange().end );
+        }
+
+        self.clearContext();
+        return self.suggest();
+
+    },
+
+    /*
+    Function: nextAction
+        Process the next ''{parameter}'' of the active snippet as you tab along
+        or after you clicked a button or closed a dialog.
+
+    Arguments:
+        txta - Textarea object
+        caret - caret object, indicating the start/end of the textarea selection
+    */
+    nextAction: function(txta, caret){
+
+        var self = this,
+            snip = self.context.snip,
+            parms = snip.parms,
+            dialog,
+            pos;
+
+        while( parms[0] /*.length > 0*/ ){
+
+            dialog = parms.shift();
+            pos = txta.getValue().indexOf(dialog, caret.start);
+
+            //console.log("next action: "+dialog+ " pos:" + pos + " parms: "+parms+" caret:"+caret.start);
+
+            //found the next {dialog} or possibly the end of the snippet
+            if( (dialog !='') && (pos > -1) ){
+
+                if( parms[0] /*.length > 0*/ ){
+
+                    // select the next {dialog}
+                    txta.setSelectionRange( pos, pos + dialog.length );
+
+                    //invoke the new dialog
+                    //console.log('next action: invoke '+dialog+" "+snip[dialog])
+                    self.commands.action( dialog, snip[dialog] );
+
+                    //remember every selected snippet dialog
+                    self.undoredo.onChange();
+
+                    return; // and retain the context snip for subsequent {dialogs}
+
+                } else {
+
+                    // no more {dialogs}, move the caret after the end of the snippet
+                    txta.setSelectionRange( pos + dialog.length );
+
+                }
+            }
+        }
+
+        self.clearContext();
+    },
+
+    /*
+    Function: getState
+        Return the current state which consist of the content and selection of the textarea.
+        It implements the ''Undoable'' interface called from the [UndoRedo] class.
+    */
+    getState: function(){
+
+        var txta = this.textarea,
+            el = $(txta);
+
+        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 [UndoRedo] class.
+
+    Argument:
+        state - object originally created by the getState funcion
+    */
+    putState: function(state){
+
+        var self = this,
+            txta = self.textarea,
+            el = $(txta);
+
+        self.clearContext();
+        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 )
+            .fireEvent('change',[state.value]);
+    }
+
+});

Added: jspwiki/trunk/jspwiki-war/src/main/scripts/wiki-edit/UndoRedo.js
URL: http://svn.apache.org/viewvc/jspwiki/trunk/jspwiki-war/src/main/scripts/wiki-edit/UndoRedo.js?rev=1573337&view=auto
==============================================================================
--- jspwiki/trunk/jspwiki-war/src/main/scripts/wiki-edit/UndoRedo.js (added)
+++ jspwiki/trunk/jspwiki-war/src/main/scripts/wiki-edit/UndoRedo.js Sun Mar  2 18:27:50 2014
@@ -0,0 +1,130 @@
+/*
+Class: UndoRedo
+    The UndoRedo class implements a simple undo/redo stack to save and restore
+    the state of an 'undo-able' object.
+    The object needs to provide a {{getState()}} and a {{putState(obj)}} methods.
+    Whenever the object changes, it should call the UndoRedo onChange() handler.
+    Optionally, event-handlers can be attached for undo() and redo() functions.
+
+Arguments:
+    obj - the undo-able object
+    options - optional, see options below
+
+Options:
+    maxundo - integer , maximal size of the undo and redo stack (default 20)
+    redo - (optional) DOM element, will get a click handler to the redo() function
+    undo - (optional) DOM element, will get a click handler to the undo() function
+
+Example:
+(start code)
+        var undoredo = new UndoRedo(this, {
+            redoElement:'redoID',
+            undoElement:'undoID'
+        });
+
+        //when a change occurs on the calling object which needs to be persisted
+        undoredo.onChange( );
+(end)
+*/
+var UndoRedo = new Class({
+
+    Implements: Options,
+
+    options: {
+        //redo : redo button selector
+        //undo : undo button selector
+        maxundo:40
+    },
+    initialize: function(obj, options){
+
+        var self = this,
+            btn = this.btn = { redo:options.redo, undo:options.undo };
+
+        self.setOptions(options);
+        self.obj = obj;
+        self.redo = [];
+        self.undo = [];
+
+        self.btnStyle();
+    },
+
+    /*
+    Function: onChange
+        Call the onChange function to persist the current state of the undo-able object.
+        The UndoRedo class will call the {{obj.getState()}} to retrieve the state info.
+
+    Arguments:
+        state - (optional) state object to be persisted. If not present,
+            the state will be retrieved via a call to the {{obj.getState()}} function.
+    */
+    onChange: function(state){
+
+        var self = this;
+
+        self.undo.push( state || self.obj.getState() );
+        self.redo = [];
+
+        if(self.undo[self.options.maxundo]){ 
+            self.undo.shift(); 
+        }
+        self.btnStyle();
+
+    },
+
+    /*
+    Function: onUndo
+        Click event-handler to recall the state of the object
+    */
+    onUndo: function(e){
+
+        var self = this;
+
+        if(e){ e.stop(); }
+
+        //if(self.undo.length > 0){
+        if(self.undo[0] /*length>0*/){
+
+            self.redo.push( self.obj.getState() );
+            self.obj.putState( self.undo.pop() );
+
+        }
+        self.btnStyle();
+
+    },
+
+    /*
+    Function: onRedo
+        Click event-handler to recall the state of the object after a previous undo action.
+        The state will be reset by means of the {{obj.putState()}} method
+    */
+    onRedo: function(e){
+
+        var self = this;
+
+        if(e){ e.stop(); }
+
+        //if(self.redo.length > 0){
+        if(self.redo[0] /*.length > 0*/){
+
+            self.undo.push( self.obj.getState() );
+            self.obj.putState( self.redo.pop() );
+
+        }
+        self.btnStyle();
+
+    },
+
+    /*
+    Function: btnStyle
+        Helper function to change the css style of the undo/redo buttons.
+    */
+    btnStyle: function(){
+
+        var self = this, btn = self.btn;
+
+        if(btn.undo){ btn.undo.ifClass( !self.undo[0] /*length==0*/, 'disabled'); }
+        if(btn.redo){ btn.redo.ifClass( !self.redo[0] /*length==0*/, 'disabled'); }
+
+    }
+
+});