outline.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  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. title: getEntryText(state, name),
  141. from: command.from,
  142. to: command.to,
  143. level: nestingLevel,
  144. }
  145. items.push(thisNode)
  146. }
  147. if (node.type.is('$Environment')) {
  148. const environmentNode = node.node
  149. if (getEnvironmentName(environmentNode, state) === 'frame') {
  150. const beginEnv = environmentNode.getChild('BeginEnv')!
  151. if (!nodeIntersectsChange(beginEnv)) {
  152. // This should already be in `items`
  153. return
  154. }
  155. const args = getEnvironmentArguments(environmentNode)?.map(textArg =>
  156. textArg.getChild('LongArg')
  157. )
  158. if (args?.length) {
  159. const titleNode = args[0]
  160. const title = titleNode
  161. ? state.sliceDoc(titleNode.from, titleNode.to)
  162. : ''
  163. const thisNode = {
  164. line: state.doc.lineAt(beginEnv.from).number,
  165. title,
  166. from: beginEnv.from,
  167. to: beginEnv.to,
  168. level: NestingLevel.Frame,
  169. }
  170. items.push(thisNode)
  171. }
  172. }
  173. }
  174. }
  175. const flatItemToOutline = (item: {
  176. title: string
  177. line: number
  178. level: number
  179. }): Outline => ({
  180. title: item.title,
  181. line: item.line,
  182. level: item.level,
  183. })
  184. export const nestOutline = (flatOutline: PartialFlatOutline): Outline[] => {
  185. const parentStack: Outline[] = []
  186. const outline = []
  187. for (const item of flatOutline) {
  188. const outlineItem = flatItemToOutline(item)
  189. // Pop all higher-leveled potential parents from the parent stack
  190. while (
  191. parentStack.length &&
  192. parentStack[parentStack.length - 1].level >= outlineItem.level
  193. ) {
  194. parentStack.pop()
  195. }
  196. // Append to parent if any, and otherwise add root element
  197. if (!parentStack.length) {
  198. parentStack.push(outlineItem)
  199. outline.push(outlineItem)
  200. } else {
  201. const parent = parentStack[parentStack.length - 1]
  202. if (!parent.children) {
  203. parent.children = [outlineItem]
  204. } else {
  205. parent.children.push(outlineItem)
  206. }
  207. parentStack.push(outlineItem)
  208. }
  209. }
  210. return outline
  211. }