lively.ide_text_snippets.js

import { connect, disconnect } from 'lively.bindings';
import { string } from 'lively.lang';
import KeyHandler from 'lively.morphic/events/KeyHandler.js';
import { Range } from 'lively.morphic/text/range.js';

function addIndexToTextPos (textMorph, textPos, index) {
  return textMorph.indexToPosition(textMorph.positionToIndex(textPos) + index);
}

export class Snippet {
  /**
   * When `expansion` is a string the default templating language is used.
   * When `expansion` is a function, if will be executed and get passed all text following the insert position in the document.
   * The return value of the function will then be used for the expansion, i.e. the function needs to return a string.
   * The replacement of the function with its return value happens when `expandAtCursor` of the snippet is executed.
   * The string can contain the normal templating language. This allows to define context-aware snippets.
   */
  constructor (opts = { trigger: null, expansion: '' }) {
    const { trigger, expansion } = opts;
    this.trigger = trigger;
    this.expansion = expansion;
    this.resetExpansionState();
  }

  get isTextSnippet () { return true; }

  attach (textMorph) {
    if (!this.isExpanding) return;
    this.textMorph = textMorph;
    connect(this.textMorph, 'selectionChange', this, 'onCursorMove');
  }

  detach (textMorph) {
    if (!this.isExpanding) return;
    disconnect(textMorph, 'selectionChange', this, 'onCursorMove');
    this.textMorph = null;
  }

  onCursorMove () {
    const { startAnchor, endAnchor, isExpanding } = this.expansionState;
    if (!isExpanding) { this.resetExpansionState(); return; }

    const range = Range.fromPositions(startAnchor.position, endAnchor.position);
    if (!range.containsPosition(this.textMorph.cursorPosition)) { this.resetExpansionState(); }
  }

  resetExpansionState () {
    const m = this.textMorph;
    if (m) {
      const { marker, startAnchor, endAnchor, steps } = this.expansionState || {};
      if (startAnchor) m.removeAnchor(startAnchor);
      if (endAnchor) m.removeAnchor(endAnchor);
      if (marker) m.removeMarker(marker);
      steps.forEach(({ anchor }) => m.removeAnchor(anchor));
    }

    this.expansionState = { stepIndex: -1, steps: [], isExpanding: false, startMarker: null, endMarker: null };

    m && m.removePlugin(this);
  }

  get isExpanding () { return this.expansionState.isExpanding; }

  createExpansionSteps (expansion) {
    const steps = [];
    const matches = string.reMatches(expansion, /\$[0-9]+|\$\{[0-9]+:[^\}]*\}/g);
    let offset = 0;

    matches.forEach(({ start, end, match }) => {
      let n; let prefill = '';
      if (match.startsWith('${')) {
        const [_, nString, _prefill] = match.match(/^\$\{([0-9]+):([^\}]*)\}/);
        n = Number(nString);
        prefill = _prefill;
      } else {
        n = Number(match.replace(/^\$/, ''));
      }
      expansion = expansion.slice(0, start - offset) + prefill + expansion.slice(end - offset);
      steps[n] = { index: start - offset, prefill, anchor: null };
      offset += end - start - prefill.length;
    });

    return { steps, expansion };
  }

  expandAtCursor (textMorph) {
    const m = textMorph; const sel = m.selection;
    if (typeof this.expansion === 'function') {
      this.expansion = this.expansion(m.document.textInRange({ start: m.cursorPosition, end: m.document.endPosition }));
    }
    let indent = m.cursorPosition.column;
    var expansion = this.expansion; // eslint-disable-line no-var

    if (this.trigger) { indent -= this.trigger.length; }
    indent = Math.max(0, indent);

    if (indent) {
      let lines = string.lines(expansion);
      lines = [lines[0], ...lines.slice(1).map(line => string.indent(line, ' ', indent))];
      expansion = lines.join('\n');
    }

    var { expansion, steps } = this.createExpansionSteps(expansion); // eslint-disable-line no-var

    if (this.trigger) { sel.growLeft(this.trigger.length); }

    sel.text = expansion;
    const { start, end } = sel;
    sel.collapseToEnd();

    if (!steps.length) return;

    const id = string.newUUID();

    // add anchors to expansion steps, this are the selection insertion points
    // the user can tab through. To keep their position where it was intended to
    // be even if stuff gets inserted before, use anchors
    steps.forEach((step, i) =>
      step.anchor = m.addAnchor({
        id: `snippet-step-${i}-` + id,
        ...addIndexToTextPos(m, start, step.index)
      }));

    const startAnchor = m.addAnchor({ id: 'snippet-start-' + id, ...start, insertBehavior: 'stay' });
    const endAnchor = m.addAnchor({ id: 'snippet-end-' + id, ...end });
    const marker = m.addMarker({
      id: 'snippet-marker-' + id,
      get range () { return { start: startAnchor.position, end: endAnchor.position }; },
      style: {
        'border-radius': '4px',
        'background-color': 'rgba(30, 200, 140, 0.3)',
        'box-shadow': '0 0 4px rgba(30, 200, 140, 0.3)',
        'pointer-events': 'none',
        content: 'fooooo'
      }
    });

    this.expansionState = {
      marker,
      startAnchor,
      endAnchor,
      stepIndex: 0,
      steps,
      isExpanding: true
    };

    m.addPlugin(this);

    this.nextStep();
  }

  nextStep () {
    const { steps, stepIndex, isExpanding } = this.expansionState;
    const m = this.textMorph;
    if (!isExpanding || !m) return;
    const sel = m.selection;
    const { anchor: { position: stepPosition }, prefill } = steps[stepIndex];
    sel.lead = sel.anchor = stepPosition;
    sel.growRight(prefill.length);
    this.expansionState.stepIndex++;
    if (this.expansionState.stepIndex >= steps.length) {
      this.resetExpansionState();
      console.log(`[snippet] expansion of ${this.expansion} done`);
    }
  }

  canExpand (text, position = text.cursorPosition) {
    const triggerEnd = text.positionToIndex(text.cursorPosition);
    const triggerStart = triggerEnd - this.trigger.length;
    return text.textString.slice(triggerStart, triggerEnd) === this.trigger;
  }

  tryTrigger (text, position = text.cursorPosition) {
    if (!this.canExpand(text, position = text.cursorPosition)) return false;
    this.expandAtCursor(text);
    return true;
  }

  getCommands (commands) {
    return commands.concat([
      {
        name: '[snippet] next expansion step',
        exec: (textMorph) => { this.nextStep(); return true; }
      },

      {
        name: '[snippet] cancel expansion',
        exec: (textMorph) => { this.resetExpansionState(); return true; }
      }
    ]);
  }

  getKeyHandlers (handlers) {
    return handlers.concat(
      KeyHandler.withBindings([
        { keys: 'Tab', command: '[snippet] next expansion step' },
        { keys: 'Escape', command: '[snippet] cancel expansion' }
      ]));
  }
}

export const snippetCommands = [{
  name: 'get snippets',
  exec: (textMorph) => {
    return textMorph.pluginCollect('getSnippets', []);
  }
}];