outline.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { EditorState } from '@codemirror/state'
  2. import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
  3. import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
  4. import * as tokens from '../../lezer-latex/latex.terms.mjs'
  5. import { getEnvironmentArguments, getEnvironmentName } from './environments'
  6. export type Outline = {
  7. line: number
  8. title: string
  9. level: number
  10. children?: Outline[]
  11. }
  12. /**
  13. * A projection of a part of the file outline, typically a (sub)section heading
  14. */
  15. export class FlatOutlineItem extends ProjectionItem {
  16. readonly level: number = 0
  17. readonly title: string = ''
  18. }
  19. export type FlatOutline = FlatOutlineItem[]
  20. /* eslint-disable no-unused-vars */
  21. enum NestingLevel {
  22. Book = 1,
  23. Part = 2,
  24. Chapter = 3,
  25. Section = 4,
  26. SubSection = 5,
  27. SubSubSection = 6,
  28. Paragraph = 7,
  29. SubParagraph = 8,
  30. Frame = 9,
  31. Invalid = -1,
  32. }
  33. const fallbackSectionNames: { [index: string]: NestingLevel } = {
  34. book: NestingLevel.Book,
  35. part: NestingLevel.Part,
  36. chapter: NestingLevel.Part,
  37. section: NestingLevel.Section,
  38. subsection: NestingLevel.SubSection,
  39. subsubsection: NestingLevel.SubSubSection,
  40. paragraph: NestingLevel.Paragraph,
  41. subparagraph: NestingLevel.SubParagraph,
  42. frame: NestingLevel.Frame,
  43. }
  44. export const getNestingLevel = (token: number | string): NestingLevel => {
  45. if (typeof token === 'string') {
  46. return fallbackSectionNames[token] ?? NestingLevel.Invalid
  47. }
  48. switch (token) {
  49. case tokens.Book:
  50. return NestingLevel.Book
  51. case tokens.Part:
  52. return NestingLevel.Part
  53. case tokens.Chapter:
  54. return NestingLevel.Chapter
  55. case tokens.Section:
  56. return NestingLevel.Section
  57. case tokens.SubSection:
  58. return NestingLevel.SubSection
  59. case tokens.SubSubSection:
  60. return NestingLevel.SubSubSection
  61. case tokens.Paragraph:
  62. return NestingLevel.Paragraph
  63. case tokens.SubParagraph:
  64. return NestingLevel.SubParagraph
  65. default:
  66. return NestingLevel.Invalid
  67. }
  68. }
  69. const getEntryText = (state: EditorState, node: SyntaxNodeRef): string => {
  70. const titleParts: string[] = []
  71. node.node.cursor().iterate(token => {
  72. // For some reason, iterate can possibly visit sibling nodes as well as
  73. // child nodes
  74. if (token.from >= node.to) {
  75. return false
  76. }
  77. // Hide label definitions within the sectioning command
  78. if (token.type.is('Label')) {
  79. return false
  80. }
  81. // Only add text from leaf nodes
  82. if (token.node.firstChild) {
  83. return true
  84. }
  85. titleParts.push(state.doc.sliceString(token.from, token.to))
  86. })
  87. return titleParts.join('')
  88. }
  89. /**
  90. * Extracts FlatOutlineItem instances from the syntax tree
  91. */
  92. export const enterNode = (
  93. state: EditorState,
  94. node: SyntaxNodeRef,
  95. items: FlatOutlineItem[],
  96. nodeIntersectsChange: NodeIntersectsChangeFn
  97. ): any => {
  98. if (node.type.is('SectioningCommand')) {
  99. const command = node.node
  100. const parent = command.parent
  101. if (!nodeIntersectsChange(command)) {
  102. // This should already be in `items`
  103. return
  104. }
  105. const name = command.getChild('SectioningArgument')?.getChild('LongArg')
  106. if (name == null || command == null) {
  107. return
  108. }
  109. // Filter out descendants of newcommand/renewcommand
  110. for (
  111. let ancestor: SyntaxNode | null = parent;
  112. ancestor;
  113. ancestor = ancestor.parent
  114. ) {
  115. if (ancestor.type.is('NewCommand') || ancestor.type.is('RenewCommand')) {
  116. return false
  117. }
  118. }
  119. const getCommandName = () => {
  120. const ctrlSeq = command.firstChild
  121. if (!ctrlSeq) return ''
  122. // Ignore the \
  123. return state.doc.sliceString(ctrlSeq.from + 1, ctrlSeq.to)
  124. }
  125. const nestingLevel = parent?.type.is('$SectioningCommand')
  126. ? getNestingLevel(parent.type.id)
  127. : getNestingLevel(getCommandName())
  128. const thisNode = {
  129. line: state.doc.lineAt(command.from).number,
  130. title: getEntryText(state, name),
  131. from: command.from,
  132. to: command.to,
  133. level: nestingLevel,
  134. }
  135. items.push(thisNode)
  136. }
  137. if (node.type.is('$Environment')) {
  138. const environmentNode = node.node
  139. if (getEnvironmentName(environmentNode, state) === 'frame') {
  140. const beginEnv = environmentNode.getChild('BeginEnv')!
  141. if (!nodeIntersectsChange(beginEnv)) {
  142. // This should already be in `items`
  143. return
  144. }
  145. const args = getEnvironmentArguments(environmentNode)?.map(textArg =>
  146. textArg.getChild('LongArg')
  147. )
  148. if (args?.length) {
  149. const titleNode = args[0]
  150. const title = titleNode
  151. ? state.sliceDoc(titleNode.from, titleNode.to)
  152. : ''
  153. const thisNode = {
  154. line: state.doc.lineAt(beginEnv.from).number,
  155. title,
  156. from: beginEnv.from,
  157. to: beginEnv.to,
  158. level: NestingLevel.Frame,
  159. }
  160. items.push(thisNode)
  161. }
  162. }
  163. }
  164. }
  165. const flatItemToOutline = (item: {
  166. title: string
  167. line: number
  168. level: number
  169. }): Outline => {
  170. const { title, line, level } = item
  171. return { title, line, level }
  172. }
  173. export const nestOutline = (
  174. flatOutline: { title: string; line: number; level: number }[]
  175. ): Outline[] => {
  176. const parentStack: Outline[] = []
  177. const outline = []
  178. for (const item of flatOutline) {
  179. const outlineItem = flatItemToOutline(item)
  180. // Pop all higher-leveled potential parents from the parent stack
  181. while (
  182. parentStack.length &&
  183. parentStack[parentStack.length - 1].level >= outlineItem.level
  184. ) {
  185. parentStack.pop()
  186. }
  187. // Append to parent if any, and otherwise add root element
  188. if (!parentStack.length) {
  189. parentStack.push(outlineItem)
  190. outline.push(outlineItem)
  191. } else {
  192. const parent = parentStack[parentStack.length - 1]
  193. if (!parent.children) {
  194. parent.children = [outlineItem]
  195. } else {
  196. parent.children.push(outlineItem)
  197. }
  198. parentStack.push(outlineItem)
  199. }
  200. }
  201. return outline
  202. }