Source: ui/widget/chat-log.js

import { TextArea } from './text-area.js';
import { TextAreaInput } from './text-area-input.js';
import { Label } from './label.js';
import { RemoteBrowser } from './remote-browser.js';
import { VRSpaceAPI } from './../../client/rest-api.js'
import { VRSPACEUI } from '../vrspace-ui.js';
import { ServerCapabilities } from '../../client/openapi/model/ServerCapabilities.js';
class ChatLogInput extends TextAreaInput {
  inputFocused(input, focused) {
    super.inputFocused(input,focused);
    if ( focused ) {
      console.log("Focused ", this.textArea);
      ChatLog.activeInstance = this.textArea;
    }
  }
}

class Link {
  constructor( text, enterWorld=false ) {
    this.url = text;
    this.enterWorld = enterWorld;
    let pos = text.indexOf("://");
    if ( pos > 0 ) {
      text = text.substring(pos+3);
    } else {
      this.url = "https://"+this.url;
    }
    pos = text.indexOf("/");
    if ( pos > 0 ) {
      this.site = text.substring(0,pos);
    } else {
      this.site = text;
    }
    console.log('new link: '+this.url+" "+this.site);
    this.label = null;
    this.buttons = [];
  }
  openHere(local) {
    if ( local ) {
      console.log("TODO: Enter the world like AvatarSelection.enterWorld does");
    }
    window.location.href = this.url;    
  }
  openTab() {
    window.open(this.url, '_blank').focus();    
  }
  dispose() {
    this.label.dispose();
    this.buttons.forEach(b=>b.dispose());
  }
}

class LinkStack {
  /** @type {ServerCapabilities} */
  static serverCapablities = null;
  constructor(scene, parent, position, scaling = new BABYLON.Vector3(.02,.02,.02)) {
    this.scene = scene;
    this.parent = parent;
    this.scaling = scaling;
    this.position = position;
    this.capacity = 5;
    this.links = [];
    this.meshes = []; // XR selectables
    this.clickHandler = this.scene.onPointerObservable.add((pointerInfo) => {
      if ( pointerInfo.type == BABYLON.PointerEventTypes.POINTERDOWN
        && pointerInfo.pickInfo.hit
      ) {
        for ( let i = 0; i < this.links.length; i++ ) {
          if ( this.links[i].label.textPlane == pointerInfo.pickInfo.pickedMesh ) {
            this.clicked(this.links[i]);
            break;
          }
        }
      }
    });
    if ( LinkStack.serverCapablities == null ) {
      VRSpaceAPI.getInstance().endpoint.server.getServerCapabilities().then(c=>LinkStack.serverCapablities=c);
    }
  }
  addLink(word, enterWorld, local){
    let link = new Link(word, enterWorld);
    this.scroll();
    
    // add buttons to open in new tab, this tab, optionally internal browser
    if ( enterWorld ) {
      this.addButton(link, "enter", () => link.openHere(local));
    } else {
      this.addButton(link, "external-link", () => link.openTab());
      if (LinkStack.serverCapablities.remoteBrowser) {
        this.addButton(link, "play", () => this.openBrowser(link.url));
      }
    }

    let x = this.scaling.x*link.buttons.length+this.position.x+link.site.length/(Label.fontRatio*2)*this.scaling.x;
    let pos = new BABYLON.Vector3(x,this.position.y,this.position.z);
    let label = new Label(link.site,pos,this.parent);
    //label.background = "black";
    label.display();
    label.textPlane.scaling = this.scaling;
    link.label = label;
    
    this.links.push(link);
    this.meshes.push(label.textPlane);
    return link;
  }
  
  addButton(link, name, callback) {
    let button = BABYLON.GUI.Button.CreateImageOnlyButton(name+"-"+link.site, VRSPACEUI.contentBase+"/content/icons/"+name+".png");
    let buttonPlane = BABYLON.MeshBuilder.CreatePlane(name+"-"+link.site, {height:1,width:1});
    buttonPlane.parent = this.parent;
    buttonPlane.position = new BABYLON.Vector3(this.position.x + this.scaling.x*link.buttons.length,this.position.y,this.position.z);
    buttonPlane.scaling = this.scaling;
    let buttonTexture = BABYLON.GUI.AdvancedDynamicTexture.CreateForMesh(
      buttonPlane,
      128,
      128,
      false // mouse events disabled
    );
    buttonTexture.addControl(button);
    button.onPointerDownObservable.add( () => callback());
    link.buttons.push(buttonPlane);    
    this.meshes.push(buttonPlane);
  }
  
  /** @param {Link} link */
  async clicked(link) {
    // process invitations
    console.log("Clicked "+link.url);
    if ( link.enterWorld ) {
      link.openHere();
    } else if (LinkStack.serverCapablities.remoteBrowser) {
      this.openBrowser(link.url);
    } else {
      link.openTab();
    }
  }
  openBrowser(url) {
    if ( this.browser ) {
      this.browser.dispose();
    }
    this.browser = new RemoteBrowser(this.scene);
    this.browser.show();
    //this.browser.attachToCamera();
    this.browser.attachToHud();
    if ( url.toLowerCase().endsWith(".jpg") || url.toLowerCase().endsWith(".jpg") ) {
      this.browser.loadUrl(url);
    } else {
      this.browser.get(url);
    }
  }
  scroll() {
    if ( this.links.length == this.capacity ) {
      this.links[0].dispose();
      let link = this.links.splice(0,1)[0];
      link.dispose();
    }
    for ( let i = 0; i < this.links.length; i++ ) {
      let label = this.links[i].label;
      let y = label.textPlane.position.y + label.textPlane.scaling.y*1.5;
      label.textPlane.position = new BABYLON.Vector3( label.textPlane.position.x, y, label.textPlane.position.z );
      this.links[i].buttons.forEach(b=>b.position.y = y );
    }
  }
  dispose() {
    this.scene.onPointerObservable.remove(this.clickHandler);
    this.links.forEach(l=>l.dispose());
    this.meshes = [];
    if ( this.browser ) {
      this.browser.dispose();
    }
  }
  isSelectableMesh(mesh) {
    return this.meshes.includes(mesh);
  }
}
/**
 * Chat log with TextArea and TextAreaInput, attached by to HUD. 
 * By default alligned to left side of the screen.
 */
export class ChatLog extends TextArea {
  static instanceCount = 0;
  static instances = {}
  /** @type {ChatLog} */
  static activeInstance = null;
  static instanceId(name, title) {
    return name+"_"+title;
  }
  /** @return {ChatLog} */
  static findInstance(title, name="ChatLog") {
    if ( ChatLog.instances.hasOwnProperty(ChatLog.instanceId(name,title)) ) {
      return ChatLog.instances[ChatLog.instanceId(name,title)];
    }
  }
  static getInstance(scene, title, name="ChatLog") {
    if ( ChatLog.instances.hasOwnProperty(ChatLog.instanceId(name,title)) ) {
      return ChatLog.instances[ChatLog.instanceId(name,title)];
    }
    return new ChatLog(scene, title, name);
  }
  constructor(scene, title, name="ChatLog") {
    super(scene, name, title);
    if ( ChatLog.instances.hasOwnProperty(this.instanceId()) ) {
      throw "Instance already exists: "+this.instanceId();
    }
    this.input = new ChatLogInput(this, "Say", title);
    this.input.submitName = "send";
    this.input.showNoMatch = false;
    this.inputPrefix = "ME";
    this.showLinks = true;
    this.minimizeInput = false;
    this.minimizeTitle = true;
    this.autoHide = true;
    this.size = .3;
    this.baseAnchor = -.4;
    this.verticalAnchor = -.1;
    //this.baseAnchor = 0;
    this.anchor = this.baseAnchor;
    this.leftSide();
    this.linkStack = new LinkStack(this.scene, this.group, new BABYLON.Vector3(this.size/2*1.25,-this.size/2,0));
    this.listeners = [];
    ChatLog.instanceCount++;
    ChatLog.instances[this.instanceId()] = this;
  }
  instanceId() {
    return ChatLog.instanceId(this.name, this.titleText);
  }
  /**
   * Show both TextArea and TextAreaInput, and attach to HUD.
   */
  show() {
    super.show();
    this.setActiveInstance();
    this.input.inputPrefix = this.inputPrefix;
    this.input.addListener( text => this.notifyListeners(text) );
    // order matters: InputArea.init() shows the title, so call hide after
    this.input.init();
    if ( this.handles ) {
      if ( !this.minimizeInput ) {
        this.handles.dontMinimize.push(this.input.plane);
      }
      if ( this.title && !this.minimizeTitle ) {
        this.handles.dontMinimize.push(this.title.textPlane);
        // reposition the title
        this.handles.onMinMax = (minimized) => {
          if ( minimized ) {
            this.title.position.y = -1.2 * this.size/2 + this.title.height/2;
            this.clearActiveInstance();
          } else {
            // CHECKME: copied from TextArea.showTitle:
            this.title.position.y = 1.2 * this.size/2 + this.title.height/2;
            this.setActiveInstance();
          }
        };
      } else {
        this.handles.onMinMax = (minimized) => {
          if ( minimized ) {
            this.clearActiveInstance();
          } else {
            this.setActiveInstance();
          }
        };
      }
    }
    this.hide(this.autoHide);
    this.attachToHud();
    this.handleResize();
    this.resizeHandler = () => this.handleResize();
    window.addEventListener("resize", this.resizeHandler);
  }
  /**
   * Log something written by someone.
   * @param {String} who who wrote that
   * @param {String} what what they wrote
   * @param {String} link optional url to open 
   */
  log( who, what, link, local ) {
    this.input.write(what,who);
    if ( link ) {
      this.showLink(link, true);
    }
  }
  attachToHud(){
    super.attachToHud();
  }
  /**
   * Move to left side of the screen
   */
  leftSide() {
    this.anchor = - Math.abs(this.anchor);
    this.moveToAnchor();
  }
  /**
   * Move to right side of the screen
   */
  rightSide() {
    this.anchor = Math.abs(this.anchor);
    this.moveToAnchor();
  }
  /**
   * Move either left or right, whatever is the current anchor
   */
  moveToAnchor() {
    //this.position = new BABYLON.Vector3(this.anchor, this.size/2-.025, 0);
    this.position = new BABYLON.Vector3(this.anchor, this.size/2+this.verticalAnchor, 0.2);
    this.group.position = this.position;
  }
  /**
   * Handle window resize, recalculates the current anchor and positions appropriatelly.
   */
  handleResize() {
    let aspectRatio = this.scene.getEngine().getAspectRatio(this.scene.activeCamera);
    // 0.67 -> anchor 0.1 (e.g. smartphone vertical)
    // 2 -> anchor 0.4 (pc, smartphone horizontal)
    let diff = (aspectRatio-0.67)/1.33;
    //this.anchor = -this.baseAnchor * diff * Math.sign(this.anchor);
    this.anchor = this.baseAnchor * diff;
    //console.log("Aspect ratio: "+aspectRatio+" anchor "+Math.sign(this.anchor)+" "+this.anchor+" base "+this.baseAnchor+" diff "+diff);
    this.moveToAnchor();
  }
  hasLink(line) {
    // TODO improve link detection
    return line.indexOf("://") > -1 || line.indexOf('www.') > -1 ;
  }
  processLinks(line) {
    if ( this.showLinks && typeof(line) === "string" && this.hasLink(line)) {
      line.split(' ').forEach((word)=>{
        if ( this.hasLink(word) ) {
          this.showLink(word);
        }
      });
    }
  }
  showLink(word, enterWorld, local) {
    console.log("Link found: "+word);
    this.linkStack.addLink(word, enterWorld);
  }
  write(string) {
    this.processLinks(string);
    super.write(string);
    this.hide(false);
  }
  setActiveInstance() {
    ChatLog.activeInstance = this;
    console.log("Focused ", this);
  }
  clearActiveInstance() {
    if ( ChatLog.activeInstance == this ) {
      console.log("Focus removed from ", this);
      ChatLog.activeInstance = null;
    }
  }
  notifyListeners(text,link) {
    this.listeners.forEach(l=>l(text, link));
  }
  /**
   * Add a listener to be called when input text is changed
   */
  addListener(listener) {
    this.listeners.push(listener);
  }
  /** Remove a listener */
  removeListener(listener) {
    let pos = this.listeners.indexOf(listener);
    if ( pos > -1 ) {
      this.listeners.splice(pos,1);
    }
  }
 
  /** Clean up */
  dispose() {
    window.removeEventListener("resize", this.resizeHandler);
    this.input.dispose();
    super.dispose();
    this.linkStack.dispose();
    this.clearActiveInstance();
    delete ChatLog.instances[this.instanceId()];
    ChatLog.instanceCount--;
  }
  /** XR pointer selection support */
  isSelectableMesh(mesh) {
    return super.isSelectableMesh(mesh) || this.input.isSelectableMesh(mesh) || this.linkStack.isSelectableMesh(mesh);
  }
}