Commit 76883b8e authored by dmacfarlane's avatar dmacfarlane
Browse files

Improved support for content editable and TinyMCE.

parent 1246b8a4
Loading
Loading
Loading
Loading
+3 −4
Original line number Diff line number Diff line
@@ -32,10 +32,9 @@ Where `items` is a string array of the items to suggest. For example:
#### TODO:

- Remove debug (always)
- Fix line-feed issue in content editable
- Iframe based (TinyMCE)
- Styled menu items
- Load items via http (config for number of chars before search)
- Menu clicks not working in content editable
- Configurable prefix
- Configurable limit on number of items shown via config
- Load items via http (config for number of chars before search)
- Styled menu items
- Tests...
+6 −2
Original line number Diff line number Diff line
import {Component} from 'angular2/core';
import {Mention} from './mention/mention';
import {COMMON_NAMES} from './common-names';
import {TinyMCE} from './tinymce.component';

@Component({
    selector: 'my-app',
@@ -17,12 +18,15 @@ import {COMMON_NAMES} from './common-names';
    <textarea [mention]="items" class="form-control" cols="60" rows="4"></textarea>

    <h3>Content Editable</h3>
    <div [mention]="items" contenteditable="true" style="border:1px lightgrey solid;min-height:88px"></div>
    <div [mention]="items" class="form-control" contenteditable="true" style="border:1px lightgrey solid;min-height:88px"></div>

    <h3>Embedded Editor</h3>
    <tinymce></tinymce>

    <br><p style="color:grey">ng2-mentions on <a href="">Github</a></p>
    <a href="https://github.com/dmacfarlane/ng2-mentions"><img style="position: absolute; top: 0; right: 0; border: 0;" src="https://camo.githubusercontent.com/652c5b9acfaddf3a9c326fa6bde407b87f7be0f4/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f6f72616e67655f6666373630302e706e67" alt="Fork me on GitHub" data-canonical-src="https://s3.amazonaws.com/github/ribbons/forkme_right_orange_ff7600.png"></a>
    `,
    directives: [Mention]
    directives: [Mention, TinyMCE]
})
export class AppComponent {
  items:string [] = COMMON_NAMES;
+12 −10
Original line number Diff line number Diff line
import {Component, ElementRef, Output, EventEmitter} from 'angular2/core';
import {isInputOrTextArea, getSelectionCoords} from './mention-utils';
import {isInputOrTextAreaElement, getContentEditableCaretCoords} from './mention-utils';

declare var getCaretCoordinates:any;

/**
 * Angular 2 Mentions.
 * https://github.com/dmacfarlane/ng2-mentions
 *
 * Copyright (c) 2016 Dan MacFarlane
 */
@Component({
    selector: 'mention-list',
    styles: [`
@@ -31,23 +37,19 @@ export class MentionList {
  hidden = false;
  @Output() itemClick = new EventEmitter();
  constructor(private _element: ElementRef) {}
  position(nativeParentElement) {
  position(nativeParentElement, iframe=null) {
    var coords = {top:0,left:0};
    if (isInputOrTextArea(nativeParentElement)) {
    if (isInputOrTextAreaElement(nativeParentElement)) {
      coords = getCaretCoordinates(nativeParentElement, nativeParentElement.selectionStart);
      coords.top = nativeParentElement.offsetTop + coords.top;
      coords.top = nativeParentElement.offsetTop + coords.top + 16;
      coords.left = nativeParentElement.offsetLeft + coords.left;
    }
    else {
      var doc = document.documentElement;
      var scrollLeft = (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0);
      var scrollTop  = (window.pageYOffset || doc.scrollTop)  - (doc.clientTop || 0);
      var position = getSelectionCoords(window);
      coords = {top:position.y+scrollTop, left:position.x+scrollLeft};
      coords = getContentEditableCaretCoords(nativeParentElement, iframe);
    }
    this._element.nativeElement.style.position = "absolute";
    this._element.nativeElement.style.left = coords.left + 'px';
    this._element.nativeElement.style.top  = coords.top+16 + 'px';
    this._element.nativeElement.style.top  = coords.top + 'px';
  }
  get activeItem() {
    return this.items[this.activeIndex];
+125 −127
Original line number Diff line number Diff line
// DOM element manipulation functions
// DOM element manipulation functions...
//

function setValue(el, value) {
  if (isInputOrTextArea(el)) {
  //console.log("setValue", el.nodeName, value);
  if (isInputOrTextAreaElement(el))
  {
    el.value = value;
  }
  else {
    //el.textContent = value; //el.appendChild(document.createTextNode(value));
    el.innerHTML = value + (value.endsWith(" ") ? "&nbsp;" : "");
    el.textContent = value;
  }
}

export function getValue(el) {
  return isInputOrTextArea(el) ? el.value : el.textContent;
  return isInputOrTextAreaElement(el) ? el.value : el.textContent;
}

export function insertValue(el, start, end, text) {
export function insertValue(el, start, end, text, iframe, noRecursion=false) {
  //console.log("insertValue", el.nodeName, start, end, text, el);
  if (isTextElement(el)) {
    var val = getValue(el);
    setValue(el, val.substring(0, start) + text + val.substring(end, val.length));
  setCaretPosition(el, start+text.length);
    setCaretPosition(el, start+text.length, iframe);
  }
  else if (!noRecursion) {
    var selObj = getWindowSelection(iframe);
    var selRange = selObj.getRangeAt(0);
    var position = selRange.startOffset;
    var anchorNode = selObj.anchorNode;
    insertValue(anchorNode, position-(end-start), position, text, iframe, true);
  }
}

export function isInputOrTextAreaElement(el) {
  return el!=null && (el.nodeName == 'INPUT' || el.nodeName == 'TEXTAREA');
};

export function isInputOrTextArea(el) {
    return el.nodeName == 'INPUT' || el.nodeName == 'TEXTAREA';
export function isTextElement(el) {
  return el!=null && (el.nodeName == 'INPUT' || el.nodeName == 'TEXTAREA' || el.nodeName=='#text');
};

export function getCaretPosition(el) {
  if (isInputOrTextArea(el)) {
export function setCaretPosition(el, pos, iframe=null) {
  //console.log("setCaretPosition", pos, el, iframe==null);
  if(isInputOrTextAreaElement(el) && el.selectionStart) {
    el.focus();
    el.setSelectionRange(pos, pos);
  }
  else {
    let range = getDocument(iframe).createRange();
    range.setStart(el, pos);
    range.collapse(true);
    let sel = getWindowSelection(iframe);
    sel.removeAllRanges();
    sel.addRange(range);
  }
}

export function getCaretPosition(el, iframe=null) {
  //console.log("getCaretPosition", el);
  if (isInputOrTextAreaElement(el)) {
    var val = el.value;
    return val.slice(0, el.selectionStart).length;
  }
  else {
    return getCaretCharacterOffsetWithin(el);
    var selObj = getWindowSelection(iframe); //window.getSelection();
    var selRange = selObj.getRangeAt(0);
    var position = selRange.startOffset;
    return position;
  }
}

export function setCaretPosition(el, pos) {
  if (isInputOrTextArea(el)) {
    // http://stackoverflow.com/questions/512528/set-cursor-position-in-html-textbox
    if(el.createTextRange) {
      let range = el.createTextRange();
      range.move('character', pos);
      range.select();
    }
    else if(el.selectionStart) {
      el.focus();
      el.setSelectionRange(pos, pos);
export function getContentEditableCaretCoords(nativeParentElement, iframe) {
  let ctx = iframe ? { iframe: iframe } : null;
  return getContentEditableCaretPositionMentIo(ctx);
}
    else {
      el.focus();

// Based on ment.io functions...
//

function getDocument(iframe) {
    if (!iframe) {
        return document;
    } else {
        return iframe.contentWindow.document;
    }
}
  else {
    // http://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div
    let range = document.createRange();
    let sel = window.getSelection();
    range.setStart(el.firstChild, pos);
    range.collapse(true);
    sel.removeAllRanges();
    sel.addRange(range);

function getWindowSelection(iframe) {
    if (!iframe) {
        return window.getSelection();
    } else {
        return iframe.contentWindow.getSelection();
    }
}

// http://stackoverflow.com/questions/4811822/get-a-ranges-start-and-end-offsets-relative-to-its-parent-container/4812022#4812022
export function getCaretCharacterOffsetWithin(element) {
    var caretOffset = 0;
    var doc = element.ownerDocument || element.document;
    var win = doc.defaultView || doc.parentWindow;
    var sel;
    if (typeof win.getSelection != "undefined") {
        sel = win.getSelection();
        if (sel.rangeCount > 0) {
            var range = win.getSelection().getRangeAt(0);
            var preCaretRange = range.cloneRange();
            preCaretRange.selectNodeContents(element);
            preCaretRange.setEnd(range.endContainer, range.endOffset);
            caretOffset = preCaretRange.toString().length;
        }
    } else if ( (sel = doc.selection) && sel.type != "Control") {
        var textRange = sel.createRange();
        var preCaretTextRange = doc.body.createTextRange();
        preCaretTextRange.moveToElementText(element);
        preCaretTextRange.setEndPoint("EndToEnd", textRange);
        caretOffset = preCaretTextRange.text.length;
    }
    return caretOffset;
}

// http://stackoverflow.com/questions/6846230/coordinates-of-selected-text-in-browser-page
export function getSelectionCoords(win) {
    win = win || window;
    var doc = win.document;
    var sel = doc.selection, range, rects, rect;
    var x = 0, y = 0;
    if (sel) {
        if (sel.type != "Control") {
            range = sel.createRange();
            range.collapse(true);
            x = range.boundingLeft;
            y = range.boundingTop;
        }
    } else if (win.getSelection) {
        sel = win.getSelection();
        if (sel.rangeCount) {
            range = sel.getRangeAt(0).cloneRange();
            if (range.getClientRects) {
                range.collapse(true);
                rects = range.getClientRects();
                if (rects.length > 0) {
                    rect = rects[0];
                }
                x = rect.left;
                y = rect.top;
            }
            // Fall back to inserting a temporary element
            if (x == 0 && y == 0) {
                var span = doc.createElement("span");
                if (span.getClientRects) {
                    // Ensure span has dimensions and position by
                    // adding a zero-width space character
                    span.appendChild( doc.createTextNode("\u200b") );
                    range.insertNode(span);
                    rect = span.getClientRects()[0];
                    x = rect.left;
                    y = rect.top;
                    var spanParent = span.parentNode;
                    spanParent.removeChild(span);

                    // Glue any broken text nodes back together
                    spanParent.normalize();
                }
            }
        }
    }
    return { x: x, y: y };
}

/*
insertNodeAtCaret(el, start, end, text) {
    // var sel=window.getSelection();
    // if (sel.rangeCount) {
    //     var range = sel.getRangeAt(0);
    //     range.collapse(false);
    //     range.insertNode(node);
    //     range.collapseAfter(node);
    //     sel.setSingleRange(range);
    // }
    //var el = document.getElementById("editable");
    var range = document.createRange();
    var sel = window.getSelection();
    //var range = sel.getRangeAt(0);
    range.setStart(el.firstChild, start);
    range.setEnd(el.firstChild, end);
    range.deleteContents();
    //range.collapse(true);
    range.insertNode(document.createTextNode(text));
function getContentEditableCaretPositionMentIo(ctx/*, selectedNodePosition*/) {
    var markerTextChar = '\ufeff';
    var markerId = 'sel_' + new Date().getTime() + '_' + Math.random().toString().substr(2);
    var doc = getDocument(ctx?ctx.iframe:null);
    var sel = getWindowSelection(ctx?ctx.iframe:null);
    var prevRange = sel.getRangeAt(0);

    // create new range and set postion using prevRange
    var range = doc.createRange();
    range.setStart(sel.anchorNode, prevRange.startOffset);
    range.setEnd(sel.anchorNode, prevRange.startOffset);
    range.collapse(false);

    // Create the marker element containing a single invisible character
    // using DOM methods and insert it at the position in the range
    var markerEl = doc.createElement('span');
    markerEl.id = markerId;
    markerEl.appendChild(doc.createTextNode(markerTextChar));
    range.insertNode(markerEl);
    sel.removeAllRanges();
    //sel.addRange(range);
}*/
    sel.addRange(prevRange);

    var coordinates = {
        left: 0,
        top: markerEl.offsetHeight
    };

    localToGlobalCoordinates(ctx, markerEl, coordinates);

    markerEl.parentNode.removeChild(markerEl);
    return coordinates;
}

function localToGlobalCoordinates(ctx, element, coordinates) {
    var obj = element;
    var iframe = ctx ? ctx.iframe : null;
    while(obj) {
        coordinates.left += obj.offsetLeft + obj.clientLeft;
        coordinates.top += obj.offsetTop + obj.clientTop;
        obj = obj.offsetParent;
        if (!obj && iframe) {
            obj = iframe;
            iframe = null;
        }
    }
    obj = element;
    iframe = ctx ? ctx.iframe : null;
    while(obj !== getDocument(null).body && obj!=null) {
        if (obj.scrollTop && obj.scrollTop > 0) {
            coordinates.top -= obj.scrollTop;
        }
        if (obj.scrollLeft && obj.scrollLeft > 0) {
            coordinates.left -= obj.scrollLeft;
        }
        obj = obj.parentNode;
        if (!obj && iframe) {
            obj = iframe;
            iframe = null;
        }
    }
 }
+59 −47
Original line number Diff line number Diff line
@@ -16,6 +16,12 @@ const KEY_RIGHT = 39;
const KEY_DOWN = 40;
const KEY_2 = 50;

/**
 * Angular 2 Mentions.
 * https://github.com/dmacfarlane/ng2-mentions
 *
 * Copyright (c) 2016 Dan MacFarlane
 */
@Component({
  selector: '[mention]',
  template: '',
@@ -28,21 +34,26 @@ export class Mention {
  mentionStart:number;
  searchList: MentionList;
  escapePressed:boolean;
  iframe:any; // optional
  constructor(private _element: ElementRef, private _dcl: DynamicComponentLoader) {}

  @Input() set mention(items:string []){
    this.items = items.sort();
  }

  setIframe(iframe) {
    this.iframe = iframe;
  }

  stopEvent(event) {
      event.preventDefault();
      event.stopPropagation();
      event.stopImmediatePropagation();
  }

  keyHandler(event) {
    let val = getValue(this._element.nativeElement);
    let pos = getCaretPosition(this._element.nativeElement);
  keyHandler(event, nativeElement=this._element.nativeElement) {
    let val = getValue(nativeElement);
    let pos = getCaretPosition(nativeElement, this.iframe);
    let charPressed = event.key;
    if (!charPressed) {
      let charCode = event.which || event.keyCode;
@@ -58,22 +69,27 @@ export class Mention {
        charPressed = String.fromCharCode(event.which || event.keyCode);
      }
    }
    //console.log(pos, val, event, charPressed);
    console.log("keyHandler", this.mentionStart, pos, val, charPressed, event);
    if (charPressed=="@") {
      this.mentionStart = pos;
      this.escapePressed = false;
      this.showSeachList();
      this.showSearchList(nativeElement);
    }
    else if (this.mentionStart>=0 && !this.escapePressed) {
      if (event.keyCode!=KEY_SHIFT && pos>this.mentionStart) {
        if (event.keyCode === KEY_SPACE) {
          this.mentionStart = -1;
        }
        else if (event.keyCode === KEY_TAB || event.keyCode === KEY_ENTER) {
        else if (event.keyCode === KEY_BACKSPACE && pos>0) {
          this.searchList.hidden = this.escapePressed;
          pos--;
        }
        else if (!this.searchList.hidden) {
          if (event.keyCode === KEY_TAB || event.keyCode === KEY_ENTER) {
            this.stopEvent(event);
            this.searchList.hidden = true;
          insertValue(this._element.nativeElement,
            this.mentionStart, pos, "@"+this.searchList.activeItem+" ");
            insertValue(nativeElement,
              this.mentionStart, pos, "@"+this.searchList.activeItem+" ", this.iframe);
            this.mentionStart = -1;
            return false;
          }
@@ -93,12 +109,8 @@ export class Mention {
            this.searchList.activatePreviousItem();
            return false;
          }
        else if (event.keyCode === KEY_BACKSPACE && pos>0) {
          this.searchList.hidden = this.escapePressed;
          pos--;
        }

        if (!this.searchList.hidden) {
        if (event.keyCode === KEY_LEFT || event.keyCode === KEY_RIGHT) {
          this.stopEvent(event);
          return false;
@@ -117,26 +129,26 @@ export class Mention {
      }
    }
  }
  }

  showSeachList() {
  showSearchList(nativeElement) {
    if (this.searchList==null) {
      this._dcl.loadNextToLocation(MentionList, this._element)
        .then((containerRef: ComponentRef) => {
          this.searchList = containerRef.instance;
          this.searchList.items = this.items; //matches;
          this.searchList.items = this.items;
          this.searchList.hidden = false;
          this.searchList.position(this._element.nativeElement);
          this.searchList.position(nativeElement, this.iframe);
          containerRef.instance['itemClick'].subscribe(ev => {
              let fakeKeydown = new KeyboardEvent('keydown', <KeyboardEventInit>{"keyCode":KEY_ENTER});
              this.keyHandler(fakeKeydown);
              this.keyHandler(fakeKeydown, nativeElement);
          });
      });
    }
    else {
      this.searchList.activeIndex = 0;
      this.searchList.items = this.items;
      this.searchList.hidden = false;
      this.searchList.position(this._element.nativeElement);
      this.searchList.position(nativeElement, this.iframe);
    }
  }
}
Loading