@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.
- package/ChunkProvider/ChunkContext.d.mts +10 -0
- package/ChunkProvider/ChunkContext.mjs +15 -0
- package/ChunkProvider/ChunkProvider.d.mts +14 -0
- package/ChunkProvider/ChunkProvider.mjs +38 -0
- package/ChunkProvider/PreloadContext.d.mts +14 -0
- package/ChunkProvider/PreloadContext.mjs +18 -0
- package/ChunkProvider/PreloadProvider.d.mts +13 -0
- package/ChunkProvider/PreloadProvider.mjs +33 -0
- package/ChunkProvider/index.d.mts +7 -0
- package/ChunkProvider/index.mjs +7 -0
- package/ChunkProvider/types.d.mts +23 -0
- package/ChunkProvider/types.mjs +1 -0
- package/ChunkProvider/usePreload.d.mts +8 -0
- package/ChunkProvider/usePreload.mjs +21 -0
- package/CodeControllerContext/CodeControllerContext.d.mts +11 -0
- package/CodeControllerContext/CodeControllerContext.mjs +2 -1
- package/CodeHighlighter/CodeHighlighter.d.mts +15 -1
- package/CodeHighlighter/CodeHighlighter.mjs +97 -319
- package/CodeHighlighter/CodeHighlighterChunk.d.mts +42 -0
- package/CodeHighlighter/CodeHighlighterChunk.mjs +77 -0
- package/CodeHighlighter/CodeHighlighterClient.mjs +597 -128
- package/CodeHighlighter/CodeHighlighterContext.d.mts +57 -1
- package/CodeHighlighter/CodeHighlighterFallbackContext.d.mts +14 -2
- package/CodeHighlighter/CodeHighlighterFallbackContext.mjs +1 -3
- package/CodeHighlighter/CodeInitialSourceLoader.d.mts +10 -0
- package/CodeHighlighter/CodeInitialSourceLoader.mjs +108 -0
- package/CodeHighlighter/CodeSourceLoader.d.mts +11 -0
- package/CodeHighlighter/CodeSourceLoader.mjs +128 -0
- package/CodeHighlighter/buildCodeHighlighterChunkProps.d.mts +47 -0
- package/CodeHighlighter/buildCodeHighlighterChunkProps.mjs +61 -0
- package/CodeHighlighter/buildStringFallback.d.mts +29 -0
- package/CodeHighlighter/buildStringFallback.mjs +42 -0
- package/CodeHighlighter/codeToFallbackProps.d.mts +31 -2
- package/CodeHighlighter/codeToFallbackProps.mjs +347 -42
- package/CodeHighlighter/createClientProps.d.mts +17 -0
- package/CodeHighlighter/createClientProps.mjs +78 -0
- package/CodeHighlighter/errors.d.mts +6 -0
- package/CodeHighlighter/errors.mjs +10 -0
- package/CodeHighlighter/fallbackCompression.d.mts +96 -0
- package/CodeHighlighter/fallbackCompression.mjs +253 -0
- package/CodeHighlighter/fallbackFormat.d.mts +137 -0
- package/CodeHighlighter/fallbackFormat.mjs +422 -0
- package/CodeHighlighter/index.d.mts +4 -1
- package/CodeHighlighter/index.mjs +3 -1
- package/CodeHighlighter/mergeComments.d.mts +38 -0
- package/CodeHighlighter/mergeComments.mjs +80 -0
- package/CodeHighlighter/prepareInitialSource.d.mts +42 -0
- package/CodeHighlighter/prepareInitialSource.mjs +292 -0
- package/CodeHighlighter/resolveFallbackCritical.d.mts +23 -0
- package/CodeHighlighter/resolveFallbackCritical.mjs +44 -0
- package/CodeHighlighter/types.d.mts +272 -8
- package/CodeHighlighter/useCodeFallback.d.mts +94 -0
- package/CodeHighlighter/useCodeFallback.mjs +204 -0
- package/CodeHighlighter/useGrammarsReady.d.mts +18 -0
- package/CodeHighlighter/useGrammarsReady.mjs +45 -0
- package/CodeHighlighter/useSpeculativeCodePreload.d.mts +26 -0
- package/CodeHighlighter/useSpeculativeCodePreload.mjs +40 -0
- package/CodeHighlighter/useSpeculativeEditingPreload.d.mts +33 -0
- package/CodeHighlighter/useSpeculativeEditingPreload.mjs +58 -0
- package/CodeHighlighter/useSpeculativeGrammarPreload.d.mts +23 -0
- package/CodeHighlighter/useSpeculativeGrammarPreload.mjs +31 -0
- package/CodeHighlighter/useSpeculativeUseCodePreload.d.mts +22 -0
- package/CodeHighlighter/useSpeculativeUseCodePreload.mjs +41 -0
- package/CodeProvider/CodeContext.d.mts +47 -12
- package/CodeProvider/CodeContext.mjs +7 -0
- package/CodeProvider/CodeProvider.d.mts +4 -2
- package/CodeProvider/CodeProvider.mjs +40 -102
- package/CodeProvider/CodeProviderLazy.d.mts +40 -0
- package/CodeProvider/CodeProviderLazy.mjs +96 -0
- package/CodeProvider/constants.d.mts +26 -0
- package/CodeProvider/constants.mjs +24 -0
- package/CodeProvider/createParseSourceWorkerClient.d.mts +6 -0
- package/CodeProvider/createParseSourceWorkerClient.mjs +22 -2
- package/CodeProvider/index.d.mts +2 -1
- package/CodeProvider/index.mjs +9 -1
- package/CodeProvider/parseSourceWorker.mjs +33 -0
- package/CodeProvider/useCodeProviderValue.d.mts +54 -0
- package/CodeProvider/useCodeProviderValue.mjs +188 -0
- package/CoordinatedLazy/ChunkServerLoader.d.mts +25 -0
- package/CoordinatedLazy/ChunkServerLoader.mjs +97 -0
- package/CoordinatedLazy/CoordinatedContentContext.d.mts +15 -0
- package/CoordinatedLazy/CoordinatedContentContext.mjs +22 -0
- package/CoordinatedLazy/CoordinatedFallbackContext.d.mts +11 -0
- package/CoordinatedLazy/CoordinatedFallbackContext.mjs +13 -0
- package/CoordinatedLazy/CoordinatedGateContext.d.mts +14 -0
- package/CoordinatedLazy/CoordinatedGateContext.mjs +19 -0
- package/CoordinatedLazy/CoordinatedLazy.d.mts +14 -0
- package/CoordinatedLazy/CoordinatedLazy.mjs +86 -0
- package/CoordinatedLazy/CoordinatedLazyClient.d.mts +24 -0
- package/CoordinatedLazy/CoordinatedLazyClient.mjs +65 -0
- package/CoordinatedLazy/LazyContent.d.mts +26 -0
- package/CoordinatedLazy/LazyContent.mjs +80 -0
- package/CoordinatedLazy/LazyContentServer.d.mts +18 -0
- package/CoordinatedLazy/LazyContentServer.mjs +25 -0
- package/CoordinatedLazy/buildChunkRenderInputs.d.mts +8 -0
- package/CoordinatedLazy/buildChunkRenderInputs.mjs +35 -0
- package/CoordinatedLazy/createCoordinatedLazy.d.mts +32 -0
- package/CoordinatedLazy/createCoordinatedLazy.mjs +127 -0
- package/CoordinatedLazy/index.d.mts +14 -0
- package/CoordinatedLazy/index.mjs +18 -0
- package/CoordinatedLazy/resolveChunkRender.d.mts +26 -0
- package/CoordinatedLazy/resolveChunkRender.mjs +73 -0
- package/CoordinatedLazy/types.d.mts +408 -0
- package/CoordinatedLazy/types.mjs +1 -0
- package/CoordinatedLazy/useChunk.d.mts +30 -0
- package/CoordinatedLazy/useChunk.mjs +135 -0
- package/CoordinatedLazy/useCoordinatedFallback.d.mts +12 -0
- package/CoordinatedLazy/useCoordinatedFallback.mjs +40 -0
- package/CoordinatedLazy/useCoordinatedSwap.d.mts +16 -0
- package/CoordinatedLazy/useCoordinatedSwap.mjs +124 -0
- package/LICENSE +1 -1
- package/abstractCreateDemo/abstractCreateDemo.d.mts +54 -3
- package/abstractCreateDemo/abstractCreateDemo.mjs +47 -7
- package/abstractCreateDemo/resolveDemoFlag.d.mts +20 -0
- package/abstractCreateDemo/resolveDemoFlag.mjs +25 -0
- package/abstractCreateStream/abstractCreateStream.d.mts +18 -0
- package/abstractCreateStream/abstractCreateStream.mjs +45 -0
- package/abstractCreateStream/index.d.mts +2 -0
- package/abstractCreateStream/index.mjs +1 -0
- package/abstractCreateStream/types.d.mts +34 -0
- package/abstractCreateStream/types.mjs +1 -0
- package/abstractCreateTypes/TypeCode.mjs +12 -11
- package/abstractCreateTypes/typesToJsx.mjs +30 -9
- package/cli/ensureDemoClients.mjs +4 -148
- package/cli/ensureDemoPages.d.mts +45 -0
- package/cli/ensureDemoPages.mjs +99 -0
- package/cli/fileUtils/index.d.mts +11 -0
- package/cli/fileUtils/index.mjs +48 -0
- package/cli/findDemoIndexFiles.d.mts +15 -0
- package/cli/findDemoIndexFiles.mjs +121 -0
- package/cli/index.mjs +1 -1
- package/cli/loadNextConfig.d.mts +25 -0
- package/cli/loadNextConfig.mjs +60 -1
- package/cli/runBrowser.mjs +1 -1
- package/cli/runValidate.mjs +44 -1
- package/package.json +85 -5
- package/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.mjs +30 -0
- package/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasisLazy.d.mts +17 -0
- package/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasisLazy.mjs +52 -0
- package/pipeline/hastUtils/frameFallbackFromSpans.d.mts +18 -0
- package/pipeline/hastUtils/frameFallbackFromSpans.mjs +24 -0
- package/pipeline/hastUtils/hast.d.mts +27 -0
- package/pipeline/hastUtils/hastCompression.d.mts +3 -1
- package/pipeline/hastUtils/hastCompression.mjs +9 -1
- package/pipeline/hastUtils/hastDecompress.mjs +10 -4
- package/pipeline/hastUtils/hastDictionary.mjs +9 -0
- package/pipeline/hastUtils/hastUtils.d.mts +4 -3
- package/pipeline/hastUtils/hastUtils.mjs +24 -12
- package/pipeline/hastUtils/index.d.mts +2 -1
- package/pipeline/hastUtils/index.mjs +2 -1
- package/pipeline/hastUtils/stripHighlightingSpans.d.mts +6 -2
- package/pipeline/hastUtils/stripHighlightingSpans.mjs +22 -10
- package/pipeline/lintJavascriptDemoFocus/lintJavascriptDemoFocus.mjs +10 -7
- package/pipeline/loadIsomorphicCodeVariant/applyCodeTransform.d.mts +31 -13
- package/pipeline/loadIsomorphicCodeVariant/applyCodeTransform.mjs +50 -55
- package/pipeline/loadIsomorphicCodeVariant/applyCodeTransformWithComments.d.mts +78 -0
- package/pipeline/loadIsomorphicCodeVariant/applyCodeTransformWithComments.mjs +405 -0
- package/pipeline/loadIsomorphicCodeVariant/computeHastDeltas.d.mts +5 -5
- package/pipeline/loadIsomorphicCodeVariant/computeHastDeltas.mjs +36 -66
- package/pipeline/loadIsomorphicCodeVariant/decodeHastSource.d.mts +23 -0
- package/pipeline/loadIsomorphicCodeVariant/decodeHastSource.mjs +92 -0
- package/pipeline/loadIsomorphicCodeVariant/decodeSource.d.mts +19 -0
- package/pipeline/loadIsomorphicCodeVariant/decodeSource.mjs +25 -0
- package/pipeline/loadIsomorphicCodeVariant/decodeSourceToText.d.mts +17 -0
- package/pipeline/loadIsomorphicCodeVariant/decodeSourceToText.mjs +26 -0
- package/pipeline/loadIsomorphicCodeVariant/diffHast.d.mts +26 -2
- package/pipeline/loadIsomorphicCodeVariant/diffHast.mjs +563 -19
- package/pipeline/loadIsomorphicCodeVariant/embedTransforms.d.mts +49 -0
- package/pipeline/loadIsomorphicCodeVariant/embedTransforms.mjs +152 -0
- package/pipeline/loadIsomorphicCodeVariant/findExpandingRanges.d.mts +51 -0
- package/pipeline/loadIsomorphicCodeVariant/findExpandingRanges.mjs +161 -0
- package/pipeline/loadIsomorphicCodeVariant/flattenCodeVariant.mjs +6 -3
- package/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.d.mts +12 -0
- package/pipeline/loadIsomorphicCodeVariant/getAvailableTransforms.mjs +44 -0
- package/pipeline/loadIsomorphicCodeVariant/getInitialVisibleSourceLines.d.mts +16 -0
- package/pipeline/loadIsomorphicCodeVariant/getInitialVisibleSourceLines.mjs +74 -0
- package/pipeline/loadIsomorphicCodeVariant/loadCodeFallback.mjs +17 -5
- package/pipeline/loadIsomorphicCodeVariant/loadIsomorphicCodeVariant.mjs +229 -15
- package/pipeline/loadIsomorphicCodeVariant/transformSource.d.mts +2 -2
- package/pipeline/loadIsomorphicCodeVariant/transformSource.mjs +56 -22
- package/pipeline/loadPrecomputedCodeHighlighter/loadPrecomputedCodeHighlighter.d.mts +18 -0
- package/pipeline/loadPrecomputedCodeHighlighter/loadPrecomputedCodeHighlighter.mjs +11 -7
- package/pipeline/loadServerTypes/hastTypeUtils.d.mts +2 -2
- package/pipeline/loadServerTypes/hastTypeUtils.mjs +4 -4
- package/pipeline/loadServerTypes/loadServerTypes.mjs +1 -1
- package/pipeline/loadServerTypesMeta/extractJSDocText.d.mts +14 -0
- package/pipeline/loadServerTypesMeta/extractJSDocText.mjs +60 -0
- package/pipeline/loadServerTypesMeta/processTypes.mjs +43 -46
- package/pipeline/loadServerTypesText/order.mjs +1 -1
- package/pipeline/loadServerTypesText/parseTypesMarkdown.mjs +3 -1
- package/pipeline/loaderUtils/index.d.mts +0 -1
- package/pipeline/loaderUtils/index.mjs +0 -1
- package/pipeline/loaderUtils/parseImportsAndComments.d.mts +5 -1
- package/pipeline/loaderUtils/parseImportsAndComments.mjs +19 -9
- package/pipeline/loaderUtils/resolveModulePath.mjs +23 -1
- package/pipeline/parseCreateFactoryCall/parseCreateFactoryCall.d.mts +12 -0
- package/pipeline/parseCreateFactoryCall/parseCreateFactoryCall.mjs +17 -13
- package/pipeline/parseSource/addLineGutters.mjs +45 -11
- package/pipeline/parseSource/calculateFrameRanges.d.mts +22 -0
- package/pipeline/parseSource/calculateFrameRanges.mjs +69 -25
- package/pipeline/parseSource/detectGrammarScopes.d.mts +13 -0
- package/pipeline/parseSource/detectGrammarScopes.mjs +35 -0
- package/pipeline/parseSource/extendSyntaxTokens.mjs +501 -43
- package/pipeline/parseSource/frameVisibility.d.mts +47 -0
- package/pipeline/parseSource/frameVisibility.mjs +114 -0
- package/pipeline/parseSource/grammarCache.d.mts +33 -0
- package/pipeline/parseSource/grammarCache.mjs +73 -0
- package/pipeline/parseSource/grammarLoaders.d.mts +14 -0
- package/pipeline/parseSource/grammarLoaders.mjs +24 -0
- package/pipeline/parseSource/grammarMaps.d.mts +21 -1
- package/pipeline/parseSource/grammarMaps.mjs +36 -0
- package/pipeline/parseSource/isFrameSpan.d.mts +19 -0
- package/pipeline/parseSource/isFrameSpan.mjs +24 -0
- package/pipeline/parseSource/parseSource.d.mts +41 -6
- package/pipeline/parseSource/parseSource.mjs +184 -36
- package/pipeline/parseSource/redistributeFrameFallbacks.d.mts +40 -0
- package/pipeline/parseSource/redistributeFrameFallbacks.mjs +138 -0
- package/pipeline/parseSource/restructureFrames.d.mts +5 -0
- package/pipeline/parseSource/restructureFrames.mjs +179 -16
- package/pipeline/syncPageIndex/metadataToMarkdown.mjs +6 -2
- package/pipeline/transformHtmlCodeBlock/transformHtmlCodeBlock.d.mts +26 -0
- package/pipeline/transformHtmlCodeBlock/transformHtmlCodeBlock.mjs +181 -114
- package/pipeline/transformHtmlCodeInline/removeSuffixFromHighlightedNodes.d.mts +12 -0
- package/pipeline/transformHtmlCodeInline/removeSuffixFromHighlightedNodes.mjs +52 -0
- package/pipeline/transformHtmlCodeInline/transformHtmlCodeInline.mjs +22 -1
- package/pipeline/transformTypescriptToJavascript/removeTypes.d.mts +5 -8
- package/pipeline/transformTypescriptToJavascript/removeTypes.mjs +27 -93
- package/useCode/EditableEngine.d.mts +233 -0
- package/useCode/EditableEngine.mjs +1712 -0
- package/useCode/EditingEngine.d.mts +13 -0
- package/useCode/EditingEngine.mjs +14 -0
- package/useCode/Pre.browser.mjs +5 -1
- package/useCode/Pre.d.mts +127 -1
- package/useCode/Pre.mjs +417 -165
- package/useCode/SourceEditingEngine.d.mts +50 -0
- package/useCode/SourceEditingEngine.mjs +461 -0
- package/useCode/TransformEngine.d.mts +39 -0
- package/useCode/TransformEngine.mjs +208 -0
- package/useCode/editingEngineCache.d.mts +29 -0
- package/useCode/editingEngineCache.mjs +68 -0
- package/useCode/sourceLineCounts.d.mts +80 -0
- package/useCode/sourceLineCounts.mjs +284 -0
- package/useCode/subscribeToggleNudge.d.mts +3 -0
- package/useCode/subscribeToggleNudge.mjs +95 -0
- package/useCode/transformEngineCache.d.mts +21 -0
- package/useCode/transformEngineCache.mjs +60 -0
- package/useCode/useCode.d.mts +140 -1
- package/useCode/useCode.mjs +250 -19
- package/useCode/useCodeUtils.d.mts +131 -20
- package/useCode/useCodeUtils.mjs +267 -194
- package/useCode/useCopyFunctionality.d.mts +13 -1
- package/useCode/useCopyFunctionality.mjs +39 -9
- package/useCode/useEditable.browser.mjs +10 -2
- package/useCode/useEditable.d.mts +27 -106
- package/useCode/useEditable.integration.browser.d.mts +1 -0
- package/useCode/useEditable.integration.browser.mjs +870 -0
- package/useCode/useEditable.mjs +198 -1247
- package/useCode/useEditableUtils.d.mts +50 -1
- package/useCode/useEditableUtils.mjs +29 -0
- package/useCode/useFileNavigation.d.mts +91 -3
- package/useCode/useFileNavigation.mjs +201 -41
- package/useCode/useHighlightGate.d.mts +17 -0
- package/useCode/useHighlightGate.mjs +147 -0
- package/useCode/useSourceEditing.d.mts +8 -0
- package/useCode/useSourceEditing.mjs +158 -314
- package/useCode/useSourceEnhancing.d.mts +5 -1
- package/useCode/useSourceEnhancing.mjs +22 -36
- package/useCode/useTransformManagement.d.mts +93 -5
- package/useCode/useTransformManagement.mjs +496 -28
- package/useCode/useTransitionPhase.d.mts +24 -0
- package/useCode/useTransitionPhase.mjs +49 -0
- package/useCode/useUIState.d.mts +2 -2
- package/useCode/useUIState.mjs +8 -8
- package/useCode/useVariantSelection.d.mts +130 -6
- package/useCode/useVariantSelection.mjs +529 -93
- package/useCodeWindow/useCodeWindow.d.mts +19 -2
- package/useCodeWindow/useCodeWindow.mjs +98 -71
- package/useCoordinated/coordinatePreference.d.mts +439 -0
- package/useCoordinated/coordinatePreference.mjs +951 -0
- package/useCoordinated/coordinatePreference.testUtils.d.mts +21 -0
- package/useCoordinated/coordinatePreference.testUtils.mjs +69 -0
- package/useCoordinated/createSettleGate.d.mts +96 -0
- package/useCoordinated/createSettleGate.mjs +171 -0
- package/useCoordinated/index.d.mts +8 -0
- package/useCoordinated/index.mjs +8 -0
- package/useCoordinated/layoutShiftGate.d.mts +24 -0
- package/useCoordinated/layoutShiftGate.mjs +79 -0
- package/useCoordinated/pageSettleGate.d.mts +11 -0
- package/useCoordinated/pageSettleGate.mjs +13 -0
- package/useCoordinated/scheduleTasks.d.mts +23 -0
- package/useCoordinated/scheduleTasks.mjs +45 -0
- package/useCoordinated/useCoordinated.d.mts +193 -0
- package/useCoordinated/useCoordinated.mjs +469 -0
- package/useCoordinated/useCoordinatedLazy.d.mts +17 -0
- package/useCoordinated/useCoordinatedLazy.mjs +38 -0
- package/useCoordinated/useCoordinatedLocalStorage.d.mts +16 -0
- package/useCoordinated/useCoordinatedLocalStorage.mjs +22 -0
- package/useCoordinated/useCoordinatedPreference.d.mts +20 -0
- package/useCoordinated/useCoordinatedPreference.mjs +26 -0
- package/useCoordinated/useSettleGate.d.mts +11 -0
- package/useCoordinated/useSettleGate.mjs +34 -0
- package/useDemo/exportVariant.d.mts +12 -5
- package/useDemo/exportVariant.mjs +59 -5
- package/useDemo/useDemo.d.mts +5 -2
- package/useScrollAnchor/useScrollAnchor.mjs +28 -5
- package/useStream/index.d.mts +6 -0
- package/useStream/index.mjs +6 -0
- package/useStream/streamChunks.d.mts +23 -0
- package/useStream/streamChunks.mjs +85 -0
- package/useStream/types.d.mts +45 -0
- package/useStream/types.mjs +1 -0
- package/useStream/useStream.d.mts +57 -0
- package/useStream/useStream.mjs +119 -0
- package/useStream/useStreamController.d.mts +15 -0
- package/useStream/useStreamController.mjs +90 -0
- package/withDocsInfra/withDocsInfra.d.mts +19 -0
- package/withDocsInfra/withDocsInfra.mjs +13 -5
- package/pipeline/loaderUtils/convertCommentsToOneIndexed.d.mts +0 -8
- 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
|
+
};
|