outline.ts 6.2 KB

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