Xpoint
   [напомнить пароль]

Динамические деревья на XUL

Оглавление

Общее о динамических деревьях

Самое позднее после прочтения знаменитого XUL Tutorial программист на XUL узнает, что обозначить данные, которые должны отображаться в дереве, можно двумя способами. Во-первых, можно жестко прописать в XUL-файле все нужные элементы treeitem, treerow и treecell:

<tree id="tree" flex="1">
  <treecols>
    <treecol id="title" label="Название" primary="true" flex="1" />
    <treecol id="path" label="Путь" flex="1" />
  </treecols>
  <treechildren id="tree-children">
    <treeitem container="true" open="true">
      <treerow>
        <treecell label="Perl" />
        <treecell label="programming/perl" />
      </treerow>

      <treechildren>
        <treeitem>
          <treerow>
            <treecell label="Разное" />
            <treecell label="programming/perl/misc" />
          </treerow>
        </treeitem>

        <treeitem>
          <treerow>
            <treecell label="Основы" />
            <treecell label="programming/perl/nursery" />
          </treerow>
        </treeitem>
      </treechildren>
    </treeitem>

    <treeitem>
      <treerow>
        <treecell label="PHP" />
        <treecell label="programming/php" />
      </treerow>
    </treeitem>
  </treechildren>
</tree>

[Посмотреть пример] [Исходники примера]

Этот вариант не слишком удобен (так, к примеру, невозможна сортировка), но позволяет менять содержимое дерева привычными методами DOM:

function addItem(title, path)
{
  var item = document.createElement('treeitem');
  var row = document.createElement('treerow');
  var cell1 = document.createElement('treecell');
  var cell2 = document.createElement('treecell');

  cell1.setAttribute('label', title);
  cell2.setAttribute('label', path);
  row.appendChild(cell1);
  row.appendChild(cell2);
  item.appendChild(row);
  document.getElementById('tree-children').appendChild(item);
}

[JavaScript-файл к примеру]

Вторая возможность — хранить данные в RDF, а в дереве поставить шаблон, преобразующий его нужным образом:

<tree id="tree" flex="1" datasources="forums.rdf"
    ref="http://xpoint.ru/forums#root" flags="dont-build-content">
  <treecols>
    <treecol id="title" label="Название" primary="true" flex="1" />
    <treecol id="path" label="Путь" flex="1" />
  </treecols>
  <template>
    <treechildren>
      <treeitem uri="rdf:*">
        <treerow>
          <treecell label="rdf:http://xpoint.ru/forums#title" />
          <treecell label="rdf:http://xpoint.ru/forums#path" />
        </treerow>
      </treeitem>
    </treechildren>
  </template>
</tree>

[Посмотреть пример] [Исходники примера] [RDF-файл]

Казалось бы, так намного проще. Данные не загромождают описание интерфейса. Изменения в RDF автоматически отображаются в дереве; если внутреннее представление данных программы как раз и есть RDF, то это вообще идеально. Заодно автоматически запоминаются и восстанавливаются при следующем запуске открытые контейнеры (только при использовании URL типа chrome://), и элементарно добавляется возможность сортировки.

Разумеется, и у этого решения тоже есть свои недостатки:

  1. Написание шаблонов для чуть более сложных структур данных, чем приведенная выше — сущее мучение. Помимо нехватки документации и отладочных систем для шаблонов, роль играет и непривычность шаблонной логики.
  2. Синхронизация RDF с содержимым дерева не идеальна. К примеру, на данный момент (Mozilla 1.7) не поддерживается вставка элемента в произвольное место списка, любое добавление появляется в конце дерева. Из-за этого время от времени приходится вызывать tree.builder.rebuild(), то есть расходовать время на построение дерева заново. При этом инициализируются заново и отмеченные строки дерева, что неудобно для пользователя.
  3. Эффективность перевода данных с помощью шаблонов не очень высока. Разработчики утверждают, что для эффективности такой системы существует принципиальное ограничение.
  4. Все данные дерева должны быть в памяти (это касается, конечно, и варианта с описанием данных в самом XUL-файле). Это не подходит в случаях, когда дерево очень большое и занимает много памяти, или когда требуется много времени на построение дерева целиком.

В этой статье будет рассмотрен еще один способ представления данных дерева, использующий собственную реализацию интерфейса nsITreeView. Он требует сравнительно высоких начальных затрат, но обеспечивает максимальную гибкость решения. Тем самым он годится для систем со сложными структурами данных, в частности и с такими, которые сложно перевести в RDF. Становится возможной реализация динамической подгрузки/генерации частей дерева. Больше всего преимуществ он дает системам с высокодинамичными данными, к примеру с регулярным изменением порядка строк или фильтрацией отображаемых данных.

Привязка данных к деревьям в XUL

Как и полагается для правильной объектной структуры, деревья в XUL не имеют прямого доступа к данным. Вместо этого у каждого дерева есть свойство view, которое содержит объект с интерфейсом nsITreeView. Через этот объект и осуществляется весь доступ к данным. TreeView-объект создается автоматически при создании дерева. Стандартных классов TreeView-объектов два, они соответствуют двум описанным выше способам определения данных дерева. Объекты типа nsTreeContentView читают данные для дерева из содержимого тега treechildren. Объекты типа nsXULTreeBuilder обрабатывают шаблон дерева и применяют его к указанным RDF-данным.

Этим, собственно, и объясняется факт, нередко удивляющий программистов на XUL, — нельзя указывать CSS-стили для treecell-элементов (как и для treerow или treeitem). Вот такое правило не будет работать:

treecell {
  color: red;
}

Вместо этого надо писать:

treechildren:-moz-tree-cell-text
{
  color: red;
}

Дело в том, что XUL игнорирует treecell-элементы, у них нет визуального отображения. Этим элементам придает смысл лишь класс nsTreeContentView, который считывает из них данные для дерева. В пользовательском интерфейсе же отображается лишь элемент treechildren, поэтому все стили и обработчики событий нужно указывать в нем.

Реализация интерфейса nsITreeView

Хотя и существуют стандартные TreeView-объекты, программа может использовать собственный. Для этого достаточно сделать объект, который будет реализовывать методы интерфейса nsITreeView, и записать его в свойство view дерева. В этом разделе мы подробно разберем все методы интерфейса и рассмотрим примеры их реализации. В результате мы получим работоспособную, пусть и не слишком эффективную, реализацию всего интерфейса, которую можно будет использовать как базу для собственных наработок. Конечно, многое можно сделать намного лучше, используя более подходящую организацию данных или кешируя результаты предыдущих запросов. Но это всего лишь пример и данные примера, а оптимизацию надо будет делать под реальные данные.

[Посмотреть весь пример] [XUL-файл] [CSS-файл]

Инициализация

Для начала посмотрим на создание объекта и методы, вызывающиеся при его инициализации.

Конструктор

В конструкторе нашего примера мы только определим данные дерева (динамическая подгрузка остается домашним заданием читателю) и создадим несколько атомов, которые можно будет использовать позже для описания свойств ячеек:

function ForumsTreeView()
{
  var atomService = Components.classes['@mozilla.org/atom-service;1']
      .getService(Components.interfaces.nsIAtomService);
  var redAtom = atomService.getAtom('red');
  var greenAtom = atomService.getAtom('green');
  this.atoms = {
    title: atomService.getAtom('title'),
    path: atomService.getAtom('path'),
    selected: atomService.getAtom('title')
  };

  this.data = [
    {title: 'Perl', path: 'programming/perl', open: true, children: [
        {title: 'Разное', path: 'programming/perl/misc', properties: [greenAtom]},
        {title: 'Детская', path: 'programming/perl/nursery', properties: [redAtom]},
      ]},
    {title: 'PHP', path: 'programming/php', selectedValue: true}
  ];
}

Метод QueryInterface()

В XPCOM объекты могут реализовывать любое количество интерфейсов. Если не известно, реализует ли объект нужный интерфейс, вызывается метод QueryInterface(), который определен в базовом интерфейсе nsISupports. Метод должен либо вернуть объект, реализующий нужный интерфейс (обычно this), либо вызвать исключение. Мы поддерживаем интерфейсы nsITreeView и nsISupports, кроме того нам нужно реализовать интерфейс nsISecurityCheckedComponent (для объяснений см. Ограничения в непривилегированных скриптах):

ForumsTreeView.prototype.QueryInterface = function(uuid)
{
  if (!uuid.equals(Components.interfaces.nsISupports) &&
      !uuid.equals(Components.interfaces.nsITreeView) &&
      !uuid.equals(Components.interfaces.nsISecurityCheckedComponent))
  {
    throw Components.results.NS_ERROR_NO_INTERFACE;
  }

  return this;
}

При использовании в непривилегированных скриптах Components.results.NS_ERROR_NO_INTERFACE придется заменить на 0x80004002 — это свойство недоступно без соответствующих прав.

Метод setTree()

Этот метод вызывается, когда объект привязывается к дереву. Параметром передается не само дерево, а значение его свойства boxObject (объект, реализующий интерфейс nsITreeBoxObject). При желании, само дерево можно получить выражением boxObject.treeBody.parentNode. Но здесь мы ограничимся лишь тем, что запомним этот объект — он нам понадобится позже.

ForumsTreeView.prototype.boxObject = null;
ForumsTreeView.prototype.setTree = function(aBoxObject)
{
  this.boxObject = aBoxObject;
}

Опрос данных

Рассмотрим методы, с помощью которых дерево опрашивает данные.

Свойство rowCount

Это свойство должно отображать количество строк таблицы. При этом считаются только видимые строки — про строки в свернутых контейнерах дерево ничего не знает, они исключительно наша забота. В примере мы будем пересчитывать количество строк при каждом запросе свойства rowCount:

// Вспомогательная функция для рекурсивного определения количества видимых строк
ForumsTreeView.prototype._getRowCount = function(aData)
{
  var count = 0;
  for (var i = 0; i < aData.length; i++)
  {
    count++;

    // Для открытых контейнеров учитываем детей тоже
    if ('children' in aData[i] && aData[i].open)
      count += this._getRowCount(aData[i].children);
  }
  return count;
}

// ReadOnly-свойство, так что определяем только функцию для чтения
ForumsTreeView.prototype.__defineGetter__('rowCount',
    function() {return this._getRowCount(this.data)});

Метод getCellText()

Этот метод, собственно, и опрашивает содержимое ячеек таблицы, все остальные возвращают лишь вспомогательные данные. Он соответствует атрибуту label обычного treecell-элемента. По номеру строки и идентификатору столбца нужно вернуть текст, который должен показываться в этом месте. Тут есть два нюанса:

  1. Как и для свойства rowCount, здесь учитываются только видимые строки. То есть надо определить, какому элементу реальных данных соответствует номер строки, учитывая то, что некоторые контейнеры могут быть закрыты.
  2. Идентификатор столбца изменился в Mozilla 1.8. Если раньше здесь передавался атрибут id столбца, то теперь, для большей универсальности, передается объект с интерфейсом nsITreeColumn.

Для решения первой проблемы мы будем использовать в данном примере неэффективный код, аналогичный тому, который использовался для свойства rowCount:

// Вспомогательная функция для нахождения элемента по номеру строки
ForumsTreeView.prototype._getRowElement = function(aData, aRow)
{
  for (var i = 0; i < aData.length && aRow > 0; i++)
  {
    aRow--;

    // Для открытых контейнеров учитываем детей тоже
    if ('children' in aData[i] && aData[i].open)
    {
      var count = this._getRowCount(aData[i].children);

      // Если нужная строка среди детей текущей
      if (aRow < count)
        return this._getRowElement(aData[i].children, aRow);
      else
        aRow -= count;
    }
  }

  if (i < aData.length)
    return aData[i];
  else
    return undefined;
}

Для решения второй проблемы мы будем проверять, получили ли мы объект вместо строки; если да, то заменим его на его id. В дальнейшем мы сможем исходить из того, что у нас id столбца.

ForumsTreeView.prototype.getCellText = function(aRow, aCol)
{
  // Если aCol не строка, то это должен быть nsITreeColumn-объект
  if (typeof aCol != 'string')
    aCol = aCol.id;

  var element = this._getRowElement(this.data, aRow);
  if (typeof element == 'undefined')
    return null;

  // Возвращаем свойство элемента, соответствующее идентификатору столбца
  return element[aCol];
}

Методы getCellValue(), getProgressMode(), getImageSrc()

Метод getCellValue() должен возвращать значение ячейки для нетекстовых столбцов. То есть, если для столбца selected указан тип type="checkbox", то посредством этого метода мы можем указать, стоит ли галочка в данной ячейке. У обычных treecell-элементов есть соответствующий атрибут value. Реализация почти не отличается от метода getCellText()

ForumsTreeView.prototype.getCellValue = function(aRow, aCol)
{
  // Если aCol не строка, то это должен быть nsITreeColumn-объект
  if (typeof aCol != 'string')
    aCol = aCol.id;

  var element = this._getRowElement(this.data, aRow);
  if (typeof element == 'undefined')
    return null;

  // Возвращаем значение только для столбца selected
  if (aCol == 'selected')
    return ('selectedValue' in element && element.selectedValue ? 'true' : 'false');
  else
    return null;
}

Примечание. Поддержка type="checkbox" для столбцов появится только в Mozilla 1.8.

Для столбцов типа progressmeter дополнительно вызывается метод getProgressMode(), который должен возвращать none, normal или undetermined (соответствует атрибуту mode). Реализация этого метода не отличается от getCellValue(), поэтому мы ее не будем демонстрировать на примере.

Аналогично реализуется и метод getImageSrc(), который соответствует атрибуту src. Он позволяет показывать в ячейке картинку. Следует заметить, что пользоваться этой возможностью не рекомендуется, предпочтительней устанавливать картинки через стили (см. метод getCellProperties()).

Методы getCellProperties(), getRowProperties(), getColumnProperties()

Для любой ячейки, строки или столбца дерева можно указать список свойств (см. properties). Эти свойства можно использовать, чтобы применять стили только к определенным ячейкам. К примеру, такой стиль изменит цвет текста только для тех ячеек, у которых есть свойство red:

treechildren:-moz-tree-cell-text(red)
{
  color: red;
}

А вот так в тех ячейках, у которых есть свойства green и folder, можно показать картинку:

treechildren:-moz-tree-image(green, folder)
{
  list-style-image: url(folder_green.gif);
}

Методы getCellProperties(), getRowProperties() и getColumnProperties() должны добавлять нужные свойства в массив (последний параметр функции, объект типа nsISupportsArray). Для экономии памяти здесь предписывается использование атомов — объектов, которые для каждой строки существуют только в одном экземпляре. Получить атом для строки можно с помощью nsIAtomService. Поскольку эта операция занимает некоторое время, мы создали все нужные атомы заранее в конструкторе.

В примере мы будем брать свойства строки из наших данных. Для столбцов мы добавим в свойства только id столбца. Ячейки будут объединять свойства их строки и столбца. Надо также учесть, что до Mozilla 1.8 у метода getColumnProperties() был еще один параметр перед aProperties, поэтому придется проверять, не получили ли мы три параметра.

ForumsTreeView.prototype.getColumnProperties = function(aCol, aProperties)
{
  // Если aCol не строка, то это должен быть nsITreeColumn-объект
  if (typeof aCol != 'string')
    aCol = aCol.id;

  // Если мы получили три параметра, то aProperties третий параметр
  if (this.getColumnProperties.arguments.length == 3)
    aProperties = this.getColumnProperties.arguments[2];

  if (aCol in this.atoms)
    aProperties.AppendElement(this.atoms[aCol]);
}
ForumsTreeView.prototype.getRowProperties = function(aRow, aProperties)
{
  var element = this._getRowElement(this.data, aRow);
  if (typeof element != 'undefined' && 'properties' in element)
  {
    for (var i = 0; i < element.properties.length; i++)
      aProperties.AppendElement(element.properties[i]);
  }
}
ForumsTreeView.prototype.getCellProperties = function(aRow, aCol, aProperties)
{
  this.getColumnProperties(aCol, aProperties);
  this.getRowProperties(aRow, aProperties);
}

Метод isSeparator()

Некоторые строки в дереве могут быть просто разделителями, в них рисуется только горизонтальная черта. Является ли строка разделителем, определяет метод isSeparator() (соответствует элементу treeseparator).

ForumsTreeView.prototype.isSeparator = function(aRow)
{
  var element = this._getRowElement(this.data, aRow);
  return (typeof element != 'undefined' && 'separator' in element);
}

Опрос структуры дерева

Чтобы правильно отобразить иерархию, дереву, кроме самих данных, нужны также сведения об их структуре. Для этого предназначены представленные далее методы.

Meтоды isContainer(), isContainerOpen(), isContainerEmpty()

Есть три метода, с помощью которых дерево получает информацию о контейнерах: isContainer() указывает, является ли вообще данная строка контейнером (соответствует атрибуту container), isContainerOpen() определяет, открыт ли контейнер (соответствует атрибуту open), isContainerEmpty() должен возвращать true для пустых контейнеров. Тривиальная реализация этих методов:

ForumsTreeView.prototype.isContainer = function(aRow)
{
  var element = this._getRowElement(this.data, aRow);
  return (typeof element != 'undefined' && 'children' in element);
}
ForumsTreeView.prototype.isContainerOpen = function(aRow)
{
  var element = this._getRowElement(this.data, aRow);
  return (typeof element != 'undefined' && element.open);
}
ForumsTreeView.prototype.isContainerEmpty = function(aRow)
{
  var element = this._getRowElement(this.data, aRow);
  return (typeof element == 'undefined' || element.children.length == 0);
}

Метод getLevel()

getLevel() определяет, на каком уровне в иерархии находится строка. При этом у строк верхнего уровня уровень 0, у их детей уровень 1 и т.д. Для вычисления уровня строки мы используем рекурсивный подход, как и для свойства rowCount.

// Вспомогательная функция для рекурсивного определения уровня элемента в иерархии
ForumsTreeView.prototype._getLevel = function(aData, aRow)
{
  for (var i = 0; i < aData.length && aRow > 0; i++)
  {
    aRow--;

    // Для открытых контейнеров учитываем и детей тоже
    if ('children' in aData[i] && aData[i].open)
    {
      var count = this._getRowCount(aData[i].children);

      // Если нужная строка среди детей текущей
      if (aRow < count)
        return this._getLevel(aData[i].children, aRow) + 1;
      else
        aRow -= count;
    }
  }

  if (i < aData.length)
    return 0;
  else
    return -1;
}

ForumsTreeView.prototype.getLevel = function(aRow)
{
  return this._getLevel(this.data, aRow);
}

Метод getParentIndex()

В принципе, метода getLevel() достаточно, чтобы полностью определить структуру данных. Но дерево помнит только параметры тех строк, которые видны на данный момент. Иногда требуется быстро узнать индекс родителя какой-то строки (к примеру, при нажатии клавиши ВЛЕВО на строке, то есть переход к родителю), который может быть за пределами видимой части дерева. Для этого и предназначен метод getParentIndex(). Мы реализуем его аналогично методу getLevel(), но сохраняем номер строки, в которой мы находимся на данный момент, чтобы его можно было прибавить к результату рекурсивного вызова.

// Вспомогательная функция для рекурсивного определения индекса родителя элемента
ForumsTreeView.prototype._getParentIndex = function(aData, aRow)
{
  var currentIndex = 0;
  for (var i = 0; i < aData.length && aRow > currentIndex; i++)
  {
    currentIndex++;

    // Для открытых контейнеров учитываем и детей тоже
    if ('children' in aData[i] && aData[i].open)
    {
      var count = this._getRowCount(aData[i].children);

      // Если нужная строка среди детей текущей
      if (aRow < currentIndex + count)
        return currentIndex + this._getParentIndex(aData[i].children, aRow - currentIndex);

      currentIndex += count;
    }
  }

  return -1;
}

ForumsTreeView.prototype.getParentIndex = function(aRow)
{
  return this._getParentIndex(this.data, aRow);
}

Метод hasNextSibling()

Наличие метода hasNextSibling() опять же связано с оптимизацией дерева. Этот метод вызывается при прокручивании дерева вниз. Он должен проверять, есть ли в контейнере, в котором находится строка, строки после определенного индекса (за пределами видимой части дерева). Тривиальная реализация этого метода выглядит так:

ForumsTreeView.prototype.hasNextSibling = function(aRow, aAfterRow)
{
  var parent = this.getParentIndex(aRow);
  var parentElement = this._getRowElement(this.data, parent);
  if (typeof parentElement == 'undefined')
    return false;

  // Проверяем для всех детей, не расположен ли один из них после aAfterRow
  var data = parentElement.children;
  var child = parent + 1;
  for (var i = 0; i < data.length; i++)
  {
    if (child > aAfterRow)
      return true;

    child++;
    if ('children' in data[i] && data[i].open)
      child += this._getRowCount(data[i].children);
  }
  return false;
}

Динамические изменения

nsITreeView определяет и несколько методов, которые позволяют пользователю влиять на данные дерева. В дальнейшем мы рассмотрим их назначение.

Метод toggleOpenState() и добавление/удаление строк

Метод toggleOpenState() вызывается, когда пользователь хочет открыть или закрыть контейнер. Как правило, он должен изменить статус контейнера и сообщить дереву об изменившемся количестве видимых строк с помощью метода boxObject.rowCountChanged(). В нашем примере это делается достаточно просто:

ForumsTreeView.prototype.toggleOpenState = function(aRow)
{
  var element = this._getRowElement(this.data, aRow);
  if (typeof element == 'undefined' || !element.children.length)
    return;

  var count = this._getRowCount(element.children);
  element.open = !element.open;
  if (element.open)
    this.boxObject.rowCountChanged(aRow + 1, count);
  else
    this.boxObject.rowCountChanged(aRow + 1, -count);

  // Перерисовываем значок контейнера
  if (typeof this.boxObject.columns != 'undefined')
    this.boxObject.invalidateCell(aRow, this.boxObject.columns.getPrimaryColumn());
  else
    this.boxObject.invalidatePrimaryCell(aRow);
}

nsXULTreeBuilder в этом методе записывает статус контейнера в LocalStore, чтобы восстановить его, когда пользователь в следующий раз откроет приложение. Аналогичное можно реализовать и для своего TreeView-объекта.

Обратим внимание на то, что для метода rowCountChanged() недостаточно указать, сколько строк прибыло/убыло. Нужно еще и сказать, в каком именно месте мы вставили/удалили строки. Это важно, чтобы отмеченными остались те же строки, что и до вставки/удаления.

А что делать, если мы ничего не вставляли и не удаляли, а просто изменили данные нескольких строк? Дерево не отобразит изменения, если ему на них не указать. Для этого nsITreeBoxObject предоставляет методы invalidate() (обновляет все дерево, обычно это не нужно), invalidateColumn() (если изменения затронули только один столбец), invalidateRow() (если изменилась одна строка), invalidateRange() (если нужно обновить несколько строк, идущих подряд), invalidateCell() (для обновления одной ячейки) и invalidatePrimaryCell() (полезно, к примеру, если нужно перерисовать значок контейнера, как в данном случае). Заметим, однако, что последний из этих методов удален в Mozilla 1.8, вместо него рекомендуется использовать метод invalidateCell() со столбцом, который возвращает метод boxObject.columns.getPrimaryColumn(). Иногда приходится для обновления отображения вызывать много функций подряд. Чтобы дерево не перерисовывалось после каждого вызова, можно в самом начале вызвать метод beginUpdateBatch(), а после завершения — endUpdateBatch(), только после этого дерево перерисуется.

Методы cycleHeader() и isSorted()

Метод cycleHeader() вызывается, когда пользователь нажимает мышкой на заголовок столбца. Как правило, данные должны при этом пересортироваться по данному столбцу. Саму сортировку мы в примере опустим, меняться будет только значок на заголовке столбца (определяется атрибутом sortDirection).

ForumsTreeView.prototype.sortColumn = null;
ForumsTreeView.prototype.cycleHeader = function(aCol)
{
  // Если aCol не строка, то это должен быть nsITreeColumn-объект
  if (typeof aCol != 'string')
    aCol = aCol.id;

  var treeCol = document.getElementById(aCol);
  if (!treeCol)
    return;

  var cycle = {
    natural: 'ascending',
    ascending: 'descending',
    descending: 'natural'};
  var curDirection = 'natural';
  if (this.sortColumn == treeCol)
    curDirection = treeCol.getAttribute('sortDirection');
  else if (this.sortColumn)
    this.sortColumn.removeAttribute('sortDirection');

  curDirection = cycle[curDirection];

  //*******************
  //* Здесь сортируем *
  //*******************

  treeCol.setAttribute('sortDirection', curDirection);
  this.sortColumn = treeCol;
}

В настоящей программе нужно будет также учесть, что у какой-нибудь колонки может быть установлен атрибут sortDirection уже при создании дерева (особенно, если его сохраняют с помощью атрибута persist). В таком случае, в методе setTree() надо проверять, не нужно ли отсортировать дерево и инициализировать свойство sortColumn.

Дерево проверяет наличие сортировки с помощью метода isSorted(). Если этот метод возвращает true, то в список свойств автоматически добавляется sorted. В нашем случае этот метод выглядит так:

ForumsTreeView.prototype.isSorted = function()
{
  return (this.sortColumn && this.sortColumn.getAttribute('sortDirection') != 'natural');
}

Методы isEditable(), setCellText() и cycleCell()

Судя по всему, методы isEditable(), setCellText() и cycleCell() пока не используются, хотя в некоторых стандартных классах они и реализованы. В будущем эти методы позволят пользователю менять содержимое ячеек, но сейчас такой функциональности у деревьев еще нет. Вместо них мы ставим заглушки:

ForumsTreeView.prototype.isEditable = function(aRow, aCol)
{
  return false;
}
ForumsTreeView.prototype.setCellText = function(aRow, aCol, aValue) {}
ForumsTreeView.prototype.cycleCell = function(aRow, aCol) {}

Дополнительные операции (методы performAction(), performActionOnRow(), performActionOnCell())

Помимо стандартных операций, можно определить и собственные. Для этого существуют методы performAction(), performActionOnRow() и performActionOnCell(). В примере мы определим операцию removeRow:

ForumsTreeView.prototype.performActionOnRow = function(aAction, aRow)
{
  if (aAction == 'removeRow')
  {
    var element = this._getRowElement(this.data, aRow);
    if (typeof element == 'undefined')
      return;

    // Ищем строку-родителя
    var parent = this.getParentIndex(aRow);
    var children = this.data;
    if (parent >= 0)
      children = this._getRowElement(this.data, parent).children;

    // Считаем количество удаляемых строк
    var count = 1;
    if ('children' in element)
      count += this._getRowCount(element.children);

    // Удаляем строку
    for (var i = 0; i < children.length; i++)
    {
      if (children[i] == element)
      {
        children.splice(i, 1);
        break;
      }
    }

    // Обновляем дерево
    this.boxObject.rowCountChanged(aRow, -count);
  }
}

Drag & Drop

Деревья поддерживают Drag & Drop. Посмотреть, как это работает, можно в диалоге Bookmarks. Можно опустить объект на папку (похоже, что в Mozilla 1.8 это ограничение снято, и опускать объекты можно на любую строку), а можно и вставить его между строк. TreeView решает, какие объекты можно принять и где.

Методы canDrop(), canDropOn() и canDropBeforeAfter()

Изменения, внесенные в Mozilla 1.8, коснулись и методов, определяющих, могут ли строки дерева принимать объекты. Если раньше этим занимались два метода (canDropOn() и canDropBeforeAfter()), то теперь их объединили в метод canDrop(). Для совместимости мы определим все три метода, причем canDropOn() и canDropBeforeAfter() будут просто вызывать canDrop(). В примере мы разрешим всем строкам принимать объекты, но запретим вставку перед ними или после них.

ForumsTreeView.prototype.canDrop = function(aRow, aOrientation)
{
  return (aOrientation == this.DROP_ON);
}
ForumsTreeView.prototype.canDropOn = function(aRow)
{
  return this.canDrop(aRow, this.DROP_ON);
}
ForumsTreeView.prototype.canDropBeforeAfter = function(aRow, aBefore)
{
  return this.canDrop(aRow, aBefore ? this.DROP_BEFORE : this.DROP_AFTER);
}

Есть еще проблема с константами DROP_ON, DROP_BEFORE и DROP_AFTER, т.к. они определены в интерфейсе nsITreeView только начиная с Mozilla 1.8. В более ранних версиях Mozilla они называются inDropOn, inDropBefore и inDropAfter и имеют другое значение. Придется различать две версии интерфейса.

var iface = Components.interfaces.nsITreeView;
ForumsTreeView.prototype.DROP_ON = ('DROP_ON' in iface ? iface.DROP_ON : iface.inDropOn);
ForumsTreeView.prototype.DROP_BEFORE = ('DROP_BEFORE' in iface ? iface.DROP_BEFORE : iface.inDropBefore);
ForumsTreeView.prototype.DROP_AFTER = ('DROP_AFTER' in iface ? iface.DROP_AFTER : iface.inDropAfter);

Метод drop()

Когда пользователь опускает объект, вызывается метод drop(), параметры которого соответствуют методу canDrop(). Определить, что именно опустил пользователь, можно с помощью интерфейса nsIDragService. В примере мы будем искать и выдавать данные типа text/unicode, который присутствует почти всегда. Это может быть строка, которую пользователь потянул на дерево, а может быть и закладка. В последнем случае мы получим саму ссылку и ее название.

ForumsTreeView.prototype.drop = function(aRow, aOrientation)
{
  var dragService = Components.classes['@mozilla.org/widget/dragservice;1']
      .getService(Components.interfaces.nsIDragService);
  var session = dragService.getCurrentSession();
  if (!session)
    return;

  var transferable = Components.classes['@mozilla.org/widget/transferable;1']
      .createInstance(Components.interfaces.nsITransferable);
  transferable.addDataFlavor("text/unicode");

  for (var i = 0; i < session.numDropItems; i++)
  {
    session.getData(transferable, i);

    var data = {};
    var dataLen = {};
    transferable.getTransferData('text/unicode', data, dataLen);
    if (data.value)
    {
      data = data.value.QueryInterface(Components.interfaces.nsISupportsString);
      data = data.data.substring(0, dataLen.value / 2);
      alert('Unicode string received: ' + data);
    }
  }

  dragService.endDragSession();
}

Отмеченные строки (свойство selection и метод selectionChanged())

По каким-то причинам разработчики Mozilla решили, что хранить selection-объект должен именно view-объект дерева. Гораздо логичнее было бы перенести эти обязанности на boxObject, который работает с визуальным отображением дерева. Тем не менее, в nsITreeView определены свойство selection и метод selectionChanged(). Поскольку делать с отмеченными строками ничего не надо, достаточно создать свойство и пустой метод:

ForumsTreeView.prototype.selection = null;
ForumsTreeView.prototype.selectionChanged = function() {}

Для того, чтобы узнать, когда пользователь выберет строку, метод selectionChanged() лучше не использовать. Судя по всему, он больше не нужен и нигде не вызывается. Вместо него можно обрабатывать событие select дерева:

var tree = this.boxObject.treeBody.parentNode;
tree.addEventListener('select', function(e) {
  // Здесь реагируем на изменения
}, false);

Ограничения в непривилегированных скриптах

Начиная с Gecko 1.8.0.4 (Firefox 1.5.0.4, SeaMonkey 1.0.2) непривилегированные скрипты не могут менять свойство treeView из соображений безопасности. Тем самым, весь следующий раздел приобретает теоретический оттенок. Будем надеяться, что это еще изменится.

Все вышесказанное подразумевает, что наш XUL-файл загружается с протокола chrome://, то есть работает с неограниченными привилегиями интерфейса браузера. Для расширений браузера это действительно так, но иногда хочется использовать динамические деревья в удаленных XUL-файлах, которые обладают ограниченным доступом. В частности, в этом случае нет доступа к свойствам Components.classes и Components.results, которые мы используем в конструкторе и методах QueryInterface(), drop(). Нельзя использовать и массив, который методы getCellProperties(), getRowProperties() и getColumnProperties() получают параметром, а также параметр uuid метода QueryInterface().

Что касается метода drop(), решение сложностей не вызывает — Drag & Drop в принципе не работает для деревьев в удаленных XUL-файлах, так что этот код можно попросту удалить. Поддержку же свойств для ячеек таблицы можно оставить, если получить необходимые привилегии, для чего нужно лишь добавить в начало конструктора и методов getRowProperties() и getColumnProperties() команду:

netscape.security.PrivilegeManager.enablePrivilege('UniversalXPConnect');

Примечание. Это будет работать только в подписанных скриптах, иначе браузер просто создаст исключение. При корректном выполнении появится диалог, спрашивающий пользователя, согласен ли он дать нашей программе нужные права. Пользователь должен будет поставить галочку на "Remember this decision", иначе диалог будет выскакивать постоянно (свойства ячеек опрашиваются очень часто).

В QueryInterface() тоже можно запросить привилегии, это решит проблему. Если такой возможности нет, то придется опустить проверки и всегда возвращать this, что не слишком красиво, но работает. В этом случае XPCOM будет исходить из того, что класс реализует интерфейс nsIClassInfo. Чтобы избавиться от сообщений об ошибках, можно добавить в класс пустые методы getInterfaces() и getHelperForLanguage()

ForumsTreeView.prototype.getInterfaces = function(aCount) {}
ForumsTreeView.prototype.getHelperForLanguage = function(aLanguage) { return null; }

Даже после того, как мы обеспечим нужные права для всех методов нашего класса, дерево все еще будет работать неправильно. Дело в том, что часть реализации самого дерева выполняется с ограниченными правами, и получается, что у дерева нет прав для доступа к нашему классу. Чтобы определить права на доступ к нашему классу, мы реализуем интерфейс nsISecurityCheckedComponent. Наш класс сам выполняется с ограниченными правами, поэтому никаких проблем безопасности доступ к нему создать не может. Мы просто разрешаем доступ без всяких проверок:

ForumsTreeView.prototype.canCreateWrapper
  = ForumsTreeView.prototype.canCallMethod
  = ForumsTreeView.prototype.canGetProperty
  = ForumsTreeView.prototype.canSetProperty
  = function()
{
  return 'AllAccess';
}

[Посмотреть пример, выполняющийся с ограниченными правами]

Заключение

Итак, мы имеем теперь готовую реализацию интерфейса nsITreeView, которую можно приспособить для нужд нашего приложения. Конечно, вместо этого можно было бы использовать стандартные классы, для чего достаточно написать лишь пару строчек кода. Но такие классы ограничены в своих возможностях, а кроме того, навязывают свою модель представления данных (DOM либо RDF). Собственная же реализация позволяет писать гибкие и эффективные программы. Стоит ли это усилий, затраченных на написание класса, зависит, как всегда, от приложения.

Powered by POEM™ Engine Copyright © 2002-2005