1 /* See license.txt for terms of usage */
  2 
  3 define([
  4     "firebug/lib/object",
  5     "firebug/firebug",
  6     "firebug/lib/domplate",
  7     "firebug/lib/locale",
  8     "firebug/lib/events",
  9     "firebug/lib/css",
 10     "firebug/lib/dom",
 11     "firebug/lib/string",
 12     "firebug/lib/array",
 13     "firebug/chrome/menu",
 14     "firebug/trace/debug",
 15 ],
 16 function(Obj, Firebug, Domplate, Locale, Events, Css, Dom, Str, Arr, Menu, Debug) {
 17 
 18 // ********************************************************************************************* //
 19 // Constants
 20 
 21 const saveTimeout = 400;
 22 const largeChangeAmount = 10;
 23 const smallChangeAmount = 0.1;
 24 
 25 // ********************************************************************************************* //
 26 // Globals
 27 
 28 // xxxHonza: it's bad design to have these globals.
 29 var currentTarget = null;
 30 var currentGroup = null;
 31 var currentPanel = null;
 32 var currentEditor = null;
 33 
 34 var defaultEditor = null;
 35 
 36 var originalClassName = null;
 37 
 38 var originalValue = null;
 39 var defaultValue = null;
 40 var previousValue = null;
 41 
 42 var invalidEditor = false;
 43 var ignoreNextInput = false;
 44 
 45 // ********************************************************************************************* //
 46 
 47 Firebug.Editor = Obj.extend(Firebug.Module,
 48 {
 49     supportsStopEvent: true,
 50 
 51     dispatchName: "editor",
 52     tabCharacter: "    ",
 53 
 54     setSelection: function(selectionData)
 55     {
 56         if (currentEditor && currentEditor.setSelection)
 57             currentEditor.setSelection(selectionData);
 58     },
 59 
 60     startEditing: function(target, value, editor, selectionData)
 61     {
 62         this.stopEditing();
 63 
 64         if (Css.hasClass(target, "insertBefore") || Css.hasClass(target, "insertAfter"))
 65             return;
 66 
 67         var panel = Firebug.getElementPanel(target);
 68         if (!panel.editable)
 69             return;
 70 
 71         if (FBTrace.DBG_EDITOR)
 72             FBTrace.sysout("editor.startEditing " + value, target);
 73 
 74         defaultValue = target.getAttribute("defaultValue");
 75         if (value == undefined)
 76         {
 77             value = target.textContent;
 78             if (value == defaultValue)
 79                 value = "";
 80         }
 81 
 82         invalidEditor = false;
 83         currentTarget = target;
 84         currentPanel = panel;
 85         currentGroup = Dom.getAncestorByClass(target, "editGroup");
 86 
 87         currentPanel.editing = true;
 88 
 89         var panelEditor = currentPanel.getEditor(target, value);
 90         currentEditor = editor ? editor : panelEditor;
 91         if (!currentEditor)
 92             currentEditor = getDefaultEditor(currentPanel);
 93 
 94         Css.setClass(panel.panelNode, "editing");
 95         Css.setClass(target, "editing");
 96         if (currentGroup)
 97             Css.setClass(currentGroup, "editing");
 98 
 99         originalValue = previousValue = value = currentEditor.getInitialValue(target, value);
100 
101         currentEditor.show(target, currentPanel, value, selectionData);
102         Events.dispatch(this.fbListeners, "onBeginEditing", [currentPanel, currentEditor, target, value]);
103         currentEditor.beginEditing(target, value);
104 
105         if (FBTrace.DBG_EDITOR)
106             FBTrace.sysout("Editor start panel "+currentPanel.name);
107 
108         this.attachListeners(currentEditor, panel.context);
109     },
110 
111     saveAndClose: function()
112     {
113         if (!currentTarget)
114             return;
115 
116         Events.dispatch(currentPanel.fbListeners, "onInlineEditorClose", [currentPanel,
117             currentTarget, !originalValue]);
118 
119         this.stopEditing();
120     },
121 
122     stopEditing: function(cancel)
123     {
124         if (!currentTarget)
125             return;
126 
127         if (FBTrace.DBG_EDITOR)
128         {
129             FBTrace.sysout("editor.stopEditing cancel:" + cancel+" saveTimeout: " +
130                 this.saveTimeout);
131         }
132 
133         // Make sure the content is save if there is a timeout in progress.
134         if (this.saveTimeout)
135             this.save();
136 
137         clearTimeout(this.saveTimeout);
138         delete this.saveTimeout;
139 
140         this.detachListeners(currentEditor, currentPanel.context);
141 
142         Css.removeClass(currentPanel.panelNode, "editing");
143         Css.removeClass(currentTarget, "editing");
144         if (currentGroup)
145             Css.removeClass(currentGroup, "editing");
146 
147         var value = currentEditor.getValue();
148         if (value == defaultValue)
149             value = "";
150 
151         // Reset the editor's value so it isn't accidentally reused the next time
152         // the editor instance is reused (see also 3280, 3332).
153         currentEditor.setValue("");
154 
155         var removeGroup = currentEditor.endEditing(currentTarget, value, cancel);
156 
157         try
158         {
159             if (cancel)
160             {
161                 Events.dispatch(currentPanel.fbListeners, "onInlineEditorClose",
162                     [currentPanel, currentTarget, removeGroup && !originalValue]);
163 
164                 if (value != originalValue)
165                     this.saveEditAndNotifyListeners(currentTarget, originalValue, previousValue);
166 
167                 currentEditor.cancelEditing(currentTarget, originalValue);
168 
169                 if (removeGroup && !originalValue && currentGroup)
170                     currentGroup.parentNode.removeChild(currentGroup);
171             }
172             else if (!value)
173             {
174                 this.saveEditAndNotifyListeners(currentTarget, "", previousValue);
175 
176                 if (removeGroup && currentGroup && currentGroup.parentNode)
177                     currentGroup.parentNode.removeChild(currentGroup);
178             }
179             else
180             {
181                 this.save(value);
182             }
183         }
184         catch (exc)
185         {
186             Debug.ERROR(exc);
187         }
188 
189         currentEditor.hide();
190         currentPanel.editing = false;
191 
192         Events.dispatch(this.fbListeners, "onStopEdit", [currentPanel, currentEditor,
193             currentTarget]);
194 
195         if (FBTrace.DBG_EDITOR)
196             FBTrace.sysout("Editor stop panel " + currentPanel.name);
197 
198         currentTarget = null;
199         currentGroup = null;
200         currentPanel = null;
201         currentEditor = null;
202         originalValue = null;
203         invalidEditor = false;
204 
205         return value;
206     },
207 
208     cancelEditing: function()
209     {
210         return this.stopEditing(true);
211     },
212 
213     update: function(saveNow)
214     {
215         if (this.saveTimeout)
216             clearTimeout(this.saveTimeout);
217 
218         invalidEditor = true;
219 
220         currentEditor.layout();
221 
222         if (saveNow)
223         {
224             this.save();
225         }
226         else
227         {
228             var context = currentPanel.context;
229             this.saveTimeout = context.setTimeout(Obj.bindFixed(this.save, this), saveTimeout);
230 
231             if (FBTrace.DBG_EDITOR)
232                 FBTrace.sysout("editor.update saveTimeout: "+this.saveTimeout);
233         }
234     },
235 
236     save: function(value)
237     {
238         if (!invalidEditor)
239             return;
240 
241         if (value == undefined)
242             value = currentEditor.getValue();
243 
244         if (FBTrace.DBG_EDITOR)
245             FBTrace.sysout("editor.save saveTimeout: " + this.saveTimeout + " currentPanel: " +
246                 (currentPanel ? currentPanel.name : "null"));
247 
248         try
249         {
250             this.saveEditAndNotifyListeners(currentTarget, value, previousValue);
251 
252             previousValue = value;
253             invalidEditor = false;
254         }
255         catch (exc)
256         {
257             if (FBTrace.DBG_ERRORS)
258                 FBTrace.sysout("editor.save FAILS "+exc, exc);
259         }
260     },
261 
262     saveEditAndNotifyListeners: function(currentTarget, value, previousValue)
263     {
264         currentEditor.saveEdit(currentTarget, value, previousValue);
265         Events.dispatch(this.fbListeners, "onSaveEdit", [currentPanel, currentEditor,
266             currentTarget, value, previousValue]);
267     },
268 
269     setEditTarget: function(element)
270     {
271         if (!element)
272         {
273             Events.dispatch(currentPanel.fbListeners, "onInlineEditorClose",
274                 [currentPanel, currentTarget, true]);
275             this.stopEditing();
276         }
277         else if (Css.hasClass(element, "insertBefore"))
278             this.insertRow(element, "before");
279         else if (Css.hasClass(element, "insertAfter"))
280             this.insertRow(element, "after");
281         else
282             this.startEditing(element);
283     },
284 
285     tabNextEditor: function()
286     {
287         if (!currentTarget)
288             return;
289 
290         var value = currentEditor.getValue();
291         var nextEditable = currentTarget;
292         do
293         {
294             nextEditable = !value && currentGroup
295                 ? getNextOutsider(nextEditable, currentGroup)
296                 : Dom.getNextByClass(nextEditable, "editable");
297         }
298         while (nextEditable && !nextEditable.offsetHeight);
299 
300         this.setEditTarget(nextEditable);
301     },
302 
303     tabPreviousEditor: function()
304     {
305         if (!currentTarget)
306             return;
307 
308         var value = currentEditor.getValue();
309         var prevEditable = currentTarget;
310         do
311         {
312             prevEditable = !value && currentGroup
313                 ? getPreviousOutsider(prevEditable, currentGroup)
314                 : Dom.getPreviousByClass(prevEditable, "editable");
315         }
316         while (prevEditable && !prevEditable.offsetHeight);
317 
318         this.setEditTarget(prevEditable);
319     },
320 
321     insertRow: function(relative, insertWhere)
322     {
323         var group =
324             relative || Dom.getAncestorByClass(currentTarget, "editGroup") || currentTarget;
325         var value = this.stopEditing();
326 
327         currentPanel = Firebug.getElementPanel(group);
328 
329         currentEditor = currentPanel.getEditor(group, value);
330         if (!currentEditor)
331             currentEditor = getDefaultEditor(currentPanel);
332 
333         currentGroup = currentEditor.insertNewRow(group, insertWhere);
334         if (!currentGroup)
335             return;
336 
337         var editable = Css.hasClass(currentGroup, "editable")
338             ? currentGroup
339             : Dom.getNextByClass(currentGroup, "editable");
340 
341         if (editable)
342             this.setEditTarget(editable);
343     },
344 
345     insertRowForObject: function(relative)
346     {
347         var container = Dom.getAncestorByClass(relative, "insertInto");
348         if (container)
349         {
350             relative = Dom.getChildByClass(container, "insertBefore");
351             if (relative)
352                 this.insertRow(relative, "before");
353         }
354     },
355 
356     // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
357 
358     attachListeners: function(editor, context)
359     {
360         var win = currentTarget.ownerDocument.defaultView;
361         Events.addEventListener(win, "resize", this.onResize, true);
362         Events.addEventListener(win, "blur", this.onBlur, true);
363 
364         var chrome = Firebug.chrome;
365 
366         this.listeners = [
367             chrome.keyCodeListen("ESCAPE", null, Obj.bind(this.cancelEditing, this)),
368         ];
369 
370         if (editor.arrowCompletion)
371         {
372             this.listeners.push(
373                 chrome.keyCodeListen("UP", null, Obj.bindFixed(editor.completeValue, editor, -1)),
374                 chrome.keyCodeListen("DOWN", null, Obj.bindFixed(editor.completeValue, editor, 1)),
375                 chrome.keyCodeListen("UP", Events.isShift, Obj.bindFixed(editor.completeValue, editor, -largeChangeAmount)),
376                 chrome.keyCodeListen("DOWN", Events.isShift, Obj.bindFixed(editor.completeValue, editor, largeChangeAmount)),
377                 chrome.keyCodeListen("UP", Events.isControl, Obj.bindFixed(editor.completeValue, editor, -smallChangeAmount)),
378                 chrome.keyCodeListen("DOWN", Events.isControl, Obj.bindFixed(editor.completeValue, editor, smallChangeAmount)),
379                 chrome.keyCodeListen("PAGE_UP", null, Obj.bindFixed(editor.completeValue, editor, -largeChangeAmount)),
380                 chrome.keyCodeListen("PAGE_DOWN", null, Obj.bindFixed(editor.completeValue, editor, largeChangeAmount))
381             );
382         }
383 
384         if (currentEditor.tabNavigation)
385         {
386             this.listeners.push(
387                 chrome.keyCodeListen("RETURN", null, Obj.bind(this.tabNextEditor, this)),
388                 chrome.keyCodeListen("RETURN", Events.isShift, Obj.bind(this.saveAndClose, this)),
389                 chrome.keyCodeListen("RETURN", Events.isControl, Obj.bind(this.insertRow, this, null, "after")),
390                 chrome.keyCodeListen("TAB", null, Obj.bind(this.tabNextEditor, this)),
391                 chrome.keyCodeListen("TAB", Events.isShift, Obj.bind(this.tabPreviousEditor, this))
392             );
393         }
394         else if (currentEditor.multiLine)
395         {
396             this.listeners.push(
397                 chrome.keyCodeListen("TAB", null, insertTab)
398             );
399         }
400         else
401         {
402             this.listeners.push(
403                 chrome.keyCodeListen("RETURN", null, Obj.bindFixed(this.stopEditing, this))
404             );
405 
406             if (currentEditor.tabCompletion)
407             {
408                 this.listeners.push(
409                     chrome.keyCodeListen("TAB", null, Obj.bind(editor.completeValue, editor, 1)),
410                     chrome.keyCodeListen("TAB", Events.isShift, Obj.bind(editor.completeValue, editor, -1)),
411                     chrome.keyCodeListen("UP", null, Obj.bindFixed(editor.completeValue, editor, -1, true)),
412                     chrome.keyCodeListen("DOWN", null, Obj.bindFixed(editor.completeValue, editor, 1, true)),
413                     chrome.keyCodeListen("UP", Events.isShift, Obj.bindFixed(editor.completeValue, editor, -largeChangeAmount)),
414                     chrome.keyCodeListen("DOWN", Events.isShift, Obj.bindFixed(editor.completeValue, editor, largeChangeAmount)),
415                     chrome.keyCodeListen("UP", Events.isControl, Obj.bindFixed(editor.completeValue, editor, -smallChangeAmount)),
416                     chrome.keyCodeListen("DOWN", Events.isControl, Obj.bindFixed(editor.completeValue, editor, smallChangeAmount)),
417                     chrome.keyCodeListen("PAGE_UP", null, Obj.bindFixed(editor.completeValue, editor, -largeChangeAmount)),
418                     chrome.keyCodeListen("PAGE_DOWN", null, Obj.bindFixed(editor.completeValue, editor, largeChangeAmount))
419                 );
420             }
421         }
422     },
423 
424     detachListeners: function(editor, context)
425     {
426         if (!this.listeners)
427             return;
428 
429         var win = currentTarget.ownerDocument.defaultView;
430         Events.removeEventListener(win, "resize", this.onResize, true);
431         Events.removeEventListener(win, "blur", this.onBlur, true);
432         Events.removeEventListener(win, "input", this.onInput, true);
433 
434         var chrome = Firebug.chrome;
435         if (chrome)
436         {
437             for (var i = 0; i < this.listeners.length; ++i)
438                 chrome.keyIgnore(this.listeners[i]);
439         }
440 
441         delete this.listeners;
442     },
443 
444     onResize: function(event)
445     {
446         currentEditor.layout(true);
447     },
448 
449     onBlur: function(event)
450     {
451         if (currentEditor.enterOnBlur && Dom.isAncestor(event.target, currentEditor.box))
452             this.stopEditing();
453     },
454 
455     // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
456     // extends Module
457 
458     initialize: function()
459     {
460         this.onResize = Obj.bindFixed(this.onResize, this);
461         this.onBlur = Obj.bind(this.onBlur, this);
462 
463         Firebug.Module.initialize.apply(this, arguments);
464     },
465 
466     disable: function()
467     {
468         this.stopEditing();
469     },
470 
471     showContext: function(browser, context)
472     {
473         this.stopEditing();
474     },
475 
476     showPanel: function(browser, panel)
477     {
478         this.stopEditing();
479     }
480 });
481 
482 // ********************************************************************************************* //
483 // BaseEditor
484 
485 Firebug.BaseEditor = Obj.extend(Firebug.MeasureBox,
486 {
487     getInitialValue: function(target, value)
488     {
489         return value;
490     },
491 
492     getValue: function()
493     {
494     },
495 
496     setValue: function(value)
497     {
498     },
499 
500     show: function(target, panel, value, selectionData)
501     {
502     },
503 
504     hide: function()
505     {
506     },
507 
508     layout: function(forceAll)
509     {
510     },
511 
512     // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
513     // Support for context menus within inline editors.
514 
515     getContextMenuItems: function(target)
516     {
517         var items = [];
518         items.push({label: "Cut", command: Obj.bind(this.onCommand, this, "cmd_cut")});
519         items.push({label: "Copy", command: Obj.bind(this.onCommand, this, "cmd_copy")});
520         items.push({label: "Paste", command: Obj.bind(this.onCommand, this, "cmd_paste")});
521         return items;
522     },
523 
524     onCommand: function(command, cmdId)
525     {
526         var browserWindow = Firebug.chrome.window;
527 
528         // Use the right browser window to get the current command controller (issue 4177).
529         var controller = browserWindow.document.commandDispatcher.getControllerForCommand(cmdId);
530         var enabled = controller.isCommandEnabled(cmdId);
531         if (controller && enabled)
532             controller.doCommand(cmdId);
533     },
534 
535     // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
536     // Editor Module listeners will get "onBeginEditing" just before this call
537 
538     beginEditing: function(target, value)
539     {
540     },
541 
542     // Editor Module listeners will get "onSaveEdit" just after this call
543     saveEdit: function(target, value, previousValue)
544     {
545     },
546 
547     endEditing: function(target, value, cancel)
548     {
549         // Remove empty groups by default
550         return true;
551     },
552 
553     cancelEditing: function(target, value)
554     {
555     },
556 
557     insertNewRow: function(target, insertWhere)
558     {
559     },
560 });
561 
562 // ********************************************************************************************* //
563 // InlineEditor
564 
565 Firebug.InlineEditor = function(doc)
566 {
567     this.initializeInline(doc);
568 };
569 
570 with (Domplate) {
571 Firebug.InlineEditor.prototype = domplate(Firebug.BaseEditor,
572 {
573     enterOnBlur: true,
574 
575     tag:
576         DIV({"class": "inlineEditor"},
577             INPUT({"class": "textEditorInner", type: "text",
578                 oninput: "$onInput", onkeypress: "$onKeyPress", onoverflow: "$onOverflow",
579                 oncontextmenu: "$onContextMenu"}
580             )
581         ),
582 
583     inputTag :
584         INPUT({"class": "textEditorInner", type: "text",
585             oninput: "$onInput", onkeypress: "$onKeyPress", onoverflow: "$onOverflow"}
586         ),
587 
588     expanderTag:
589         SPAN({"class": "inlineExpander", style: "-moz-user-focus:ignore;opacity:0.5"}),
590 
591     initialize: function()
592     {
593         this.fixedWidth = false;
594         this.completeAsYouType = true;
595         this.tabNavigation = true;
596         this.multiLine = false;
597         this.tabCompletion = false;
598         this.arrowCompletion = true;
599         this.noWrap = true;
600         this.numeric = false;
601     },
602 
603     destroy: function()
604     {
605         this.destroyInput();
606     },
607 
608     initializeInline: function(doc)
609     {
610         this.box = this.tag.replace({}, doc, this);
611         this.input = this.box.firstChild;
612         this.expander = this.expanderTag.replace({}, doc, this);
613         this.initialize();
614     },
615 
616     destroyInput: function()
617     {
618         // XXXjoe Need to remove input/keypress handlers to avoid leaks
619     },
620 
621     getValue: function()
622     {
623         return this.input.value;
624     },
625 
626     setValue: function(value)
627     {
628         // It's only a one-line editor, so new lines shouldn't be allowed
629         return this.input.value = Str.stripNewLines(value);
630     },
631 
632     setSelection: function(selectionData)
633     {
634         this.input.setSelectionRange(selectionData.start, selectionData.end);
635         // Ci.nsISelectionController SELECTION_NORMAL SELECTION_ANCHOR_REGION SCROLL_SYNCHRONOUS
636         this.input.QueryInterface(Components.interfaces.nsIDOMNSEditableElement)
637             .editor.selectionController.scrollSelectionIntoView(1, 0, 2);
638     },
639 
640     show: function(target, panel, value, selectionData)
641     {
642         if (FBTrace.DBG_EDITOR)
643         {
644             FBTrace.sysout("Firebug.InlineEditor.show",
645                 {target: target, panel: panel, value: value, selectionData: selectionData});
646         }
647 
648         Events.dispatch(panel.fbListeners, "onInlineEditorShow", [panel, this]);
649         this.target = target;
650         this.panel = panel;
651 
652         this.targetOffset = Dom.getClientOffset(target);
653 
654         this.originalClassName = this.box.className;
655 
656         var classNames = target.className.split(" ");
657         for (var i = 0; i < classNames.length; ++i)
658             Css.setClass(this.box, "editor-" + classNames[i]);
659 
660         // remove error information
661         this.box.removeAttribute('saveSuccess');
662 
663         // Make the editor match the target's font style
664         Css.copyTextStyles(target, this.box);
665 
666         this.setValue(value);
667 
668         this.getAutoCompleter().reset();
669 
670         panel.panelNode.appendChild(this.box);
671         this.input.select();
672         if (selectionData) //transfer selection to input element
673             this.setSelection(selectionData);
674 
675         // Insert the "expander" to cover the target element with white space
676         if (!this.fixedWidth)
677         {
678             this.startMeasuring(target);
679 
680             Css.copyBoxStyles(target, this.expander);
681             target.parentNode.replaceChild(this.expander, target);
682             Dom.collapse(target, true);
683             this.expander.parentNode.insertBefore(target, this.expander);
684             this.textSize = this.measureInputText(value);
685         }
686 
687         this.updateLayout(true);
688 
689         Dom.scrollIntoCenterView(this.box, null, true);
690     },
691 
692     hide: function()
693     {
694         if (FBTrace.DBG_EDITOR)
695             FBTrace.sysout("Firebug.InlineEditor.hide");
696 
697         this.box.className = this.originalClassName;
698 
699         if (!this.fixedWidth)
700         {
701             this.stopMeasuring();
702 
703             Dom.collapse(this.target, false);
704 
705             if (this.expander.parentNode)
706                 this.expander.parentNode.removeChild(this.expander);
707         }
708 
709         if (this.box.parentNode)
710         {
711             try { this.input.setSelectionRange(0, 0); } catch (exc) {}
712             this.box.parentNode.removeChild(this.box);
713         }
714 
715         delete this.target;
716         delete this.panel;
717     },
718 
719     layout: function(forceAll)
720     {
721         if (!this.fixedWidth)
722             this.textSize = this.measureInputText(this.input.value);
723 
724         if (forceAll)
725             this.targetOffset = Dom.getClientOffset(this.expander);
726 
727         this.updateLayout(false, forceAll);
728     },
729 
730     // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
731 
732     beginEditing: function(target, value)
733     {
734     },
735 
736     saveEdit: function(target, value, previousValue)
737     {
738     },
739 
740     endEditing: function(target, value, cancel)
741     {
742         if (FBTrace.DBG_EDITOR)
743         {
744             FBTrace.sysout("Firebug.InlineEditor.endEditing",
745                 {target: target, value: value, cancel: cancel});
746         }
747 
748         // Remove empty groups by default
749         return true;
750     },
751 
752     insertNewRow: function(target, insertWhere)
753     {
754     },
755 
756     advanceToNext: function(target, charCode)
757     {
758         return false;
759     },
760 
761     // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
762 
763     getAutoCompleteRange: function(value, offset)
764     {
765     },
766 
767     getAutoCompleteList: function(preExpr, expr, postExpr)
768     {
769         return [];
770     },
771 
772     getAutoCompletePropSeparator: function(range, expr, prefixOf)
773     {
774         return null;
775     },
776 
777     autoCompleteAdjustSelection: function(value, offset)
778     {
779         return null;
780     },
781 
782     // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
783 
784     getAutoCompleter: function()
785     {
786         if (!this.autoCompleter)
787         {
788             this.autoCompleter = new Firebug.AutoCompleter(false,
789                 Obj.bind(this.getAutoCompleteRange, this),
790                 Obj.bind(this.getAutoCompleteList, this),
791                 Obj.bind(this.getAutoCompletePropSeparator, this),
792                 Obj.bind(this.autoCompleteAdjustSelection, this));
793         }
794 
795         return this.autoCompleter;
796     },
797 
798     completeValue: function(amt)
799     {
800         if (this.getAutoCompleter().complete(currentPanel.context, this.input, amt, true))
801             Firebug.Editor.update(true);
802         else
803             this.incrementValue(amt);
804     },
805 
806     incrementValue: function(amt)
807     {
808         var value = this.input.value;
809         var offset = this.input.selectionStart;
810         var offsetEnd = this.input.selectionEnd;
811 
812         var newValue = this.doIncrementValue(value, amt, offset, offsetEnd);
813         if (!newValue)
814             return false;
815 
816         this.input.value = newValue.value;
817         this.input.setSelectionRange(newValue.start, newValue.end);
818 
819         Firebug.Editor.update(true);
820         return true;
821     },
822 
823     incrementExpr: function(expr, amt, info)
824     {
825         var num = parseFloat(expr);
826         if (isNaN(num))
827             return null;
828 
829         var m = /\d+(\.\d+)?/.exec(expr);
830         var digitPost = expr.substr(m.index+m[0].length);
831         var newValue = Math.round((num-amt)*1000)/1000; // avoid rounding errors
832 
833         if (info && "minValue" in info)
834             newValue = Math.max(newValue, info.minValue);
835         if (info && "maxValue" in info)
836             newValue = Math.min(newValue, info.maxValue);
837 
838         newValue = newValue.toString();
839 
840         // Preserve trailing zeroes of small increments.
841         if (Math.abs(amt) < 1)
842         {
843             if (newValue.indexOf(".") === -1)
844                 newValue += ".";
845             var dec = newValue.length - newValue.lastIndexOf(".") - 1;
846             var incDec = Math.abs(amt).toString().length - 2;
847             while (dec < incDec)
848             {
849                 newValue += "0";
850                 ++dec;
851             }
852         }
853 
854         return newValue + digitPost;
855     },
856 
857     doIncrementValue: function(value, amt, offset, offsetEnd, info)
858     {
859         // Try to find a number around the cursor to increment.
860         var start, end;
861         if (/^-?[0-9.]/.test(value.substring(offset, offsetEnd)) &&
862             !(info && /\d/.test(value.charAt(offset-1) + value.charAt(offsetEnd))))
863         {
864             // We have a number selected, possibly with a suffix, and we are not in
865             // the disallowed case of just part of a known number being selected.
866             // Use that number.
867             start = offset;
868             end = offsetEnd;
869         }
870         else
871         {
872             // Parse periods as belonging to the number only if we are in a known number
873             // context. (This makes incrementing the 1 in 'image1.gif' work.)
874             var pattern = "[" + (info ? "0-9." : "0-9") + "]*";
875 
876             var before = new RegExp(pattern + "$").exec(value.substr(0, offset))[0].length;
877             var after = new RegExp("^" + pattern).exec(value.substr(offset))[0].length;
878             start = offset - before;
879             end = offset + after;
880 
881             // Expand the number to contain an initial minus sign if it seems
882             // free-standing.
883             if (value.charAt(start-1) === "-" &&
884                 (start-1 === 0 || /[ (:,='"]/.test(value.charAt(start-2))))
885             {
886                 --start;
887             }
888         }
889 
890         if (start !== end)
891         {
892             // Include percentages as part of the incremented number (they are
893             // common enough).
894             if (value.charAt(end) === "%")
895                 ++end;
896 
897             var first = value.substr(0, start);
898             var mid = value.substring(start, end);
899             var last = value.substr(end);
900             mid = this.incrementExpr(mid, amt, info);
901             if (mid !== null)
902             {
903                 return {
904                     value: first + mid + last,
905                     start: start,
906                     end: start + mid.length
907                 };
908             }
909         }
910     },
911 
912     // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
913 
914     onKeyPress: function(event)
915     {
916         if (event.keyCode == KeyEvent.DOM_VK_ESCAPE && !this.completeAsYouType)
917         {
918             var reverted = this.getAutoCompleter().revert(this.input);
919             if (reverted)
920                 Events.cancelEvent(event);
921         }
922         else if (event.keyCode == KeyEvent.DOM_VK_RIGHT && this.completeAsYouType)
923         {
924             if (this.getAutoCompleter().acceptCompletion(this.input))
925                 Events.cancelEvent(event);
926         }
927         else if (event.charCode && this.advanceToNext(this.target, event.charCode))
928         {
929             Firebug.Editor.tabNextEditor();
930             Events.cancelEvent(event);
931         }
932         else if (this.numeric && event.charCode &&
933             !(event.ctrlKey || event.metaKey || event.altKey) &&
934             !(KeyEvent.DOM_VK_0 <= event.charCode && event.charCode <= KeyEvent.DOM_VK_9) &&
935             event.charCode !== KeyEvent.DOM_VK_INSERT && event.charCode !== KeyEvent.DOM_VK_DELETE)
936         {
937             Events.cancelEvent(event);
938         }
939         else if (event.keyCode == KeyEvent.DOM_VK_BACK_SPACE ||
940             event.keyCode == KeyEvent.DOM_VK_DELETE)
941         {
942             // If the user deletes text, don't autocomplete after the upcoming input event
943             this.ignoreNextInput = true;
944         }
945     },
946 
947     onOverflow: function()
948     {
949         this.updateLayout(false, false, 3);
950     },
951 
952     onInput: function()
953     {
954         if (this.ignoreNextInput)
955         {
956             this.ignoreNextInput = false;
957             this.getAutoCompleter().reset();
958         }
959         else if (this.completeAsYouType)
960             this.getAutoCompleter().complete(currentPanel.context, this.input, 0, false);
961         else
962             this.getAutoCompleter().reset();
963 
964         Firebug.Editor.update();
965     },
966 
967     onContextMenu: function(event)
968     {
969         Events.cancelEvent(event);
970 
971         var popup = Firebug.chrome.$("fbInlineEditorPopup");
972         Dom.eraseNode(popup);
973 
974         var target = event.target;
975         var items = this.getContextMenuItems(target);
976         if (items)
977             Menu.createMenuItems(popup, items);
978 
979         if (!popup.firstChild)
980             return false;
981 
982         popup.openPopupAtScreen(event.screenX, event.screenY, true);
983         return true;
984     },
985 
986     // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
987 
988     updateLayout: function(initial, forceAll, extraWidth)
989     {
990         if (this.fixedWidth)
991         {
992             this.box.style.left = this.targetOffset.x + "px";
993             this.box.style.top = this.targetOffset.y + "px";
994 
995             var w = this.target.offsetWidth;
996             var h = this.target.offsetHeight;
997             this.input.style.width = w + "px";
998             this.input.style.height = (h-3) + "px";
999         }
1000         else
1001         {
1002             this.expander.textContent = this.input.value;
1003 
1004             var clR = this.expander.getClientRects(),
1005                 wasWrapped = this.wrapped, inputWidth = Infinity;
1006 
1007             if(clR.length == 1)
1008             {
1009                 this.wrapped = false;
1010             }
1011             else if (clR.length == 2)
1012             {
1013                 var w1 = clR[0].width;
1014                 var w2 = clR[1].width;
1015 
1016                 if (w2 > w1){
1017                     this.wrapped = true;
1018                     inputWidth = w2;
1019                 } else
1020                     this.wrapped = false;
1021             }
1022             else if (clR.length == 3)
1023             {
1024                 this.wrapped = true;
1025                 if (clR[2].width > 50)
1026                     inputWidth = clR[1].width;
1027             }
1028             else if(clR.length > 3)
1029             {
1030                 this.wrapped = true;
1031             }
1032 
1033             if(this.wrapped)
1034             {
1035                 var fixupL = clR[1].left - clR[0].left;
1036                     fixupT = clR[1].top - clR[0].top;
1037             }
1038             else
1039             {
1040                 var fixupL = 0, fixupT = 0;
1041                 var approxTextWidth = this.textSize.width;
1042                 // Make the input one character wider than the text value so that
1043                 // typing does not ever cause the textbox to scroll
1044                 var charWidth = this.measureInputText('m').width;
1045 
1046                 // Sometimes we need to make the editor a little wider, specifically when
1047                 // an overflow happens, otherwise it will scroll off some text on the left
1048                 if (extraWidth)
1049                     charWidth *= extraWidth;
1050 
1051                 var inputWidth = approxTextWidth + charWidth;
1052             }
1053 
1054 
1055             var container = currentPanel.panelNode;
1056             var maxWidth = container.clientWidth - this.targetOffset.x - fixupL +
1057                 container.scrollLeft-6;
1058 
1059             if(inputWidth > maxWidth)
1060                 inputWidth = maxWidth;
1061 
1062             if (forceAll || initial || this.wrapped != wasWrapped)
1063             {
1064                 this.box.style.left = (this.targetOffset.x + fixupL) + "px";
1065                 this.box.style.top = (this.targetOffset.y + fixupT) + "px";
1066             }
1067             this.input.style.width = inputWidth + "px";
1068         }
1069 
1070         if (forceAll)
1071             Dom.scrollIntoCenterView(this.box, null, true);
1072     }
1073 })};
1074 
1075 // ********************************************************************************************* //
1076 // Autocompletion
1077 
1078 Firebug.AutoCompleter = function(caseSensitive, getRange, evaluator, getNewPropSeparator,
1079     adjustSelectionOnAccept)
1080 {
1081     var candidates = null;
1082     var suggestedDefault = null;
1083     var lastValue = "";
1084     var originalOffset = -1;
1085     var originalValue = null;
1086     var lastExpr = null;
1087     var lastOffset = -1;
1088     var exprOffset = 0;
1089     var lastIndex = null;
1090     var preExpr = null;
1091     var postExpr = null;
1092 
1093     this.revert = function(textBox)
1094     {
1095         if (originalOffset != -1)
1096         {
1097             textBox.value = lastValue = originalValue;
1098             textBox.setSelectionRange(originalOffset, originalOffset);
1099 
1100             this.reset();
1101             return true;
1102         }
1103         else
1104         {
1105             this.reset();
1106             return false;
1107         }
1108     };
1109 
1110     this.reset = function()
1111     {
1112         candidates = null;
1113         suggestedDefault = null;
1114         originalOffset = -1;
1115         originalValue = null;
1116         lastExpr = null;
1117         lastOffset = 0;
1118         exprOffset = 0;
1119         lastIndex = null;
1120     };
1121 
1122     this.acceptCompletion = function(textBox)
1123     {
1124         if (!adjustSelectionOnAccept)
1125             return false;
1126 
1127         var value = textBox.value;
1128         var offset = textBox.selectionStart;
1129         var offsetEnd = textBox.selectionEnd;
1130         if (!candidates || value !== lastValue || offset !== lastOffset || offset >= offsetEnd)
1131             return false;
1132 
1133         var ind = adjustSelectionOnAccept(value, offsetEnd);
1134         if (ind === null)
1135             return false;
1136 
1137         textBox.setSelectionRange(ind, ind);
1138         return true;
1139     };
1140 
1141     this.complete = function(context, textBox, cycle)
1142     {
1143         if (!textBox.value && !cycle)
1144         {
1145             // Don't complete an empty field.
1146             return false;
1147         }
1148 
1149         var offset = textBox.selectionStart; // defines the cursor position
1150 
1151         var found = this.pickCandidates(textBox, context, cycle);
1152 
1153         if (!found)
1154             this.reset();
1155 
1156         return found;
1157     };
1158 
1159     /**
1160      * returns true if candidate list was created
1161      */
1162     this.pickCandidates = function(textBox, context, cycle)
1163     {
1164         var value = textBox.value;
1165         var offset = textBox.selectionStart;
1166 
1167         if (!candidates || !cycle || value != lastValue || offset != lastOffset)
1168         {
1169             originalOffset = lastOffset = offset;
1170             originalValue = lastValue = value;
1171 
1172             // Find the part of the string that is being completed
1173             var range = getRange(value, lastOffset);
1174             if (!range)
1175                 range = {start: 0, end: value.length};
1176 
1177             preExpr = value.substr(0, range.start);
1178             lastExpr = value.substring(range.start, range.end);
1179             postExpr = value.substr(range.end);
1180             exprOffset = range.start;
1181 
1182             if (FBTrace.DBG_EDITOR)
1183             {
1184                 var sep = (value.indexOf("|") > -1) ? "^" : "|";
1185                 FBTrace.sysout(preExpr+sep+lastExpr+sep+postExpr + " offset: " + lastOffset);
1186             }
1187 
1188             var search = false;
1189 
1190             // Check if the cursor is somewhere in the middle of the expression
1191             if (lastExpr && offset != range.end)
1192             {
1193                 if (cycle)
1194                 {
1195                     // Complete by resetting the completion list to a more complete
1196                     // list of candidates, finding our current position in it, and
1197                     // cycling from there.
1198                     search = true;
1199                     lastOffset = range.start;
1200                 }
1201                 else if (offset != range.start+1)
1202                 {
1203                     // Nothing new started, just fail.
1204                     return false;
1205                 }
1206                 else
1207                 {
1208                     // Try to parse the typed character as the start of a new
1209                     // property, moving the rest of lastExpr over into postExpr
1210                     // (possibly with a separator added). If there is no support
1211                     // for prefix-completions, fail. If the character could
1212                     // plausibly be part of a leftwards expansion, fail.
1213                     // Note that this does not show unless there is a completion.
1214                     var moveOver = lastExpr.substr(1);
1215                     lastExpr = lastExpr.charAt(0);
1216                     range.start = offset - 1;
1217                     range.end = offset;
1218 
1219                     var cand = evaluator(preExpr, lastExpr, postExpr, range, false, context, {});
1220                     var imov = (caseSensitive ? moveOver : moveOver.toLowerCase());
1221                     for (var i = 0; i < cand.length; ++i)
1222                     {
1223                         var c = cand[i];
1224                         if (c.length <= imov.length || c.charAt(0) !== lastExpr)
1225                             continue;
1226                         c = (caseSensitive ? c : c.toLowerCase());
1227                         if (c.substr(-imov.length) === imov)
1228                             return false;
1229                     }
1230 
1231                     var sep = getNewPropSeparator(range, lastExpr, moveOver);
1232                     if (sep === null)
1233                         return false;
1234                     if (!Str.hasPrefix(moveOver, sep))
1235                         moveOver = sep + moveOver;
1236 
1237                     postExpr = moveOver + postExpr;
1238                 }
1239             }
1240 
1241             // Don't complete globals unless cycling.
1242             if (!cycle && !lastExpr)
1243                 return false;
1244 
1245             var out = {};
1246             var values = evaluator(preExpr, lastExpr, postExpr, range, search, context, out);
1247             suggestedDefault = out.suggestion || null;
1248 
1249             if (search)
1250                 this.setCandidatesBySearchExpr(lastExpr, values);
1251             else
1252                 this.setCandidatesByExpr(lastExpr, values);
1253         }
1254 
1255         if (!candidates.length)
1256             return false;
1257 
1258         this.adjustLastIndex(cycle);
1259         var completion = candidates[lastIndex];
1260 
1261         // Adjust the case of the completion - when editing colors, 'd' should
1262         // be completed into 'darkred', not 'darkRed'.
1263         var userTyped = lastExpr.substr(0, lastOffset-exprOffset);
1264         completion = this.convertCompletionCase(completion, userTyped);
1265 
1266         var line = preExpr + completion + postExpr;
1267         var offsetEnd = exprOffset + completion.length;
1268 
1269         // Show the completion
1270         lastValue = textBox.value = line;
1271         textBox.setSelectionRange(lastOffset, offsetEnd);
1272 
1273         return true;
1274     };
1275 
1276     this.setCandidatesByExpr = function(expr, values)
1277     {
1278         // Filter the list of values to those which begin with expr. We
1279         // will then go on to complete the first value in the resulting list.
1280         candidates = [];
1281 
1282         var findExpr = (caseSensitive ? expr : expr.toLowerCase());
1283         for (var i = 0; i < values.length; ++i)
1284         {
1285             var name = values[i];
1286             var testName = (caseSensitive ? name : name.toLowerCase());
1287 
1288             if (Str.hasPrefix(testName, findExpr))
1289                 candidates.push(name);
1290         }
1291 
1292         lastIndex = null;
1293     };
1294 
1295     this.setCandidatesBySearchExpr = function(expr, values)
1296     {
1297         var searchIndex = -1;
1298 
1299         var findExpr = (caseSensitive ? expr : expr.toLowerCase());
1300 
1301         // Find the first instance of expr in the values list. We
1302         // will then complete the string that is found.
1303         for (var i = 0; i < values.length; ++i)
1304         {
1305             var name = values[i];
1306             if (!caseSensitive)
1307                 name = name.toLowerCase();
1308 
1309             if (Str.hasPrefix(name, findExpr))
1310             {
1311                 searchIndex = i;
1312                 break;
1313             }
1314         }
1315 
1316         if (searchIndex == -1)
1317         {
1318             // Nothing found, so there's nothing to complete to
1319             candidates = [];
1320             return;
1321         }
1322 
1323         candidates = Arr.cloneArray(values);
1324         lastIndex = searchIndex;
1325     };
1326 
1327     this.adjustLastIndex = function(cycle)
1328     {
1329         if (!cycle)
1330         {
1331             // We have a valid lastIndex but we are not cycling, so reset it
1332             lastIndex = this.pickDefaultCandidate();
1333         }
1334         else if (lastIndex === null)
1335         {
1336             // There is no old lastIndex, so use the default
1337             lastIndex = this.pickDefaultCandidate();
1338         }
1339         else
1340         {
1341             // cycle
1342             lastIndex += cycle;
1343             if (lastIndex >= candidates.length)
1344                 lastIndex = 0;
1345             else if (lastIndex < 0)
1346                 lastIndex = candidates.length - 1;
1347         }
1348     };
1349 
1350     this.convertCompletionCase = function(completion, userTyped)
1351     {
1352         var preCompletion = completion.substr(0, userTyped.length);
1353         if (preCompletion === userTyped)
1354         {
1355             // Trust the completion to be correct.
1356             return completion;
1357         }
1358         else
1359         {
1360             // If the typed string is entirely in one case, use that.
1361             if (userTyped === userTyped.toLowerCase())
1362                 return completion.toLowerCase();
1363             if (userTyped === userTyped.toUpperCase())
1364                 return completion.toUpperCase();
1365 
1366             // The typed string mixes case in some odd way; use the rest of
1367             // the completion as-is.
1368             return userTyped + completion.substr(userTyped.length);
1369         }
1370     };
1371 
1372     this.pickDefaultCandidate = function()
1373     {
1374         // If we have a suggestion and it's in the candidate list, use that
1375         if (suggestedDefault)
1376         {
1377             var ind = candidates.indexOf(suggestedDefault);
1378             if (ind !== -1)
1379                 return ind;
1380         }
1381 
1382         var userTyped = lastExpr.substr(0, lastOffset-exprOffset);
1383         var utLen = userTyped.length;
1384 
1385         // Otherwise, default to the shortest candidate that matches the case,
1386         // or the shortest one that doesn't
1387         var pick = -1, pcand, pcaseState;
1388         for (var i = 0; i < candidates.length; i++)
1389         {
1390             var cand = candidates[i];
1391             var caseState = (cand.substr(0, utLen) === userTyped ? 1 : 0);
1392             if (pick === -1 ||
1393                 caseState > pcaseState ||
1394                 (caseState === pcaseState && cand.length < pcand.length))
1395             {
1396                 pick = i;
1397                 pcand = cand;
1398                 pcaseState = caseState;
1399             }
1400         }
1401         return pick;
1402     };
1403 };
1404 
1405 // ********************************************************************************************* //
1406 // Local Helpers
1407 
1408 function getDefaultEditor(panel)
1409 {
1410     if (!defaultEditor)
1411     {
1412         var doc = panel.document;
1413         defaultEditor = new Firebug.InlineEditor(doc);
1414     }
1415 
1416     return defaultEditor;
1417 }
1418 
1419 /**
1420  * An outsider is the first element matching the stepper element that
1421  * is not an child of group. Elements tagged with insertBefore or insertAfter
1422  * classes are also excluded from these results unless they are the sibling
1423  * of group, relative to group's parent editGroup. This allows for the proper insertion
1424  * rows when groups are nested.
1425  */
1426 function getOutsider(element, group, stepper)
1427 {
1428     var parentGroup = Dom.getAncestorByClass(group.parentNode, "editGroup");
1429     var next;
1430     do
1431     {
1432         next = stepper(next || element);
1433     }
1434     while (Dom.isAncestor(next, group) || isGroupInsert(next, parentGroup));
1435 
1436     return next;
1437 }
1438 
1439 function isGroupInsert(next, group)
1440 {
1441     return (!group || Dom.isAncestor(next, group))
1442         && (Css.hasClass(next, "insertBefore") || Css.hasClass(next, "insertAfter"));
1443 }
1444 
1445 function getNextOutsider(element, group)
1446 {
1447     return getOutsider(element, group, Obj.bind(Dom.getNextByClass, Dom, "editable"));
1448 }
1449 
1450 function getPreviousOutsider(element, group)
1451 {
1452     return getOutsider(element, group, Obj.bind(Dom.getPreviousByClass, Dom, "editable"));
1453 }
1454 
1455 function getInlineParent(element)
1456 {
1457     var lastInline = element;
1458     for (; element; element = element.parentNode)
1459     {
1460         var s = element.ownerDocument.defaultView.getComputedStyle(element, "");
1461         if (s.display != "inline")
1462             return lastInline;
1463         else
1464             lastInline = element;
1465     }
1466     return null;
1467 }
1468 
1469 function insertTab()
1470 {
1471     Dom.insertTextIntoElement(currentEditor.input, Firebug.Editor.tabCharacter);
1472 }
1473 
1474 // ********************************************************************************************* //
1475 // Registration
1476 
1477 Firebug.registerModule(Firebug.Editor);
1478 
1479 return Firebug.Editor;
1480 
1481 // ********************************************************************************************* //
1482 });
1483