@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
@@ -0,0 +1,1712 @@
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 clears the whole hidden indent (caret stays on the line at column 0)
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
42
+
43
+ import * as ReactDOM from 'react-dom';
44
+ import { adjustCursorAtNewlineBoundary, asElement, getCurrentRange, getLineInfo, getOffsetAtLineColumn, getPosition, isPlaintextInputKey, isUndoRedoKey, makeRange, repairUnexpectedLineMerge, restoreSelection, setCurrentRange, toString } from "./useEditableUtils.mjs";
45
+ import { cloneRangeWithInlineStyles } from "./cloneRangeWithInlineStyles.mjs";
46
+ import { extractLeadingPerLine, stripLeadingPerLine, stripLeadingPerLineDom } from "./stripLeadingPerLine.mjs";
47
+ const observerSettings = {
48
+ characterData: true,
49
+ characterDataOldValue: true,
50
+ childList: true,
51
+ subtree: true
52
+ };
53
+
54
+ // Cross-instance batching for the `getComputedStyle` read + conditional
55
+ // inline-style writes that happen during each editable's setup.
56
+ //
57
+ // Pages like the Material UI component docs render ~30 demos at once.
58
+ // The previous implementation interleaved a write (the layout effect's
59
+ // own `element.style.whiteSpace = ...` / `tabSize` settings, plus the
60
+ // implicit invalidation from the preceding `contentEditable` write)
61
+ // with a read (`getComputedStyle(element).whiteSpace`) inside each
62
+ // instance's effect. That forced the browser to flush a fresh style
63
+ // recalc on every iteration — 30 recalcs in a row during a single
64
+ // commit.
65
+ //
66
+ // By queuing each instance's read+write block into a single microtask
67
+ // we run all of the reads (which share one recalc) followed by all the
68
+ // writes, instead of interleaving them with the other instances'.
69
+ //
70
+ // `contentEditable` itself is still set synchronously inside the layout
71
+ // effect: the keyboard/paste/focus handlers bound in the same effect
72
+ // assume the host element is already editable when the commit returns,
73
+ // so any input that lands in the same frame as the mount (autofocus,
74
+ // programmatic focus, a queued keystroke) is routed through the
75
+ // plaintext-only path instead of falling back to native contenteditable
76
+ // behavior.
77
+ //
78
+ // The cleanup-side restore (`whiteSpace` + `contentEditable` back to
79
+ // their pre-mount values) runs synchronously inside the layout-effect
80
+ // teardown, gated by `element.isConnected` so detached hosts skip the
81
+ // write. The in-flight mount-side microtask is cancelled via
82
+ // `styleSetupCancelled` so there's no race.
83
+ let pendingEditableStyleTasks = null;
84
+ function scheduleEditableStyleTask(task) {
85
+ if (pendingEditableStyleTasks === null) {
86
+ pendingEditableStyleTasks = [task];
87
+ queueMicrotask(() => {
88
+ const tasks = pendingEditableStyleTasks;
89
+ pendingEditableStyleTasks = null;
90
+ for (let i = 0; i < tasks.length; i += 1) {
91
+ tasks[i]();
92
+ }
93
+ });
94
+ } else {
95
+ pendingEditableStyleTasks.push(task);
96
+ }
97
+ }
98
+
99
+ // Computed-style properties inlined onto each element in the copied
100
+ // HTML fragment so external paste targets render with the same syntax
101
+ // highlighting without needing our stylesheet.
102
+ const CLIPBOARD_ELEMENT_STYLE_PROPS = ['color', 'background-color', 'font-weight', 'font-style', 'text-decoration'];
103
+
104
+ // Properties inlined onto the wrapper so the pasted block keeps the
105
+ // editable's typography even if only a descendant was selected.
106
+ const CLIPBOARD_ROOT_STYLE_PROPS = ['font-family', 'font-size', 'line-height', 'white-space', 'background-color', 'color'];
107
+
108
+ // A small amount of padding + rounded corners gives the pasted snippet
109
+ // a card-like appearance in rich-text targets without overriding the
110
+ // background or font that consumers already control via the editable's
111
+ // own styles.
112
+ const CLIPBOARD_ROOT_STATIC_STYLES = 'padding:1em;border-radius:0.5em;';
113
+
114
+ /**
115
+ * Everything {@link createEditableEngine} needs from its host hook. `useEditable`
116
+ * owns this state and these refs so they survive this module's lazy load; the
117
+ * engine only reads and mutates them, and they are shared by reference so the
118
+ * engine's handlers always observe live values.
119
+ */
120
+
121
+ /**
122
+ * The heavy editing runtime bound to a host element. `setup` applies
123
+ * `contentEditable` and binds the keyboard/paste/caret handlers; `observeAndRestore`
124
+ * runs the per-render MutationObserver + caret-restore pass. Each returns its cleanup.
125
+ */
126
+
127
+ /**
128
+ * Resolves the editing engine factory. `CodeProvider` supplies one via context
129
+ * (eager → bundled, resolves instantly; lazy → dynamic `import()`); `useEditable`
130
+ * also has a built-in fallback so editing works without a provider.
131
+ */
132
+
133
+ /**
134
+ * Builds the editing engine for a host element. This module statically imports
135
+ * the heavy editing utilities (`useEditableUtils`, `cloneRangeWithInlineStyles`,
136
+ * `stripLeadingPerLine`) and `react-dom`, so the bundler emits it as a separate
137
+ * chunk that `useEditable` loads on demand — read-only code blocks never pull it in.
138
+ */
139
+ export const createEditableEngine = ctx => {
140
+ const {
141
+ elementRef,
142
+ state,
143
+ observerRef,
144
+ boundsRef,
145
+ configRef,
146
+ unblock
147
+ } = ctx;
148
+
149
+ // MutationObserver is created lazily here (not in the host hook) so code
150
+ // blocks that never activate editing never allocate one. The host owns the
151
+ // ref; the engine fills it on first construction.
152
+ if (observerRef.current === null && typeof MutationObserver !== 'undefined') {
153
+ observerRef.current = new MutationObserver(batch => {
154
+ state.queue.push(...batch);
155
+ });
156
+ }
157
+ const edit = {
158
+ update(content) {
159
+ const {
160
+ current: element
161
+ } = elementRef;
162
+ if (element) {
163
+ const position = getPosition(element);
164
+ const prevContent = toString(element);
165
+ position.position += content.length - prevContent.length;
166
+ state.position = position;
167
+ state.onChange(content, position);
168
+ }
169
+ },
170
+ insert(append, deleteOffset) {
171
+ const {
172
+ current: element
173
+ } = elementRef;
174
+ if (element) {
175
+ let range = getCurrentRange();
176
+ range.deleteContents();
177
+ range.collapse();
178
+ const position = getPosition(element);
179
+ const offset = deleteOffset || 0;
180
+ const start = position.position + (offset < 0 ? offset : 0);
181
+ const end = position.position + (offset > 0 ? offset : 0);
182
+ range = makeRange(element, start, end);
183
+ adjustCursorAtNewlineBoundary(range);
184
+ range.deleteContents();
185
+ if (append) {
186
+ range.insertNode(document.createTextNode(append));
187
+ }
188
+ const cursorRange = makeRange(element, start + append.length);
189
+ adjustCursorAtNewlineBoundary(cursorRange);
190
+ setCurrentRange(cursorRange);
191
+ }
192
+ },
193
+ move(pos) {
194
+ const {
195
+ current: element
196
+ } = elementRef;
197
+ if (element) {
198
+ element.focus();
199
+ const position = typeof pos === 'number' ? pos : getOffsetAtLineColumn(element, pos.row, pos.column);
200
+ const cursorRange = makeRange(element, position);
201
+ adjustCursorAtNewlineBoundary(cursorRange);
202
+ setCurrentRange(cursorRange);
203
+ }
204
+ },
205
+ getState() {
206
+ const element = elementRef.current;
207
+ if (!element) {
208
+ // Pre-mount / unmounted: return an empty snapshot so callers
209
+ // that subscribe before the ref is attached get a stable shape.
210
+ return {
211
+ text: '',
212
+ position: {
213
+ position: 0,
214
+ extent: 0,
215
+ content: '',
216
+ line: 0
217
+ }
218
+ };
219
+ }
220
+ return {
221
+ text: toString(element),
222
+ position: getPosition(element)
223
+ };
224
+ }
225
+ };
226
+
227
+ // Per-render observe + caret-restore + external-swap snapshot. The host hook
228
+ // calls this from a layout effect on every render once the engine exists.
229
+ const observeAndRestore = () => {
230
+ // Only for SSR / server-side logic
231
+ // typeof navigator check fails on Node.js 21+ which exposes navigator.userAgent;
232
+ // typeof window is the standard isomorphic SSR guard.
233
+ if (typeof window === 'undefined') {
234
+ return undefined;
235
+ }
236
+ const config = configRef.current;
237
+ if (!elementRef.current || config.disabled) {
238
+ return undefined;
239
+ }
240
+
241
+ // Detect content swaps that happen outside the keystroke pipeline (e.g.
242
+ // a host calling `setSource(...)` from a Reset button or React state
243
+ // change) and snapshot them into the undo stack so the user can Ctrl+Z
244
+ // back to their prior text. We skip this on the post-flush re-render
245
+ // (`state.disconnected === true`): in that case `flushChanges` has just
246
+ // recorded the new content via `trackState`, so re-reading the DOM
247
+ // would only re-confirm what we already know — wasting an O(N) walk
248
+ // on every keystroke. We also skip while a user edit is in flight
249
+ // (`pendingContent !== null`) so we don't race with the imminent
250
+ // flush. Finally, we only push when there's already a recorded entry
251
+ // that the new content differs from — the initial-baseline capture
252
+ // before the very first user edit is left to `trackState`'s keydown
253
+ // path so we don't double-record (and inadvertently arm its 500ms
254
+ // dedup timestamp before flushChanges gets a chance to record the
255
+ // post-edit state).
256
+ if (!state.disconnected && state.pendingContent === null && state.history.length > 0) {
257
+ // Detect host-driven content swaps (e.g. a `setSource(...)` from a
258
+ // Reset button or an external React state change) and snapshot
259
+ // them into the undo stack so the user can Ctrl+Z back to their
260
+ // prior text. We compare the live DOM against
261
+ // `state.lastCommittedContent` — the content of the most recent
262
+ // `onChange` call. After a normal commit, React's reconciliation
263
+ // produces a DOM whose `toString()` matches `lastCommittedContent`
264
+ // exactly, so the comparison is a cheap no-op. After an external
265
+ // swap they differ and we record the new entry.
266
+ //
267
+ // We deliberately do NOT use the MutationObserver record queue as
268
+ // a gate here: React's own reconciliation between renders fires
269
+ // records too, and pushing those into `state.queue` would cause
270
+ // `commit()` to revert React's DOM patches on the next keystroke.
271
+ // The observer's per-render `disconnect()` (in the cleanup below)
272
+ // drops those records on the floor by design.
273
+ const lastCommitted = state.lastCommittedContent;
274
+ if (lastCommitted !== null) {
275
+ const currentContent = toString(elementRef.current);
276
+ if (currentContent !== lastCommitted) {
277
+ const lastEntry = state.history[state.historyAt];
278
+ // Recover edits the 500ms dedup kept out of `history`. Without
279
+ // this, a user who typed within the dedup window then
280
+ // triggered an external swap would lose those keystrokes
281
+ // entirely on undo: history holds only the pre-typing
282
+ // checkpoint, so Ctrl+Z would jump straight past the user's
283
+ // most recent state.
284
+ if (lastEntry && lastCommitted !== lastEntry[1]) {
285
+ state.historyAt += 1;
286
+ const at = state.historyAt;
287
+ state.history[at] = [state.position ?? lastEntry[0], lastCommitted];
288
+ state.history.splice(at + 1);
289
+ if (at > 500) {
290
+ state.historyAt -= 1;
291
+ state.history.shift();
292
+ }
293
+ }
294
+ const lastEntryAfter = state.history[state.historyAt];
295
+ state.historyAt += 1;
296
+ const at = state.historyAt;
297
+ state.history[at] = [lastEntryAfter ? lastEntryAfter[0] : state.position ?? {
298
+ position: 0,
299
+ extent: 0,
300
+ content: '',
301
+ line: 0
302
+ }, currentContent];
303
+ state.history.splice(at + 1);
304
+ if (at > 500) {
305
+ state.historyAt -= 1;
306
+ state.history.shift();
307
+ }
308
+ state.lastCommittedContent = currentContent;
309
+ }
310
+ }
311
+ }
312
+ state.disconnected = false;
313
+ observerRef.current?.observe(elementRef.current, observerSettings);
314
+ // Skip restoring the cursor while a key is held down. The debounced
315
+ // flushChanges hasn't run yet so state.position is stale; restoring it
316
+ // here would jump the cursor back on every incidental re-render (e.g.
317
+ // from an async enhancer setState). edit.insert() already placed the
318
+ // cursor correctly in the DOM — leave it there until the debounce fires.
319
+ //
320
+ // Also skip on the render right after an arrow-key boundary callback
321
+ // (see `state.skipNextRestore`): the native arrow movement hasn't
322
+ // applied yet, so `state.position` is the pre-arrow location and
323
+ // restoring it would visibly snap the caret back upward/downward.
324
+ if (state.skipNextRestore) {
325
+ state.skipNextRestore = false;
326
+ } else if (state.position && state.repeatFlushId === null) {
327
+ restoreSelection(elementRef.current, state.position);
328
+ }
329
+ return () => {
330
+ // Drain the observer's pending record queue into a single dirty
331
+ // bit BEFORE disconnecting. `disconnect()` per spec drops the
332
+ // queue, which would otherwise hide an external DOM swap that
333
+ // happened between this render's commit and the next render's
334
+ // snapshot block. We deliberately do NOT push the records into
335
+ // `state.queue`: React's own reconciliation mutations land here
336
+ // too, and `commit()` on the next keystroke would revert them,
337
+ // corrupting the rendered DOM. The boolean is a pure gating
338
+ // signal — the snapshot block does its own `toString` comparison
339
+ // against `lastCommittedContent` to decide whether the change was
340
+ // a real swap or just React reconciling to the committed content.
341
+ const pending = observerRef.current?.takeRecords();
342
+ if (pending && pending.length > 0) {
343
+ state.domDirty = true;
344
+ }
345
+ observerRef.current?.disconnect();
346
+ };
347
+ };
348
+
349
+ // Applies contentEditable and binds the keyboard/paste/caret handlers. The
350
+ // host hook calls this from a layout effect; it re-runs when the element,
351
+ // `disabled`, or `indentation` change (matching the prior effect deps).
352
+ const setup = () => {
353
+ if (typeof window === 'undefined') {
354
+ return undefined;
355
+ }
356
+ const config = configRef.current;
357
+ if (!elementRef.current || config.disabled) {
358
+ state.history.length = 0;
359
+ state.historyAt = -1;
360
+ return undefined;
361
+ }
362
+ const element = elementRef.current;
363
+ if (!element) {
364
+ return undefined;
365
+ }
366
+ if (state.position) {
367
+ element.focus();
368
+ restoreSelection(element, state.position);
369
+ }
370
+ const prevWhiteSpace = element.style.whiteSpace;
371
+ const prevContentEditable = element.contentEditable;
372
+ let hasPlaintextSupport = true;
373
+ try {
374
+ // Firefox and IE11 do not support plaintext-only mode
375
+ element.contentEditable = 'plaintext-only';
376
+ } catch (_error) {
377
+ element.contentEditable = 'true';
378
+ hasPlaintextSupport = false;
379
+ }
380
+
381
+ // Defer the `getComputedStyle` read + conditional inline-style
382
+ // writes into a module-level microtask so all editables on the page
383
+ // share a single style recalc instead of forcing one per instance.
384
+ // `styleSetupCancelled` shorts the task out if cleanup runs before
385
+ // the microtask fires (e.g. an unmount in the same tick as commit).
386
+ let styleSetupCancelled = false;
387
+ scheduleEditableStyleTask(() => {
388
+ if (styleSetupCancelled) {
389
+ return;
390
+ }
391
+ // Only set inline styles when the computed style isn't already
392
+ // suitable. This lets consumers control these properties via CSS
393
+ // (e.g. a `pre` selector) without us clobbering their values with
394
+ // inline styles that win specificity.
395
+ const computed = element.ownerDocument.defaultView?.getComputedStyle(element);
396
+ const computedWhiteSpace = computed?.whiteSpace ?? '';
397
+ // Any whitespace-preserving value works for an editable surface.
398
+ // `pre-line` is intentionally excluded because it collapses runs
399
+ // of spaces, which would corrupt indentation.
400
+ const whiteSpaceIsPreserving = computedWhiteSpace === 'pre' || computedWhiteSpace === 'pre-wrap' || computedWhiteSpace === 'break-spaces';
401
+ if (!whiteSpaceIsPreserving) {
402
+ element.style.whiteSpace = 'pre-wrap';
403
+ }
404
+ if (config.indentation) {
405
+ const tabSizeValue = `${config.indentation}`;
406
+ if (computed?.tabSize !== tabSizeValue) {
407
+ element.style.setProperty('-moz-tab-size', tabSizeValue);
408
+ element.style.tabSize = tabSizeValue;
409
+ }
410
+ }
411
+ });
412
+ const indentPattern = `${' '.repeat(config.indentation || 0)}`;
413
+ const indentRe = new RegExp(`^(?:${indentPattern})`);
414
+ const blanklineRe = new RegExp(`^(?:${indentPattern})*(${indentPattern})$`);
415
+ let trackStateTimestamp;
416
+ const trackState = (ignoreTimestamp, contentOverride, positionOverride) => {
417
+ // Require a live selection so getPosition() (which calls getRangeAt(0)) is safe.
418
+ // Using !state.position would block recording the initial state: state.position is
419
+ // only set by flushChanges() which runs on keyup — after the first edit. Switching
420
+ // to rangeCount === 0 lets the very first keydown snapshot the pre-edit content.
421
+ if (!elementRef.current || (window.getSelection()?.rangeCount ?? 0) === 0) {
422
+ return null;
423
+ }
424
+
425
+ // Callers may pass in already-computed (and possibly repaired) content so
426
+ // we don't re-read a buggy intermediate DOM. flushChanges uses this to
427
+ // record the repaired post-edit state instead of the merged DOM that
428
+ // Firefox/observer left behind.
429
+ const content = contentOverride ?? toString(element);
430
+ const position = positionOverride ?? getPosition(element);
431
+ const timestamp = new Date().valueOf();
432
+
433
+ // Prevent recording new state in list if last one has been new enough
434
+ const lastEntry = state.history[state.historyAt];
435
+ if (!ignoreTimestamp && timestamp - trackStateTimestamp < 500 || lastEntry && lastEntry[1] === content) {
436
+ trackStateTimestamp = timestamp;
437
+ return content;
438
+ }
439
+ state.historyAt += 1;
440
+ const at = state.historyAt;
441
+ state.history[at] = [position, content];
442
+ state.history.splice(at + 1);
443
+ if (at > 500) {
444
+ state.historyAt -= 1;
445
+ state.history.shift();
446
+ }
447
+ return content;
448
+ };
449
+ const disconnect = () => {
450
+ observerRef.current?.disconnect();
451
+ state.disconnected = true;
452
+ };
453
+ const flushChanges = (ignoreTimestamp, bypassPreParse, positionFlags) => {
454
+ const records = observerRef.current?.takeRecords() ?? [];
455
+ state.queue.push(...records);
456
+ const position = getPosition(element);
457
+ // Caller-supplied metadata that the post-edit caret can't carry on its own
458
+ // (e.g. that a selection delete started at column 0). Rides on the reported
459
+ // position into `onChange`/history so derived state and undo can use it.
460
+ if (positionFlags) {
461
+ Object.assign(position, positionFlags);
462
+ }
463
+ if (state.queue.length) {
464
+ // We DO NOT revert the queued mutations yet — letting them stay in
465
+ // the live DOM means the user's keystroke remains visible while
466
+ // `preParse` runs. The mutation queue is held until commit (below)
467
+ // so when React eventually re-renders the highlighted content, it
468
+ // first sees its expected previous DOM.
469
+ const content = repairUnexpectedLineMerge(toString(element), state.pendingContent, position);
470
+ state.position = position;
471
+
472
+ // Record the REPAIRED content into history before notifying the app.
473
+ // Reading toString() back from the DOM here would capture the buggy
474
+ // pre-repair state (e.g. a Firefox line-merge), which is what was
475
+ // previously polluting the undo stack.
476
+ trackState(ignoreTimestamp, content, position);
477
+
478
+ // Snapshot the queue length representing mutations that belong to
479
+ // THIS flush. Anything appended past this index by the time
480
+ // `commit` runs is a straggler — a newer keystroke whose own
481
+ // keyup-triggered `flushChanges` will produce a fresher commit. In
482
+ // that case we must NOT revert the stragglers (or we'd lose the
483
+ // user's character) and we must NOT call `onChange` with our now
484
+ // stale `content` (or we'd briefly render the older state on top
485
+ // of the newer DOM).
486
+ const queueLengthAtFlush = state.queue.length;
487
+
488
+ // Commit phase: revert the queued mutations and hand control to
489
+ // React. The revert + React commit are bundled into a single task
490
+ // via `flushSync` so the browser cannot paint the briefly-reverted
491
+ // DOM between the two — the user's keystroke stays continuously on
492
+ // screen, transitioning directly from "raw mutation" to
493
+ // "highlighted React render".
494
+ const commit = preParseResult => {
495
+ // Drain anything pending in the observer first so we have an
496
+ // accurate count of stragglers (mutations made after this
497
+ // flush started). The observer stays connected during the
498
+ // `preParse` await so additional keystrokes ARE captured but
499
+ // are NOT blocked by the `state.disconnected` guard in
500
+ // `onKeyDown`.
501
+ const stragglers = observerRef.current?.takeRecords() ?? [];
502
+ state.queue.push(...stragglers);
503
+ if (state.queue.length > queueLengthAtFlush) {
504
+ // A newer keystroke landed in the DOM after this flush
505
+ // started. Drop this commit on the floor — the straggler's
506
+ // own `flushChanges` (already running, or about to run on
507
+ // its keyup) will produce a fresher commit that reverts the
508
+ // entire combined mutation set and reports the up-to-date
509
+ // content. Leaving the observer connected and
510
+ // `state.disconnected` false lets onKeyDown keep accepting
511
+ // input in the meantime.
512
+ return;
513
+ }
514
+ disconnect();
515
+ while (state.queue.length > 0) {
516
+ const mutation = state.queue.pop();
517
+ if (!mutation) {
518
+ break;
519
+ }
520
+ if (mutation.oldValue !== null) {
521
+ mutation.target.textContent = mutation.oldValue;
522
+ }
523
+ for (let i = mutation.removedNodes.length - 1; i >= 0; i -= 1) {
524
+ mutation.target.insertBefore(mutation.removedNodes[i], mutation.nextSibling);
525
+ }
526
+ for (let i = mutation.addedNodes.length - 1; i >= 0; i -= 1) {
527
+ if (mutation.addedNodes[i].parentNode) {
528
+ mutation.target.removeChild(mutation.addedNodes[i]);
529
+ }
530
+ }
531
+ }
532
+ ReactDOM.flushSync(() => {
533
+ state.lastCommittedContent = content;
534
+ if (preParseResult === undefined) {
535
+ // Preserve the historical (text, position) calling convention
536
+ // for the sync / bypass path so consumers can distinguish a
537
+ // preParse-result-less commit from one whose result happened
538
+ // to be `undefined`.
539
+ state.onChange(content, position);
540
+ } else {
541
+ state.onChange(content, position, preParseResult);
542
+ }
543
+ });
544
+ };
545
+ const {
546
+ preParse
547
+ } = boundsRef.current;
548
+ if (preParse && !bypassPreParse) {
549
+ // Abort any prior in-flight preParse — only the most recent
550
+ // keystroke's parse result is worth waiting for.
551
+ if (state.preParseAbort) {
552
+ state.preParseAbort.abort();
553
+ }
554
+ const controller = new AbortController();
555
+ state.preParseAbort = controller;
556
+ const {
557
+ signal
558
+ } = controller;
559
+ preParse(content, position, signal).then(result => {
560
+ if (signal.aborted) {
561
+ return;
562
+ }
563
+ if (state.preParseAbort === controller) {
564
+ state.preParseAbort = null;
565
+ }
566
+ commit(result);
567
+ }, () => {
568
+ if (state.preParseAbort === controller) {
569
+ state.preParseAbort = null;
570
+ }
571
+ if (signal.aborted) {
572
+ // Aborted by a newer keystroke — drop silently. The
573
+ // queued mutations stay in place until the superseding
574
+ // flush commits them.
575
+ return;
576
+ }
577
+ // Real parse failure (e.g. unknown grammar, worker error).
578
+ // Fall back to committing without a preParseResult so the
579
+ // source still propagates to onChange — matching the
580
+ // historical sync path's fail-open behavior. Without this,
581
+ // the DOM would show the user's typed text while controlled
582
+ // state stayed stale, and the next render would revert it.
583
+ commit();
584
+ });
585
+ } else {
586
+ // Structural / synchronous edit — bypass preParse so the React
587
+ // state sync happens on the same commit as the DOM change.
588
+ if (state.preParseAbort) {
589
+ state.preParseAbort.abort();
590
+ state.preParseAbort = null;
591
+ }
592
+ commit();
593
+ }
594
+ }
595
+ state.pendingContent = null;
596
+ };
597
+
598
+ // Snap a collapsed caret out of an inter-line gap text node (e.g. the
599
+ // literal `\n` between `.line` spans) onto the nearest `.line` in
600
+ // `direction`. Used by both the post-arrow rAF and the pointer
601
+ // handlers — clicks can land in gap nodes too. When `isVertical`, the
602
+ // caret lands at `preferredColumn` of the target line (clamped);
603
+ // otherwise it lands at the start (forward) or end (backward).
604
+ // Returns `true` when a snap was applied.
605
+ const snapCaretOutOfGapNode = (direction, isVertical, preferredColumn) => {
606
+ const {
607
+ caretSelector
608
+ } = boundsRef.current;
609
+ if (caretSelector === undefined) {
610
+ return false;
611
+ }
612
+ const sel = element.ownerDocument.defaultView?.getSelection();
613
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
614
+ return false;
615
+ }
616
+ const snapRange = sel.getRangeAt(0);
617
+ if (!element.contains(snapRange.startContainer)) {
618
+ return false;
619
+ }
620
+ const startContainer = snapRange.startContainer;
621
+ const startElement = asElement(startContainer) ?? startContainer.parentElement;
622
+ // Caret is already inside a `.line` (or equivalent) — no snap needed.
623
+ if (startElement?.closest(caretSelector)) {
624
+ return false;
625
+ }
626
+ const lineEls = Array.from(element.querySelectorAll(caretSelector));
627
+ if (lineEls.length === 0) {
628
+ return false;
629
+ }
630
+ // Use document position to pick the right neighbour.
631
+ let target = null;
632
+ if (direction === 'forward') {
633
+ for (let i = 0; i < lineEls.length; i += 1) {
634
+ const r = element.ownerDocument.createRange();
635
+ r.selectNode(lineEls[i]);
636
+ // cmp < 0 means the caret is before this line.
637
+ if (snapRange.compareBoundaryPoints(Range.START_TO_START, r) < 0) {
638
+ target = lineEls[i];
639
+ break;
640
+ }
641
+ }
642
+ // No line ahead — caret has landed past the last line. Snap back
643
+ // to the last line so the caret stays inside an editable row.
644
+ if (!target) {
645
+ target = lineEls[lineEls.length - 1];
646
+ }
647
+ } else {
648
+ for (let i = lineEls.length - 1; i >= 0; i -= 1) {
649
+ const r = element.ownerDocument.createRange();
650
+ r.selectNode(lineEls[i]);
651
+ // cmp > 0 means the caret is after this line.
652
+ if (snapRange.compareBoundaryPoints(Range.END_TO_END, r) > 0) {
653
+ target = lineEls[i];
654
+ break;
655
+ }
656
+ }
657
+ // No line behind — caret has landed before the first line.
658
+ if (!target) {
659
+ target = lineEls[0];
660
+ }
661
+ }
662
+ if (!target) {
663
+ return false;
664
+ }
665
+ const newRange = element.ownerDocument.createRange();
666
+ if (isVertical) {
667
+ // Walk the target line's text nodes to find the offset that
668
+ // matches `preferredColumn`, clamping to the line length.
669
+ const targetText = target.textContent ?? '';
670
+ const targetColumn = Math.min(preferredColumn, targetText.length);
671
+ let remaining = targetColumn;
672
+ const walker = element.ownerDocument.createTreeWalker(target, NodeFilter.SHOW_TEXT);
673
+ let placed = false;
674
+ let node = walker.nextNode();
675
+ while (node) {
676
+ const len = node.textContent?.length ?? 0;
677
+ if (remaining <= len) {
678
+ newRange.setStart(node, remaining);
679
+ newRange.collapse(true);
680
+ placed = true;
681
+ break;
682
+ }
683
+ remaining -= len;
684
+ node = walker.nextNode();
685
+ }
686
+ if (!placed) {
687
+ newRange.selectNodeContents(target);
688
+ newRange.collapse(false);
689
+ }
690
+ } else if (direction === 'forward') {
691
+ newRange.selectNodeContents(target);
692
+ newRange.collapse(true);
693
+ } else {
694
+ newRange.selectNodeContents(target);
695
+ newRange.collapse(false);
696
+ }
697
+ sel.removeAllRanges();
698
+ sel.addRange(newRange);
699
+ return true;
700
+ };
701
+
702
+ // Snap a collapsed caret out of the clipped indent gutter (`[0, minColumn)`)
703
+ // when the user clicks there. The arrow-key handler already prevents
704
+ // landing inside the gutter via keyboard navigation; this covers
705
+ // pointer-driven clicks. Range selections are left alone — clamping the
706
+ // anchor of a drag would feel surprising mid-gesture.
707
+ const snapCaretOutOfGutter = () => {
708
+ const {
709
+ minColumn
710
+ } = boundsRef.current;
711
+ if (minColumn === undefined || minColumn <= 0) {
712
+ return;
713
+ }
714
+ const sel = element.ownerDocument.defaultView?.getSelection();
715
+ if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
716
+ return;
717
+ }
718
+ const range = sel.getRangeAt(0);
719
+ if (!element.contains(range.startContainer)) {
720
+ return;
721
+ }
722
+ const position = getPosition(element);
723
+ if (position.content.length >= minColumn) {
724
+ return;
725
+ }
726
+ // Only snap when the gutter is actually whitespace — otherwise the
727
+ // line is shorter than `minColumn` and there's nowhere to snap to.
728
+ // `getLineInfo` walks just enough text nodes to read the current
729
+ // line; avoids materializing the full document text on every click.
730
+ const lineText = getLineInfo(element, position.line).currentLine;
731
+ if (lineText.length < minColumn || !/^\s*$/.test(lineText.slice(0, minColumn))) {
732
+ return;
733
+ }
734
+ edit.move({
735
+ row: position.line,
736
+ column: minColumn
737
+ });
738
+ };
739
+
740
+ // The most recent non-empty `caretSelector`. The host may briefly drop it
741
+ // (e.g. `shouldHighlight` flips false while the post-edit re-highlight is in
742
+ // flight), but the rendered `.line` structure persists across that window,
743
+ // so we latch the selector to keep framed-line handling stable mid-edit.
744
+ let latchedCaretSelector = boundsRef.current.caretSelector;
745
+
746
+ // True when this is a framed (`caretSelector`) editor — i.e. the content is
747
+ // rendered as `.line` spans inside `.frame` wrappers separated by inter-line
748
+ // gap text nodes. Native plaintext-only typing at a `.line`/gap boundary
749
+ // lands the character in the `.frame` wrapper instead, flattening the line
750
+ // spans and splitting input across rows (which then strands the caret at the
751
+ // line start on Backspace). Routing every printable key through the
752
+ // controlled `edit.insert` keeps the character inside its line span. We key
753
+ // off the *latched* selector (not the live caret position) because the caret
754
+ // can momentarily sit in a gap node mid-edit and the host briefly drops
755
+ // `caretSelector` while a post-edit re-highlight is in flight.
756
+ const framedEditorActive = () => {
757
+ const configured = boundsRef.current.caretSelector;
758
+ if (configured !== undefined) {
759
+ latchedCaretSelector = configured;
760
+ }
761
+ return latchedCaretSelector !== undefined;
762
+ };
763
+ const onKeyDown = event => {
764
+ if (event.defaultPrevented || event.target !== element) {
765
+ return;
766
+ }
767
+ if (state.disconnected) {
768
+ // React Quirk: between flushChanges() (which calls disconnect() and
769
+ // rewinds the DOM back to the pre-edit content) and React's commit
770
+ // (which re-observes via useLayoutEffect and restores state.position),
771
+ // an event can fire that we'd otherwise mishandle.
772
+ //
773
+ // For NAVIGATION keys (arrows) the DOM revert is irrelevant — the
774
+ // browser only needs a valid caret position to compute the next
775
+ // selection — so resync inline (restore caret + re-observe) and let
776
+ // the event proceed. Otherwise the keystroke would be eaten and the
777
+ // user would lose, for example, an ArrowUp step after Enter inside
778
+ // a focus frame. We deliberately do NOT include Home/End/PageUp/
779
+ // PageDown here: they would also need to compensate for the pending
780
+ // rerender (matching the arrow-key skip-next-restore handling) and
781
+ // currently lack that coverage, so keep them on the safe path.
782
+ //
783
+ // For EDITING keys (printable text, Enter, Tab, Backspace, Delete,
784
+ // …) we must NOT fall through: the live DOM is the reverted
785
+ // pre-edit snapshot, so applying a second edit on top would target
786
+ // the wrong text and corrupt content. Keep the original block-and-
787
+ // unblock behavior for those keys — React will commit the queued
788
+ // onChange momentarily and the user can re-issue the keystroke.
789
+ const isArrowKey = event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'ArrowUp' || event.key === 'ArrowDown';
790
+ if (!isArrowKey) {
791
+ event.preventDefault();
792
+ unblock([]);
793
+ return;
794
+ }
795
+ if (state.position && state.repeatFlushId === null) {
796
+ restoreSelection(element, state.position);
797
+ }
798
+ observerRef.current?.observe(element, observerSettings);
799
+ state.disconnected = false;
800
+ // The `unblock([])` below schedules a React rerender. If that
801
+ // rerender's restore effect runs before the native arrow movement
802
+ // has updated `state.position` (which happens asynchronously via
803
+ // `selectionchange`), the restore would snap the caret back to the
804
+ // stale pre-arrow position. In practice `selectionchange` usually
805
+ // fires first so the restore is a no-op, but arming the skip flag
806
+ // makes the fast path race-free regardless of scheduling. The
807
+ // boundary-movement branches arm the same flag for the same reason.
808
+ state.skipNextRestore = true;
809
+ unblock([]);
810
+ // Fall through and let this arrow event be handled normally
811
+ // with the restored caret position.
812
+ }
813
+ if (isUndoRedoKey(event)) {
814
+ event.preventDefault();
815
+ let history;
816
+ // The state we are leaving — its position is the POST-edit caret of the
817
+ // edit being undone, which the host needs as the reversal pivot (it can
818
+ // differ from the destination's PRE-edit caret after a selection edit).
819
+ let leavingPosition;
820
+ if (!event.shiftKey) {
821
+ const leavingAt = state.historyAt;
822
+ state.historyAt -= 1;
823
+ const at = state.historyAt;
824
+ history = state.history[at];
825
+ if (!history) {
826
+ state.historyAt = 0;
827
+ } else {
828
+ leavingPosition = state.history[leavingAt]?.[0];
829
+ }
830
+ } else {
831
+ state.historyAt += 1;
832
+ const at = state.historyAt;
833
+ history = state.history[at];
834
+ if (!history) {
835
+ state.historyAt = state.history.length - 1;
836
+ }
837
+ }
838
+ if (history) {
839
+ disconnect();
840
+ state.position = history[0];
841
+ state.lastCommittedContent = history[1];
842
+ // Tag the reported position with the navigation direction so the host
843
+ // can reverse the edit's derived state (e.g. the comment/highlight map)
844
+ // relative to this PRE-edit caret instead of assuming a forward-edit
845
+ // (post-edit) caret. On undo, also pass the reversed edit's anchor line
846
+ // (the leaving state's caret) so the reversal pivots on the same line
847
+ // the forward edit did — they diverge after a selection edit (e.g.
848
+ // Select All). A fresh object keeps the stored history entry clean for
849
+ // re-navigation.
850
+ state.onChange(history[1], {
851
+ ...history[0],
852
+ history: event.shiftKey ? 'redo' : 'undo',
853
+ ...(leavingPosition ? {
854
+ historyPivotLine: leavingPosition.line,
855
+ // Carry the reversed edit's column-0 flag so the reversal drops
856
+ // its anchor by the same line the forward edit did, keeping the
857
+ // collapseMap keys aligned across delete↔undo.
858
+ deletedFromLineStart: leavingPosition.deletedFromLineStart
859
+ } : {})
860
+ });
861
+ }
862
+ return;
863
+ }
864
+
865
+ // Only capture the pre-edit snapshot when no edit is currently pending
866
+ // (i.e. the previous keystroke has already been flushed on keyup).
867
+ // Overwriting pendingContent on a rapid second keydown — whether the
868
+ // same key repeating OR a different key pressed before the first
869
+ // keyup — would lose the baseline that repairUnexpectedLineMerge
870
+ // needs to detect Firefox's line-merge quirk. The DOM may already
871
+ // contain a merged state when the second keydown fires; treating that
872
+ // as "previous" content makes the line-loss invisible.
873
+ if (state.pendingContent === null) {
874
+ state.pendingContent = trackState() ?? toString(element);
875
+ }
876
+ if (event.key === 'Enter') {
877
+ event.preventDefault();
878
+ // Firefox Quirk: Since plaintext-only is unsupported we must
879
+ // ensure that only newline characters are inserted
880
+ const position = getPosition(element);
881
+ // We also get the current line and preserve indentation for the next
882
+ // line that's created
883
+ const match = /\S/g.exec(position.content);
884
+ const index = match ? match.index : position.content.length;
885
+ const text = `\n${position.content.slice(0, index)}`;
886
+ edit.insert(text);
887
+ // Pressing Enter on the last visible row pushes the new line past the
888
+ // collapsed window's fold, where there is no rendered `.line` to host
889
+ // the caret (it would strand in the padding filler). Mirror the
890
+ // arrow-key boundary handling and ask the host to expand. Cheap: a
891
+ // single bounds read plus the `getPosition` we already need for the
892
+ // post-expand caret restore.
893
+ const {
894
+ maxRow,
895
+ onBoundary
896
+ } = boundsRef.current;
897
+ if (maxRow !== undefined && onBoundary && position.line >= maxRow) {
898
+ state.position = getPosition(element);
899
+ state.skipNextRestore = true;
900
+ onBoundary();
901
+ } else if (!event.repeat) {
902
+ // Reconcile synchronously (revert the raw newline, re-render React's
903
+ // frame structure in one `flushSync`) so an Enter that MOVES an
904
+ // emphasis frame — e.g. re-splitting a line whose earlier Backspace
905
+ // merge had scrolled the collapsed window — repositions the window in
906
+ // the same task as the native insert. Without this the live DOM keeps
907
+ // the pre-reconcile window position until the keyup flush, a visible
908
+ // flash. Mirrors the synchronous Backspace-merge path; the keyup flush
909
+ // then no-ops (content unchanged → `trackState` dedups). Held Enter
910
+ // (`event.repeat`) keeps the debounced keyup flush so the highlight
911
+ // re-runs once on release instead of once per repeat.
912
+ flushChanges(true, true);
913
+ return;
914
+ }
915
+ } else if (!event.isComposing && isPlaintextInputKey(event) && (!hasPlaintextSupport || framedEditorActive())) {
916
+ // Firefox Quirk: native typing in contentEditable="true" can insert
917
+ // directly into the frame wrapper before the current line span.
918
+ //
919
+ // Chromium/WebKit (plaintext-only) Quirk: native typing at the END of a
920
+ // framed `.line` (the boundary with the inter-line gap text node)
921
+ // likewise lands the character in the `.frame` wrapper, flattening the
922
+ // line spans and splitting subsequent input onto the next row — which
923
+ // then strands the caret at the line start on the next Backspace.
924
+ //
925
+ // Route plain text input through the controlled insert path in both
926
+ // cases so the character lands inside the current line span.
927
+ event.preventDefault();
928
+ edit.insert(event.key);
929
+ } else if ((!hasPlaintextSupport || config.indentation) && event.key === 'Backspace' && !event.metaKey && !event.ctrlKey && !event.altKey) {
930
+ // Firefox Quirk: Since plaintext-only is unsupported we must
931
+ // ensure that only a single character is deleted.
932
+ //
933
+ // Modifier guard: Ctrl/Meta/Alt+Backspace request word- or
934
+ // line-granular deletion. Mirror the forward-`Delete` branch below
935
+ // and let those modified presses fall through to the browser's
936
+ // native `deleteWord*`/`deleteSoftLine*` so a held modifier keeps its
937
+ // OS deletion semantics instead of being downgraded to a single char.
938
+ event.preventDefault();
939
+ const beforePosition = getPosition(element);
940
+ const range = getCurrentRange();
941
+ if (!range.collapsed) {
942
+ // Whether the deletion removed WHOLE lines from the first line down. True
943
+ // only when the selection BOTH started at column 0 (no content before it)
944
+ // AND ended at a line boundary (its text ends with a newline) — then the
945
+ // first line is gone and the post-delete caret lands on the line that
946
+ // shifted up from below, so the comment-map anchor sits one line higher
947
+ // (see `deletedFromLineStart`). A selection that ends MID-line instead
948
+ // collapses the spanned lines INTO the first line, which survives (emptied)
949
+ // under the caret — no shift-up — so the flag must stay false, or a marker
950
+ // on that surviving line is dragged one line too high.
951
+ const deletedFromLineStart = beforePosition.content.length === 0 && range.toString().endsWith('\n');
952
+ edit.insert('', 0);
953
+ // A multi-line selection delete can natively remove whole `.frame`
954
+ // wrapper elements (e.g. selecting exactly one emphasis frame). That
955
+ // detaches nodes React still holds, so its next reconcile throws
956
+ // `removeChild`/`NotFoundError` and unmounts the whole editor. Reconcile
957
+ // synchronously (revert the raw mutation, re-render from the new source
958
+ // in one `flushSync`) so React owns the structural change consistently.
959
+ flushChanges(true, true, {
960
+ deletedFromLineStart
961
+ });
962
+ return;
963
+ }
964
+ // Collapsed caret (the non-collapsed range case returned above).
965
+ const {
966
+ minColumn
967
+ } = boundsRef.current;
968
+ // When the caret sits at `minColumn` on a blank (whitespace-only)
969
+ // line inside a clipped indent gutter, a single-character Backspace
970
+ // would step into `[0, minColumn)` — visually invisible to the user
971
+ // since that range is hidden by the host. Clearing one indent unit
972
+ // at a time would leave the caret stranded in that hidden gutter.
973
+ // Instead, clear the WHOLE clipped indent in one Backspace so the
974
+ // line becomes truly empty and the caret lands at its visible
975
+ // column 0 — keeping the caret on the same line rather than
976
+ // collapsing the line and jumping it up to the previous one.
977
+ //
978
+ // Walk only enough text nodes to read the current line — we
979
+ // don't need the rest of the document on every Backspace.
980
+ const clearsClippedIndent = minColumn !== undefined && minColumn > 0 && beforePosition.line > 0 && beforePosition.content.length === minColumn && /^\s*$/.test(beforePosition.content);
981
+ let handled = false;
982
+ if (clearsClippedIndent && minColumn !== undefined) {
983
+ // The redundant `minColumn !== undefined` check pins TS's
984
+ // narrowing across the boundary so we can use `minColumn`
985
+ // as a number directly without an assertion.
986
+ const fullLine = getLineInfo(element, beforePosition.line).currentLine;
987
+ if (fullLine.length === minColumn && /^\s*$/.test(fullLine)) {
988
+ edit.insert('', -minColumn);
989
+ handled = true;
990
+ }
991
+ }
992
+ if (!handled) {
993
+ const match = blanklineRe.exec(beforePosition.content);
994
+ edit.insert('', match ? -match[1].length : -1);
995
+ }
996
+ // If the deletion left the current line empty, OR merged this line up
997
+ // into the previous one (a Backspace at column 0 deletes the preceding
998
+ // newline), the browser leaves a transient zero-height/collapsed
999
+ // `.line` span in the DOM that only disappears once the change commits
1000
+ // and React re-renders. Left to the keyup flush (or an async
1001
+ // re-highlight) the line blinks out and back — the visible flash when
1002
+ // "removing the last part of a line full of spaces" or backspacing a
1003
+ // line up into the one above. Reconcile synchronously (bypassing
1004
+ // preParse) so the final structure is in place before the next paint.
1005
+ const afterDelete = getPosition(element);
1006
+ const lineEmptied = getLineInfo(element, afterDelete.line).currentLine.length === 0;
1007
+ const lineMerged = afterDelete.line < beforePosition.line;
1008
+ if (lineEmptied || lineMerged) {
1009
+ flushChanges(true, true);
1010
+ return;
1011
+ }
1012
+ } else if ((!hasPlaintextSupport || framedEditorActive()) && event.key === 'Delete' && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
1013
+ // Forward delete, mirroring the Backspace handling. Native plaintext-only
1014
+ // forward-delete is unreliable in framed editors: at a `.line`/gap
1015
+ // boundary it can no-op instead of merging the next line, and when it
1016
+ // empties a line it leaves a zero-height empty `.line` that flashes
1017
+ // before the async re-highlight commits. Route it through the controlled
1018
+ // `edit.insert` so the deletion is predictable, then reconcile
1019
+ // synchronously when the line empties (same flash fix as Backspace).
1020
+ event.preventDefault();
1021
+ const range = getCurrentRange();
1022
+ if (!range.collapsed) {
1023
+ // See the Backspace branch above: deletedFromLineStart holds only when the
1024
+ // selection removed whole lines (started at column 0 AND ended at a line
1025
+ // boundary). A mid-line end collapses the lines in place, leaving the first
1026
+ // line emptied under the caret — no shift-up — so the flag must stay false.
1027
+ const deletedFromLineStart = getPosition(element).content.length === 0 && range.toString().endsWith('\n');
1028
+ edit.insert('', 0);
1029
+ // Same frame-wrapper detach crash as the Backspace branch above: a
1030
+ // multi-line selection delete must reconcile synchronously so React
1031
+ // commits the structural change instead of crashing on a detached node.
1032
+ flushChanges(true, true, {
1033
+ deletedFromLineStart
1034
+ });
1035
+ return;
1036
+ }
1037
+ edit.insert('', 1);
1038
+ const afterForwardDelete = getPosition(element);
1039
+ if (getLineInfo(element, afterForwardDelete.line).currentLine.length === 0) {
1040
+ flushChanges(true, true);
1041
+ return;
1042
+ }
1043
+ } else if (config.indentation && event.key === 'Tab') {
1044
+ event.preventDefault();
1045
+ const position = getPosition(element);
1046
+ const start = position.position - position.content.length;
1047
+ const content = toString(element);
1048
+ 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);
1049
+ edit.update(newContent);
1050
+ } else if ((event.key === 'PageDown' && boundsRef.current.maxRow !== undefined || event.key === 'PageUp' && boundsRef.current.minRow !== undefined) && !event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
1051
+ // Paging inside a COLLAPSED window: the hidden out-of-window lines are
1052
+ // still in the DOM, so the browser's native PageUp/PageDown drops the
1053
+ // caret into the non-editable padding filler beyond the fold. Instead,
1054
+ // move the caret to the far visible edge in the paging direction and ask
1055
+ // the host to expand — landing it on a real, now-revealed line. Mirrors
1056
+ // the arrow-at-edge handling: PageDown engages only when `maxRow` is set
1057
+ // (a bottom fold to protect, like `ArrowDown` at `maxRow`) and PageUp
1058
+ // only when `minRow` is set. With no bound in the press direction there
1059
+ // is no fold to strand into, so the key falls through to native handling
1060
+ // instead of half-engaging. Bounded cost (one `getLineInfo` for the edge
1061
+ // line). Only acts on a collapsed selection so Shift-paging (range
1062
+ // extension) stays native.
1063
+ const range = getCurrentRange();
1064
+ const {
1065
+ minRow,
1066
+ maxRow,
1067
+ onBoundary
1068
+ } = boundsRef.current;
1069
+ if (range.collapsed && onBoundary) {
1070
+ const column = getPosition(element).content.length;
1071
+ const targetRow = event.key === 'PageDown' ? maxRow : minRow;
1072
+ if (targetRow !== undefined) {
1073
+ event.preventDefault();
1074
+ const edge = getLineInfo(element, targetRow).currentLine;
1075
+ edit.move({
1076
+ row: targetRow,
1077
+ column: Math.min(column, edge.length)
1078
+ });
1079
+ state.position = getPosition(element);
1080
+ state.skipNextRestore = true;
1081
+ onBoundary();
1082
+ }
1083
+ }
1084
+ } 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')) {
1085
+ // Arrow-key navigation that respects the visible region:
1086
+ // - `minColumn`: skip over hidden/clipped leading indent so the
1087
+ // caret never lands before `minColumn` via horizontal navigation.
1088
+ // - `minRow`/`maxRow`: block navigation past the visible row range
1089
+ // and invoke `onBoundary` so the host can react (e.g. expand).
1090
+ // - `caretSelector`: when set, the editable contains non-selectable
1091
+ // gap text nodes between lines; handle horizontal line-wrap
1092
+ // ourselves so `ArrowLeft` at column 0 lands at the end of the
1093
+ // previous line synchronously (without flashing through the gap).
1094
+ // Only acts on a collapsed selection — let the browser handle range
1095
+ // expansion when a modifier is held or text is already selected.
1096
+ const range = getCurrentRange();
1097
+ if (range.collapsed) {
1098
+ const {
1099
+ minColumn,
1100
+ minRow,
1101
+ maxRow,
1102
+ onBoundary,
1103
+ caretSelector
1104
+ } = boundsRef.current;
1105
+ const position = getPosition(element);
1106
+ const column = position.content.length;
1107
+ // Walk just enough of the document to gather the current line
1108
+ // and its immediate neighbors instead of allocating the entire
1109
+ // document string and a full per-line array on every keypress.
1110
+ const {
1111
+ currentLine: lineText,
1112
+ prevLine,
1113
+ nextLine,
1114
+ hasNextLine
1115
+ } = getLineInfo(element, position.line);
1116
+ const lineIsIndented = minColumn !== undefined && lineText.length >= minColumn && /^\s*$/.test(lineText.slice(0, minColumn));
1117
+ const atVisibleStart = minRow !== undefined && position.line === minRow;
1118
+ const atVisibleEnd = maxRow !== undefined && position.line === maxRow;
1119
+ const atLineStart = column === 0 || lineIsIndented && minColumn !== undefined && column === minColumn;
1120
+ const atLineEnd = column === lineText.length;
1121
+
1122
+ // For caretSelector wrap, also confirm the caret is currently
1123
+ // *inside* an element matching the selector. This keeps the wrap
1124
+ // scoped to render paths that actually have inter-line gap nodes
1125
+ // (e.g. highlighted `.line` spans) and leaves plain-text editables
1126
+ // — where the browser handles arrows fine — untouched.
1127
+ const caretInLine = caretSelector !== undefined && (() => {
1128
+ const startContainer = range.startContainer;
1129
+ const startElement = asElement(startContainer) ?? startContainer.parentElement;
1130
+ return !!startElement?.closest(caretSelector);
1131
+ })();
1132
+
1133
+ // Helper: place the caret on a target line, clamping the column
1134
+ // to the line's length and respecting `minColumn` indent. Used
1135
+ // when we need to move synchronously across the inter-line gap
1136
+ // text nodes that `caretSelector`-rendered content places between
1137
+ // `.line` spans (a native arrow press would otherwise drop the
1138
+ // caret *in* the gap). The caller passes the target line's text
1139
+ // (already in hand from `getLineInfo`) so we don't re-walk the
1140
+ // document.
1141
+ const moveToLine = (targetRow, targetLine, desiredColumn) => {
1142
+ let targetColumn = Math.min(desiredColumn, targetLine.length);
1143
+ if (minColumn !== undefined && targetLine.length >= minColumn && /^\s*$/.test(targetLine.slice(0, minColumn)) && targetColumn < minColumn) {
1144
+ targetColumn = minColumn;
1145
+ }
1146
+ edit.move({
1147
+ row: targetRow,
1148
+ column: targetColumn
1149
+ });
1150
+ // Refresh the tracked caret to the new position. Arrow navigation
1151
+ // otherwise never updates `state.position` (it is only seeded on
1152
+ // click/focus and edits), so a host re-render triggered by
1153
+ // `onBoundary` (e.g. expanding a collapsed block) would restore the
1154
+ // stale pre-navigation position — snapping the caret back to where
1155
+ // the user last clicked instead of where the arrow key left it.
1156
+ state.position = getPosition(element);
1157
+ };
1158
+ if (event.key === 'ArrowUp') {
1159
+ if (atVisibleStart) {
1160
+ if (caretInLine && position.line > 0) {
1161
+ // Synchronously move the caret onto the previous `.line`
1162
+ // before notifying the host. Without this, native ArrowUp
1163
+ // can drop the caret into the inter-line gap text node
1164
+ // (e.g. the literal `\n` between `.line` spans), trapping
1165
+ // it in the "between lines" area after the host expands.
1166
+ event.preventDefault();
1167
+ moveToLine(position.line - 1, prevLine, column);
1168
+ if (onBoundary) {
1169
+ state.skipNextRestore = true;
1170
+ onBoundary();
1171
+ }
1172
+ } else if (onBoundary) {
1173
+ // Allow native caret movement so the host can scroll the
1174
+ // newly-revealed content into view alongside the caret.
1175
+ state.skipNextRestore = true;
1176
+ onBoundary();
1177
+ } else {
1178
+ event.preventDefault();
1179
+ }
1180
+ } else if (caretSelector !== undefined && position.line > 0 && (prevLine.length === 0 || lineText.length === 0)) {
1181
+ // Zero-height blank lines (`.line` blocks with no content) are
1182
+ // skipped by the browser's native vertical navigation, so a
1183
+ // single ArrowUp can jump over one or more blank rows. Step
1184
+ // exactly one logical line up synchronously — preventing the
1185
+ // native skip so the user never sees the caret land on the wrong
1186
+ // line first — whenever the row we leave or the row we enter is
1187
+ // blank. Gated on `caretSelector` (not `caretInLine`) because a
1188
+ // caret sitting *on* a blank line lives in the inter-line gap
1189
+ // text node, not inside a `.line`, so `caretInLine` is false
1190
+ // there; the logical row from `getPosition` stays accurate.
1191
+ // Non-blank rows fall through to native handling so wrapped
1192
+ // visual lines keep behaving natively.
1193
+ event.preventDefault();
1194
+ moveToLine(position.line - 1, prevLine, column);
1195
+ }
1196
+ } else if (event.key === 'ArrowDown') {
1197
+ if (atVisibleEnd) {
1198
+ if (caretInLine && hasNextLine) {
1199
+ event.preventDefault();
1200
+ moveToLine(position.line + 1, nextLine, column);
1201
+ if (onBoundary) {
1202
+ state.skipNextRestore = true;
1203
+ onBoundary();
1204
+ }
1205
+ } else if (onBoundary) {
1206
+ state.skipNextRestore = true;
1207
+ onBoundary();
1208
+ } else {
1209
+ event.preventDefault();
1210
+ }
1211
+ } else if (caretSelector !== undefined && hasNextLine && (nextLine.length === 0 || lineText.length === 0)) {
1212
+ // Mirror of ArrowUp: step onto the blank row the browser would
1213
+ // otherwise skip. See the ArrowUp branch above.
1214
+ event.preventDefault();
1215
+ moveToLine(position.line + 1, nextLine, column);
1216
+ }
1217
+ } else if (event.key === 'ArrowLeft') {
1218
+ if (atVisibleStart && atLineStart) {
1219
+ if (caretInLine && position.line > 0) {
1220
+ event.preventDefault();
1221
+ // Use `moveToLine` (not a bare `edit.move`) so `state.position`
1222
+ // is updated to the end of the previous line. Like the ArrowUp /
1223
+ // ArrowDown / ArrowRight boundary branches, `onBoundary` triggers
1224
+ // a host re-render (expand); the per-render caret restore reads
1225
+ // `state.position`, so a stale value would snap the caret back to
1226
+ // the boundary line instead of landing it on the revealed line.
1227
+ moveToLine(position.line - 1, prevLine, prevLine.length);
1228
+ if (onBoundary) {
1229
+ state.skipNextRestore = true;
1230
+ onBoundary();
1231
+ }
1232
+ } else if (onBoundary) {
1233
+ state.skipNextRestore = true;
1234
+ onBoundary();
1235
+ } else {
1236
+ event.preventDefault();
1237
+ }
1238
+ } else if (lineIsIndented && minColumn !== undefined && column === minColumn && position.line > 0) {
1239
+ event.preventDefault();
1240
+ edit.move({
1241
+ row: position.line - 1,
1242
+ column: prevLine.length
1243
+ });
1244
+ } else if (caretInLine && column === 0 && position.line > 0) {
1245
+ // With non-selectable gaps between lines the browser would
1246
+ // place the caret *in* the gap text node — making ArrowLeft
1247
+ // a no-op. Jump synchronously to the end of the previous
1248
+ // line instead.
1249
+ event.preventDefault();
1250
+ edit.move({
1251
+ row: position.line - 1,
1252
+ column: prevLine.length
1253
+ });
1254
+ }
1255
+ } else if (event.key === 'ArrowRight') {
1256
+ if (atVisibleEnd && atLineEnd) {
1257
+ if (caretInLine && hasNextLine) {
1258
+ event.preventDefault();
1259
+ moveToLine(position.line + 1, nextLine, 0);
1260
+ if (onBoundary) {
1261
+ state.skipNextRestore = true;
1262
+ onBoundary();
1263
+ }
1264
+ } else if (onBoundary) {
1265
+ state.skipNextRestore = true;
1266
+ onBoundary();
1267
+ } else {
1268
+ event.preventDefault();
1269
+ }
1270
+ } else if (minColumn !== undefined && column === lineText.length && hasNextLine) {
1271
+ const nextIsIndented = nextLine.length >= minColumn && /^\s*$/.test(nextLine.slice(0, minColumn));
1272
+ if (nextIsIndented) {
1273
+ event.preventDefault();
1274
+ edit.move({
1275
+ row: position.line + 1,
1276
+ column: minColumn
1277
+ });
1278
+ } else if (caretInLine) {
1279
+ // Same gap-flash avoidance as ArrowLeft: jump to start of
1280
+ // next line synchronously.
1281
+ event.preventDefault();
1282
+ edit.move({
1283
+ row: position.line + 1,
1284
+ column: 0
1285
+ });
1286
+ }
1287
+ } else if (caretInLine && atLineEnd && hasNextLine) {
1288
+ event.preventDefault();
1289
+ edit.move({
1290
+ row: position.line + 1,
1291
+ column: 0
1292
+ });
1293
+ }
1294
+ }
1295
+ }
1296
+
1297
+ // Schedule a post-arrow snap when `caretSelector` is set: the
1298
+ // browser's native arrow handling can drop the caret into the
1299
+ // non-selectable gap text nodes (e.g. the literal `\n` between
1300
+ // `.line` spans, especially after pressing Down on the last line
1301
+ // or Up on the first line). After the default action runs, if the
1302
+ // caret is no longer inside a matching element, jump it to the
1303
+ // nearest `.line` in the direction of travel so the caret never
1304
+ // gets stuck "between lines".
1305
+ const {
1306
+ caretSelector
1307
+ } = boundsRef.current;
1308
+ if (caretSelector !== undefined && !event.defaultPrevented) {
1309
+ const direction = event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 'forward' : 'backward';
1310
+ // For vertical arrows, capture the column the user is leaving
1311
+ // *before* the browser moves the caret, so we can land on the
1312
+ // same column of the target line if a snap is needed. Horizontal
1313
+ // arrows always snap to start/end of the adjacent line.
1314
+ const isVertical = event.key === 'ArrowUp' || event.key === 'ArrowDown';
1315
+ let preferredColumn = 0;
1316
+ if (isVertical) {
1317
+ const preSel = element.ownerDocument.defaultView?.getSelection();
1318
+ if (preSel && preSel.rangeCount > 0 && preSel.isCollapsed) {
1319
+ const preRange = preSel.getRangeAt(0);
1320
+ if (element.contains(preRange.startContainer)) {
1321
+ preferredColumn = getPosition(element).content.length;
1322
+ }
1323
+ }
1324
+ }
1325
+ // requestAnimationFrame fires after the browser has applied the
1326
+ // native caret movement but before paint, so the snap is invisible.
1327
+ window.requestAnimationFrame(() => {
1328
+ snapCaretOutOfGapNode(direction, isVertical, preferredColumn);
1329
+ });
1330
+ }
1331
+ } else if (
1332
+ // Gate on the rendered structure (`.line` spans carry `data-ln`), NOT on
1333
+ // `boundsRef.current.caretSelector`: the host drops `caretSelector` to
1334
+ // undefined whenever `shouldHighlight` is false (an EXPANDED block, or a
1335
+ // post-edit re-highlight in flight), yet the `.line`/frame structure
1336
+ // persists. The old live-`caretSelector` check silently disabled this
1337
+ // whole branch in those states, leaving native Shift+Arrow to stall on
1338
+ // the zero-height empty lines this branch exists to step over.
1339
+ element.querySelector('[data-ln]') !== null && event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
1340
+ // Shift+Up/Down selection extension in a framed editor (`caretSelector`
1341
+ // set, with non-selectable inter-line gap `\n` text nodes between
1342
+ // `.line` spans). The browser's native vertical selection-extension
1343
+ // skips zero-height blank `.line` spans (a two-line jump) and parks the
1344
+ // focus in a gap node. Step the FOCUS exactly one logical `.line`
1345
+ // synchronously — preserving the anchor — so the selection grows one
1346
+ // line at a time and the focus always lands inside a `.line`.
1347
+ const sel = element.ownerDocument.defaultView?.getSelection();
1348
+ if (sel && sel.rangeCount > 0 && sel.focusNode && element.contains(sel.focusNode)) {
1349
+ // The focus is the moving end; `getPosition` reads the range start
1350
+ // (the anchor on a forward selection), so derive the focus row/column
1351
+ // straight from the live selection's focus.
1352
+ const focusProbe = element.ownerDocument.createRange();
1353
+ focusProbe.setStart(element, 0);
1354
+ focusProbe.setEnd(sel.focusNode, sel.focusOffset);
1355
+ const beforeFocus = focusProbe.toString();
1356
+ const focusRow = beforeFocus.split('\n').length - 1;
1357
+ const focusColumn = beforeFocus.length - (beforeFocus.lastIndexOf('\n') + 1);
1358
+ const {
1359
+ hasNextLine
1360
+ } = getLineInfo(element, focusRow);
1361
+ const goingUp = event.key === 'ArrowUp';
1362
+ const {
1363
+ minColumn,
1364
+ minRow,
1365
+ maxRow
1366
+ } = boundsRef.current;
1367
+ // Don't extend the selection past the collapsed window into the
1368
+ // zero-height clipped frames above `minRow` / below `maxRow`: the
1369
+ // focus would land in an h=0 region and paint a stray highlight on a
1370
+ // hidden line (and strand the focus in a non-selectable node).
1371
+ // `preventDefault` blocks the native extension too; the user can
1372
+ // expand the window to reach the hidden lines. Mirrors the non-shift
1373
+ // arrow boundary handling, minus the `onBoundary` expand (which would
1374
+ // collapse the in-progress selection on the restore).
1375
+ const atWindowEdge = goingUp ? minRow !== undefined && focusRow <= minRow : maxRow !== undefined && focusRow >= maxRow;
1376
+ if (atWindowEdge) {
1377
+ event.preventDefault();
1378
+ } else if (goingUp ? focusRow > 0 : hasNextLine) {
1379
+ event.preventDefault();
1380
+ // Step the focus exactly ONE logical line. Crucially this is row-based
1381
+ // (text newline count), so it advances correctly even across a line
1382
+ // the browser renders at ZERO height — an empty line the CSS collapses
1383
+ // to 0px. Native Shift+Arrow stalls there (it works in visual space and
1384
+ // a zero-height line has none), which is the "two Shift+Downs land on
1385
+ // the same line / can't get past the empty line" bug. We step in
1386
+ // logical space and `extend` to a real offset, so each press advances
1387
+ // one line, empty or not.
1388
+ const targetRow = goingUp ? focusRow - 1 : focusRow + 1;
1389
+ const targetLine = getLineInfo(element, targetRow).currentLine;
1390
+ let targetColumn = Math.min(focusColumn, targetLine.length);
1391
+ if (minColumn !== undefined && targetLine.length >= minColumn && /^\s*$/.test(targetLine.slice(0, minColumn)) && targetColumn < minColumn) {
1392
+ targetColumn = minColumn;
1393
+ }
1394
+ const targetRange = makeRange(element, getOffsetAtLineColumn(element, targetRow, targetColumn));
1395
+ adjustCursorAtNewlineBoundary(targetRange);
1396
+ sel.extend(targetRange.startContainer, targetRange.startOffset);
1397
+ // Keep the tracked selection in sync so a host re-render's restore
1398
+ // preserves the extended range instead of snapping it back.
1399
+ const trackedPosition = getPosition(element);
1400
+ // `getPosition` reads the forward-normalized range start and so loses
1401
+ // which end is the focus. Record the direction explicitly: a backward
1402
+ // selection (focus above the anchor) must be rebuilt focus-at-top on
1403
+ // restore, or `addRange` would flip the focus to the bottom and the
1404
+ // next Shift+Arrow would extend from the wrong end. The focus we just
1405
+ // moved sits at the range start exactly when the selection is backward.
1406
+ if (sel.anchorNode && element.contains(sel.anchorNode)) {
1407
+ const anchorProbe = element.ownerDocument.createRange();
1408
+ anchorProbe.setStart(element, 0);
1409
+ anchorProbe.setEnd(sel.anchorNode, sel.anchorOffset);
1410
+ const anchorOffset = anchorProbe.toString().length;
1411
+ if (anchorOffset > trackedPosition.position) {
1412
+ trackedPosition.backward = true;
1413
+ }
1414
+ }
1415
+ state.position = trackedPosition;
1416
+ }
1417
+ }
1418
+ }
1419
+
1420
+ // After a controlled edit in plaintext-only contentEditable, the DOM is
1421
+ // in a known-good post-edit state. Refresh pendingContent to that state
1422
+ // so any subsequent native input within the same key burst — e.g.
1423
+ // holding Enter then pressing x in plaintext-only contentEditable, where
1424
+ // `x` falls through to native browser handling and may merge frame
1425
+ // boundary lines — is measured against the correct baseline. Without
1426
+ // this, repairUnexpectedLineMerge sees Enter add a line and the native
1427
+ // merge remove a line for a net zero delta and short-circuits, leaving
1428
+ // the merge unrepaired.
1429
+ //
1430
+ // We gate on `hasPlaintextSupport` because in the Firefox fallback
1431
+ // (contenteditable=true) `edit.insert` itself can trigger the line-merge
1432
+ // quirk, so toString() after it would already be buggy and we must keep
1433
+ // the pre-edit baseline.
1434
+ if (event.defaultPrevented && hasPlaintextSupport) {
1435
+ state.pendingContent = toString(element);
1436
+ }
1437
+
1438
+ // Flush changes as a key is held so the app can catch up.
1439
+ // Debounce: reset the timer on each repeat keydown so the expensive
1440
+ // onChange (syntax re-highlight) only fires once the user pauses typing.
1441
+ // edit.insert() already updated the DOM so the cursor and text are live.
1442
+ if (event.repeat) {
1443
+ if (state.repeatFlushId !== null) {
1444
+ clearTimeout(state.repeatFlushId);
1445
+ }
1446
+ state.repeatFlushId = setTimeout(() => {
1447
+ state.repeatFlushId = null;
1448
+ // The user may have moved focus or cleared the selection in the
1449
+ // 100ms since the last repeat keydown (e.g. clicked elsewhere,
1450
+ // unmounted, blurred). The debounced flush is best-effort; if the
1451
+ // engine is gone or there's no live selection inside the editable
1452
+ // any more, skip — the next real event will pick up state.
1453
+ //
1454
+ // Bail out before touching `window`: a stray timer can fire after
1455
+ // teardown, and in a test environment the `window` global may already
1456
+ // be removed, so `window.getSelection()` would throw a `ReferenceError`
1457
+ // (an unhandled rejection that can mask real failures).
1458
+ if (state.disconnected || typeof window === 'undefined') {
1459
+ return;
1460
+ }
1461
+ const selection = window.getSelection();
1462
+ if (!selection || selection.rangeCount === 0 || !element.contains(selection.getRangeAt(0).startContainer)) {
1463
+ return;
1464
+ }
1465
+ flushChanges();
1466
+ }, 100);
1467
+ }
1468
+ };
1469
+ const onKeyUp = event => {
1470
+ if (event.defaultPrevented || event.isComposing) {
1471
+ return;
1472
+ }
1473
+ // Cancel any pending debounced flush so keyup always flushes immediately
1474
+ if (state.repeatFlushId !== null) {
1475
+ clearTimeout(state.repeatFlushId);
1476
+ state.repeatFlushId = null;
1477
+ }
1478
+ // Structural edits (Enter) must always create their own undo checkpoint.
1479
+ // Regular character typing uses the 500ms dedup so you undo a word at a
1480
+ // time, but each Enter should be individually undoable. flushChanges
1481
+ // records the (repaired) post-edit content into history before firing
1482
+ // onChange, so we don't poison the undo stack with intermediate
1483
+ // browser-merged DOM states. Enter also forces a synchronous React
1484
+ // state sync (bypassing `preParse`) so newlines render immediately.
1485
+ if (!isUndoRedoKey(event)) {
1486
+ flushChanges(event.key === 'Enter', event.key === 'Enter');
1487
+ } else {
1488
+ flushChanges();
1489
+ }
1490
+ // Chrome Quirk: The contenteditable may lose focus after the first edit or so
1491
+ element.focus();
1492
+ };
1493
+ const onSelect = event => {
1494
+ // Chrome Quirk: The contenteditable may lose its selection immediately on first focus
1495
+ const hasRange = (window.getSelection()?.rangeCount ?? 0) > 0;
1496
+ state.position = hasRange && event.target === element ? getPosition(element) : null;
1497
+ };
1498
+ const onPaste = event => {
1499
+ event.preventDefault();
1500
+ const clipboard = event.clipboardData;
1501
+ if (!clipboard) {
1502
+ return;
1503
+ }
1504
+ state.pendingContent = trackState(true) ?? toString(element);
1505
+ edit.insert(clipboard.getData('text/plain'));
1506
+ // Paste replaces a chunk of source — flush synchronously so the
1507
+ // pasted text highlights on the same commit instead of after a
1508
+ // worker round-trip.
1509
+ flushChanges(true, true);
1510
+ };
1511
+
1512
+ // When the editable wraps lines in block-level elements (e.g. `.line`
1513
+ // spans separated by literal `\n` gap text nodes), the browser's
1514
+ // default HTML→text/plain serializer inserts an implicit newline
1515
+ // between each block element on top of the explicit `\n` already
1516
+ // present in the DOM, producing duplicated newlines in the
1517
+ // clipboard. Override copy/cut to write `Range.toString()` for
1518
+ // `text/plain` while still preserving the HTML payload (so pasting
1519
+ // into rich-text targets keeps syntax highlighting).
1520
+ const onCopyOrCut = event => {
1521
+ const selection = window.getSelection();
1522
+ if (!selection || selection.rangeCount === 0 || !event.clipboardData) {
1523
+ return;
1524
+ }
1525
+ const range = selection.getRangeAt(0);
1526
+ if (range.collapsed || !element.contains(range.commonAncestorContainer)) {
1527
+ return;
1528
+ }
1529
+ event.preventDefault();
1530
+ const minColumn = boundsRef.current.minColumn;
1531
+ // When the selection starts mid-gutter (e.g. minColumn=4 but the
1532
+ // user dragged from column 2), only the gutter portion *inside*
1533
+ // the selection should be stripped from the first line. Subsequent
1534
+ // lines always start at column 0 of the document, so they get the
1535
+ // full `minColumn` budget.
1536
+ let firstLineStrip = 0;
1537
+ const restStrip = minColumn ?? 0;
1538
+ if (minColumn !== undefined && minColumn > 0) {
1539
+ const beforeRange = element.ownerDocument.createRange();
1540
+ beforeRange.setStart(element, 0);
1541
+ beforeRange.setEnd(range.startContainer, range.startOffset);
1542
+ const beforeText = beforeRange.toString();
1543
+ const lastNewline = beforeText.lastIndexOf('\n');
1544
+ const startColumn = beforeText.length - (lastNewline + 1);
1545
+ firstLineStrip = Math.max(0, minColumn - startColumn);
1546
+ }
1547
+
1548
+ // The caret-navigation guard already treats `[0, minColumn)` as a
1549
+ // clipped indent gutter. Strip up to that many leading whitespace
1550
+ // characters per line from the clipboard so the pasted snippet
1551
+ // matches what the user sees rather than including indent that
1552
+ // is hidden in the editable.
1553
+ const plainText = restStrip > 0 ? stripLeadingPerLine(range.toString(), firstLineStrip, restStrip) : range.toString();
1554
+ event.clipboardData.setData('text/plain', plainText);
1555
+ const container = cloneRangeWithInlineStyles(element, range, {
1556
+ elementStyleProps: CLIPBOARD_ELEMENT_STYLE_PROPS,
1557
+ rootStyleProps: CLIPBOARD_ROOT_STYLE_PROPS,
1558
+ rootStaticStyles: CLIPBOARD_ROOT_STATIC_STYLES
1559
+ });
1560
+ if (restStrip > 0) {
1561
+ stripLeadingPerLineDom(container, firstLineStrip, restStrip);
1562
+ }
1563
+ event.clipboardData.setData('text/html', container.outerHTML);
1564
+ if (event.type === 'cut') {
1565
+ // Mirror the paste path: capture pre-edit state for history, then
1566
+ // delete the selection. When `minColumn` clipped the leading
1567
+ // gutter whitespace out of the clipboard, re-insert exactly
1568
+ // those characters at the selection location so cut stays
1569
+ // lossless — the document keeps the hidden indent that the user
1570
+ // could not see and never copied.
1571
+ state.pendingContent = trackState(true) ?? toString(element);
1572
+ const replacement = restStrip > 0 ? extractLeadingPerLine(range.toString(), firstLineStrip, restStrip) : '';
1573
+ edit.insert(replacement);
1574
+ // Cut also bypasses preParse so the resulting document re-renders
1575
+ // synchronously alongside the clipboard write.
1576
+ flushChanges(true, true);
1577
+ }
1578
+ };
1579
+
1580
+ // Capture the current caret/selection into `state.position` when the
1581
+ // selection lives inside the editable. The `selectstart` listener only
1582
+ // fires for newly-initiated selections (typically mouse drags) — it
1583
+ // does NOT fire for a plain click that places a collapsed caret. Without
1584
+ // this capture, a user who clicks into the editable but hasn't typed
1585
+ // yet has `state.position === null`, so the unconditional restore in
1586
+ // the first `useLayoutEffect` skips and a host re-render (e.g.
1587
+ // expanding a collapsed code block) lets the DOM mutation clobber the
1588
+ // browser's selection, producing a visible "cursor lost / text
1589
+ // selected" jump. Re-using `getPosition` matches what `onSelect` does.
1590
+ const capturePosition = () => {
1591
+ const hasRange = (window.getSelection()?.rangeCount ?? 0) > 0;
1592
+ if (!hasRange) {
1593
+ return;
1594
+ }
1595
+ const selection = window.getSelection();
1596
+ const anchorNode = selection?.anchorNode ?? null;
1597
+ if (!anchorNode || !element.contains(anchorNode)) {
1598
+ return;
1599
+ }
1600
+ state.position = getPosition(element);
1601
+ };
1602
+
1603
+ // Pull a non-collapsed selection's focus back inside the collapsed window
1604
+ // when a drag carried it past `minRow`/`maxRow` into a zero-height clipped
1605
+ // frame (the hidden lines above/below the fold). Leaving it there paints a
1606
+ // stray highlight on a line the user can't see. Browsers usually clamp a
1607
+ // drag to the visible content on their own, but autoscroll past the fold can
1608
+ // defeat that — this is the requested fix-on-mouse-up safety net. A no-op
1609
+ // when the focus already rests inside the window.
1610
+ const clampSelectionToWindow = () => {
1611
+ const {
1612
+ minRow,
1613
+ maxRow
1614
+ } = boundsRef.current;
1615
+ if (minRow === undefined && maxRow === undefined) {
1616
+ return;
1617
+ }
1618
+ const sel = element.ownerDocument.defaultView?.getSelection();
1619
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed || !sel.focusNode || !element.contains(sel.focusNode)) {
1620
+ return;
1621
+ }
1622
+ const focusProbe = element.ownerDocument.createRange();
1623
+ focusProbe.setStart(element, 0);
1624
+ focusProbe.setEnd(sel.focusNode, sel.focusOffset);
1625
+ const focusRow = focusProbe.toString().split('\n').length - 1;
1626
+ let targetRow;
1627
+ let targetColumn = 0;
1628
+ if (maxRow !== undefined && focusRow > maxRow) {
1629
+ targetRow = maxRow;
1630
+ targetColumn = getLineInfo(element, maxRow).currentLine.length;
1631
+ } else if (minRow !== undefined && focusRow < minRow) {
1632
+ targetRow = minRow;
1633
+ targetColumn = 0;
1634
+ }
1635
+ if (targetRow === undefined) {
1636
+ return;
1637
+ }
1638
+ const targetOffset = getOffsetAtLineColumn(element, targetRow, targetColumn);
1639
+ const targetRange = makeRange(element, targetOffset);
1640
+ adjustCursorAtNewlineBoundary(targetRange);
1641
+ // `extend` moves only the focus, leaving the drag's anchor put.
1642
+ sel.extend(targetRange.startContainer, targetRange.startOffset);
1643
+ };
1644
+ const onMouseUp = () => {
1645
+ // First pull a drag-selection focus out of the clipped region, then lift
1646
+ // a collapsed caret out of any inter-line gap node so the gutter check
1647
+ // below can see a real line position.
1648
+ clampSelectionToWindow();
1649
+ snapCaretOutOfGapNode('forward', false, 0);
1650
+ snapCaretOutOfGutter();
1651
+ capturePosition();
1652
+ };
1653
+
1654
+ // Tabbing into the editor places the caret at column 0 of the first
1655
+ // line, which lands inside the clipped indent gutter. Browsers set the
1656
+ // initial selection asynchronously after `focus`, so defer the snap.
1657
+ const onFocus = () => {
1658
+ const view = element.ownerDocument.defaultView;
1659
+ if (!view) {
1660
+ return;
1661
+ }
1662
+ view.requestAnimationFrame(() => {
1663
+ snapCaretOutOfGapNode('forward', false, 0);
1664
+ snapCaretOutOfGutter();
1665
+ capturePosition();
1666
+ });
1667
+ };
1668
+ document.addEventListener('selectstart', onSelect);
1669
+ window.addEventListener('keydown', onKeyDown);
1670
+ element.addEventListener('paste', onPaste);
1671
+ element.addEventListener('copy', onCopyOrCut);
1672
+ element.addEventListener('cut', onCopyOrCut);
1673
+ element.addEventListener('keyup', onKeyUp);
1674
+ element.addEventListener('mouseup', onMouseUp);
1675
+ element.addEventListener('focus', onFocus);
1676
+ return () => {
1677
+ if (state.repeatFlushId !== null) {
1678
+ clearTimeout(state.repeatFlushId);
1679
+ state.repeatFlushId = null;
1680
+ }
1681
+ // Abort any in-flight preParse so its eventual `onChange` doesn't
1682
+ // fire after the editable has been torn down or toggled disabled.
1683
+ if (state.preParseAbort) {
1684
+ state.preParseAbort.abort();
1685
+ state.preParseAbort = null;
1686
+ }
1687
+ document.removeEventListener('selectstart', onSelect);
1688
+ window.removeEventListener('keydown', onKeyDown);
1689
+ element.removeEventListener('paste', onPaste);
1690
+ element.removeEventListener('copy', onCopyOrCut);
1691
+ element.removeEventListener('cut', onCopyOrCut);
1692
+ element.removeEventListener('keyup', onKeyUp);
1693
+ element.removeEventListener('mouseup', onMouseUp);
1694
+ element.removeEventListener('focus', onFocus);
1695
+ styleSetupCancelled = true;
1696
+ // Restore synchronously so observers on the same tick as
1697
+ // `unmount()` see the pre-mount values. Skipped when the host
1698
+ // has already been detached (the typical page-transition case),
1699
+ // where the write would be wasted. The mount-side deferred style
1700
+ // task is cancelled above, so there's no microtask race.
1701
+ if (element.isConnected) {
1702
+ element.style.whiteSpace = prevWhiteSpace;
1703
+ element.contentEditable = prevContentEditable;
1704
+ }
1705
+ };
1706
+ };
1707
+ return {
1708
+ edit,
1709
+ observeAndRestore,
1710
+ setup
1711
+ };
1712
+ };