1/*
2 * outline.ts
3 *
4 * Copyright (C) 2021 by RStudio, PBC
5 *
6 * Unless you have received this program directly from RStudio pursuant
7 * to the terms of a commercial license agreement with RStudio, then
8 * this program is licensed to you under the terms of version 3 of the
9 * GNU Affero General Public License. This program is distributed WITHOUT
10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13 *
14 */
15
16import { Node as ProsemirrorNode } from 'prosemirror-model';
17import { EditorState } from 'prosemirror-state';
18
19import { NodeWithPos, findChildrenByType } from 'prosemirror-utils';
20
21import { findTopLevelBodyNodes } from './node';
22import { titleFromYamlMetadataNode } from './yaml';
23import { rmdChunkEngineAndLabel } from './rmd';
24
25export interface EditorOutlineItem {
26  navigation_id: string;
27  type: EditorOutlineItemType;
28  level: number;
29  sequence: number;
30  title: string;
31  children: EditorOutlineItem[];
32}
33
34export const kHeadingOutlineItemType = 'heading';
35export const kRmdchunkOutlineItemType = 'rmd_chunk';
36export const kYamlMetadataOutlineItemType = 'yaml_metadata';
37
38export type EditorOutlineItemType = 'heading' | 'rmd_chunk' | 'yaml_metadata';
39
40export type EditorOutline = EditorOutlineItem[];
41
42export interface EditingOutlineLocationItem {
43  type: EditorOutlineItemType;
44  level: number;
45  title: string;
46  active: boolean;
47  position: number;
48}
49
50export interface EditingOutlineLocation {
51  items: EditingOutlineLocationItem[];
52}
53
54export function getEditingOutlineLocation(state: EditorState): EditingOutlineLocation {
55  // traverse document outline to get base location info
56  const itemsWithPos = getDocumentOutline(state).map(nodeWithPos => {
57    const schema = state.schema;
58    const node = nodeWithPos.node;
59    const item: EditingOutlineLocationItem = {
60      type: kYamlMetadataOutlineItemType,
61      level: 0,
62      title: '',
63      active: false,
64      position: nodeWithPos.pos,
65    };
66    if (node.type === schema.nodes.yaml_metadata) {
67      item.type = kYamlMetadataOutlineItemType;
68      item.title = titleFromYamlMetadataNode(node) || '';
69    } else if (node.type === schema.nodes.rmd_chunk) {
70      item.type = kRmdchunkOutlineItemType;
71      const chunk = rmdChunkEngineAndLabel(node.textContent);
72      if (chunk) {
73        item.title = chunk.label;
74      }
75    } else if (node.type === schema.nodes.heading) {
76      item.type = kHeadingOutlineItemType;
77      item.level = node.attrs.level;
78      item.title = node.textContent;
79    }
80    return {
81      item,
82      pos: nodeWithPos.pos,
83    };
84  });
85
86  // return the location, set the active item by scanning backwards until
87  // we find an item with a position before the cursor
88  let foundActive = false;
89  const items: EditingOutlineLocationItem[] = [];
90  for (let i = itemsWithPos.length - 1; i >= 0; i--) {
91    const item = itemsWithPos[i].item;
92    if (!foundActive && itemsWithPos[i].pos < state.selection.from) {
93      item.active = true;
94      foundActive = true;
95    }
96    items.unshift(item);
97  }
98
99  // return the outline
100  return { items };
101}
102
103// get a document outline that matches the scheme provided in EditingOutlineLocation:
104//  - yaml metadata blocks
105//  - top-level headings
106//  - rmd chunks at the top level or within a top-level list
107export function getDocumentOutline(state: EditorState): NodeWithPos[] {
108  // get top level body nodes
109  const schema = state.schema;
110  const bodyNodes = findTopLevelBodyNodes(state.doc, node => {
111    return [
112      schema.nodes.yaml_metadata,
113      schema.nodes.rmd_chunk,
114      schema.nodes.heading,
115      schema.nodes.bullet_list,
116      schema.nodes.ordered_list,
117    ].includes(node.type);
118  });
119
120  // reduce (explode lists into contained rmd chunks)
121  const outlineNodes: NodeWithPos[] = [];
122  bodyNodes.forEach(bodyNode => {
123    // explode lists
124    if ([schema.nodes.bullet_list, schema.nodes.ordered_list].includes(bodyNode.node.type)) {
125      // look for rmd chunks within list items (non-recursive, only want top level)
126      findChildrenByType(bodyNode.node, schema.nodes.list_item, false).forEach(listItemNode => {
127        findChildrenByType(listItemNode.node, schema.nodes.rmd_chunk, false).forEach(rmdChunkNode => {
128          outlineNodes.push({
129            node: rmdChunkNode.node,
130            pos: bodyNode.pos + 1 + listItemNode.pos + 1 + rmdChunkNode.pos,
131          });
132        });
133      });
134
135      // other nodes go straight through
136    } else {
137      outlineNodes.push(bodyNode);
138    }
139  });
140
141  // return outline nodes
142  return outlineNodes;
143}
144
145export function getOutlineNodes(doc: ProsemirrorNode) {
146  return findTopLevelBodyNodes(doc, isOutlineNode);
147}
148
149export function isOutlineNode(node: ProsemirrorNode) {
150  if (node.type.spec.attrs) {
151    return node.type.spec.attrs.hasOwnProperty('navigation_id');
152  } else {
153    return false;
154  }
155}
156