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