8
votes

I am working on a Google Docs add on that turns text into a table. It allows the user to either 1) select some text or 2) place their cursor inside the text they want, and when a custom button in the sidebar is clicked, the script will:

  1. Insert a single row, single cell table at the cursor position or the startIndex of the selected text,
  2. Place inside that cell either the text a user has selected or the complete text of the element in which the cursor is placed,
  3. Delete the original selected text/element so that only the table remains

(Essentially, this is just 'drawing' a table around the selected text or element)

An overview of the documentation shows that one can insert text via the var element = cursor.insertText('ಠ‿ಠ');, but there is no similar method for inserting other elements. As such, I have been trying to use the insertTable(childIndex, cells) with varying degrees of success. Focusing on the circumstance in which a user simply places the cursor inside the element, I can insert a table with the correct text and delete the original text, but I cannot find a way to insert the table at the correct position. I have tried the following, to no avail:

  1. var newTable = body.insertTable(cursor.getOffset(), [['Test Text']]); - This creates the table but inserts it at the wrong position, seemingly based on where in the text the cursor is placed, usually towards the beginning of the document.

  2. var el = cursor.getElement().asBody(); var newTable = el.insertTable(0, [['Test Text']]); - This does nothing

  3. var el = cursor.getElement().getParent().asBody(); var newTable = el.insertTable(0, [['Test Text']]); - This does nothing

  4. var el = cursor.getElement(); var parent = el.getParent().getChildIndex(el); var newTable = body.insertTable(parent, [['Test Text']]); - This inserts the table at the very beginning of the document.

A search of Stack Overflow yields this very similar question with the suggestion of inserting some placeholder text at the cursor position, then searching the document for that inserted string and placing a table. I have two main concerns with this approach:

  1. I need to modify the text directly surrounding the insert point, and inserting extra text seems like it could make this more difficult, and
  2. More importantly, I still do not know how to obtain the correct position for the insert point of the table.

If anyone could expand upon the placeholder-text-method, or could provide a new solution, that would be very helpful. Thanks!

2

2 Answers

4
votes

I used the following GAS code to insert table at current cursor position in Google Doc. It works better if your cursor is on a newline.

  var doc = DocumentApp.getActiveDocument();
  var body = doc.getBody();
  var cursor = doc.getCursor();

  var element = cursor.getElement();
  var parent = element.getParent();
  //here table is an array of cells like
  // [[r1c1, r1c2], [r2c1, r2c2]]
  body.insertTable(parent.getChildIndex(element) + 1, table);

Edit: To get a better insight in workings of cursor position in Google Doc, I recommend you to run the following cursor inspector github project

https://github.com/google/google-apps-script-samples/tree/master/cursor_inspector

1
votes

TL;DR: use the code snippet below.

It seems that what Google Docs does when you select Insert > Table, is to break up the current paragraph into two paragraphs, and then inserts a new table in between.

The difficult part is to split the paragraph in two. I've tried to achieve this in several ways. I couldn't find any API call from Google Apps Script documentation that does this (the Spreadsheet API has a moveTo() method on a Range object, but that can't help us here). I was hoping I could copy elements from a the given position in the paragraph onward to another paragraph and remove the originals. However, since the Document Service does not allow explicit insertion of several element types but can only manipulate them in-place, such as Equation elements, copying these one-by-one is impossible.

Luckily, Paragraph has a copy() method which performs a deep copy. So my take on this is to copy the entire paragraph, remove everything from the cursor position onward in the original paragraph, and remove everything up to where the cursor was in the copy of the paragraph. This way, you have the paragraph split in the middle and you can insert a table at the required position. It works the same way for a ListItem.

Here is the code for the function splitParagraphAt() that returns the newly created paragraph (or list item; which is the next sibling of the original one). I threw in some extra checks in the code to make sure what it's doing is what you think it's doing. Following that, I added a short code excerpt on how this may be used to insert a table at the current cursor position. One might use splitParagraphAt() similarly to insert any element at the cursor position.I haven't tested this thoroughly, so any input is welcome.

/**
 * Splits the contents of the paragraph (or list item) at the given position, 
 * producing two adjacent paragraphs (or list items). This function may be used
 * to insert any kind of element at an arbitrary document position, but placing 
 * it immediately before the second paragraph (or list item).
 *
 * @param {Position} pos The position where the paragraph (or list item) should 
 *     be split. `pos.getElement()` should be either a Text, Paragraph or 
 *     ListItem object.
 *
 * @returns {ContainerElement} The second (newly created) Paragraph or ListItem 
 *     object.
 *
 */
function splitParagraphAt(pos) {
  var el = pos.getElement(), offset = pos.getOffset();

  var inParagraph = (el.getType() == DocumentApp.ElementType.PARAGRAPH || el.getType() == DocumentApp.ElementType.LIST_ITEM);

  if (!inParagraph && (el.getType() != DocumentApp.ElementType.TEXT)) {
    throw new Error("Position must be inside text or paragraph.");
  }

  var par;
  if (inParagraph) {
    // in this case, `offset` is the number of child elements before this
    // Position within the same container element 
    par = el;
    if (offset == par.getNumChildren()) {
      // we're at the end of the paragraph
      return par.getParent().insertParagraph(
        par.getParent().getChildIndex(par) + 1, "");
    }
    el = par.getChild(offset);
  }
  else {
    par = el.getParent();

    if (par == null || (par.getType() != DocumentApp.ElementType.PARAGRAPH && par.getType() != DocumentApp.ElementType.LIST_ITEM)) {
      throw new Error("Parent of text is not a paragraph or a list item.");
    }
  }

  var parContainer = par.getParent();

  if (!("insertParagraph" in parContainer)) {
    throw new Error("Cannot insert another paragraph in this container.");
  }

  // This assumes the given position is in the current document.
  // alternatively, one may traverse through parents of par until document 
  // root is reached.
  var doc = DocumentApp.getActiveDocument(); 

  var elIndex = par.getChildIndex(el);
  var newPar = par.copy();

  var newEl = newPar.getChild(elIndex);

  // remove everything up to position from the new element
  if (!inParagraph && (offset != 0)) {
    newEl.deleteText(0, offset-1);
  }
  newEl = newEl.getPreviousSibling();
  while (newEl != null) {
    // get the previous sibling before we remove the element.
    var prevEl = newEl.getPreviousSibling();
    newEl.removeFromParent(); 
    newEl = prevEl;
  }

  // since we might remove el itself, we get the next sibling here already
  var nextEl = el.getNextSibling();

  // remove everything from position onwards in the original element
  if (!inParagraph && (offset != 0)) {
    el.deleteText(offset, el.getText().length-1);
  }
  else {
    // we're at the beginning of the text (or just before a paragraph 
    // subelement) and need to remove the entire text/subelement.
    el.removeFromParent();
  }

  el = nextEl;
  while (el != null) {
    // get the next sibling before we remove the element.
    nextEl = el.getNextSibling();
    el.removeFromParent();
    el = nextEl;
  }

  // actually insert the newly created paragraph into the document tree.
  switch (par.getType()) {
    case DocumentApp.ElementType.PARAGRAPH:
      parContainer.insertParagraph(parContainer.getChildIndex(par)+1, newPar);
      break;
    case DocumentApp.ElementType.LIST_ITEM:
      parContainer.insertListItem(parContainer.getChildIndex(par)+1, newPar);
      break;
  }


  return newPar;
}

Here's the code excerpt for inserting a table at the cursor position and setting the cursor position in the first cell of the table:

var doc = DocumentApp.getActiveDocument();
var cursor = doc.getCursor();
var el = (cursor.getOffset() == 0? cursor.getElement() : splitParagraphAt(cursor));
var parentEl = el.getParent();
var table = parentEl.insertTable(parentEl.getChildIndex(el), [['ಠ‿ಠ']]);
doc.setCursor(doc.newPosition(table.getCell(0, 0), 0));

Note that there still need to be some extra checks to see if there is a selection or not, etc. Specifically, this assumed cursor will not be null.