Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Is there possibility to add shared cursor? #11

Open
tedchou12 opened this issue Sep 26, 2020 · 12 comments
Open

Is there possibility to add shared cursor? #11

tedchou12 opened this issue Sep 26, 2020 · 12 comments
Assignees
Labels
enhancement New feature or request

Comments

@tedchou12
Copy link
Contributor

Sorry for continuous spamming.

I had spent some time looking into:
https://github.com/yjs/y-prosemirror
Because there aren't any other opensource shared cursor available online.

Seems like it uses websockets and is quite deeply tied into y-websocket's other packages.

I think only the:

export * from "./plugins/cursor-plugin.js";
export * from "./plugins/sync-plugin.js";

modules should be enough?

Sorry, this part is quite difficult to me, I will continue to try though.

@tedchou12
Copy link
Contributor Author

I tried to add the module, seems like there are many errors:
Screen Shot 2020-09-27 at 22 31 30
Screen Shot 2020-09-27 at 22 31 35

@MO-Movia MO-Movia added the enhancement New feature or request label Sep 29, 2020
@MO-Movia
Copy link
Collaborator

@tedchou12 , added you to thread on the ProseMirror site discussing this.

@tedchou12
Copy link
Contributor Author

Thank you so much.
I have read through the thread.
Seems like there are no updates later on.

Actually, after spending some time. I have been able to obtain the state.selection information and there is no problem pushing that information to the other clients.

The part I am stuck is how to add the decorations and display the actual shared cursor on the client:
https://prosemirror.net/docs/ref/#view.Decorations

I tried to read through it, but I tried various examples realizing that licit has changed too much so the examples don't work quite well.

If anyone can guide me how to create the widget, decorations and how to attach it to the editor view itself, that will be so helpful.

Thank you so much again!

Ted.

@ashfaq-shamsudeen
Copy link
Contributor

ashfaq-shamsudeen commented Sep 30, 2020

@tedchou12,

Some references in Licit to create widget and decorations. Hope this helps!

https://github.com/PierBover/prosemirror-cookbook#decorations

Decorations are always owned by a plugin, and ProseMirror’s state is only updated through transactions, not with imperative methods.

@tedchou12
Copy link
Contributor Author

tedchou12 commented Sep 30, 2020

@ashfaq-shamsudeen
Thank you.
I will post my codes here, actually, I gave those a try already, quite a few times:

In the src/client/EditorConnection.js file:

// @flow

import {
  collab,
  getVersion,
  receiveTransaction,
  sendableSteps,
} from 'prosemirror-collab';
import {
  hideCursorPlaceholder,
  showCursorPlaceholder,
} from '../CursorPlaceholderPlugin2';
import { EditorState } from 'prosemirror-state';
import { Step } from 'prosemirror-transform';
import { EditorView } from 'prosemirror-view';
import EditorPlugins from '../EditorPlugins';
import EditorSchema from '../EditorSchema';
import uuid from '../uuid';
import { GET, POST } from './http';
// [FS] IRAD-1040 2020-09-02
import { Schema } from 'prosemirror-model';
import { stringify } from 'flatted';

function badVersion(err: Object) {
  return err.status == 400 && /invalid version/i.test(String(err));
}

var connection = null;

class State {
  edit: EditorState;
  comm: ?string;

  constructor(edit: ?EditorState, comm: ?string) {
    this.edit = edit;
    this.comm = comm;
  }
}

class EditorConnection {
  backOff: number;
  onReady: Function;
  ready: boolean;
  report: any;
  request: any;
  state: State;
  url: string;
  view: ?EditorView;
  schema: Schema;
  socket: any;

  constructor(onReady: Function, report: any, doc_id: string) {
    this.schema = null;
    this.report = report;
    this.doc_id = doc_id;
    this.state = new State(null, 'start');
    this.request = null;
    this.backOff = 0;
    this.view = null;
    this.dispatch = this.dispatch.bind(this);
    this.ready = false;
    this.onReady = onReady;
    this.socket = null;
    connection = this;
  }

  // [FS] IRAD-1040 2020-09-08
  getEffectiveSchema(): Schema {
    return (null != this.schema) ? this.schema : EditorSchema;
  }

  // All state changes go through this
  dispatch = (action: Object): void => {
    let newEditState = null;
    if (action.type == 'loaded') {
      const editState = EditorState.create({
        doc: action.doc,
        plugins: EditorPlugins.concat([
          collab({
            clientID: uuid(),
            version: action.version,
          }),
        ]),
      });
      this.state = new State(editState, 'poll');
      this.ready = true;
      this.onReady(editState);
      // this.poll();
      // this.cursor_poll();
    } else if (action.type == 'restart') {
      this.state = new State(null, 'start');
      this.ws_start();
    } else if (action.type == 'poll') {
      this.state = new State(this.state.edit, 'poll');
      // this.poll();
    } else if (action.type == 'recover') {
      if (action.error.status && action.error.status < 500) {
        this.report.failure(action.error);
        this.state = new State(null, null);
      } else {
        this.state = new State(this.state.edit, 'recover');
        this.recover(action.error);
      }
    } else if (action.type == 'transaction') {
      newEditState = this.state.edit
        ? this.state.edit.apply(action.transaction)
        : null;
    }

    if (newEditState) {
      let sendable;
      if (newEditState.doc.content.size > 40000) {
        if (this.state.comm !== 'detached') {
          this.report.failure('Document too big. Detached.');
        }
        this.state = new State(newEditState, 'detached');
      } else if (action.requestDone) {
        this.state = new State(newEditState, 'poll');
        // this.poll();
      } else if (
        (this.state.comm == 'poll') &&
        (sendable = this.sendable(newEditState))
      ) {
        // this.closeRequest();
        this.state = new State(newEditState, 'send');
        this.ws_send(newEditState, sendable);
      } else {
        this.state = new State(newEditState, this.state.comm);
      }
    }

    // Sync the editor with this.state.edit
    if (this.state.edit && this.view) {
      this.view.updateState(this.state.edit);
    }
  };

  // Send cursor updates to the server # this is the part that sends cursor information to the server.
  cursor_send(selection: Object): void {
    const content = {selection: selection,
                      clientID: uuid()};
    const json = JSON.stringify({type: 'selection', data: content});
    this.socket.send(json);
  }

  ws_start(): void {
    var ws_url = 'ws://192.168.1.2:9300';
    var ws_url = ws_url + '?user_id=' + this.user_id + '&doc_id=' + this.doc_id;
    this.socket = new WebSocket(ws_url);

    this.socket.onopen = function(e) {
      //does something when socket opens
    }

    // replaces poll
    this.socket.onmessage = function(e) {
      connection.report.success();
      var data = JSON.parse(e.data);
      var json = data.data;

      if (data.type == 'init') {
        connection.dispatch({
          type: 'loaded',
          doc: connection.getEffectiveSchema().nodeFromJSON(json.doc_json),
          version: json.version,
          users: json.users,
        });
      } else if (data.type == 'step') {
        connection.backOff = 0;
        if (json.steps && json.steps.length) {
          const tr = receiveTransaction(
            connection.state.edit,
            json.steps.map(j => Step.fromJSON(connection.getEffectiveSchema(), j)),
            json.clientIDs
          );
          connection.dispatch({
            type: 'transaction',
            transaction: tr,
            requestDone: false,
          });
        }
      } else {
        console.log('in');
        console.log(json);
        showCursorPlaceholder(connection.state, connection.view, json); #this is the part that receives the cursor information, and I want to trigger the showCursorPlaceholder.
      }
    }

    //try reconnects
    this.socket.onclose = function(e) {
      // Too far behind. Revert to server state
      if (true) {
        connection.report.failure(err);
        connection.dispatch({ type: 'restart' });
      } else {
        connection.closeRequest();
        connection.setView(null);
      }
    }
  }

  ws_send(editState: EditorState, sendable: Object) {
    const { steps } = sendable;
    const content = {version: getVersion(editState),
                     steps: steps ? steps.steps.map(s => s.toJSON()) : [],
                     clientID: steps ? steps.clientID : 0};
    const json = JSON.stringify({type: 'content', data: content});
    this.socket.send(json);
    this.report.success();
    this.backOff = 0;
    const tr = steps
      ? receiveTransaction(
        this.state.edit,
        steps.steps,
        repeat(steps.clientID, steps.steps.length)
      )
      : this.state.edit.tr;

    this.dispatch({
      type: 'transaction',
      transaction: tr,
      requestDone: true,
    });
  }

  ws_recover(): void {
    const newBackOff = this.backOff ? Math.min(this.backOff * 2, 6e4) : 200;
    if (newBackOff > 1000 && this.backOff < 1000) {
      this.report.delay(err);
    }
    this.backOff = newBackOff;
    setTimeout(() => {
      if (this.state.comm == 'recover') {
        this.dispatch({ type: 'restart' });
      }
    }, this.backOff);
  }

  sendable(editState: EditorState): ?{ steps: Array<Step> } {
    const steps = sendableSteps(editState);
    if (steps) {
      return { steps };
    }
    return null;
  }

  // [FS] IRAD-1040 2020-09-02
  // Send the modified schema to server
  updateSchema(schema: Schema) {
	// to avoid cyclic reference error, use flatted string.
	const schemaFlatted = stringify(schema);
    this.run(POST(this.url + '/schema/', schemaFlatted, 'text/plain')).then(
      data => {
        console.log("collab server's schema updated");
        // [FS] IRAD-1040 2020-09-08
        this.schema = schema;
        this.start();
      },
      err => {
        this.report.failure(err);
      }
    );
  }

  // Try to recover from an error
  recover(err: Error): void {
    const newBackOff = this.backOff ? Math.min(this.backOff * 2, 6e4) : 200;
    if (newBackOff > 1000 && this.backOff < 1000) {
      this.report.delay(err);
    }
    this.backOff = newBackOff;
    setTimeout(() => {
      if (this.state.comm == 'recover') {
        this.dispatch({ type: 'poll' });
      }
    }, this.backOff);
  }

  closeRequest(): void {
    if (this.request) {
      this.request.abort();
      this.request = null;
    }
  }

  run(request: any): Promise<any> {
    return (this.request = request);
  }

  close(): void {
    this.closeRequest();
    this.setView(null);
  }

  setView(view: EditorView): void {
    if (this.view) {
      this.view.destroy();
    }
    this.view = window.view = view;
  }
}

function repeat(val: any, n: number): Array<any> {
  const result = [];
  for (let i = 0; i < n; i++) {
    result.push(val);
  }
  return result;
}

export default EditorConnection;

The above code successfully sends and gets cursor information from server:

clientID: "1e8f4b00-0326-11eb-aa11-3d2f6cec7c6a"
selection:
anchor: 226
head: 226
type: "text"

But I am stuck in representing that information onto client's editor, the code below results in plugin errors that I am not sure. And I admit I don't quite understand the structure of the plugin despite reading the manual.

This is CursorPlaceholderPlugin2.js

// @flow

import {EditorState, Plugin, PluginKey} from 'prosemirror-state';
import {TextSelection} from 'prosemirror-state';
import {EditorView} from 'prosemirror-view';
import {Decoration, DecorationSet} from 'prosemirror-view';

import {MARK_LINK} from './MarkNames';
import {
  hideSelectionPlaceholder,
  showSelectionPlaceholder,
} from './SelectionPlaceholderPlugin';
import applyMark from './applyMark';
import findNodesWithSameMark from './findNodesWithSameMark';
import lookUpElement from './lookUpElement';
import LinkTooltip from './ui/LinkTooltip';
import LinkURLEditor from './ui/LinkURLEditor';
import {atAnchorTopCenter} from './ui/PopUpPosition';
import createPopUp from './ui/createPopUp';

import './ui/czi-pop-up.css';

const PLACE_HOLDER_ID = {name: 'CursorPlaceholderPlugin2'};

export function showCursor(
  json: any,
  coords: any
): boolean {
  const {runtime, state, readOnly, disabled} = view;
  const {schema, plugins} = state;
  if (readOnly || disabled || !runtime || !runtime.canUploadImage) {
    return false;
  }
}

export function showCursorPlaceholder(
  state: any,
  view: any,
  json: any
): boolean {
  // console.log('in');
  console.log(state.edit.plugins);
  console.log(state.edit.plugins[1]);
  state.edit.plugins[1].update_cursor(state.edit, json);
  // console.log(view);
  // console.log(json);


}

class CursorPlaceholderPlugin2 extends Plugin {
  _object = null;
  _editor = null;

  constructor() {
    super({
      // [FS] IRAD-1005 2020-07-07
      // Upgrade outdated packages.
      key: new PluginKey('CursorPlaceholderPlugin2'),
      state: {
        init() {
          return DecorationSet.empty;
        },
        apply(tr, set) {
          console.log('in1234567');
          set = set.map(tr.mapping, tr.doc);
          // const action = tr.getMeta(this);
          // if (!action) {
          //   return set;
          // }
          // console.log('');
          // const widget = document.createElement('czi-cursor-placeholder2');
          // widget.className = 'czi-cursor-placeholder2';
          // const deco = Decoration.widget(action.add.pos, widget, {
          //   id: PLACE_HOLDER_ID,
          // });
          // set = set.add(tr.doc, [deco]);

          console.log(set);
          return set;
        }
      },
      view(editorView: EditorView) {
        console.log('in123');
        console.log(editorView);
        this._object = new LinkTooltipView(editorView);

        this._editor = editorView;
        return this._object;
      },
    });
  }

  update_cursor(state, json) {
    console.log('in1234');
    console.log(this._editor);

    console.log(json);

    let {tr} = state;
    // if (!tr.selection) {
    //   return tr;
    // }

    // const pos = findCursorPlaceholderPos(state);
    // if (pos === null) {
    //   if (!tr.selection.empty) {
    //     // Replace the selection with a placeholder.
    //     tr = tr.deleteSelection();
    //   }
    //   tr = tr.setMeta(plugin, {
    //     add: {
    //       pos: tr.selection.from,
    //     },
    //   });
    // }

    const widget = document.createElement('czi-cursor-placeholder2');
    widget.className = 'czi-cursor-placeholder2';
    const deco = Decoration.widget(json.selection.anchor, widget, {
      id: PLACE_HOLDER_ID,
    });
    console.log(state);
    DecorationSet.create(state.doc, [deco]);



    // this._object.update(state.edit);
  }
}

class LinkTooltipView {
  _anchorEl = null;
  _popup = null;
  _editor = null;

  constructor(editorView: EditorView) {
    this._editor = editorView;
    // this.update(editorView, null);
  }

  update(lastState: EditorState): void {
    console.log(this._editor);
    console.log('in12345');
    // if (view.readOnly) {
    //   this.destroy();
    //   return;
    // }
    //
    // const {state} = view;
    // const {doc, selection, schema} = state;
    // const markType = schema.marks[MARK_LINK];
    // if (!markType) {
    //   return;
    // }
    // const {from, to} = selection;
    // const result = findNodesWithSameMark(doc, from, to, markType);
    //
    // if (!result) {
    //   this.destroy();
    //   return;
    // }
    // const domFound = view.domAtPos(from);
    // if (!domFound) {
    //   this.destroy();
    //   return;
    // }
    // const anchorEl = lookUpElement(domFound.node, el => el.nodeName === 'A');
    // if (!anchorEl) {
    //   this.destroy();
    //   return;
    // }
    //
    // const popup = this._popup;
    // const viewPops = {
    //   editorState: state,
    //   editorView: view,
    //   href: result.mark.attrs.href,
    //   onCancel: this._onCancel,
    //   onEdit: this._onEdit,
    //   onRemove: this._onRemove,
    // };
    //
    // if (popup && anchorEl === this._anchorEl) {
    //   popup.update(viewPops);
    // } else {
    //   popup && popup.close();
    //   this._anchorEl = anchorEl;
    //   this._popup = createPopUp(LinkTooltip, viewPops, {
    //     anchor: anchorEl,
    //     autoDismiss: false,
    //     onClose: this._onClose,
    //     position: atAnchorTopCenter,
    //   });
    // }
  }

  destroy() {
    // this._popup && this._popup.close();
    // this._editor && this._editor.close();
  }

  _onCancel = (view: EditorView): void => {
    // this.destroy();
    // view.focus();
  };

  _onClose = (): void => {
    this._anchorEl = null;
    this._editor = null;
    this._popup = null;
  };

  _onEdit = (view: EditorView): void => {
    if (this._editor) {
      return;
    }

    const {state} = view;
    const {schema, doc, selection} = state;
    const {from, to} = selection;
    const markType = schema.marks[MARK_LINK];
    const result = findNodesWithSameMark(doc, from, to, markType);
    if (!result) {
      return;
    }
    let {tr} = state;
    const linkSelection = TextSelection.create(
      tr.doc,
      result.from.pos,
      result.to.pos + 1
    );

    tr = tr.setSelection(linkSelection);
    tr = showSelectionPlaceholder(state, tr);
    view.dispatch(tr);

    const href = result ? result.mark.attrs.href : null;
    this._editor = createPopUp(
      LinkURLEditor,
      {href},
      {
        onClose: value => {
          this._editor = null;
          this._onEditEnd(view, selection, value);
        },
      }
    );
  };

  _onRemove = (view: EditorView): void => {
    this._onEditEnd(view, view.state.selection, null);
  };

  _onEditEnd = (
    view: EditorView,
    initialSelection: TextSelection,
    href: ?string
  ): void => {
    const {state, dispatch} = view;
    let tr = hideSelectionPlaceholder(state);

    if (href !== undefined) {
      const {schema} = state;
      const markType = schema.marks[MARK_LINK];
      if (markType) {
        const result = findNodesWithSameMark(
          tr.doc,
          initialSelection.from,
          initialSelection.to,
          markType
        );
        if (result) {
          const linkSelection = TextSelection.create(
            tr.doc,
            result.from.pos,
            result.to.pos + 1
          );
          tr = tr.setSelection(linkSelection);
          const attrs = href ? {href} : null;
          tr = applyMark(tr, schema, markType, attrs);

          // [FS] IRAD-1005 2020-07-09
          // Upgrade outdated packages.
          // reset selection to original using the latest doc.
          const origSelection = TextSelection.create(
            tr.doc,
            initialSelection.from,
            initialSelection.to
          );
          tr = tr.setSelection(origSelection);
        }
      }
    }
    dispatch(tr);
    view.focus();
  };
}

export default CursorPlaceholderPlugin2;

@tedchou12
Copy link
Contributor Author

I am not very knowledgeable in either nodejs, react or es6, actually, is my second time coming across it, but this project is beautiful and is exactly what I wanted with this single addition that I was hoping to make.

@MO-Movia
Copy link
Collaborator

MO-Movia commented Sep 30, 2020

Ted, Thank you, and your input and support is appreciated!!

Did you look at https://atlaskit.atlassian.com/packages/editor/editor-core for examples? It is all React based as well. We are hoping in the future to take some of their plugins and port them to licit (the ones without the Atlassian license clause - only a few modules).

@tedchou12
Copy link
Contributor Author

tedchou12 commented Sep 30, 2020

Decorations are always owned by a plugin, and ProseMirror’s state is only updated through transactions, not with imperative methods.

I guess this is why I am so stuck, because it sounds like gibberish to me... 😂, but I do wish to understand.

EDITED: About this part, could I ask, if I wish to add the phantom cursor of another client to the current editor, do I need to use the transaction to change the editor? My understanding is that is best just to change the current display of the editor without changing the editor's content, but my knowledge is limited.

If anyone could let me know how to make the actual object appear in the editor, then I think it will be the light at the end of the tunnel. I am not sure what I should put into the plugin CursorPlaceholderPlugin2.js is the reason I am stuck for several days.. 😭

@MO-Movia
Thank you so much, I quickly glanced through it, but seems like it is ready made packages for Atlaskit, I guess I will be drifted away with the examples provided by them..

Any suggestion is appreciated, in the mean time, I am still digging through examples as mentioned by @ashfaq-shamsudeen .

@ashfaq-shamsudeen
Copy link
Contributor

@tedchou12 ,

Are you saying state.edit.plugins[1].update_cursor(state.edit, json); - this part causing problem?

Are you getting the response from the collab server correctly? What exactly the issue you are trying to resolve here?

@tedchou12
Copy link
Contributor Author

@ashfaq-shamsudeen
Thank you so much~. I took the last few hours still digging.

The collab server gives this information correctly:

{clientID: "1e8f4b00-0326-11eb-aa11-3d2f6cec7c6a"
selection:
{anchor: 226
head: 226
type: "text"}}

I was able to show the cursor:
Screen Shot 2020-10-01 at 1 57 43
I will figure out the shape and accurate position later.

Here is the clean code for: plugin

// @flow

import {EditorState, Plugin, PluginKey} from 'prosemirror-state';
import {TextSelection} from 'prosemirror-state';
import {EditorView} from 'prosemirror-view';
import {Decoration, DecorationSet} from 'prosemirror-view';

import {MARK_LINK} from './MarkNames';
import {
  hideSelectionPlaceholder,
  showSelectionPlaceholder,
} from './SelectionPlaceholderPlugin';
import applyMark from './applyMark';
import findNodesWithSameMark from './findNodesWithSameMark';
import lookUpElement from './lookUpElement';
import LinkTooltip from './ui/LinkTooltip';
import LinkURLEditor from './ui/LinkURLEditor';
import {atAnchorTopCenter} from './ui/PopUpPosition';
import createPopUp from './ui/createPopUp';

import './ui/czi-pop-up.css';

const PLACE_HOLDER_ID = {name: 'CursorPlaceholderPlugin2'};

export function showCursor(
  json: any,
  coords: any
): boolean {
  const {runtime, state, readOnly, disabled} = view;
  const {schema, plugins} = state;
  if (readOnly || disabled || !runtime || !runtime.canUploadImage) {
    return false;
  }
}

export function showCursorPlaceholder(
  state: any,
  view: any,
  json: any
): boolean {
  // console.log('in');
  console.log(state.edit.plugins);
  console.log(state.edit.plugins[2]);
  state.edit.plugins[2].update_cursor(state.edit, json);
  // console.log(view);
  // console.log(json);


}

class CursorPlaceholderPlugin2 extends Plugin {
  _object = null;
  _editor = null;

  constructor() {
    super({
      // [FS] IRAD-1005 2020-07-07
      // Upgrade outdated packages.
      key: new PluginKey('CursorPlaceholderPlugin2'),
      state: {
        init() {
          return DecorationSet.empty;
        },
        apply(tr, set) {
          console.log('in1234567');
          set = set.map(tr.mapping, tr.doc);
          // const action = tr.getMeta(this);
          // if (!action) {
          //   return set;
          // }
          // console.log('');
          // const widget = document.createElement('czi-cursor-placeholder2');
          // widget.className = 'czi-cursor-placeholder2';
          // const deco = Decoration.widget(action.add.pos, widget, {
          //   id: PLACE_HOLDER_ID,
          // });
          // set = set.add(tr.doc, [deco]);

          console.log(set);
          return set;
        }
      },
      view(editorView: EditorView) {
        console.log('in123');
        console.log(editorView);
        this._object = new LinkTooltipView(editorView);

        this._editor = editorView;
        return this._object;
      },
    });
  }

  update_cursor(state, json) {
    this._object.update(this._editor, json);
  }
}

class LinkTooltipView {
  _anchorEl = null;
  _popup = null;
  _editor = null;
  _cursor = null;

  constructor(editorView: EditorView) {
    this._editor = editorView;
    this._cursor = document.createElement("div");
    this._cursor.className = "czi-cursor-placeholder2";
    console.log(editorView);
    editorView.dom.parentNode.appendChild(this._cursor);
    this.update(editorView, null);
  }

  update(view, json): void {
    console.log('12345')
    if (json != null) {
      this._cursor.style.display = '';
      let box = this._cursor.offsetParent.getBoundingClientRect();
      let start = view.coordsAtPos(json.selection.head), end = view.coordsAtPos(json.selection.anchor)
      let left = Math.max((start.left + end.left) / 2, start.left + 3)
      this._cursor.style.left = (left - box.left) + "px"
      this._cursor.style.bottom = (box.bottom - start.top) + "px"
      this._cursor.textContent = 'cursor'
    } else {
      this._cursor.style.display = 'none';
    }
  }

  destroy() {
    // this._popup && this._popup.close();
    // this._editor && this._editor.close();
  }
}

export default CursorPlaceholderPlugin2;

BUT, currently the LinkTooltipView.update() function is triggered when I move my own cursor, I wish this to be triggered from src/client/EditorConnection.js file, is it possible? Since it should be a shared cursor function, it should be triggered onmessage from the websocket.

Thanks.

@tedchou12
Copy link
Contributor Author

This is a one simple enough to follow, I used the sharedcursor by copying from the tooltip:
https://prosemirror.net/examples/tooltip/

@tedchou12
Copy link
Contributor Author

tedchou12 commented Sep 30, 2020

I tried to call this function showCursorPlaceholder() from EditorConnection.js but calling the update_cursor() and then the update(), but during initialization was okay, but when calling from EditorConnection, view was null...
Screen Shot 2020-10-01 at 2 08 44

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants