@mui/internal-docs-infra 0.11.1-canary.8 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/ChunkProvider/ChunkContext.d.mts +10 -0
  2. package/ChunkProvider/ChunkContext.mjs +15 -0
  3. package/ChunkProvider/ChunkProvider.d.mts +14 -0
  4. package/ChunkProvider/ChunkProvider.mjs +38 -0
  5. package/ChunkProvider/PreloadContext.d.mts +14 -0
  6. package/ChunkProvider/PreloadContext.mjs +18 -0
  7. package/ChunkProvider/PreloadProvider.d.mts +13 -0
  8. package/ChunkProvider/PreloadProvider.mjs +33 -0
  9. package/ChunkProvider/index.d.mts +7 -0
  10. package/ChunkProvider/index.mjs +7 -0
  11. package/ChunkProvider/types.d.mts +23 -0
  12. package/ChunkProvider/types.mjs +1 -0
  13. package/ChunkProvider/usePreload.d.mts +8 -0
  14. package/ChunkProvider/usePreload.mjs +21 -0
  15. package/CodeControllerContext/CodeControllerContext.d.mts +11 -0
  16. package/CodeControllerContext/CodeControllerContext.mjs +2 -1
  17. package/CodeHighlighter/CodeHighlighter.d.mts +15 -1
  18. package/CodeHighlighter/CodeHighlighter.mjs +97 -319
  19. package/CodeHighlighter/CodeHighlighterChunk.d.mts +42 -0
  20. package/CodeHighlighter/CodeHighlighterChunk.mjs +77 -0
  21. package/CodeHighlighter/CodeHighlighterClient.mjs +597 -128
  22. package/CodeHighlighter/CodeHighlighterContext.d.mts +57 -1
  23. package/CodeHighlighter/CodeHighlighterFallbackContext.d.mts +14 -2
  24. package/CodeHighlighter/CodeHighlighterFallbackContext.mjs +1 -3
  25. package/CodeHighlighter/CodeInitialSourceLoader.d.mts +10 -0
  26. package/CodeHighlighter/CodeInitialSourceLoader.mjs +108 -0
  27. package/CodeHighlighter/CodeSourceLoader.d.mts +11 -0
  28. package/CodeHighlighter/CodeSourceLoader.mjs +128 -0
  29. package/CodeHighlighter/buildCodeHighlighterChunkProps.d.mts +47 -0
  30. package/CodeHighlighter/buildCodeHighlighterChunkProps.mjs +61 -0
  31. package/CodeHighlighter/buildStringFallback.d.mts +29 -0
  32. package/CodeHighlighter/buildStringFallback.mjs +42 -0
  33. package/CodeHighlighter/codeToFallbackProps.d.mts +31 -2
  34. package/CodeHighlighter/codeToFallbackProps.mjs +347 -42
  35. package/CodeHighlighter/createClientProps.d.mts +17 -0
  36. package/CodeHighlighter/createClientProps.mjs +78 -0
  37. package/CodeHighlighter/errors.d.mts +6 -0
  38. package/CodeHighlighter/errors.mjs +10 -0
  39. package/CodeHighlighter/fallbackCompression.d.mts +96 -0
  40. package/CodeHighlighter/fallbackCompression.mjs +253 -0
  41. package/CodeHighlighter/fallbackFormat.d.mts +137 -0
  42. package/CodeHighlighter/fallbackFormat.mjs +422 -0
  43. package/CodeHighlighter/index.d.mts +4 -1
  44. package/CodeHighlighter/index.mjs +3 -1
  45. package/CodeHighlighter/mergeComments.d.mts +38 -0
  46. package/CodeHighlighter/mergeComments.mjs +80 -0
  47. package/CodeHighlighter/prepareInitialSource.d.mts +42 -0
  48. package/CodeHighlighter/prepareInitialSource.mjs +292 -0
  49. package/CodeHighlighter/resolveFallbackCritical.d.mts +23 -0
  50. package/CodeHighlighter/resolveFallbackCritical.mjs +44 -0
  51. package/CodeHighlighter/types.d.mts +272 -8
  52. package/CodeHighlighter/useCodeFallback.d.mts +94 -0
  53. package/CodeHighlighter/useCodeFallback.mjs +204 -0
  54. package/CodeHighlighter/useGrammarsReady.d.mts +18 -0
  55. package/CodeHighlighter/useGrammarsReady.mjs +45 -0
  56. package/CodeHighlighter/useSpeculativeCodePreload.d.mts +26 -0
  57. package/CodeHighlighter/useSpeculativeCodePreload.mjs +40 -0
  58. package/CodeHighlighter/useSpeculativeEditingPreload.d.mts +33 -0
  59. package/CodeHighlighter/useSpeculativeEditingPreload.mjs +58 -0
  60. package/CodeHighlighter/useSpeculativeGrammarPreload.d.mts +23 -0
  61. package/CodeHighlighter/useSpeculativeGrammarPreload.mjs +31 -0
  62. package/CodeHighlighter/useSpeculativeUseCodePreload.d.mts +22 -0
  63. package/CodeHighlighter/useSpeculativeUseCodePreload.mjs +41 -0
  64. package/CodeProvider/CodeContext.d.mts +47 -12
  65. package/CodeProvider/CodeContext.mjs +7 -0
  66. package/CodeProvider/CodeProvider.d.mts +4 -2
  67. package/CodeProvider/CodeProvider.mjs +40 -102
  68. package/CodeProvider/CodeProviderLazy.d.mts +40 -0
  69. package/CodeProvider/CodeProviderLazy.mjs +96 -0
  70. package/CodeProvider/constants.d.mts +26 -0
  71. package/CodeProvider/constants.mjs +24 -0
  72. package/CodeProvider/createParseSourceWorkerClient.d.mts +6 -0
  73. package/CodeProvider/createParseSourceWorkerClient.mjs +22 -2
  74. package/CodeProvider/index.d.mts +2 -1
  75. package/CodeProvider/index.mjs +9 -1
  76. package/CodeProvider/parseSourceWorker.mjs +33 -0
  77. package/CodeProvider/useCodeProviderValue.d.mts +54 -0
  78. package/CodeProvider/useCodeProviderValue.mjs +188 -0
  79. package/CoordinatedLazy/ChunkServerLoader.d.mts +25 -0
  80. package/CoordinatedLazy/ChunkServerLoader.mjs +97 -0
  81. package/CoordinatedLazy/CoordinatedContentContext.d.mts +15 -0
  82. package/CoordinatedLazy/CoordinatedContentContext.mjs +22 -0
  83. package/CoordinatedLazy/CoordinatedFallbackContext.d.mts +11 -0
  84. package/CoordinatedLazy/CoordinatedFallbackContext.mjs +13 -0
  85. package/CoordinatedLazy/CoordinatedGateContext.d.mts +14 -0
  86. package/CoordinatedLazy/CoordinatedGateContext.mjs +19 -0
  87. package/CoordinatedLazy/CoordinatedLazy.d.mts +14 -0
  88. package/CoordinatedLazy/CoordinatedLazy.mjs +86 -0
  89. package/CoordinatedLazy/CoordinatedLazyClient.d.mts +24 -0
  90. package/CoordinatedLazy/CoordinatedLazyClient.mjs +65 -0
  91. package/CoordinatedLazy/LazyContent.d.mts +26 -0
  92. package/CoordinatedLazy/LazyContent.mjs +80 -0
  93. package/CoordinatedLazy/LazyContentServer.d.mts +18 -0
  94. package/CoordinatedLazy/LazyContentServer.mjs +25 -0
  95. package/CoordinatedLazy/buildChunkRenderInputs.d.mts +8 -0
  96. package/CoordinatedLazy/buildChunkRenderInputs.mjs +35 -0
  97. package/CoordinatedLazy/createCoordinatedLazy.d.mts +32 -0
  98. package/CoordinatedLazy/createCoordinatedLazy.mjs +127 -0
  99. package/CoordinatedLazy/index.d.mts +14 -0
  100. package/CoordinatedLazy/index.mjs +18 -0
  101. package/CoordinatedLazy/resolveChunkRender.d.mts +26 -0
  102. package/CoordinatedLazy/resolveChunkRender.mjs +73 -0
  103. package/CoordinatedLazy/types.d.mts +408 -0
  104. package/CoordinatedLazy/types.mjs +1 -0
  105. package/CoordinatedLazy/useChunk.d.mts +30 -0
  106. package/CoordinatedLazy/useChunk.mjs +135 -0
  107. package/CoordinatedLazy/useCoordinatedFallback.d.mts +12 -0
  108. package/CoordinatedLazy/useCoordinatedFallback.mjs +40 -0
  109. package/CoordinatedLazy/useCoordinatedSwap.d.mts +16 -0
  110. package/CoordinatedLazy/useCoordinatedSwap.mjs +124 -0
  111. package/LICENSE +1 -1
  112. package/abstractCreateDemo/abstractCreateDemo.d.mts +54 -3
  113. package/abstractCreateDemo/abstractCreateDemo.mjs +47 -7
  114. package/abstractCreateDemo/resolveDemoFlag.d.mts +20 -0
  115. package/abstractCreateDemo/resolveDemoFlag.mjs +25 -0
  116. package/abstractCreateStream/abstractCreateStream.d.mts +18 -0
  117. package/abstractCreateStream/abstractCreateStream.mjs +45 -0
  118. package/abstractCreateStream/index.d.mts +2 -0
  119. package/abstractCreateStream/index.mjs +1 -0
  120. package/abstractCreateStream/types.d.mts +34 -0
  121. package/abstractCreateStream/types.mjs +1 -0
  122. package/abstractCreateTypes/TypeCode.mjs +12 -11
  123. package/abstractCreateTypes/typesToJsx.mjs +30 -9
  124. package/cli/ensureDemoClients.mjs +4 -148
  125. package/cli/ensureDemoPages.d.mts +45 -0
  126. package/cli/ensureDemoPages.mjs +99 -0
  127. package/cli/fileUtils/index.d.mts +11 -0
  128. package/cli/fileUtils/index.mjs +48 -0
  129. package/cli/findDemoIndexFiles.d.mts +15 -0
  130. package/cli/findDemoIndexFiles.mjs +121 -0
  131. package/cli/index.mjs +1 -1
  132. package/cli/loadNextConfig.d.mts +25 -0
  133. package/cli/loadNextConfig.mjs +60 -1
  134. package/cli/runBrowser.mjs +1 -1
  135. package/cli/runValidate.mjs +44 -1
  136. package/package.json +85 -5
  137. package/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.mjs +30 -0
  138. package/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasisLazy.d.mts +17 -0
  139. package/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasisLazy.mjs +52 -0
  140. package/pipeline/hastUtils/frameFallbackFromSpans.d.mts +18 -0
  141. package/pipeline/hastUtils/frameFallbackFromSpans.mjs +24 -0
  142. package/pipeline/hastUtils/hast.d.mts +27 -0
  143. package/pipeline/hastUtils/hastCompression.d.mts +3 -1
  144. package/pipeline/hastUtils/hastCompression.mjs +9 -1
  145. package/pipeline/hastUtils/hastDecompress.mjs +10 -4
  146. package/pipeline/hastUtils/hastDictionary.mjs +9 -0
  147. package/pipeline/hastUtils/hastUtils.d.mts +4 -3
  148. package/pipeline/hastUtils/hastUtils.mjs +24 -12
  149. package/pipeline/hastUtils/index.d.mts +2 -1
  150. package/pipeline/hastUtils/index.mjs +2 -1
  151. package/pipeline/hastUtils/stripHighlightingSpans.d.mts +6 -2
  152. package/pipeline/hastUtils/stripHighlightingSpans.mjs +22 -10
  153. package/pipeline/lintJavascriptDemoFocus/lintJavascriptDemoFocus.mjs +10 -7
  154. package/pipeline/loadIsomorphicCodeVariant/applyCodeTransform.d.mts +31 -13
  155. package/pipeline/loadIsomorphicCodeVariant/applyCodeTransform.mjs +50 -55
  156. package/pipeline/loadIsomorphicCodeVariant/applyCodeTransformWithComments.d.mts +78 -0
  157. package/pipeline/loadIsomorphicCodeVariant/applyCodeTransformWithComments.mjs +405 -0
  158. package/pipeline/loadIsomorphicCodeVariant/computeHastDeltas.d.mts +5 -5
  159. package/pipeline/loadIsomorphicCodeVariant/computeHastDeltas.mjs +36 -66
  160. package/pipeline/loadIsomorphicCodeVariant/decodeHastSource.d.mts +23 -0
  161. package/pipeline/loadIsomorphicCodeVariant/decodeHastSource.mjs +92 -0
  162. package/pipeline/loadIsomorphicCodeVariant/decodeSource.d.mts +19 -0
  163. package/pipeline/loadIsomorphicCodeVariant/decodeSource.mjs +25 -0
  164. package/pipeline/loadIsomorphicCodeVariant/decodeSourceToText.d.mts +17 -0
  165. package/pipeline/loadIsomorphicCodeVariant/decodeSourceToText.mjs +26 -0
  166. package/pipeline/loadIsomorphicCodeVariant/diffHast.d.mts +26 -2
  167. package/pipeline/loadIsomorphicCodeVariant/diffHast.mjs +563 -19
  168. package/pipeline/loadIsomorphicCodeVariant/embedTransforms.d.mts +49 -0
  169. package/pipeline/loadIsomorphicCodeVariant/embedTransforms.mjs +152 -0
  170. package/pipeline/loadIsomorphicCodeVariant/findExpandingRanges.d.mts +51 -0
  171. package/pipeline/loadIsomorphicCodeVariant/findExpandingRanges.mjs +161 -0
  172. package/pipeline/loadIsomorphicCodeVariant/flattenCodeVariant.mjs +6 -3
  173. package/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.d.mts +12 -0
  174. package/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.mjs +44 -0
  175. package/pipeline/loadIsomorphicCodeVariant/getInitialVisibleSourceLines.d.mts +16 -0
  176. package/pipeline/loadIsomorphicCodeVariant/getInitialVisibleSourceLines.mjs +74 -0
  177. package/pipeline/loadIsomorphicCodeVariant/loadCodeFallback.mjs +17 -5
  178. package/pipeline/loadIsomorphicCodeVariant/loadIsomorphicCodeVariant.mjs +229 -15
  179. package/pipeline/loadIsomorphicCodeVariant/transformSource.d.mts +2 -2
  180. package/pipeline/loadIsomorphicCodeVariant/transformSource.mjs +56 -22
  181. package/pipeline/loadPrecomputedCodeHighlighter/loadPrecomputedCodeHighlighter.d.mts +18 -0
  182. package/pipeline/loadPrecomputedCodeHighlighter/loadPrecomputedCodeHighlighter.mjs +11 -7
  183. package/pipeline/loadServerTypes/hastTypeUtils.d.mts +2 -2
  184. package/pipeline/loadServerTypes/hastTypeUtils.mjs +4 -4
  185. package/pipeline/loadServerTypes/loadServerTypes.mjs +1 -1
  186. package/pipeline/loadServerTypesMeta/extractJSDocText.d.mts +14 -0
  187. package/pipeline/loadServerTypesMeta/extractJSDocText.mjs +60 -0
  188. package/pipeline/loadServerTypesMeta/processTypes.mjs +43 -46
  189. package/pipeline/loadServerTypesText/order.mjs +1 -1
  190. package/pipeline/loadServerTypesText/parseTypesMarkdown.mjs +3 -1
  191. package/pipeline/loaderUtils/index.d.mts +0 -1
  192. package/pipeline/loaderUtils/index.mjs +0 -1
  193. package/pipeline/loaderUtils/parseImportsAndComments.d.mts +5 -1
  194. package/pipeline/loaderUtils/parseImportsAndComments.mjs +19 -9
  195. package/pipeline/loaderUtils/resolveModulePath.mjs +23 -1
  196. package/pipeline/parseCreateFactoryCall/parseCreateFactoryCall.d.mts +12 -0
  197. package/pipeline/parseCreateFactoryCall/parseCreateFactoryCall.mjs +17 -13
  198. package/pipeline/parseSource/addLineGutters.mjs +45 -11
  199. package/pipeline/parseSource/calculateFrameRanges.d.mts +22 -0
  200. package/pipeline/parseSource/calculateFrameRanges.mjs +69 -25
  201. package/pipeline/parseSource/detectGrammarScopes.d.mts +13 -0
  202. package/pipeline/parseSource/detectGrammarScopes.mjs +35 -0
  203. package/pipeline/parseSource/extendSyntaxTokens.mjs +501 -43
  204. package/pipeline/parseSource/frameVisibility.d.mts +47 -0
  205. package/pipeline/parseSource/frameVisibility.mjs +114 -0
  206. package/pipeline/parseSource/grammarCache.d.mts +33 -0
  207. package/pipeline/parseSource/grammarCache.mjs +73 -0
  208. package/pipeline/parseSource/grammarLoaders.d.mts +14 -0
  209. package/pipeline/parseSource/grammarLoaders.mjs +24 -0
  210. package/pipeline/parseSource/grammarMaps.d.mts +21 -1
  211. package/pipeline/parseSource/grammarMaps.mjs +36 -0
  212. package/pipeline/parseSource/isFrameSpan.d.mts +19 -0
  213. package/pipeline/parseSource/isFrameSpan.mjs +24 -0
  214. package/pipeline/parseSource/parseSource.d.mts +41 -6
  215. package/pipeline/parseSource/parseSource.mjs +184 -36
  216. package/pipeline/parseSource/redistributeFrameFallbacks.d.mts +40 -0
  217. package/pipeline/parseSource/redistributeFrameFallbacks.mjs +138 -0
  218. package/pipeline/parseSource/restructureFrames.d.mts +5 -0
  219. package/pipeline/parseSource/restructureFrames.mjs +179 -16
  220. package/pipeline/syncPageIndex/metadataToMarkdown.mjs +6 -2
  221. package/pipeline/transformHtmlCodeBlock/transformHtmlCodeBlock.d.mts +26 -0
  222. package/pipeline/transformHtmlCodeBlock/transformHtmlCodeBlock.mjs +181 -114
  223. package/pipeline/transformHtmlCodeInline/removeSuffixFromHighlightedNodes.d.mts +12 -0
  224. package/pipeline/transformHtmlCodeInline/removeSuffixFromHighlightedNodes.mjs +52 -0
  225. package/pipeline/transformHtmlCodeInline/transformHtmlCodeInline.mjs +22 -1
  226. package/pipeline/transformTypescriptToJavascript/removeTypes.d.mts +5 -8
  227. package/pipeline/transformTypescriptToJavascript/removeTypes.mjs +27 -93
  228. package/useCode/EditableEngine.d.mts +233 -0
  229. package/useCode/EditableEngine.mjs +1712 -0
  230. package/useCode/EditingEngine.d.mts +13 -0
  231. package/useCode/EditingEngine.mjs +14 -0
  232. package/useCode/Pre.browser.mjs +5 -1
  233. package/useCode/Pre.d.mts +127 -1
  234. package/useCode/Pre.mjs +417 -165
  235. package/useCode/SourceEditingEngine.d.mts +50 -0
  236. package/useCode/SourceEditingEngine.mjs +461 -0
  237. package/useCode/TransformEngine.d.mts +39 -0
  238. package/useCode/TransformEngine.mjs +208 -0
  239. package/useCode/editingEngineCache.d.mts +29 -0
  240. package/useCode/editingEngineCache.mjs +68 -0
  241. package/useCode/sourceLineCounts.d.mts +80 -0
  242. package/useCode/sourceLineCounts.mjs +284 -0
  243. package/useCode/subscribeToggleNudge.d.mts +3 -0
  244. package/useCode/subscribeToggleNudge.mjs +95 -0
  245. package/useCode/transformEngineCache.d.mts +21 -0
  246. package/useCode/transformEngineCache.mjs +60 -0
  247. package/useCode/useCode.d.mts +140 -1
  248. package/useCode/useCode.mjs +250 -19
  249. package/useCode/useCodeUtils.d.mts +131 -20
  250. package/useCode/useCodeUtils.mjs +267 -194
  251. package/useCode/useCopyFunctionality.d.mts +13 -1
  252. package/useCode/useCopyFunctionality.mjs +39 -9
  253. package/useCode/useEditable.browser.mjs +10 -2
  254. package/useCode/useEditable.d.mts +27 -106
  255. package/useCode/useEditable.integration.browser.d.mts +1 -0
  256. package/useCode/useEditable.integration.browser.mjs +870 -0
  257. package/useCode/useEditable.mjs +198 -1247
  258. package/useCode/useEditableUtils.d.mts +50 -1
  259. package/useCode/useEditableUtils.mjs +29 -0
  260. package/useCode/useFileNavigation.d.mts +91 -3
  261. package/useCode/useFileNavigation.mjs +201 -41
  262. package/useCode/useHighlightGate.d.mts +17 -0
  263. package/useCode/useHighlightGate.mjs +147 -0
  264. package/useCode/useSourceEditing.d.mts +8 -0
  265. package/useCode/useSourceEditing.mjs +158 -314
  266. package/useCode/useSourceEnhancing.d.mts +5 -1
  267. package/useCode/useSourceEnhancing.mjs +22 -36
  268. package/useCode/useTransformManagement.d.mts +93 -5
  269. package/useCode/useTransformManagement.mjs +496 -28
  270. package/useCode/useTransitionPhase.d.mts +24 -0
  271. package/useCode/useTransitionPhase.mjs +49 -0
  272. package/useCode/useUIState.d.mts +2 -2
  273. package/useCode/useUIState.mjs +8 -8
  274. package/useCode/useVariantSelection.d.mts +130 -6
  275. package/useCode/useVariantSelection.mjs +529 -93
  276. package/useCodeWindow/useCodeWindow.d.mts +19 -2
  277. package/useCodeWindow/useCodeWindow.mjs +98 -71
  278. package/useCoordinated/coordinatePreference.d.mts +439 -0
  279. package/useCoordinated/coordinatePreference.mjs +951 -0
  280. package/useCoordinated/coordinatePreference.testUtils.d.mts +21 -0
  281. package/useCoordinated/coordinatePreference.testUtils.mjs +69 -0
  282. package/useCoordinated/createSettleGate.d.mts +96 -0
  283. package/useCoordinated/createSettleGate.mjs +171 -0
  284. package/useCoordinated/index.d.mts +8 -0
  285. package/useCoordinated/index.mjs +8 -0
  286. package/useCoordinated/layoutShiftGate.d.mts +24 -0
  287. package/useCoordinated/layoutShiftGate.mjs +79 -0
  288. package/useCoordinated/pageSettleGate.d.mts +11 -0
  289. package/useCoordinated/pageSettleGate.mjs +13 -0
  290. package/useCoordinated/scheduleTasks.d.mts +23 -0
  291. package/useCoordinated/scheduleTasks.mjs +45 -0
  292. package/useCoordinated/useCoordinated.d.mts +193 -0
  293. package/useCoordinated/useCoordinated.mjs +469 -0
  294. package/useCoordinated/useCoordinatedLazy.d.mts +17 -0
  295. package/useCoordinated/useCoordinatedLazy.mjs +38 -0
  296. package/useCoordinated/useCoordinatedLocalStorage.d.mts +16 -0
  297. package/useCoordinated/useCoordinatedLocalStorage.mjs +22 -0
  298. package/useCoordinated/useCoordinatedPreference.d.mts +20 -0
  299. package/useCoordinated/useCoordinatedPreference.mjs +26 -0
  300. package/useCoordinated/useSettleGate.d.mts +11 -0
  301. package/useCoordinated/useSettleGate.mjs +34 -0
  302. package/useDemo/exportVariant.d.mts +12 -5
  303. package/useDemo/exportVariant.mjs +59 -5
  304. package/useDemo/useDemo.d.mts +5 -2
  305. package/useScrollAnchor/useScrollAnchor.mjs +28 -5
  306. package/useStream/index.d.mts +6 -0
  307. package/useStream/index.mjs +6 -0
  308. package/useStream/streamChunks.d.mts +23 -0
  309. package/useStream/streamChunks.mjs +85 -0
  310. package/useStream/types.d.mts +45 -0
  311. package/useStream/types.mjs +1 -0
  312. package/useStream/useStream.d.mts +57 -0
  313. package/useStream/useStream.mjs +119 -0
  314. package/useStream/useStreamController.d.mts +15 -0
  315. package/useStream/useStreamController.mjs +90 -0
  316. package/withDocsInfra/withDocsInfra.d.mts +19 -0
  317. package/withDocsInfra/withDocsInfra.mjs +13 -5
  318. package/pipeline/loaderUtils/convertCommentsToOneIndexed.d.mts +0 -8
  319. package/pipeline/loaderUtils/convertCommentsToOneIndexed.mjs +0 -16
@@ -1,1288 +1,239 @@
1
- /*
2
-
3
- MIT License
4
-
5
- Copyright (c) 2020 Phil Plückthun,
6
- Copyright (c) 2021 Formidable
7
- Copyright (c) 2026 Material-UI SAS
8
-
9
- Permission is hereby granted, free of charge, to any person obtaining a copy
10
- of this software and associated documentation files (the "Software"), to deal
11
- in the Software without restriction, including without limitation the rights
12
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
- copies of the Software, and to permit persons to whom the Software is
14
- furnished to do so, subject to the following conditions:
15
-
16
- The above copyright notice and this permission notice shall be included in all
17
- copies or substantial portions of the Software.
18
-
19
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
- SOFTWARE.
26
-
27
- */
28
-
29
- // Forked from https://github.com/FormidableLabs/use-editable
30
- // Changes (see git history and inline comments for rationale):
31
- // - Linting, formatting, tests, and React 19 compatibility (lazy useState, useRef MutationObserver, SSR guards)
32
- // - Performance: TreeWalker-based makeRange/getPosition, deduped toString() calls, getLineInfo walks only neighboring lines
33
- // - Firefox quirks: preserve pendingContent across rapid keydowns, refresh baseline after controlled edits, repair line-merges, route plaintext keys through edit.insert in the contentEditable="true" fallback
34
- // - Undo stack: record repaired (not raw) content, allow tracking before first flush, bypass 500ms dedup for structural edits (Enter)
35
- // - Repeat-key flush debouncing so syntax re-highlight fires once on key release
36
- // - Resync (instead of block) on stale-DOM arrow keys so navigation isn't eaten after a pending edit
37
- // - adjustCursorAtNewlineBoundary applied to all programmatic caret placements; getState() returns an empty snapshot pre-mount
38
- // - New `minColumn` option: skip clipped indent gutter via arrow navigation, click, and tab-focus; Backspace on a fully-clipped blank line collapses the line
39
- // - New `minRow`/`maxRow`/`onBoundary` options: arrow navigation past the visible region invokes the callback (and falls through natively when provided so hosts can expand collapsed regions)
40
- // - New `caretSelector` option: synchronous horizontal line-wrap and post-arrow rAF snap to lift the caret out of inter-line gap text nodes (e.g. `\n` between `.line` spans)
41
- // - Override copy/cut: write `Range.toString()` for `text/plain` (avoids duplicated newlines from block-level line wrappers) and an inline-styled `<pre>` clone for `text/html`; strip the clipped indent gutter from both payloads when `minColumn` is set
1
+ // `useEditable` is the lightweight, always-mounted shell for live code editing.
2
+ // It owns the editing state and refs (undo history, caret, the MutationObserver
3
+ // ref) so they survive across renders, but the heavy runtime — the
4
+ // contentEditable setup and the keyboard/paste/caret handlers — lives in the
5
+ // separately-loaded `./EditableEngine` chunk. `contentEditable` is applied to
6
+ // the element only once that engine resolves, so read-only code blocks never
7
+ // pull the engine into their bundle. The engine factory is injected (typically
8
+ // by `CodeProvider` via context); a built-in fallback keeps editing working
9
+ // without a provider. The original fork attribution lives in `./EditableEngine`.
42
10
 
43
11
  import * as React from 'react';
44
- import * as ReactDOM from 'react-dom';
45
- import { adjustCursorAtNewlineBoundary, asElement, getCurrentRange, getLineInfo, getOffsetAtLineColumn, getPosition, isPlaintextInputKey, isUndoRedoKey, makeRange, repairUnexpectedLineMerge, setCurrentRange, toString } from "./useEditableUtils.mjs";
46
- import { cloneRangeWithInlineStyles } from "./cloneRangeWithInlineStyles.mjs";
47
- import { extractLeadingPerLine, stripLeadingPerLine, stripLeadingPerLineDom } from "./stripLeadingPerLine.mjs";
48
- const observerSettings = {
49
- characterData: true,
50
- characterDataOldValue: true,
51
- childList: true,
52
- subtree: true
53
- };
54
-
55
- // Computed-style properties inlined onto each element in the copied
56
- // HTML fragment so external paste targets render with the same syntax
57
- // highlighting without needing our stylesheet.
58
- const CLIPBOARD_ELEMENT_STYLE_PROPS = ['color', 'background-color', 'font-weight', 'font-style', 'text-decoration'];
59
-
60
- // Properties inlined onto the wrapper so the pasted block keeps the
61
- // editable's typography even if only a descendant was selected.
62
- const CLIPBOARD_ROOT_STYLE_PROPS = ['font-family', 'font-size', 'line-height', 'white-space', 'background-color', 'color'];
63
-
64
- // A small amount of padding + rounded corners gives the pasted snippet
65
- // a card-like appearance in rich-text targets without overriding the
66
- // background or font that consumers already control via the editable's
67
- // own styles.
68
- const CLIPBOARD_ROOT_STATIC_STYLES = 'padding:1em;border-radius:0.5em;';
12
+ import { peekEditingEngine, loadEditingEngine, preloadEditingEngine, resetEditingEngineCache } from "./editingEngineCache.mjs";
13
+ // A fresh empty snapshot per call the pre-load `edit.getState()` must not hand
14
+ // out a shared mutable object, or one caller mutating it would corrupt the
15
+ // snapshot every other pre-load caller sees.
16
+ const emptySnapshot = () => ({
17
+ text: '',
18
+ position: {
19
+ position: 0,
20
+ extent: 0,
21
+ content: '',
22
+ line: 0
23
+ }
24
+ });
25
+
26
+ // The resolved engine is cached in the shared `editingEngineCache` (so the
27
+ // FIRST editable block resolves the loader once and every block after attaches
28
+ // synchronously and `useSourceEditing` shares the same warm module). These
29
+ // are back-compat aliases over that cache; the param is now an
30
+ // `EditingEngineLoader` (resolves the module, not just the factory).
31
+
32
+ /**
33
+ * Eagerly loads the editing engine and primes the shared cache so the next
34
+ * editable block attaches synchronously instead of after a load round-trip.
35
+ * Optional — `useEditable` loads on demand anyway. Pass the provider's
36
+ * `editingEngineLoader` to share its deduplication.
37
+ */
38
+ export const preloadEditableEngine = preloadEditingEngine;
39
+
40
+ /**
41
+ * Clears the shared editing-engine cache so the next editable block resolves its
42
+ * loader from scratch. Intended for tests that exercise the cold path.
43
+ */
44
+ export const resetEditableEngineCache = resetEditingEngineCache;
45
+
46
+ /**
47
+ * The lightweight, always-mounted shell for live code editing. Owns the editing
48
+ * state/refs and a stable `edit` proxy; the heavy runtime is loaded on demand
49
+ * from `./EditableEngine` and `contentEditable` is applied only once it resolves.
50
+ *
51
+ * The host element (`elementRef.current`) is expected to be **stable for the
52
+ * lifetime of the hook** once the block is editable: the engine attaches once
53
+ * and its setup effect does not re-run on a node swap, so a caller that replaces
54
+ * the bound element in place would leave `contentEditable` on the stale node.
55
+ */
69
56
  export const useEditable = (elementRef, onChange, opts) => {
70
- // Normalize once into a non-optional local so closures (effects, the
71
- // edit object, event handlers) can read `config.X` directly without
72
- // any non-null assertions on `opts`.
57
+ // Normalize once into a non-optional local so the effects below can read
58
+ // `config.X` directly without any non-null assertions on `opts`.
73
59
  const config = opts ?? {};
74
60
  const unblock = React.useState([])[1];
75
- const state = React.useState(() => ({
76
- disconnected: false,
77
- onChange,
78
- pendingContent: null,
79
- queue: [],
80
- history: [],
81
- historyAt: -1,
82
- lastCommittedContent: null,
83
- domDirty: false,
84
- position: null,
85
- repeatFlushId: null,
86
- skipNextRestore: false,
87
- preParseAbort: null
88
- }))[0];
89
61
 
90
- // MutationObserver is created once via useRef so it is never recreated on
91
- // re-render and is not subject to React Strict Mode double-invocation of
92
- // useState initializers (which would silently discard the first observer).
62
+ // The editing state bag, the visible-region bounds, and a config snapshot are
63
+ // all mutable refs the engine reads/writes. They're synced in the layout effect
64
+ // below (never during render React refs must not be touched while rendering).
65
+ const stateRef = React.useRef(null);
93
66
  const observerRef = React.useRef(null);
94
- if (observerRef.current === null && typeof MutationObserver !== 'undefined') {
95
- observerRef.current = new MutationObserver(batch => {
96
- state.queue.push(...batch);
97
- });
98
- }
99
-
100
- // The visible-region bounds (`minColumn`/`minRow`/`maxRow`/`onBoundary`)
101
- // and `caretSelector` only affect handler logic, not the contentEditable
102
- // setup itself. We mirror them in a ref so the handlers always read the
103
- // latest values, while keeping these values out of the main effect's deps.
104
- // Listing them as deps would tear down and re-bind contentEditable every
105
- // time they change (e.g. when a host expands a collapsed code block),
106
- // which causes the browser to drop focus mid-animation.
107
- const boundsRef = React.useRef({
108
- minColumn: config.minColumn,
109
- minRow: config.minRow,
110
- maxRow: config.maxRow,
111
- onBoundary: config.onBoundary,
112
- caretSelector: config.caretSelector,
113
- preParse: config.preParse
114
- });
115
- boundsRef.current.minColumn = config.minColumn;
116
- boundsRef.current.minRow = config.minRow;
117
- boundsRef.current.maxRow = config.maxRow;
118
- boundsRef.current.onBoundary = config.onBoundary;
119
- boundsRef.current.caretSelector = config.caretSelector;
120
- boundsRef.current.preParse = config.preParse;
121
-
122
- // useMemo with [] is a performance hint, not a semantic guarantee — React 19
123
- // may discard the cache and recreate the object. useState with a lazy
124
- // initializer is the correct primitive for a referentially stable object.
67
+ const boundsRef = React.useRef({});
68
+ const configRef = React.useRef(config);
69
+ const [engine, setEngine] = React.useState(null);
70
+ const engineRef = React.useRef(null);
71
+ // Fires `onActivate` once per block lifetime, the first time the block engages
72
+ // for editing (mount in `'eager'`; hover/focus/click in `'interaction'`).
73
+ const activatedRef = React.useRef(false);
74
+
75
+ // Stable Edit proxy. Delegates to the loaded engine; before the engine
76
+ // resolves the mutators are no-ops and `getState` returns an empty snapshot
77
+ // (matching the historical pre-mount behavior).
125
78
  const [edit] = React.useState(() => ({
126
79
  update(content) {
127
- const {
128
- current: element
129
- } = elementRef;
130
- if (element) {
131
- const position = getPosition(element);
132
- const prevContent = toString(element);
133
- position.position += content.length - prevContent.length;
134
- state.position = position;
135
- state.onChange(content, position);
136
- }
80
+ engineRef.current?.edit.update(content);
137
81
  },
138
- insert(append, deleteOffset) {
139
- const {
140
- current: element
141
- } = elementRef;
142
- if (element) {
143
- let range = getCurrentRange();
144
- range.deleteContents();
145
- range.collapse();
146
- const position = getPosition(element);
147
- const offset = deleteOffset || 0;
148
- const start = position.position + (offset < 0 ? offset : 0);
149
- const end = position.position + (offset > 0 ? offset : 0);
150
- range = makeRange(element, start, end);
151
- adjustCursorAtNewlineBoundary(range);
152
- range.deleteContents();
153
- if (append) {
154
- range.insertNode(document.createTextNode(append));
155
- }
156
- const cursorRange = makeRange(element, start + append.length);
157
- adjustCursorAtNewlineBoundary(cursorRange);
158
- setCurrentRange(cursorRange);
159
- }
82
+ insert(append, offset) {
83
+ engineRef.current?.edit.insert(append, offset);
160
84
  },
161
85
  move(pos) {
162
- const {
163
- current: element
164
- } = elementRef;
165
- if (element) {
166
- element.focus();
167
- const position = typeof pos === 'number' ? pos : getOffsetAtLineColumn(element, pos.row, pos.column);
168
- const cursorRange = makeRange(element, position);
169
- adjustCursorAtNewlineBoundary(cursorRange);
170
- setCurrentRange(cursorRange);
171
- }
86
+ engineRef.current?.edit.move(pos);
172
87
  },
173
88
  getState() {
174
- const element = elementRef.current;
175
- if (!element) {
176
- // Pre-mount / unmounted: return an empty snapshot so callers
177
- // that subscribe before the ref is attached get a stable shape.
178
- return {
179
- text: '',
180
- position: {
181
- position: 0,
182
- extent: 0,
183
- content: '',
184
- line: 0
185
- }
186
- };
187
- }
188
- return {
189
- text: toString(element),
190
- position: getPosition(element)
191
- };
89
+ return engineRef.current?.edit.getState() ?? emptySnapshot();
192
90
  }
193
91
  }));
194
- React.useLayoutEffect(() => {
195
- // Only for SSR / server-side logic
196
- // typeof navigator check fails on Node.js 21+ which exposes navigator.userAgent;
197
- // typeof window is the standard isomorphic SSR guard.
198
- if (typeof window === 'undefined') {
199
- return undefined;
200
- }
201
- state.onChange = onChange;
202
- if (!elementRef.current || config.disabled) {
203
- return undefined;
204
- }
205
92
 
206
- // Detect content swaps that happen outside the keystroke pipeline (e.g.
207
- // a host calling `setSource(...)` from a Reset button or React state
208
- // change) and snapshot them into the undo stack so the user can Ctrl+Z
209
- // back to their prior text. We skip this on the post-flush re-render
210
- // (`state.disconnected === true`): in that case `flushChanges` has just
211
- // recorded the new content via `trackState`, so re-reading the DOM
212
- // would only re-confirm what we already know — wasting an O(N) walk
213
- // on every keystroke. We also skip while a user edit is in flight
214
- // (`pendingContent !== null`) so we don't race with the imminent
215
- // flush. Finally, we only push when there's already a recorded entry
216
- // that the new content differs from — the initial-baseline capture
217
- // before the very first user edit is left to `trackState`'s keydown
218
- // path so we don't double-record (and inadvertently arm its 500ms
219
- // dedup timestamp before flushChanges gets a chance to record the
220
- // post-edit state).
221
- if (!state.disconnected && state.pendingContent === null && state.history.length > 0) {
222
- // Detect host-driven content swaps (e.g. a `setSource(...)` from a
223
- // Reset button or an external React state change) and snapshot
224
- // them into the undo stack so the user can Ctrl+Z back to their
225
- // prior text. We compare the live DOM against
226
- // `state.lastCommittedContent` — the content of the most recent
227
- // `onChange` call. After a normal commit, React's reconciliation
228
- // produces a DOM whose `toString()` matches `lastCommittedContent`
229
- // exactly, so the comparison is a cheap no-op. After an external
230
- // swap they differ and we record the new entry.
231
- //
232
- // We deliberately do NOT use the MutationObserver record queue as
233
- // a gate here: React's own reconciliation between renders fires
234
- // records too, and pushing those into `state.queue` would cause
235
- // `commit()` to revert React's DOM patches on the next keystroke.
236
- // The observer's per-render `disconnect()` (in the cleanup below)
237
- // drops those records on the floor by design.
238
- const lastCommitted = state.lastCommittedContent;
239
- if (lastCommitted !== null) {
240
- const currentContent = toString(elementRef.current);
241
- if (currentContent !== lastCommitted) {
242
- const lastEntry = state.history[state.historyAt];
243
- // Recover edits the 500ms dedup kept out of `history`. Without
244
- // this, a user who typed within the dedup window then
245
- // triggered an external swap would lose those keystrokes
246
- // entirely on undo: history holds only the pre-typing
247
- // checkpoint, so Ctrl+Z would jump straight past the user's
248
- // most recent state.
249
- if (lastEntry && lastCommitted !== lastEntry[1]) {
250
- state.historyAt += 1;
251
- const at = state.historyAt;
252
- state.history[at] = [state.position ?? lastEntry[0], lastCommitted];
253
- state.history.splice(at + 1);
254
- if (at > 500) {
255
- state.historyAt -= 1;
256
- state.history.shift();
257
- }
258
- }
259
- const lastEntryAfter = state.history[state.historyAt];
260
- state.historyAt += 1;
261
- const at = state.historyAt;
262
- state.history[at] = [lastEntryAfter ? lastEntryAfter[0] : state.position ?? {
263
- position: 0,
264
- extent: 0,
265
- content: '',
266
- line: 0
267
- }, currentContent];
268
- state.history.splice(at + 1);
269
- if (at > 500) {
270
- state.historyAt -= 1;
271
- state.history.shift();
272
- }
273
- state.lastCommittedContent = currentContent;
274
- }
275
- }
276
- }
277
- state.disconnected = false;
278
- observerRef.current?.observe(elementRef.current, observerSettings);
279
- // Skip restoring the cursor while a key is held down. The debounced
280
- // flushChanges hasn't run yet so state.position is stale; restoring it
281
- // here would jump the cursor back on every incidental re-render (e.g.
282
- // from an async enhancer setState). edit.insert() already placed the
283
- // cursor correctly in the DOM — leave it there until the debounce fires.
284
- //
285
- // Also skip on the render right after an arrow-key boundary callback
286
- // (see `state.skipNextRestore`): the native arrow movement hasn't
287
- // applied yet, so `state.position` is the pre-arrow location and
288
- // restoring it would visibly snap the caret back upward/downward.
289
- if (state.skipNextRestore) {
290
- state.skipNextRestore = false;
291
- } else if (state.position && state.repeatFlushId === null) {
292
- const {
293
- position,
294
- extent
295
- } = state.position;
296
- const cursorRange = makeRange(elementRef.current, position, position + extent);
297
- adjustCursorAtNewlineBoundary(cursorRange);
298
- setCurrentRange(cursorRange);
93
+ // Keep the mutable refs current. Runs every render in a layout effect (not
94
+ // during render, so the React Compiler ref rules are satisfied) and before the
95
+ // resolve effect below, so the engine is always built against fresh values.
96
+ // The engine's handlers read these refs at event time, long after this commits.
97
+ React.useLayoutEffect(() => {
98
+ let editingState = stateRef.current;
99
+ if (editingState === null) {
100
+ editingState = {
101
+ disconnected: false,
102
+ onChange,
103
+ pendingContent: null,
104
+ queue: [],
105
+ history: [],
106
+ historyAt: -1,
107
+ lastCommittedContent: null,
108
+ domDirty: false,
109
+ position: null,
110
+ repeatFlushId: null,
111
+ skipNextRestore: false,
112
+ preParseAbort: null
113
+ };
114
+ stateRef.current = editingState;
115
+ } else {
116
+ // `onChange` can change without a remount (e.g. controlled code updates the
117
+ // closure), so refresh it every render. It's declared as a method on
118
+ // `State`, so the assignment needs no cast.
119
+ editingState.onChange = onChange;
299
120
  }
300
- return () => {
301
- // Drain the observer's pending record queue into a single dirty
302
- // bit BEFORE disconnecting. `disconnect()` per spec drops the
303
- // queue, which would otherwise hide an external DOM swap that
304
- // happened between this render's commit and the next render's
305
- // snapshot block. We deliberately do NOT push the records into
306
- // `state.queue`: React's own reconciliation mutations land here
307
- // too, and `commit()` on the next keystroke would revert them,
308
- // corrupting the rendered DOM. The boolean is a pure gating
309
- // signal — the snapshot block does its own `toString` comparison
310
- // against `lastCommittedContent` to decide whether the change was
311
- // a real swap or just React reconciling to the committed content.
312
- const pending = observerRef.current?.takeRecords();
313
- if (pending && pending.length > 0) {
314
- state.domDirty = true;
315
- }
316
- observerRef.current?.disconnect();
317
- };
121
+ const bounds = boundsRef.current;
122
+ bounds.minColumn = config.minColumn;
123
+ bounds.minRow = config.minRow;
124
+ bounds.maxRow = config.maxRow;
125
+ bounds.onBoundary = config.onBoundary;
126
+ bounds.caretSelector = config.caretSelector;
127
+ bounds.preParse = config.preParse;
128
+ configRef.current = config;
318
129
  });
130
+
131
+ // Resolve the engine when the block is editable. `'eager'` (default) loads on
132
+ // mount; `'interaction'` defers the load until the user engages: hover
133
+ // (pointerenter) warms the chunk so the eventual commit is instant, and focus
134
+ // or click commits (loads + attaches). `contentEditable` is applied only after
135
+ // the engine resolves (via `setup`).
319
136
  React.useLayoutEffect(() => {
320
- if (typeof window === 'undefined') {
137
+ const editingState = stateRef.current;
138
+ if (typeof window === 'undefined' || config.disabled || !elementRef.current || !editingState || engineRef.current) {
321
139
  return undefined;
322
140
  }
323
- if (!elementRef.current || config.disabled) {
324
- state.history.length = 0;
325
- state.historyAt = -1;
326
- return undefined;
327
- }
328
- const element = elementRef.current;
329
- if (!element) {
330
- return undefined;
331
- }
332
- if (state.position) {
333
- element.focus();
334
- const {
335
- position,
336
- extent
337
- } = state.position;
338
- const cursorRange = makeRange(element, position, position + extent);
339
- adjustCursorAtNewlineBoundary(cursorRange);
340
- setCurrentRange(cursorRange);
341
- }
342
- const prevWhiteSpace = element.style.whiteSpace;
343
- const prevContentEditable = element.contentEditable;
344
- let hasPlaintextSupport = true;
345
- try {
346
- // Firefox and IE11 do not support plaintext-only mode
347
- element.contentEditable = 'plaintext-only';
348
- } catch (_error) {
349
- element.contentEditable = 'true';
350
- hasPlaintextSupport = false;
351
- }
352
-
353
- // Only set inline styles when the computed style isn't already
354
- // suitable. This lets consumers control these properties via CSS
355
- // (e.g. a `pre` selector) without us clobbering their values with
356
- // inline styles that win specificity.
357
- const computed = element.ownerDocument.defaultView?.getComputedStyle(element);
358
- const computedWhiteSpace = computed?.whiteSpace ?? '';
359
- // Any whitespace-preserving value works for an editable surface.
360
- // `pre-line` is intentionally excluded because it collapses runs of
361
- // spaces, which would corrupt indentation.
362
- const whiteSpaceIsPreserving = computedWhiteSpace === 'pre' || computedWhiteSpace === 'pre-wrap' || computedWhiteSpace === 'break-spaces';
363
- if (!whiteSpaceIsPreserving) {
364
- element.style.whiteSpace = 'pre-wrap';
365
- }
366
- if (config.indentation) {
367
- const tabSizeValue = `${config.indentation}`;
368
- if (computed?.tabSize !== tabSizeValue) {
369
- element.style.setProperty('-moz-tab-size', tabSizeValue);
370
- element.style.tabSize = tabSizeValue;
371
- }
372
- }
373
- const indentPattern = `${' '.repeat(config.indentation || 0)}`;
374
- const indentRe = new RegExp(`^(?:${indentPattern})`);
375
- const blanklineRe = new RegExp(`^(?:${indentPattern})*(${indentPattern})$`);
376
- let trackStateTimestamp;
377
- const trackState = (ignoreTimestamp, contentOverride, positionOverride) => {
378
- // Require a live selection so getPosition() (which calls getRangeAt(0)) is safe.
379
- // Using !state.position would block recording the initial state: state.position is
380
- // only set by flushChanges() which runs on keyup — after the first edit. Switching
381
- // to rangeCount === 0 lets the very first keydown snapshot the pre-edit content.
382
- if (!elementRef.current || (window.getSelection()?.rangeCount ?? 0) === 0) {
383
- return null;
384
- }
385
-
386
- // Callers may pass in already-computed (and possibly repaired) content so
387
- // we don't re-read a buggy intermediate DOM. flushChanges uses this to
388
- // record the repaired post-edit state instead of the merged DOM that
389
- // Firefox/observer left behind.
390
- const content = contentOverride ?? toString(element);
391
- const position = positionOverride ?? getPosition(element);
392
- const timestamp = new Date().valueOf();
393
-
394
- // Prevent recording new state in list if last one has been new enough
395
- const lastEntry = state.history[state.historyAt];
396
- if (!ignoreTimestamp && timestamp - trackStateTimestamp < 500 || lastEntry && lastEntry[1] === content) {
397
- trackStateTimestamp = timestamp;
398
- return content;
399
- }
400
- state.historyAt += 1;
401
- const at = state.historyAt;
402
- state.history[at] = [position, content];
403
- state.history.splice(at + 1);
404
- if (at > 500) {
405
- state.historyAt -= 1;
406
- state.history.shift();
407
- }
408
- return content;
409
- };
410
- const disconnect = () => {
411
- observerRef.current?.disconnect();
412
- state.disconnected = true;
413
- };
414
- const flushChanges = (ignoreTimestamp, bypassPreParse) => {
415
- const records = observerRef.current?.takeRecords() ?? [];
416
- state.queue.push(...records);
417
- const position = getPosition(element);
418
- if (state.queue.length) {
419
- // We DO NOT revert the queued mutations yet — letting them stay in
420
- // the live DOM means the user's keystroke remains visible while
421
- // `preParse` runs. The mutation queue is held until commit (below)
422
- // so when React eventually re-renders the highlighted content, it
423
- // first sees its expected previous DOM.
424
- const content = repairUnexpectedLineMerge(toString(element), state.pendingContent, position);
425
- state.position = position;
426
-
427
- // Record the REPAIRED content into history before notifying the app.
428
- // Reading toString() back from the DOM here would capture the buggy
429
- // pre-repair state (e.g. a Firefox line-merge), which is what was
430
- // previously polluting the undo stack.
431
- trackState(ignoreTimestamp, content, position);
432
-
433
- // Snapshot the queue length representing mutations that belong to
434
- // THIS flush. Anything appended past this index by the time
435
- // `commit` runs is a straggler — a newer keystroke whose own
436
- // keyup-triggered `flushChanges` will produce a fresher commit. In
437
- // that case we must NOT revert the stragglers (or we'd lose the
438
- // user's character) and we must NOT call `onChange` with our now
439
- // stale `content` (or we'd briefly render the older state on top
440
- // of the newer DOM).
441
- const queueLengthAtFlush = state.queue.length;
442
-
443
- // Commit phase: revert the queued mutations and hand control to
444
- // React. The revert + React commit are bundled into a single task
445
- // via `flushSync` so the browser cannot paint the briefly-reverted
446
- // DOM between the two — the user's keystroke stays continuously on
447
- // screen, transitioning directly from "raw mutation" to
448
- // "highlighted React render".
449
- const commit = preParseResult => {
450
- // Drain anything pending in the observer first so we have an
451
- // accurate count of stragglers (mutations made after this
452
- // flush started). The observer stays connected during the
453
- // `preParse` await so additional keystrokes ARE captured but
454
- // are NOT blocked by the `state.disconnected` guard in
455
- // `onKeyDown`.
456
- const stragglers = observerRef.current?.takeRecords() ?? [];
457
- state.queue.push(...stragglers);
458
- if (state.queue.length > queueLengthAtFlush) {
459
- // A newer keystroke landed in the DOM after this flush
460
- // started. Drop this commit on the floor — the straggler's
461
- // own `flushChanges` (already running, or about to run on
462
- // its keyup) will produce a fresher commit that reverts the
463
- // entire combined mutation set and reports the up-to-date
464
- // content. Leaving the observer connected and
465
- // `state.disconnected` false lets onKeyDown keep accepting
466
- // input in the meantime.
467
- return;
468
- }
469
- disconnect();
470
- while (state.queue.length > 0) {
471
- const mutation = state.queue.pop();
472
- if (!mutation) {
473
- break;
474
- }
475
- if (mutation.oldValue !== null) {
476
- mutation.target.textContent = mutation.oldValue;
477
- }
478
- for (let i = mutation.removedNodes.length - 1; i >= 0; i -= 1) {
479
- mutation.target.insertBefore(mutation.removedNodes[i], mutation.nextSibling);
480
- }
481
- for (let i = mutation.addedNodes.length - 1; i >= 0; i -= 1) {
482
- if (mutation.addedNodes[i].parentNode) {
483
- mutation.target.removeChild(mutation.addedNodes[i]);
484
- }
485
- }
486
- }
487
- ReactDOM.flushSync(() => {
488
- state.lastCommittedContent = content;
489
- if (preParseResult === undefined) {
490
- // Preserve the historical (text, position) calling convention
491
- // for the sync / bypass path so consumers can distinguish a
492
- // preParse-result-less commit from one whose result happened
493
- // to be `undefined`.
494
- state.onChange(content, position);
495
- } else {
496
- state.onChange(content, position, preParseResult);
497
- }
498
- });
499
- };
500
- const {
501
- preParse
502
- } = boundsRef.current;
503
- if (preParse && !bypassPreParse) {
504
- // Abort any prior in-flight preParse — only the most recent
505
- // keystroke's parse result is worth waiting for.
506
- if (state.preParseAbort) {
507
- state.preParseAbort.abort();
508
- }
509
- const controller = new AbortController();
510
- state.preParseAbort = controller;
511
- const {
512
- signal
513
- } = controller;
514
- preParse(content, position, signal).then(result => {
515
- if (signal.aborted) {
516
- return;
517
- }
518
- if (state.preParseAbort === controller) {
519
- state.preParseAbort = null;
520
- }
521
- commit(result);
522
- }, () => {
523
- if (state.preParseAbort === controller) {
524
- state.preParseAbort = null;
525
- }
526
- if (signal.aborted) {
527
- // Aborted by a newer keystroke — drop silently. The
528
- // queued mutations stay in place until the superseding
529
- // flush commits them.
530
- return;
531
- }
532
- // Real parse failure (e.g. unknown grammar, worker error).
533
- // Fall back to committing without a preParseResult so the
534
- // source still propagates to onChange — matching the
535
- // historical sync path's fail-open behavior. Without this,
536
- // the DOM would show the user's typed text while controlled
537
- // state stayed stale, and the next render would revert it.
538
- commit();
539
- });
540
- } else {
541
- // Structural / synchronous edit — bypass preParse so the React
542
- // state sync happens on the same commit as the DOM change.
543
- if (state.preParseAbort) {
544
- state.preParseAbort.abort();
545
- state.preParseAbort = null;
546
- }
547
- commit();
548
- }
549
- }
550
- state.pendingContent = null;
551
- };
552
-
553
- // Snap a collapsed caret out of an inter-line gap text node (e.g. the
554
- // literal `\n` between `.line` spans) onto the nearest `.line` in
555
- // `direction`. Used by both the post-arrow rAF and the pointer
556
- // handlers — clicks can land in gap nodes too. When `isVertical`, the
557
- // caret lands at `preferredColumn` of the target line (clamped);
558
- // otherwise it lands at the start (forward) or end (backward).
559
- // Returns `true` when a snap was applied.
560
- const snapCaretOutOfGapNode = (direction, isVertical, preferredColumn) => {
561
- const {
562
- caretSelector
563
- } = boundsRef.current;
564
- if (caretSelector === undefined) {
565
- return false;
566
- }
567
- const sel = element.ownerDocument.defaultView?.getSelection();
568
- if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
569
- return false;
570
- }
571
- const snapRange = sel.getRangeAt(0);
572
- if (!element.contains(snapRange.startContainer)) {
573
- return false;
574
- }
575
- const startContainer = snapRange.startContainer;
576
- const startElement = asElement(startContainer) ?? startContainer.parentElement;
577
- // Caret is already inside a `.line` (or equivalent) — no snap needed.
578
- if (startElement?.closest(caretSelector)) {
579
- return false;
580
- }
581
- const lineEls = Array.from(element.querySelectorAll(caretSelector));
582
- if (lineEls.length === 0) {
583
- return false;
584
- }
585
- // Use document position to pick the right neighbour.
586
- let target = null;
587
- if (direction === 'forward') {
588
- for (let i = 0; i < lineEls.length; i += 1) {
589
- const r = element.ownerDocument.createRange();
590
- r.selectNode(lineEls[i]);
591
- // cmp < 0 means the caret is before this line.
592
- if (snapRange.compareBoundaryPoints(Range.START_TO_START, r) < 0) {
593
- target = lineEls[i];
594
- break;
595
- }
596
- }
597
- // No line ahead — caret has landed past the last line. Snap back
598
- // to the last line so the caret stays inside an editable row.
599
- if (!target) {
600
- target = lineEls[lineEls.length - 1];
601
- }
602
- } else {
603
- for (let i = lineEls.length - 1; i >= 0; i -= 1) {
604
- const r = element.ownerDocument.createRange();
605
- r.selectNode(lineEls[i]);
606
- // cmp > 0 means the caret is after this line.
607
- if (snapRange.compareBoundaryPoints(Range.END_TO_END, r) > 0) {
608
- target = lineEls[i];
609
- break;
610
- }
611
- }
612
- // No line behind — caret has landed before the first line.
613
- if (!target) {
614
- target = lineEls[0];
615
- }
616
- }
617
- if (!target) {
618
- return false;
619
- }
620
- const newRange = element.ownerDocument.createRange();
621
- if (isVertical) {
622
- // Walk the target line's text nodes to find the offset that
623
- // matches `preferredColumn`, clamping to the line length.
624
- const targetText = target.textContent ?? '';
625
- const targetColumn = Math.min(preferredColumn, targetText.length);
626
- let remaining = targetColumn;
627
- const walker = element.ownerDocument.createTreeWalker(target, NodeFilter.SHOW_TEXT);
628
- let placed = false;
629
- let node = walker.nextNode();
630
- while (node) {
631
- const len = node.textContent?.length ?? 0;
632
- if (remaining <= len) {
633
- newRange.setStart(node, remaining);
634
- newRange.collapse(true);
635
- placed = true;
636
- break;
637
- }
638
- remaining -= len;
639
- node = walker.nextNode();
640
- }
641
- if (!placed) {
642
- newRange.selectNodeContents(target);
643
- newRange.collapse(false);
644
- }
645
- } else if (direction === 'forward') {
646
- newRange.selectNodeContents(target);
647
- newRange.collapse(true);
648
- } else {
649
- newRange.selectNodeContents(target);
650
- newRange.collapse(false);
651
- }
652
- sel.removeAllRanges();
653
- sel.addRange(newRange);
654
- return true;
141
+ const loader = config.engineLoader;
142
+ const ctx = {
143
+ elementRef,
144
+ state: editingState,
145
+ observerRef,
146
+ boundsRef,
147
+ configRef,
148
+ unblock
655
149
  };
656
-
657
- // Snap a collapsed caret out of the clipped indent gutter (`[0, minColumn)`)
658
- // when the user clicks there. The arrow-key handler already prevents
659
- // landing inside the gutter via keyboard navigation; this covers
660
- // pointer-driven clicks. Range selections are left alone — clamping the
661
- // anchor of a drag would feel surprising mid-gesture.
662
- const snapCaretOutOfGutter = () => {
663
- const {
664
- minColumn
665
- } = boundsRef.current;
666
- if (minColumn === undefined || minColumn <= 0) {
667
- return;
668
- }
669
- const sel = element.ownerDocument.defaultView?.getSelection();
670
- if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
671
- return;
672
- }
673
- const range = sel.getRangeAt(0);
674
- if (!element.contains(range.startContainer)) {
675
- return;
676
- }
677
- const position = getPosition(element);
678
- if (position.content.length >= minColumn) {
679
- return;
680
- }
681
- // Only snap when the gutter is actually whitespace — otherwise the
682
- // line is shorter than `minColumn` and there's nowhere to snap to.
683
- // `getLineInfo` walks just enough text nodes to read the current
684
- // line; avoids materializing the full document text on every click.
685
- const lineText = getLineInfo(element, position.line).currentLine;
686
- if (lineText.length < minColumn || !/^\s*$/.test(lineText.slice(0, minColumn))) {
150
+ const attach = create => {
151
+ if (engineRef.current) {
687
152
  return;
688
153
  }
689
- edit.move({
690
- row: position.line,
691
- column: minColumn
692
- });
154
+ const created = create(ctx);
155
+ engineRef.current = created;
156
+ setEngine(created);
693
157
  };
694
- const onKeyDown = event => {
695
- if (event.defaultPrevented || event.target !== element) {
696
- return;
697
- }
698
- if (state.disconnected) {
699
- // React Quirk: between flushChanges() (which calls disconnect() and
700
- // rewinds the DOM back to the pre-edit content) and React's commit
701
- // (which re-observes via useLayoutEffect and restores state.position),
702
- // an event can fire that we'd otherwise mishandle.
703
- //
704
- // For NAVIGATION keys (arrows) the DOM revert is irrelevant — the
705
- // browser only needs a valid caret position to compute the next
706
- // selection — so resync inline (restore caret + re-observe) and let
707
- // the event proceed. Otherwise the keystroke would be eaten and the
708
- // user would lose, for example, an ArrowUp step after Enter inside
709
- // a focus frame. We deliberately do NOT include Home/End/PageUp/
710
- // PageDown here: they would also need to compensate for the pending
711
- // rerender (matching the arrow-key skip-next-restore handling) and
712
- // currently lack that coverage, so keep them on the safe path.
713
- //
714
- // For EDITING keys (printable text, Enter, Tab, Backspace, Delete,
715
- // …) we must NOT fall through: the live DOM is the reverted
716
- // pre-edit snapshot, so applying a second edit on top would target
717
- // the wrong text and corrupt content. Keep the original block-and-
718
- // unblock behavior for those keys — React will commit the queued
719
- // onChange momentarily and the user can re-issue the keystroke.
720
- const isArrowKey = event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'ArrowUp' || event.key === 'ArrowDown';
721
- if (!isArrowKey) {
722
- event.preventDefault();
723
- unblock([]);
724
- return;
725
- }
726
- if (state.position && state.repeatFlushId === null) {
727
- const {
728
- position,
729
- extent
730
- } = state.position;
731
- const cursorRange = makeRange(element, position, position + extent);
732
- adjustCursorAtNewlineBoundary(cursorRange);
733
- setCurrentRange(cursorRange);
734
- }
735
- observerRef.current?.observe(element, observerSettings);
736
- state.disconnected = false;
737
- // The `unblock([])` below schedules a React rerender. If that
738
- // rerender's restore effect runs before the native arrow movement
739
- // has updated `state.position` (which happens asynchronously via
740
- // `selectionchange`), the restore would snap the caret back to the
741
- // stale pre-arrow position. In practice `selectionchange` usually
742
- // fires first so the restore is a no-op, but arming the skip flag
743
- // makes the fast path race-free regardless of scheduling. The
744
- // boundary-movement branches arm the same flag for the same reason.
745
- state.skipNextRestore = true;
746
- unblock([]);
747
- // Fall through and let this arrow event be handled normally
748
- // with the restored caret position.
749
- }
750
- if (isUndoRedoKey(event)) {
751
- event.preventDefault();
752
- let history;
753
- if (!event.shiftKey) {
754
- state.historyAt -= 1;
755
- const at = state.historyAt;
756
- history = state.history[at];
757
- if (!history) {
758
- state.historyAt = 0;
759
- }
760
- } else {
761
- state.historyAt += 1;
762
- const at = state.historyAt;
763
- history = state.history[at];
764
- if (!history) {
765
- state.historyAt = state.history.length - 1;
766
- }
767
- }
768
- if (history) {
769
- disconnect();
770
- state.position = history[0];
771
- state.lastCommittedContent = history[1];
772
- state.onChange(history[1], history[0]);
773
- }
774
- return;
775
- }
776
-
777
- // Only capture the pre-edit snapshot when no edit is currently pending
778
- // (i.e. the previous keystroke has already been flushed on keyup).
779
- // Overwriting pendingContent on a rapid second keydown — whether the
780
- // same key repeating OR a different key pressed before the first
781
- // keyup — would lose the baseline that repairUnexpectedLineMerge
782
- // needs to detect Firefox's line-merge quirk. The DOM may already
783
- // contain a merged state when the second keydown fires; treating that
784
- // as "previous" content makes the line-loss invisible.
785
- if (state.pendingContent === null) {
786
- state.pendingContent = trackState() ?? toString(element);
787
- }
788
- if (event.key === 'Enter') {
789
- event.preventDefault();
790
- // Firefox Quirk: Since plaintext-only is unsupported we must
791
- // ensure that only newline characters are inserted
792
- const position = getPosition(element);
793
- // We also get the current line and preserve indentation for the next
794
- // line that's created
795
- const match = /\S/g.exec(position.content);
796
- const index = match ? match.index : position.content.length;
797
- const text = `\n${position.content.slice(0, index)}`;
798
- edit.insert(text);
799
- } else if (!hasPlaintextSupport && !event.isComposing && isPlaintextInputKey(event)) {
800
- // Firefox Quirk: native typing in contentEditable="true" can insert
801
- // directly into the frame wrapper before the current line span.
802
- // Route plain text input through the controlled insert path instead.
803
- event.preventDefault();
804
- edit.insert(event.key);
805
- } else if ((!hasPlaintextSupport || config.indentation) && event.key === 'Backspace') {
806
- // Firefox Quirk: Since plaintext-only is unsupported we must
807
- // ensure that only a single character is deleted
808
- event.preventDefault();
809
- const range = getCurrentRange();
810
- if (!range.collapsed) {
811
- edit.insert('', 0);
812
- } else {
813
- const position = getPosition(element);
814
- const {
815
- minColumn
816
- } = boundsRef.current;
817
- // When the caret sits at `minColumn` on a blank (whitespace-only)
818
- // line inside a clipped indent gutter, a normal Backspace would
819
- // step into `[0, minColumn)` — visually invisible to the user
820
- // since that range is hidden by the host. The user has nothing
821
- // useful to delete on this line, so collapse the entire blank
822
- // line and land the caret at the end of the previous line. This
823
- // matches the mental model: "Backspace from an empty indented
824
- // line removes the line."
825
- //
826
- // Walk only enough text nodes to read the current line — we
827
- // don't need the rest of the document on every Backspace.
828
- const couldCollapse = minColumn !== undefined && minColumn > 0 && position.line > 0 && position.content.length === minColumn && /^\s*$/.test(position.content);
829
- if (couldCollapse && minColumn !== undefined) {
830
- // The redundant `minColumn !== undefined` check pins TS's
831
- // narrowing across the boundary so we can use `minColumn`
832
- // as a number directly without an assertion.
833
- const fullLine = getLineInfo(element, position.line).currentLine;
834
- if (fullLine.length === minColumn && /^\s*$/.test(fullLine)) {
835
- edit.insert('', -(minColumn + 1));
836
- return;
837
- }
838
- }
839
- const match = blanklineRe.exec(position.content);
840
- edit.insert('', match ? -match[1].length : -1);
841
- }
842
- } else if (config.indentation && event.key === 'Tab') {
843
- event.preventDefault();
844
- const position = getPosition(element);
845
- const start = position.position - position.content.length;
846
- const content = toString(element);
847
- const newContent = event.shiftKey ? content.slice(0, start) + position.content.replace(indentRe, '') + content.slice(start + position.content.length) : content.slice(0, start) + (config.indentation ? ' '.repeat(config.indentation) : '\t') + content.slice(start);
848
- edit.update(newContent);
849
- } else if ((boundsRef.current.minColumn !== undefined || boundsRef.current.minRow !== undefined || boundsRef.current.maxRow !== undefined || boundsRef.current.caretSelector !== undefined) && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
850
- // Arrow-key navigation that respects the visible region:
851
- // - `minColumn`: skip over hidden/clipped leading indent so the
852
- // caret never lands before `minColumn` via horizontal navigation.
853
- // - `minRow`/`maxRow`: block navigation past the visible row range
854
- // and invoke `onBoundary` so the host can react (e.g. expand).
855
- // - `caretSelector`: when set, the editable contains non-selectable
856
- // gap text nodes between lines; handle horizontal line-wrap
857
- // ourselves so `ArrowLeft` at column 0 lands at the end of the
858
- // previous line synchronously (without flashing through the gap).
859
- // Only acts on a collapsed selection — let the browser handle range
860
- // expansion when a modifier is held or text is already selected.
861
- const range = getCurrentRange();
862
- if (range.collapsed) {
863
- const {
864
- minColumn,
865
- minRow,
866
- maxRow,
867
- onBoundary,
868
- caretSelector
869
- } = boundsRef.current;
870
- const position = getPosition(element);
871
- const column = position.content.length;
872
- // Walk just enough of the document to gather the current line
873
- // and its immediate neighbors instead of allocating the entire
874
- // document string and a full per-line array on every keypress.
875
- const {
876
- currentLine: lineText,
877
- prevLine,
878
- nextLine,
879
- hasNextLine
880
- } = getLineInfo(element, position.line);
881
- const lineIsIndented = minColumn !== undefined && lineText.length >= minColumn && /^\s*$/.test(lineText.slice(0, minColumn));
882
- const atVisibleStart = minRow !== undefined && position.line === minRow;
883
- const atVisibleEnd = maxRow !== undefined && position.line === maxRow;
884
- const atLineStart = column === 0 || lineIsIndented && minColumn !== undefined && column === minColumn;
885
- const atLineEnd = column === lineText.length;
886
-
887
- // For caretSelector wrap, also confirm the caret is currently
888
- // *inside* an element matching the selector. This keeps the wrap
889
- // scoped to render paths that actually have inter-line gap nodes
890
- // (e.g. highlighted `.line` spans) and leaves plain-text editables
891
- // — where the browser handles arrows fine — untouched.
892
- const caretInLine = caretSelector !== undefined && (() => {
893
- const startContainer = range.startContainer;
894
- const startElement = asElement(startContainer) ?? startContainer.parentElement;
895
- return !!startElement?.closest(caretSelector);
896
- })();
897
-
898
- // Helper: place the caret on a target line, clamping the column
899
- // to the line's length and respecting `minColumn` indent. Used
900
- // when we need to move synchronously across the inter-line gap
901
- // text nodes that `caretSelector`-rendered content places between
902
- // `.line` spans (a native arrow press would otherwise drop the
903
- // caret *in* the gap). The caller passes the target line's text
904
- // (already in hand from `getLineInfo`) so we don't re-walk the
905
- // document.
906
- const moveToLine = (targetRow, targetLine, desiredColumn) => {
907
- let targetColumn = Math.min(desiredColumn, targetLine.length);
908
- if (minColumn !== undefined && targetLine.length >= minColumn && /^\s*$/.test(targetLine.slice(0, minColumn)) && targetColumn < minColumn) {
909
- targetColumn = minColumn;
910
- }
911
- edit.move({
912
- row: targetRow,
913
- column: targetColumn
914
- });
915
- };
916
- if (event.key === 'ArrowUp') {
917
- if (atVisibleStart) {
918
- if (caretInLine && position.line > 0) {
919
- // Synchronously move the caret onto the previous `.line`
920
- // before notifying the host. Without this, native ArrowUp
921
- // can drop the caret into the inter-line gap text node
922
- // (e.g. the literal `\n` between `.line` spans), trapping
923
- // it in the "between lines" area after the host expands.
924
- event.preventDefault();
925
- moveToLine(position.line - 1, prevLine, column);
926
- if (onBoundary) {
927
- state.skipNextRestore = true;
928
- onBoundary();
929
- }
930
- } else if (onBoundary) {
931
- // Allow native caret movement so the host can scroll the
932
- // newly-revealed content into view alongside the caret.
933
- state.skipNextRestore = true;
934
- onBoundary();
935
- } else {
936
- event.preventDefault();
937
- }
938
- }
939
- } else if (event.key === 'ArrowDown') {
940
- if (atVisibleEnd) {
941
- if (caretInLine && hasNextLine) {
942
- event.preventDefault();
943
- moveToLine(position.line + 1, nextLine, column);
944
- if (onBoundary) {
945
- state.skipNextRestore = true;
946
- onBoundary();
947
- }
948
- } else if (onBoundary) {
949
- state.skipNextRestore = true;
950
- onBoundary();
951
- } else {
952
- event.preventDefault();
953
- }
954
- }
955
- } else if (event.key === 'ArrowLeft') {
956
- if (atVisibleStart && atLineStart) {
957
- if (caretInLine && position.line > 0) {
958
- event.preventDefault();
959
- edit.move({
960
- row: position.line - 1,
961
- column: prevLine.length
962
- });
963
- if (onBoundary) {
964
- state.skipNextRestore = true;
965
- onBoundary();
966
- }
967
- } else if (onBoundary) {
968
- state.skipNextRestore = true;
969
- onBoundary();
970
- } else {
971
- event.preventDefault();
972
- }
973
- } else if (lineIsIndented && minColumn !== undefined && column === minColumn && position.line > 0) {
974
- event.preventDefault();
975
- edit.move({
976
- row: position.line - 1,
977
- column: prevLine.length
978
- });
979
- } else if (caretInLine && column === 0 && position.line > 0) {
980
- // With non-selectable gaps between lines the browser would
981
- // place the caret *in* the gap text node — making ArrowLeft
982
- // a no-op. Jump synchronously to the end of the previous
983
- // line instead.
984
- event.preventDefault();
985
- edit.move({
986
- row: position.line - 1,
987
- column: prevLine.length
988
- });
989
- }
990
- } else if (event.key === 'ArrowRight') {
991
- if (atVisibleEnd && atLineEnd) {
992
- if (caretInLine && hasNextLine) {
993
- event.preventDefault();
994
- moveToLine(position.line + 1, nextLine, 0);
995
- if (onBoundary) {
996
- state.skipNextRestore = true;
997
- onBoundary();
998
- }
999
- } else if (onBoundary) {
1000
- state.skipNextRestore = true;
1001
- onBoundary();
1002
- } else {
1003
- event.preventDefault();
1004
- }
1005
- } else if (minColumn !== undefined && column === lineText.length && hasNextLine) {
1006
- const nextIsIndented = nextLine.length >= minColumn && /^\s*$/.test(nextLine.slice(0, minColumn));
1007
- if (nextIsIndented) {
1008
- event.preventDefault();
1009
- edit.move({
1010
- row: position.line + 1,
1011
- column: minColumn
1012
- });
1013
- } else if (caretInLine) {
1014
- // Same gap-flash avoidance as ArrowLeft: jump to start of
1015
- // next line synchronously.
1016
- event.preventDefault();
1017
- edit.move({
1018
- row: position.line + 1,
1019
- column: 0
1020
- });
1021
- }
1022
- } else if (caretInLine && atLineEnd && hasNextLine) {
1023
- event.preventDefault();
1024
- edit.move({
1025
- row: position.line + 1,
1026
- column: 0
1027
- });
1028
- }
1029
- }
1030
- }
1031
158
 
1032
- // Schedule a post-arrow snap when `caretSelector` is set: the
1033
- // browser's native arrow handling can drop the caret into the
1034
- // non-selectable gap text nodes (e.g. the literal `\n` between
1035
- // `.line` spans, especially after pressing Down on the last line
1036
- // or Up on the first line). After the default action runs, if the
1037
- // caret is no longer inside a matching element, jump it to the
1038
- // nearest `.line` in the direction of travel so the caret never
1039
- // gets stuck "between lines".
1040
- const {
1041
- caretSelector
1042
- } = boundsRef.current;
1043
- if (caretSelector !== undefined && !event.defaultPrevented) {
1044
- const direction = event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 'forward' : 'backward';
1045
- // For vertical arrows, capture the column the user is leaving
1046
- // *before* the browser moves the caret, so we can land on the
1047
- // same column of the target line if a snap is needed. Horizontal
1048
- // arrows always snap to start/end of the adjacent line.
1049
- const isVertical = event.key === 'ArrowUp' || event.key === 'ArrowDown';
1050
- let preferredColumn = 0;
1051
- if (isVertical) {
1052
- const preSel = element.ownerDocument.defaultView?.getSelection();
1053
- if (preSel && preSel.rangeCount > 0 && preSel.isCollapsed) {
1054
- const preRange = preSel.getRangeAt(0);
1055
- if (element.contains(preRange.startContainer)) {
1056
- preferredColumn = getPosition(element).content.length;
1057
- }
1058
- }
1059
- }
1060
- // requestAnimationFrame fires after the browser has applied the
1061
- // native caret movement but before paint, so the snap is invisible.
1062
- window.requestAnimationFrame(() => {
1063
- snapCaretOutOfGapNode(direction, isVertical, preferredColumn);
1064
- });
1065
- }
1066
- }
1067
-
1068
- // After a controlled edit in plaintext-only contentEditable, the DOM is
1069
- // in a known-good post-edit state. Refresh pendingContent to that state
1070
- // so any subsequent native input within the same key burst — e.g.
1071
- // holding Enter then pressing x in plaintext-only contentEditable, where
1072
- // `x` falls through to native browser handling and may merge frame
1073
- // boundary lines — is measured against the correct baseline. Without
1074
- // this, repairUnexpectedLineMerge sees Enter add a line and the native
1075
- // merge remove a line for a net zero delta and short-circuits, leaving
1076
- // the merge unrepaired.
1077
- //
1078
- // We gate on `hasPlaintextSupport` because in the Firefox fallback
1079
- // (contenteditable=true) `edit.insert` itself can trigger the line-merge
1080
- // quirk, so toString() after it would already be buggy and we must keep
1081
- // the pre-edit baseline.
1082
- if (event.defaultPrevented && hasPlaintextSupport) {
1083
- state.pendingContent = toString(element);
1084
- }
1085
-
1086
- // Flush changes as a key is held so the app can catch up.
1087
- // Debounce: reset the timer on each repeat keydown so the expensive
1088
- // onChange (syntax re-highlight) only fires once the user pauses typing.
1089
- // edit.insert() already updated the DOM so the cursor and text are live.
1090
- if (event.repeat) {
1091
- if (state.repeatFlushId !== null) {
1092
- clearTimeout(state.repeatFlushId);
1093
- }
1094
- state.repeatFlushId = setTimeout(() => {
1095
- state.repeatFlushId = null;
1096
- flushChanges();
1097
- }, 100);
1098
- }
1099
- };
1100
- const onKeyUp = event => {
1101
- if (event.defaultPrevented || event.isComposing) {
159
+ // Notify the host the block has engaged for editing, exactly once. The host
160
+ // (e.g. `CodeHighlighter`) uses this to warm the rest of the live-editing
161
+ // dependencies grammars and the worker — at the activation moment.
162
+ const notifyActivated = () => {
163
+ if (activatedRef.current) {
1102
164
  return;
1103
165
  }
1104
- // Cancel any pending debounced flush so keyup always flushes immediately
1105
- if (state.repeatFlushId !== null) {
1106
- clearTimeout(state.repeatFlushId);
1107
- state.repeatFlushId = null;
1108
- }
1109
- // Structural edits (Enter) must always create their own undo checkpoint.
1110
- // Regular character typing uses the 500ms dedup so you undo a word at a
1111
- // time, but each Enter should be individually undoable. flushChanges
1112
- // records the (repaired) post-edit content into history before firing
1113
- // onChange, so we don't poison the undo stack with intermediate
1114
- // browser-merged DOM states. Enter also forces a synchronous React
1115
- // state sync (bypassing `preParse`) so newlines render immediately.
1116
- if (!isUndoRedoKey(event)) {
1117
- flushChanges(event.key === 'Enter', event.key === 'Enter');
1118
- } else {
1119
- flushChanges();
1120
- }
1121
- // Chrome Quirk: The contenteditable may lose focus after the first edit or so
1122
- element.focus();
1123
- };
1124
- const onSelect = event => {
1125
- // Chrome Quirk: The contenteditable may lose its selection immediately on first focus
1126
- const hasRange = (window.getSelection()?.rangeCount ?? 0) > 0;
1127
- state.position = hasRange && event.target === element ? getPosition(element) : null;
166
+ activatedRef.current = true;
167
+ configRef.current.onActivate?.();
1128
168
  };
1129
- const onPaste = event => {
1130
- event.preventDefault();
1131
- const clipboard = event.clipboardData;
1132
- if (!clipboard) {
169
+ let cancelled = false;
170
+ // Attach the engine: synchronously from the warm shared cache (a later block
171
+ // on the page, or a test pre-warm), otherwise via the loader. Fail open on a
172
+ // load error — leave the block as read-only plain text rather than crash.
173
+ const load = () => {
174
+ const warmModule = peekEditingEngine();
175
+ if (warmModule) {
176
+ attach(warmModule.createEditableEngine);
1133
177
  return;
1134
178
  }
1135
- state.pendingContent = trackState(true) ?? toString(element);
1136
- edit.insert(clipboard.getData('text/plain'));
1137
- // Paste replaces a chunk of source — flush synchronously so the
1138
- // pasted text highlights on the same commit instead of after a
1139
- // worker round-trip.
1140
- flushChanges(true, true);
1141
- };
1142
-
1143
- // When the editable wraps lines in block-level elements (e.g. `.line`
1144
- // spans separated by literal `\n` gap text nodes), the browser's
1145
- // default HTML→text/plain serializer inserts an implicit newline
1146
- // between each block element on top of the explicit `\n` already
1147
- // present in the DOM, producing duplicated newlines in the
1148
- // clipboard. Override copy/cut to write `Range.toString()` for
1149
- // `text/plain` while still preserving the HTML payload (so pasting
1150
- // into rich-text targets keeps syntax highlighting).
1151
- const onCopyOrCut = event => {
1152
- const selection = window.getSelection();
1153
- if (!selection || selection.rangeCount === 0 || !event.clipboardData) {
1154
- return;
1155
- }
1156
- const range = selection.getRangeAt(0);
1157
- if (range.collapsed || !element.contains(range.commonAncestorContainer)) {
1158
- return;
1159
- }
1160
- event.preventDefault();
1161
- const minColumn = boundsRef.current.minColumn;
1162
- // When the selection starts mid-gutter (e.g. minColumn=4 but the
1163
- // user dragged from column 2), only the gutter portion *inside*
1164
- // the selection should be stripped from the first line. Subsequent
1165
- // lines always start at column 0 of the document, so they get the
1166
- // full `minColumn` budget.
1167
- let firstLineStrip = 0;
1168
- const restStrip = minColumn ?? 0;
1169
- if (minColumn !== undefined && minColumn > 0) {
1170
- const beforeRange = element.ownerDocument.createRange();
1171
- beforeRange.setStart(element, 0);
1172
- beforeRange.setEnd(range.startContainer, range.startOffset);
1173
- const beforeText = beforeRange.toString();
1174
- const lastNewline = beforeText.lastIndexOf('\n');
1175
- const startColumn = beforeText.length - (lastNewline + 1);
1176
- firstLineStrip = Math.max(0, minColumn - startColumn);
1177
- }
1178
-
1179
- // The caret-navigation guard already treats `[0, minColumn)` as a
1180
- // clipped indent gutter. Strip up to that many leading whitespace
1181
- // characters per line from the clipboard so the pasted snippet
1182
- // matches what the user sees rather than including indent that
1183
- // is hidden in the editable.
1184
- const plainText = restStrip > 0 ? stripLeadingPerLine(range.toString(), firstLineStrip, restStrip) : range.toString();
1185
- event.clipboardData.setData('text/plain', plainText);
1186
- const container = cloneRangeWithInlineStyles(element, range, {
1187
- elementStyleProps: CLIPBOARD_ELEMENT_STYLE_PROPS,
1188
- rootStyleProps: CLIPBOARD_ROOT_STYLE_PROPS,
1189
- rootStaticStyles: CLIPBOARD_ROOT_STATIC_STYLES
1190
- });
1191
- if (restStrip > 0) {
1192
- stripLeadingPerLineDom(container, firstLineStrip, restStrip);
1193
- }
1194
- event.clipboardData.setData('text/html', container.outerHTML);
1195
- if (event.type === 'cut') {
1196
- // Mirror the paste path: capture pre-edit state for history, then
1197
- // delete the selection. When `minColumn` clipped the leading
1198
- // gutter whitespace out of the clipboard, re-insert exactly
1199
- // those characters at the selection location so cut stays
1200
- // lossless — the document keeps the hidden indent that the user
1201
- // could not see and never copied.
1202
- state.pendingContent = trackState(true) ?? toString(element);
1203
- const replacement = restStrip > 0 ? extractLeadingPerLine(range.toString(), firstLineStrip, restStrip) : '';
1204
- edit.insert(replacement);
1205
- // Cut also bypasses preParse so the resulting document re-renders
1206
- // synchronously alongside the clipboard write.
1207
- flushChanges(true, true);
1208
- }
179
+ Promise.resolve(loadEditingEngine(loader)).then(mod => {
180
+ if (!cancelled) {
181
+ attach(mod.createEditableEngine);
182
+ }
183
+ }).catch(() => {});
1209
184
  };
185
+ if ((config.activation ?? 'eager') === 'eager') {
186
+ notifyActivated();
187
+ load();
188
+ return () => {
189
+ cancelled = true;
190
+ };
191
+ }
1210
192
 
1211
- // Capture the current caret/selection into `state.position` when the
1212
- // selection lives inside the editable. The `selectstart` listener only
1213
- // fires for newly-initiated selections (typically mouse drags) it
1214
- // does NOT fire for a plain click that places a collapsed caret. Without
1215
- // this capture, a user who clicks into the editable but hasn't typed
1216
- // yet has `state.position === null`, so the unconditional restore in
1217
- // the first `useLayoutEffect` skips and a host re-render (e.g.
1218
- // expanding a collapsed code block) lets the DOM mutation clobber the
1219
- // browser's selection, producing a visible "cursor lost / text
1220
- // selected" jump. Re-using `getPosition` matches what `onSelect` does.
1221
- const capturePosition = () => {
1222
- const hasRange = (window.getSelection()?.rangeCount ?? 0) > 0;
1223
- if (!hasRange) {
1224
- return;
1225
- }
1226
- const selection = window.getSelection();
1227
- const anchorNode = selection?.anchorNode ?? null;
1228
- if (!anchorNode || !element.contains(anchorNode)) {
1229
- return;
1230
- }
1231
- state.position = getPosition(element);
193
+ // 'interaction': defer attaching (and thus `contentEditable`) until the user
194
+ // engages the block, regardless of whether the engine is already cached.
195
+ // Hover (pointerenter) warms the chunk so the eventual commit is instant;
196
+ // focus and pointerdown commit (load + attach).
197
+ const element = elementRef.current;
198
+ const warm = () => {
199
+ notifyActivated();
200
+ preloadEditingEngine(loader).catch(() => {});
1232
201
  };
1233
- const onMouseUp = () => {
1234
- // First lift the caret out of any inter-line gap node so the
1235
- // gutter check below can see a real line position.
1236
- snapCaretOutOfGapNode('forward', false, 0);
1237
- snapCaretOutOfGutter();
1238
- capturePosition();
202
+ const commit = () => {
203
+ notifyActivated();
204
+ load();
1239
205
  };
1240
-
1241
- // Tabbing into the editor places the caret at column 0 of the first
1242
- // line, which lands inside the clipped indent gutter. Browsers set the
1243
- // initial selection asynchronously after `focus`, so defer the snap.
1244
- const onFocus = () => {
1245
- const view = element.ownerDocument.defaultView;
1246
- if (!view) {
1247
- return;
1248
- }
1249
- view.requestAnimationFrame(() => {
1250
- snapCaretOutOfGapNode('forward', false, 0);
1251
- snapCaretOutOfGutter();
1252
- capturePosition();
1253
- });
1254
- };
1255
- document.addEventListener('selectstart', onSelect);
1256
- window.addEventListener('keydown', onKeyDown);
1257
- element.addEventListener('paste', onPaste);
1258
- element.addEventListener('copy', onCopyOrCut);
1259
- element.addEventListener('cut', onCopyOrCut);
1260
- element.addEventListener('keyup', onKeyUp);
1261
- element.addEventListener('mouseup', onMouseUp);
1262
- element.addEventListener('focus', onFocus);
206
+ element.addEventListener('pointerenter', warm);
207
+ element.addEventListener('pointerdown', commit);
208
+ element.addEventListener('focus', commit);
1263
209
  return () => {
1264
- if (state.repeatFlushId !== null) {
1265
- clearTimeout(state.repeatFlushId);
1266
- state.repeatFlushId = null;
1267
- }
1268
- // Abort any in-flight preParse so its eventual `onChange` doesn't
1269
- // fire after the editable has been torn down or toggled disabled.
1270
- if (state.preParseAbort) {
1271
- state.preParseAbort.abort();
1272
- state.preParseAbort = null;
1273
- }
1274
- document.removeEventListener('selectstart', onSelect);
1275
- window.removeEventListener('keydown', onKeyDown);
1276
- element.removeEventListener('paste', onPaste);
1277
- element.removeEventListener('copy', onCopyOrCut);
1278
- element.removeEventListener('cut', onCopyOrCut);
1279
- element.removeEventListener('keyup', onKeyUp);
1280
- element.removeEventListener('mouseup', onMouseUp);
1281
- element.removeEventListener('focus', onFocus);
1282
- element.style.whiteSpace = prevWhiteSpace;
1283
- element.contentEditable = prevContentEditable;
210
+ cancelled = true;
211
+ element.removeEventListener('pointerenter', warm);
212
+ element.removeEventListener('pointerdown', commit);
213
+ element.removeEventListener('focus', commit);
1284
214
  };
215
+ // `config.disabled` drives the re-run once the block becomes editable; the
216
+ // refs the effect reads are stable (and a ref can't be a dependency), so they
217
+ // are intentionally omitted.
1285
218
  // eslint-disable-next-line react-hooks/exhaustive-deps
1286
- }, [elementRef.current, opts?.disabled, opts?.indentation]);
219
+ }, [config.disabled, config.engineLoader, config.activation]);
220
+
221
+ // Per-render observe + caret-restore, delegated to the engine once it exists.
222
+ React.useLayoutEffect(() => {
223
+ if (typeof window === 'undefined' || !engine) {
224
+ return undefined;
225
+ }
226
+ return engine.observeAndRestore();
227
+ });
228
+
229
+ // contentEditable setup + handler binding, delegated to the engine. Re-runs
230
+ // once the engine resolves and on `disabled`/`indentation` changes (the engine
231
+ // re-reads them and the previous cleanup detaches contentEditable first).
232
+ React.useLayoutEffect(() => {
233
+ if (typeof window === 'undefined' || !engine) {
234
+ return undefined;
235
+ }
236
+ return engine.setup();
237
+ }, [engine, config.disabled, config.indentation]);
1287
238
  return edit;
1288
239
  };