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