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